Files
iptvnator/docs/architecture/parental-lock.md
T
e45cd85a78 feat(settings): PIN-protected parental lock for categories (#285) (#1601)
* feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compile the PIN hashing helper in the Node backend build

The web backend compiles the shared interfaces library without DOM typings,
so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker
build. The helper now describes the WebCrypto surface it needs structurally
and reaches it through `globalThis`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor

- The Xtream detail guard hydrates the PWA session cache before judging an
  item on a cold navigation and fails closed when the catalog cannot place
  the item.
- The dedicated Stalker search route filters withheld genres, re-fires on
  lock changes, judges paging on the raw page and restarts from page 1 on a
  lock flip.
- The Xtream category dialog loads its lock candidates through the
  capability-selected data source; the PWA source now lists its raw
  categories with lock flags, so locks can be configured there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store

- A Stalker search page issued before a relock was filtered with the
  pre-relock withheld set and could still be applied after it; the
  staleness check now includes the parental lock version.
- A lock store payload that does not parse or is not an object is a
  failed read (everything withheld until it reads again), no longer an
  empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(xtream): move the parental-lock reload specs beside the content spec

The content feature spec sits at the 1200-line spec cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration

- The Xtream category guard no longer runs the item check on category-only
  routes (Number(null) is 0), which prompted for the PIN on every unlocked
  VOD and series category while the lock was active.
- A lock change during the initial Xtream hydration withholds the rows the
  hydration publishes and runs the filtered reload once it has settled,
  on every path that marks the content initialized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload

The Xtream store stays populated after leaving that portal, so its reload
runs on every apply; a locked M3U channel no longer keeps playing behind a
slow database or provider read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries

- The workspace route resolver awaits ParentalLockService.initialize()
  next to the settings load, so no route or catalog activates before the
  PIN and lock store are known.
- Every lock write re-reads a failed store before building its edit, so a
  recovered store is edited rather than overwritten.
- The deferred hydration reload runs under the publish guard of the
  request that deferred it.
- Backup import validates every parental lock entry and rejects a damaged
  list instead of erasing the persisted locks on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the Electron cached category/content reads while locks are unknown

The warm-route hydration reads the cache directly; it now returns nothing
while the lock store withholds everything, like the live reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps

- clearSearchResults() advances the search request version, so a search
  issued under the previous lock state cannot republish what a relock
  just cleared.
- A lock write publishes its store revision only once every touched type
  is stamped, so a reload triggered by it cannot read a later type
  through its old stamps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(settings): one lock entry point per rail plus a right-click Lock/Unlock

- Stalker's dedicated lock button becomes the same "Manage categories"
  (tune) button the Xtream rail has; it opens the lock-only dialog, so
  every portal type shares one entry point and the rail header keeps
  three actions.
- Right-clicking a category (Xtream, Stalker) or an M3U group offers a
  single-row Lock / Unlock through the shared CategoryLockMenuComponent,
  behind the same PIN gate and lock store as the dialog.
- The settings hint explains where locks are set; group lock strings added
  to all locales (ru/de translated).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): apply single-row lock toggles inside the lock store's write queue

The right-click Lock/Unlock (portal categories and M3U groups) built the
new list before entering the queue, so two quick toggles shared one
snapshot and the second dropped the first. Lock writes now accept an edit
of the current list, evaluated inside the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed settings read before any parental-lock settings write

updateSettings writes the whole settings object, which after a failed
startup read is the defaults; enabling the lock or changing the relock
timeout then replaced the user's persisted preferences. The read is
retried first and the write refused while settings stay unreadable. The
settings writes move to parental-lock-settings-writer.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value

- The M3U groups rail now renders the same "N locked · Enter PIN to show"
  row as the portal category rail, so locked groups no longer vanish
  without an in-context unlock.
- A failed relock-timeout write rolls back to the value read after the
  settings retry, not to the pre-retry default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then fails, the index is re-stamped from the
  previous locks at once. Title matching and multi-source discovery query
  the worker directly and trust the index, so the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the M3U groups view when every group is locked

With every group locked the filtered channel list is empty, so the
container showed its generic empty state and the groups rail's
"N locked · Enter PIN to show" row never appeared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the lazy Stalker enforcement step cannot load

A rejected chunk (e.g. a stale PWA page after a deployment) escaped
applyStalker(), so a locked Stalker selection kept playing after a relock
and the Xtream step was skipped. The step now leaves the Stalker route on
a load failure, which clears the selection and stops playback, and the
Xtream step still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld

