Files
iptvnator/docs/architecture/parental-lock.md
T
4grayandClaude Fable 5.1 83e70a52c5 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>
2026-09-13 22:48:27 +02:00

9.1 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 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.parentalLockRelockMinutes minutes 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

  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.

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 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.
  • Xtream (PWA): PwaXtreamDataSource drops withheld categories and streams at read time; the same reloads apply.
  • 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. 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.
  • Lock toggles in the Xtream category-management dialog and the M3U group dialog (rendered only while the feature is on), plus a new Stalker StalkerCategoryLockDialogComponent reached from a lock button above the categories rail; it offers "Lock adult (18+)" for genres the portal flags censored. Opening any of these while locked asks for the PIN first.
  • Header lock/unlock button and the parental-lock-now / parental-unlock palette 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).