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>
9.1 KiB
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 asks for the current PIN and 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.
- The unlock lives in memory only. The app locks again on every restart, on
"Lock now" (header button, command palette, settings), and after
Settings.parentalLockRelockMinutesminutes without user interaction (default 15;0= only on restart). Active playback of a built-in web player counts as interaction, so a film never locks half way. - 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.
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
- SQLite worker (Electron).
apps/electron-backend/src/app/database/parental-lock-state.tsholds one process-wide flag. While active, every content-returning read appends "category is not locked":getCategories,getContent,searchContent, the threeglobalSearchcandidate selectors,getGlobalRecentlyAddedByType,DB_MATCH_TITLESand the multi-source discovery queries. A surface added later is therefore withheld by default. The flag is seeded throughworkerData.parentalLockActivewhen the worker is (re)started and flipped by theparental-lockcontrol message on the request port (ordered with the requests, so a read posted after it observes the new state).getAllCategoriesis exempt: it feeds the management dialog, which itself sits behind the PIN. - Renderer
ParentalLockService(libs/services/src/lib/parental-lock/). Signalsenabled,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 theis*Lockedpredicates, which answertrueonly whileactive. The PIN dialog is reached through thePARENTAL_LOCK_PROMPTtoken, provided by the app (AppParentalLockPromptService→ParentalLockPinDialogComponentinlibs/ui/components), so the data-access lib stays free of UI.
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. 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.
In-memory catalogs
- Xtream (Electron): the store reads categories and streams through the
worker, so they arrive filtered. On
versionchangesParentalLockEnforcementService(apps/web/src/app/services/) callsXtreamStore.reloadCategories()+reloadCachedContent()and, if the selected category vanished, clears the selection and navigates to the section root. - Xtream (PWA):
PwaXtreamDataSourcedrops withheld categories and streams at read time; the same reloads apply. - Stalker: genres are stored unfiltered;
getCategoryResourcefilters them,getAllCategoriesForSelectedTypeis the raw list for the lock dialog.itvFullChannelListand the content loader drop rows whosetv_genre_id/category_idis withheld, the content resource's params carryparentalLockVersionso lock/unlock re-fires it, and a stale response is discarded when the version moved. A selected withheld genre is cleared and the section root is navigated to. - M3U:
ChannelListContainerComponentderives onevisibleChannelList(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;createSettingsFromFormValuecarries both values from the current settings so Save cannot undo them.- Lock toggles in the Xtream category-management dialog and the M3U group
dialog (rendered only while the feature is on), plus a new Stalker
StalkerCategoryLockDialogComponentreached from a lock button above the categories rail; it offers "Lock adult (18+)" for genres the portal flagscensored. Opening any of these while locked asks for the PIN first. - Header lock/unlock button and the
parental-lock-now/parental-unlockpalette commands.
Backup
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 and the command palette's channel search
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/,apps/web/src/app/services/parental-lock-prompt.service.ts,parental-lock-enforcement.service.ts,apps/web/src/app/settings/settings-parental-lock*,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).