On Electron a relock that overtook the initial content hydration waited
for the category reload before the content reload set the deferral flag;
the older unlocked hydration could publish its streams in that window.
withholdCatalog() now sets the flag itself, before anything is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands

Lock edits that take a lock away now commit only while the session is
unlocked, checked inside the write queue at commit time, so an editor
save still in flight (or queued) when the app relocks cannot remove
locks. Adding locks stays allowed. The Xtream category dialog drops its
lock draft after a relock, and a backup restore re-asks the PIN only
when it would remove a lock. Clearing a playlist's last lock keeps its
index stale until the store write has landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): clear an Xtream detail from a hidden category synchronously on relock

The synchronous relock step now clears a selected Xtream item whose
category the visible category list cannot place (a manually hidden
category opened through search), instead of leaving it usable until the
awaited reloads and lookup finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile

When writing the previous store back after a relock-refused removal
fails, the lock store now keeps a pending rewrite, is not readable (the
locked session withholds everything) and rewrites the persisted store
from memory on the next access, so a restart cannot load the removal.
A failed "Remove all playlists" clear shares the same retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode

A lock removal is now authorized right before its first write is issued;
a relock that lands after that is ordered after the write, which
completes. This drops the post-write rollback, whose own failure could
leave the persisted store diverged from memory across a restart. The
Stalker search closes a detail without a genre on relock while every
category is withheld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stalker): cover page-one rows leaving the screen on relock while the reload hangs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): offer the portal unlock row in fail-closed mode

When the lock store cannot be read every portal category is withheld
but no locked ids are known, so the rail showed no "Enter PIN to show"
row. It now shows the row without a count in that state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN

Catalog title matching and multi-source discovery query the SQLite
worker directly; they now return nothing while the parental lock
withholds everything (unreadable store or a bridge without the worker
filter). The Stalker lock dialog captures its playlist, provider and
section before the PIN prompt and re-checks them after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist

A locked session can no longer change the relock timeout: the Settings
selector is disabled until the PIN is entered and the service refuses
the change while locked. An M3U right-click lock toggle now captures its
playlist before the PIN prompt and is saved only if that playlist is
still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): reset a locked M3U channel however it becomes active

The enforcement service now checks the active M3U channel whenever it
changes while locked, so numeric zapping, next/previous and remote
commands, which select from the full channel list, cannot start a
channel of a locked group. Numeric zapping also skips such a channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): bind the M3U group management result to its playlist

The groups view captures the playlist before the PIN prompt and drops
the management dialog's hidden and locked group lists once another
playlist is open, so they cannot be saved under that playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(parental-lock): record the accepted restart case of a failed rollback write

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value

The Settings switch snaps back to the saved state when clicked and
follows it once the PIN action succeeds, so a cancelled or refused PIN
no longer leaves it showing the opposite state. A failed switch write is
undone to the value read after the settings retry instead of the
hard-coded inverse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): match noncanonical PWA Xtream category ids against their locks

The PWA data source compared raw provider category ids such as "009"
with locks stored as numbers, so such a category stayed visible while
locked. Both sides are now compared in canonical numeric form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): close the M3U group editor on any relock

The group management dialog lists every group name, locked ones
included, even when it opened without lock toggles (unreadable lock
store). It now closes on any relock, and the groups view re-checks the
lock state before opening it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 16:38:34 +02:00

36 KiB
Raw Blame History

Parental Lock

Issue #285. A PIN-protected lock for individual categories: Xtream categories, Stalker genres and M3U group-titles. While the lock is active, everything in a locked category is withheld from the app; entering the PIN shows it again until the app locks itself.

