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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 11:10:48 +02:00
1 parent d4d0754e12
commit 4bd4bf2fc2
12 files changed
+257 -60

No files matched your search

@@ -152,21 +152,58 @@ export function normalizeParentalLockStalkerCategories(
return result;
}
function isWellFormedList(
value: unknown,
isWellFormedEntry: (entry: unknown) => boolean
/** A list whose every entry the matching normalizer would KEEP. */
export function isWellFormedParentalLockXtreamCategories(
value: unknown
): boolean {
return Array.isArray(value) && value.every(isWellFormedEntry);
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
)
);
}
export function isWellFormedParentalLockStalkerCategories(
value: unknown
): boolean {
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
)
);
}
export function isWellFormedParentalLockGroupTitles(value: unknown): boolean {
return (
Array.isArray(value) &&
value.every((entry) => typeof entry === 'string')
);
}
/**
* Whether a persisted store has the shape `writeLocks` produces: every
* playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all
* present (a missing list is corruption, not "empty") and hold only
* entries the normalizers accept. The normalizers DROP what they
* do not understand, which is right for user-supplied backups but turns a
* corrupted persisted store into "nothing is locked"; a store that fails
* this check is treated as unreadable instead.
* entries the normalizers accept. The normalizers DROP what they do not
* understand; on a persisted store (and on a backup's lock lists, which a
* restore treats as authoritative) that would turn corruption into
* "nothing is locked", so such data is rejected instead.
*/
export function isWellFormedParentalLockStore(value: unknown): boolean {
if (!isRecord(value)) {
@@ -175,29 +212,9 @@ export function isWellFormedParentalLockStore(value: unknown): boolean {
return Object.values(value).every(
(locks) =>
isRecord(locks) &&
isWellFormedList(
locks['xtream'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
) &&
isWellFormedList(
locks['stalker'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
) &&
isWellFormedList(locks['m3u'], (entry) => typeof entry === 'string')
isWellFormedParentalLockXtreamCategories(locks['xtream']) &&
isWellFormedParentalLockStalkerCategories(locks['stalker']) &&
isWellFormedParentalLockGroupTitles(locks['m3u'])
);
}