This document is the contract. Phase 1 (this document's scope) covers the portals' own surfaces; the aggregate surfaces listed under "Not yet covered" follow in a second PR.

Product rules

  • Off by default. Enabling asks for a new PIN (4–8 digits); disabling and changing the PIN always verify the current PIN against the stored hash, even while the session is unlocked (verifyCurrentPin(), never the requestUnlock() short-cut) — a parent leaving the app unlocked must not leave the lock removable. Disabling keeps the locks for a later re-enable.
  • The unit of locking is the category. Nothing is blurred or greyed: a withheld category and its rows are absent. The only trace is one "N locked · Enter PIN to show" row at the bottom of a portal's category rail, which opens the PIN prompt. In fail-closed mode (lock store unreadable) the row shows without a count, since which categories are locked is unknown while every one of them is withheld.
  • The unlock lives in memory only. The app locks again on every restart, on "Lock now" (header button, command palette, settings), and after Settings.parentalLockRelockMinutes minutes without user interaction (default 15; 0 = only on restart). The idle timer follows the UNLOCKED transition: armed the moment a session is unlocked — including the session that just enabled the feature — and disarmed on lock. Active playback of a built-in web player counts as interaction, so a film never locks half way — video through the keep-awake tracker, and audio (the radio player) read from the playing <audio> elements, since the keep-awake service ignores them.
  • This is a child lock, not a security boundary: the PIN hash and the lock store sit in user-readable app data. The UI says so. There is no PIN recovery; resetting the app data is the way out.

Storage

What Where (Electron) Where (PWA)
PIN hash app_state key parental-lock:pin localStorage, same key
Lock store app_state key parental-lock:locks localStorage, same key
Feature switch Settings.parentalLockEnabled, mirrored to electron-conf PARENTAL_LOCK_ENABLED Settings
Relock timeout Settings.parentalLockRelockMinutes same
Xtream query index categories.locked column none (filtered in memory)

The PIN is hashed with PBKDF2-SHA256 through WebCrypto (parental-lock-pin.util.ts, format v1$<iterations>$<salt>$<hash>), so Electron and PWA share one implementation. The hash is deliberately not part of Settings: settings are logged, backed up and mirrored to the main process. A hash that could not be READ is not an absent PIN: ParentalLockStorageService.readPinHash() reports the failure as null (an absent PIN is {hash: null}), the session then stays locked (enabled treats an unreadable PIN as set while the switch is unknown) and every PIN-protected step — unlock, change PIN, disable — re-reads it first, so storage answering later is enough to recover without a restart.

The lock store (ParentalLockStore in parental-lock.util.ts) is keyed by playlist id and holds, per playlist, Xtream {categoryType, xtreamId} pairs, Stalker {categoryType, categoryId} pairs and M3U group titles. It is the source of truth for all three portal types. Xtream additionally keeps categories.locked as a SQLite index derived from it: DB_SET_CATEGORY_LOCKS re-stamps one playlist/type from a provider-id list (idempotent, so the store can be replayed after a refresh or a backup restore), and DB_SAVE_CATEGORIES takes lockedCategoryXtreamIds so a refresh recreates the rows already stamped. Locks are keyed by provider ids, never by SQLite row ids, because a refresh deletes and re-inserts the rows.

Enforcement — two layers, both default-closed

  1. SQLite worker (Electron). apps/electron-backend/src/app/database/parental-lock-state.ts holds one process-wide flag. While active, every content-returning read appends "category is not locked": getCategories, getContent, searchContent, the three globalSearch candidate selectors, getGlobalRecentlyAddedByType, DB_MATCH_TITLES and the multi-source discovery queries. A surface added later is therefore withheld by default. The flag is seeded through workerData.parentalLockActive when the worker is (re)started and flipped by the parental-lock control message on the request port (ordered with the requests, so a read posted after it observes the new state). getAllCategories is exempt: it feeds the management dialog, which itself sits behind the PIN.
  2. Renderer ParentalLockService (libs/services/src/lib/parental-lock/). Signals enabled, unlocked, active (= enabled && !unlocked), hasPin, version; requestUnlock() (one shared prompt for concurrent callers), lock(), setupPin(), changePin(), disable(), the lock store API (setXtreamLocks, setStalkerLocks, setM3uLocks, replacePlaylistLocks, lockedXtreamIds, …) and the is*Locked predicates, which answer true only while active. The PIN dialog is reached through the PARENTAL_LOCK_PROMPT token, provided by the app (AppParentalLockPromptService → ParentalLockPinDialogComponent in libs/ui/components), so the data-access lib stays free of UI. Five wrong PINs pause the prompt for 30 seconds. The count and the pause live in the service (createParentalLockPinThrottle, handed to every unlock prompt), so dismissing the dialog and opening it again does not reset them.

The renderer reports active to the main process over PARENTAL_LOCK_SET_STATE (apps/electron-backend/src/app/events/parental-lock.events.ts), which forwards it to the worker. SETTINGS_UPDATE only persists the parentalLockEnabled mirror; it never derives the live state from a settings save (every save carries the flag unchanged and would re-lock the worker under a renderer that shows "unlocked"). The one exception is a switch-off, which releases the worker at once. requestUnlock() awaits initialize() before it can answer "not active", so a slow settings load cannot open a gate. Like the playback keep-awake vote, the renderer's word does not outlive its page: on reload, main-frame navigation or a dead render process the flag falls back to the mirrored PARENTAL_LOCK_ENABLED setting, i.e. locked while the feature is on. The renderer, in turn, reports nothing before its settings have loaded, so it can never send a spurious "unlocked" while the main process still holds the locked default. Unreadable settings fail the same way: SettingsStore serves defaults and records storageFailure = 'load', and the switch is then unknown rather than off — a stored PIN stands in for it (enabled follows hasPin, so the session starts locked and one PIN entry unlocks it), and without a PIN the renderer does not report at all, leaving the main process on its mirrored default instead of announcing "unlocked" on the strength of default settings. The lock store itself fails closed the same way: ParentalLockStorageService.readLocks() reports a failed read as null (distinct from an absent store, {}; Electron reads through DatabaseService.readAppState, which keeps a rejected IPC apart from a missing key, and a stored payload that does not parse or does not have the shape writeLocks produces — isWellFormedParentalLockStore, all three lists present, down to the nested entries — is a failed read too — corruption never becomes an empty store), and while the lock is active with the store unreadable ParentalLockService.withholdsEverything is true — every is*Locked predicate answers true and the set-based filters (PWA Xtream, Stalker content and search, the M3U channel list) receive ALL_CATEGORIES_WITHHELD, and ElectronXtreamDataSource serves no categories, content, cached categories/content (the warm-route hydration path) or search hits either, since its SQLite index may still carry a stale stamp — under which a row WITHOUT a genre is withheld too (isStalkerItemWithheld, isStalkerCategoryLocked), since "no genre" must not be the one row a withheld catalog still shows — so the whole catalog is withheld until the PIN is entered or the store reads again (requestUnlock and every lock write retry the read first, and a write is refused while it still fails, since it would be built on an empty in-memory store and wipe the persisted locks). The same fail-closed mode applies while locked when Electron reads Xtream through the SQLite worker (supportsXtreamSqliteDataSource) but the bridge lacks the worker filter (supportsParentalLockSqliteFilter: setParentalLockState and dbSetCategoryLocks, e.g. a partial or older preload): the worker would never learn the lock state, so it cannot withhold locked rows itself. The same holds while locked after the lock-state sync itself was rejected (ParentalLockWorkerSync), until a later sync succeeds. The direct worker consumers (CatalogTitleMatchService, VodSourceDiscoveryService) ask the worker nothing while withholdsEverything is true, in either case. The VOD multi-source host keys its discovery session to the lock version: a lock change drops the discovered sources, retires discoveries and switches in flight, and rediscovers through the worker's new lock state. Title-match results cached by the Actor/Discover routes and dashboard services are not yet retired on relock (tracked in #1723). The window before the initial read settles is treated the same way (ParentalLockLockStore.readable is false until then): settings can report the feature as on before the locks are known — and the workspace route's settingsReady resolver awaits ParentalLockService.initialize() next to the settings load, so no route or catalog activates before the PIN and the lock store are known (in the PWA the settings read can be the slower one). Every lock write re-reads a failed store BEFORE building its edit, so a recovered store is edited, never overwritten by an edit built on the empty fail-closed one. The feature switch itself is persisted through one guarded path (persistEnabled), and every parental-lock settings write first retries a failed startup settings read (ensureSettingsReadable) and is refused while settings stay unreadable — updateSettings writes the whole settings object, which after a failed read is the defaults and would replace the user's persisted preferences. Within that path updateSettings patches memory before it writes, so a failed write is undone in memory and setupPin/disable report false (whether to persist is decided from the settings switch BEFORE the PIN is stored, since enabled follows hasPin while the switch is unknown); the Electron mirror write is awaited next, and a mirror that cannot be written undoes the settings write the same way — the toggle never shows a state the next launch will not have, on either side. The undo restores the value read AFTER the settings retry (not the hard-coded inverse, which a recovered read may already hold). The Settings switch itself only requests the change: it snaps back to the saved state at once and follows enabled() when the PIN action succeeds, so a cancelled or refused PIN leaves it showing the real state.

In-memory catalogs

  • Xtream (Electron): the store reads categories and streams through the worker, so they arrive filtered. On version changes ParentalLockEnforcementService (apps/web/src/app/services/) calls XtreamStore.reloadCategories() + reloadCachedContent() and, if the selected category vanished, clears the selection and navigates to the section root. The selected ITEM is judged separately, on its own category_id against the reloaded category list: a detail opened from "All", recently added or search has no selected category to vanish with, so its row disappearing from a list is not enough — the item is cleared and the route returns to the section root while the (still readable) selected category stays. Within one apply the synchronous surfaces (the M3U active channel, the Stalker selection) are stepped off BEFORE the Xtream reloads are awaited, so a playing M3U channel never waits behind a slow portal read — the Xtream store stays populated after leaving that portal, so its reload runs on every apply. The M3U check also runs whenever the active channel changes while locked: numeric zapping, next/previous and remote commands select from the full channel list without any lock change following, so a channel of a locked group is reset as soon as it becomes active (numeric zapping also skips it). Applies run one at a time and each is abandoned once a newer version exists (the queued apply reads the latest state): ElectronXtreamDataSource shares in-flight category/content reads per playlist and type, and its share key carries the lock version, so an unlock refresh still running when "Lock now" arrives can never hand the relock its unfiltered rows. The apply also re-runs the stored in-portal search (XtreamStore.refreshSearchResults, the last searchContent call as issued) — searchResults is a separate array the search page renders directly and would otherwise keep locked titles until the query changes. A RELOCK fails closed at once rather than after the database answers — ahead of the serialized apply queue, so an earlier apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when the lock store already names its category (the pre-reload category list maps Electron's row id to the provider id) or when that list cannot place it at all (a manually hidden category opened through search), then withholdCatalog() empties every catalog list and clearSearchResults() the stored search (retiring a search still in flight, which was issued under the previous lock state) before the filtered reads refill them; both reloads take a publish guard answered before every state patch, so a read issued under an older lock version is dropped instead of published. The guard also turns false once another Xtream playlist is open (the store is a singleton), and the store itself refuses to publish a reload into a playlist it was not read for; the post-reload search refresh and selection checks are skipped then too. The post-reload checks of the selected category and item decide by the LOCK STORE through the unfiltered category rows (IXtreamDataSource.getAllCategories), not by absence from the reloaded list, which also omits categories the user merely hid; rows that cannot be read fail closed. A lock change that overtakes the INITIAL hydration (the content is not initialized yet, so the reload has nothing to re-read) sets a deferred-reload flag instead — already when withholdCatalog() empties the lists, before the category reload is awaited: the hydration then publishes empty lists in place of the rows it read under the previous lock state (categories included), and the filtered reload of categories and content runs as soon as the hydration settles, on every path that marks the content initialized, under the publish guard of the request that deferred it. Both reloads fail closed: a category reload that rejects empties the three category lists, and a per-type content reload that rejects empties that type and sets it back to idle so the next visit loads it again (filtered) — rows read under the previous lock state are never kept. Live playback is stopped by the layout itself: LiveStreamLayoutComponent keeps the playing channel's provider category id (mapped from the SQLite row id on Electron — through the visible category list, else through the unfiltered rows, since search can play a channel of a HIDDEN category; until that lookup lands the id is unknown and a relock stops the channel, and a lookup that lands after a later playback — same provider id, other playlist — is discarded) and drops activePlayback on a version change that locks it, because the player is gated on that signal, not on the store selection — which an ordinary category switch also clears while the channel keeps playing.
  • Stalker: the same service checks the selected genre through isStalkerCategoryLocked and, independently, the selected item's own genre — tv_genre_id for live and radio rows, category_id for VOD and series, the rule the store's withheld filter applies — so an item opened from * (All) or search is withheld by its genre even though * itself can never be locked. A withheld item is cleared and the section root is navigated to; the category selection is reset only when the genre itself is locked. A stream still RESOLVING at relock time is retired too: the embedded player defers selecting the channel until resolution, so the cleared selection cannot retire it — the playback request carries the lock version it was issued under (isStalkerPlaybackRequestLockCurrent) and is dropped when the session relocked meanwhile (an unlock lets it complete). StalkerLiveStreamLayoutComponent drops its activePlayback whenever the store selection is cleared: its template mounts the player only with a selection, and the held stream must not resurface with the next one.
  • M3U groups rail: ChannelListContainerComponent derives withheldGroupCount (groups of the playlist the active lock withholds) and the groups rail renders the same "N locked · Enter PIN to show" row as the portal category rail, so locked groups do not simply vanish — also when EVERY group is locked: the container keeps the groups view (showChannelViews) and the groups view keeps its rail instead of the generic empty state.
  • Xtream (PWA): PwaXtreamDataSource drops withheld categories, streams and search hits at read time; the same reloads apply. Provider category ids are compared in canonical numeric form ("009" matches the stored lock 9), as the lock editor stores them with Number.
  • Warm-cache detection (Electron): the filtered category/content reads can be empty while the offline cache is complete (every category locked), so ElectronXtreamDataSource confirms an empty read with the unfiltered hasXtreamCategories / hasXtreamContent before refetching from the provider.
  • Routes: parentalLockXtreamCategoryGuard(section) on every Xtream :categoryId route (live, vod, series and their detail children) and parentalLockStalkerCategoryGuard(section) on the Stalker vod/series :categoryId routes prompt for the PIN when a locked category is reached by URL — bookmark, typed address, stale link — and redirect to the section root on refusal. Detail routes also resolve the ITEM's own category through getContentByXtreamId, so a locked movie paired with an unlocked category id in the URL is still refused; on a cold PWA navigation the guard hydrates the session cache first (getPlaylist + getContent), and an item the catalog cannot place fails closed (PIN prompt) rather than passing as unlocked. Electron routes carry SQLite row ids, so the Xtream guard maps them through the unfiltered category read; Stalker routes already carry the genre id.
  • Stalker withheld-row bookkeeping: the paging logic that advances past a page made only of withheld rows keys those rows by stalkerWithheldRowKey (id, else stream_id/movie_id/series_id, else the row's cmd/name), never by a shared '', so a page of new locked rows is not mistaken for a stalled portal.
  • Stalker search relock: a lock change drops the withheld rows already on screen at once (applyRelockToResults, page 1 included) and closes an open detail of a now-withheld genre (in fail-closed mode also one without a genre), before the replacement page is awaited — otherwise they stay clickable while that request is pending.
  • Stalker search staleness: portal requests are not aborted, so a search page issued before a relock can finish after it, filtered with the pre-relock withheld set; isStalkerSearchRequestCurrent keys the response on the parental lock version as well as term/type/page/portal, so such a page is dropped instead of repopulating the grid.
  • Stalker search route (/workspace/stalker/:id/search, no category in the URL): StalkerSearchComponent filters each portal page through the same withheld-genre predicate, carries parentalLockVersion in its resource params, judges paging progress on the raw page (a page made only of locked rows is not the end of the results), advances past a run of fully withheld pages by itself (the infinite scroll stops auto-filling after a few no-growth loads), closes an open detail whose genre is newly withheld and, on a lock flip past page 1, drops the withheld rows and restarts from page 1.
  • Stalker: genres are stored unfiltered; getCategoryResource filters them, getAllCategoriesForSelectedType is the raw list for the lock dialog. itvFullChannelList and the content loader drop rows whose tv_genre_id / category_id is withheld, the content resource's params carry parentalLockVersion so lock/unlock re-fires it, and a stale response is discarded when the version moved. Paging is judged on the RAW page: withheld ids the list has not seen before count as progress (so a portal page made only of locked rows does not end the list), a page that is entirely withheld requests the next page by itself, and the VOD/series totalCount is reduced by the withheld ids seen so the grid stops asking once every visible row is in. A lock flip while the list sits past page 1 drops the withheld rows on screen at once and restarts from page 1, so rows accumulated under the old lock state are never appended to. A selected withheld genre is cleared and the section root is navigated to.
  • M3U: ChannelListContainerComponent derives one visibleChannelList (all views, favorites, recents, the fullscreen panel and numeric zapping read from it); locked groups are absent from the groups rail. A playing channel of a locked group is reset on lock.

UI

  • Settings → Parental lock (/workspace/settings/parental): enable toggle, state + Lock now / Unlock, relock select, Change PIN, the disclaimer. Everything applies immediately (it gates on the PIN), outside the shared dirty form; createSettingsFromFormValue carries both values from the current settings so Save cannot undo them.
  • One entry point per rail, the same on every portal type: the "Manage categories" button (tune icon; "Manage groups" on an M3U playlist). Xtream and M3U render lock toggles inside their existing hide/show dialogs while the feature is on; Stalker has no hide/show dialog, so its button — shown only while the feature is on — opens the lock-only StalkerCategoryLockDialogComponent, which also offers "Lock adult (18+)" for genres the portal flags censored. All three list the locked names and can rewrite the locks, so each opens only after requestUnlock() succeeds — and closes itself, discarding the draft, the moment active becomes true again (idle relock, Lock now), since the PIN gate covers only the opening. The M3U group dialog does this only while it carries lock toggles; the plain hide/show editor is not behind the PIN. The Xtream dialog loads its candidates through the capability-selected data source (IXtreamDataSource.getAllCategories, which the PWA source answers from its session cache or the API), so PWA users can set locks too; the hide/show checkboxes remain Electron-only. A deliberate non-choice: no dedicated lock button in the rail header (the header already carries search, sort and manage, and the lock is a category-management concern) and no keyword-based "adult" recognition for Xtream/M3U (provider naming is arbitrary; the only automation is the portal's own censored flag on Stalker).
  • Right-click accelerator: a contextmenu on a category row (Xtream and Stalker rail, WorkspaceContextCategoryViewComponent) or an M3U group row opens the shared CategoryLockMenuComponent (libs/ui/components) with one "Lock / Unlock category (group)" item, only while the feature is on. The portal rail persists through WorkspaceCategoryLockActionService (PIN gate, then one-id edit of the lock store, failure snackbar); the M3U rail emits the toggled group to ChannelListContainerComponent, which captures the playlist, asks for the PIN, and persists the edit only if that playlist is still open (two playlists can share a group name). The M3U management dialog is bound the same way: GroupsViewComponent captures its playlistId input before the PIN and drops the dialog's result once another playlist is open. Every bulk lock editor (Xtream and M3U management dialogs, the Stalker lock dialog) builds its draft only from a lock store that was read (ensureLocksReadable): a draft taken from the empty fail-closed snapshot would erase the real locks on Save if storage recovered in between. The M3U and Xtream dialogs then open without lock toggles; the Stalker dialog does not open. Every one of them closes on a relock, the M3U one also when it opened without toggles, since it still lists every group name. Bulk actions stay in the dialog, and while the session is locked a locked category is not in the rail, so unlocking always goes through the "N locked · Enter PIN to show" row or the dialog. The M3U dialog hands its lock list back to ChannelListContainerComponent, which awaits the write and reports a failed save in a snackbar (the dialog has closed by then). On Electron the categories.locked re-stamp (setCategoryLocks) clears and re-locks one playlist/type inside ONE transaction, so a failed restamp keeps the previous index instead of leaving every category unlocked. The relock timeout persists through the same undo-on-failure pattern as the switch (setRelockMinutes reverts the in-memory value and the facade shows the settings save-failure snackbar). A locked session cannot change it: the selector is disabled until the PIN is entered, and setRelockMinutes refuses while active, so a child cannot switch the idle relock off for the next unlock.
  • Header lock/unlock button and the parental-lock-now / parental-unlock palette commands.

Startup footprint

The lock service, lock store and enforcement are on the renderer's initial path by necessity (the workspace resolver and the catalog data sources gate on them). Everything else stays lazy: the PIN dialog loads through parental-lock-pin-dialog.lazy.ts on the first prompt (a failed chunk load reads as a cancelled prompt), and the enforcement's Stalker step loads through parental-lock-stalker-enforcement.ts only while a Stalker route is open — a static import of the Stalker store would put the whole Stalker data layer back into main.js. The chunk is preloaded when a Stalker route opens, so a relock there runs the step synchronously; if it is not loaded yet (still fetching, or it cannot load — a stale PWA page after a deployment) the step fails closed at once by navigating to /workspace/sources: leaving the Stalker route clears its selection and stops its playback, and the Xtream step still runs. The feature costs about 30 KB of renderer.initialBytes.

Lock store lifetime

On Electron the SQLite categories.locked index is derived from the lock store and must never lag behind it, because the worker filters every read by the index alone:

  • The store commits BEFORE the re-stamp, so a failed re-stamp rolls the store back to the previous locks AND re-stamps every touched type from them (a backup restore stamps three types, and the ones before the failing type already carry the new locks). The store revision consumers reload on is published only once every touched type is stamped — also after a rollback — so a reload cannot read a later type through its old stamps. If the rollback write or its re-stamp fails too, the playlist is marked stale and re-stamped on the next store access. A failed rollback WRITE still puts memory back to the previous locks and leaves a pending rewrite: the next store access persists them before it re-stamps, so the failed edit cannot take effect through that re-stamp. The pending rewrite lives in memory: if the app exits before it lands, the next launch loads the persisted copy, i.e. the edit the parent submitted with the PIN. That is accepted — it is not a bypass, and a durable rollback journal would hit the same storage failure. For the whole re-stamp window — store changed, stamps not yet landed — the playlist counts as stale, so readable is false and a relock inside the window reloads fail-closed instead of through the old stamps. Such in-flight entries are not retried by a reconcile running beside the write (a PIN prompt or backup calling ensureReadable()): it would stamp from a store the write has not committed yet. Only failed stamps are retryable.
  • A write that removes a playlist's LAST lock clears the index first and drops the key afterwards: the launch-time reconcile finds playlists only through their key, so an interruption must leave store-with-lock and index-without, which the next reconcile repairs toward locked. If the clear or the store write fails, the index is re-stamped from the previous locks at once — title matching and multi-source discovery query the worker directly and trust the index, so a stale flag alone would not protect them. The playlist stays stale until the store write has landed too: while it is pending the index is already cleared but the store still holds the lock.
  • An edit that takes a lock away commits only while the session is unlocked, checked inside the write queue at commit time (ParentalLockLockStore.setRemovalGate, set by ParentalLockService). An editor opened while unlocked may still be saving, or queued behind another write, when the app relocks. The gate is asked right before the edit's first write is issued; a relock that lands after that is ordered after the write, which completes: the parent authorized that removal, and a post-write rollback could itself fail and leave the persisted store diverged from memory across a restart. (During the Xtream stamps the playlist is stale anyway, so the relock reloads fail-closed until they land.) Adding locks is always allowed. Playlist deletion and "Remove all playlists" are not edits and are not gated.
  • Every launch re-derives the index from the store for each playlist that has locks, and a store recovered by a later successful read marks its playlists stale the same way. The reconcile is awaited inside the store's load(), so readable (and with it every catalog read the renderer gates) stays false until the index agrees with the store; a re-stamp that keeps failing keeps the session fail-closed.

Every lock-store mutation runs through one write queue in ParentalLockLockStore: each rewrites the whole persisted store from the in-memory copy, so overlapping edits (two right-click toggles, a dialog save during a restore) would otherwise snapshot the same store and the later write would drop the earlier edit. Single-row changes (the right-click Lock/Unlock) are passed as an EDIT of the current list (LockListEdit), evaluated inside that queue, so two quick toggles each see the other's result instead of one shared snapshot; the dialogs pass a full list, which is the replacement they mean. A deleted playlist's locks leave the store through the PLAYLIST_DELETE_CLEANUP hook (provideParentalLockPlaylistCleanup, run by PlaylistsService.deletePlaylist for every single-playlist deletion; no re-stamp, the category rows go with the playlist), and "Remove all playlists" empties it through ParentalLockService.clearAllLocks once the deletion has succeeded — the in-memory store empties at once, and a failed persisted clear is retried on the next store access (the store is not readable until it lands). A backup restore that CREATES a playlist (not a merge) starts that playlist from empty locks, so a reused id can never inherit entries a failed cleanup left behind; the restore retries a failed lock-store read first (ensureLocksReadable) and aborts while it still fails, so that check never runs against the empty fail-closed snapshot.

Backup

A backup carrying lock lists replaces the matching playlists' locks, possibly with an emptier set, so the import asks for the PIN first (after the file was chosen) and aborts when it is refused. That answer can go stale during a long import (idle relock, "Lock now"), so an entry whose restore would take a lock away asks again right before it replaces locks if the app has relocked; a refusal fails that entry and keeps its previous locks. A restore that only adds locks is not asked again. Locks are restored LAST for each entry, after the Xtream data restore, so a failed merge leaves the playlist's previous locks in place. M3U group titles travel verbatim (normalizeParentalLockGroupTitles, exact dedup): locks match channel.group.title exactly, so the trimming uniqueStrings used for favorites would weaken a lock on a title with surrounding whitespace. A backup's lock lists are validated entry by entry on import (isWellFormedParentalLock* in the shared contract): restore treats them as authoritative and the normalizer drops what it does not understand, so a damaged list is rejected rather than erasing the playlist's persisted protection.

lockedGroupTitles (M3U), lockedCategories (Xtream {categoryType, xtreamId}, Stalker {categoryType, categoryId}) travel in each entry's userState, written only when the playlist has locks. Absent means "no opinion" and restore leaves the target's locks alone; present replaces them (replacePlaylistLocks, which also re-stamps the Xtream index). The PIN is never exported.

Not yet covered (phase 2)

Favorites, recently viewed, playback positions / Continue Watching, the dashboard rails built from Stalker and M3U documents, global favorites / recent pages, the EPG guide, downloads and recordings lists, the remote control's channel-number resolvers, the command palette's channel search and the Electron-only global search page (/workspace/search, whose stored results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist (unlockedCategoryCondition() / unlockedCategorySql()), the renderer predicate is ParentalLockService.is*Locked.

Files

  • Contracts: libs/shared/interfaces/src/lib/parental-lock.util.ts, parental-lock-pin.util.ts, Settings.parentalLock*, PARENTAL_LOCK_SET_STATE.
  • Renderer: libs/services/src/lib/parental-lock/ (ParentalLockService owns the session state and PIN flows; ParentalLockLockStore owns the persisted lock set, its unreadable state and the SQLite re-stamp), apps/web/src/app/services/parental-lock-prompt.service.ts, parental-lock-enforcement.service.ts, apps/web/src/app/settings/settings-parental-section.component.* and settings-parental-lock.facade.ts (rows anchored and indexed for settings search), libs/ui/components/src/lib/parental-lock-pin-dialog/, libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/.
  • Electron: apps/electron-backend/src/app/database/parental-lock-state.ts, services/parental-lock-state.ts, events/parental-lock.events.ts, database/operations/category.operations.ts (setCategoryLocks).