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

@@ -146,6 +146,33 @@ describe('withContent parental-lock reloads', () => {
expect(store.isContentInitialized()).toBe(true);
});
it("carries the deferred request's publish guard into the reload", async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
return calls === 1
? live.promise
: Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
let current = true;
await store.reloadCachedContent(() => current);
// The lock moved on again before the hydration settled.
current = false;
live.resolve([{ xtream_id: 1 }]);
await initialization;
// The reload stops at its first guard check instead of publishing
// the older rows; the superseding apply reads everything again.
expect(calls).toBe(4);
expect(store.liveStreams()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
@@ -226,13 +226,20 @@ export function withContent() {
* the reload runs once the hydration has settled.
*/
let reloadAfterInitialization = false;
/** The publish guard of the LATEST deferred request (see below). */
let deferredPublishGuard: () => boolean = () => true;
const runDeferredReload = async (): Promise<void> => {
if (!reloadAfterInitialization) {
return;
}
reloadAfterInitialization = false;
await methods.reloadCategories();
await methods.reloadCachedContent();
// The requester's guard travels with the deferred reload: a
// relock during these reads supersedes them exactly as it
// would an ordinary reload.
const shouldPublish = deferredPublishGuard;
deferredPublishGuard = () => true;
await methods.reloadCategories(shouldPublish);
await methods.reloadCachedContent(shouldPublish);
};
const dataService = inject(DataService);
const databaseService = inject(DatabaseService);
@@ -1598,6 +1605,7 @@ export function withContent() {
// read under the previous lock state: withhold those
// and reload once it has settled.
reloadAfterInitialization = true;
deferredPublishGuard = shouldPublish;
return;
}
const loadStates = store.contentLoadStateByType();
@@ -80,6 +80,21 @@ describe('ParentalLockLockStore', () => {
expect(store.readable()).toBe(true);
});
it('builds an edit on the recovered store, never on the empty fail-closed one', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
await expect(store.setM3uLocks('pl-1', ['Adult'])).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith({
'pl-1': {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [],
m3u: ['Adult'],
},
});
});
it('re-derives the index from a store recovered after a failed read', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
@@ -182,11 +182,20 @@ export class ParentalLockLockStore {
return this.locks()[playlistId]?.m3u ?? [];
}
/**
* Every mutation re-reads a store that failed to read BEFORE building
* the edit: `locksFor` on the empty fail-closed store would otherwise
* turn one type's edit into the playlist's entire lock set once the
* write goes through, discarding its other locks.
*/
async setXtreamLocks(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
if (!(await this.ensureReadable())) {
return false;
}
const previous = this.locksFor(playlistId);
const next = withXtreamLocks(previous, categoryType, xtreamIds);
return this.commitLocks(playlistId, previous, next, [categoryType]);
@@ -197,6 +206,9 @@ export class ParentalLockLockStore {
categoryType: ParentalLockStalkerCategoryType,
categoryIds: string[]
): Promise<boolean> {
if (!(await this.ensureReadable())) {
return false;
}
return this.persistPlaylistLocks(
playlistId,
withStalkerLocks(
@@ -211,6 +223,9 @@ export class ParentalLockLockStore {
playlistId: string,
groupTitles: string[]
): Promise<boolean> {
if (!(await this.ensureReadable())) {
return false;
}
return this.persistPlaylistLocks(
playlistId,
withM3uLocks(this.locksFor(playlistId), groupTitles)
@@ -222,6 +237,9 @@ export class ParentalLockLockStore {
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
if (!(await this.ensureReadable())) {
return false;
}
return this.commitLocks(
playlistId,
this.locksFor(playlistId),
@@ -69,8 +69,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const backup = await service.exportBackup();
const entry = backup.manifest
.playlists[0] as XtreamPlaylistBackupEntry;
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
// Without this every "main source" choice vanishes on restore, with
// nothing in the archive to say it was ever made.
expect(entry.userState.sourcePins).toEqual([
@@ -161,7 +160,8 @@ describe('PlaylistBackupService Xtream source pins', () => {
await Promise.all([firstImport, secondImport]);
expect(
replacePins.mock.calls.map(
([, pins]) => (pins as Array<{ contentId: number }>)[0].contentId
([, pins]) =>
(pins as Array<{ contentId: number }>)[0].contentId
)
).toEqual([501, 502]);
});
@@ -229,9 +229,11 @@ describe('PlaylistBackupService export → import round-trip', () => {
}),
]);
expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']);
expect(
state.playlists.map((playlist) => playlist._id)
).toEqual(['m3u-1', 'xtream-1', 'stalker-1']);
expect(state.playlists.map((playlist) => playlist._id)).toEqual([
'm3u-1',
'xtream-1',
'stalker-1',
]);
// Exporting the restored state must reproduce the original
// manifest byte for byte (modulo the export timestamp): any field
@@ -26,6 +26,9 @@ import {
XtreamBackupSourcePin,
XtreamPendingRestoreState,
createRandomId,
isWellFormedParentalLockGroupTitles,
isWellFormedParentalLockStalkerCategories,
isWellFormedParentalLockXtreamCategories,
normalizeParentalLockGroupTitles,
normalizeParentalLockStalkerCategories,
normalizeParentalLockXtreamCategories,
@@ -525,6 +528,19 @@ export class PlaylistBackupService {
`M3U backup "${entry.title}" is missing raw playlist data.`
);
}
// Restore replaces the playlist's locks with the archive's,
// and the normalizer drops what it does not understand — a
// damaged lock list would erase the persisted protection.
if (
entry.userState?.lockedGroupTitles !== undefined &&
!isWellFormedParentalLockGroupTitles(
entry.userState.lockedGroupTitles
)
) {
throw new PlaylistBackupError(
`M3U backup "${entry.title}" has invalid parental locks.`
);
}
break;
case 'xtream':
if (
@@ -554,7 +570,9 @@ export class PlaylistBackupService {
if (
entry.userState.lockedCategories !== undefined &&
!Array.isArray(entry.userState.lockedCategories)
!isWellFormedParentalLockXtreamCategories(
entry.userState.lockedCategories
)
) {
throw new PlaylistBackupError(
`Xtream backup "${entry.title}" has invalid parental locks.`
@@ -581,6 +599,16 @@ export class PlaylistBackupService {
`Stalker backup "${entry.title}" is missing connection metadata.`
);
}
if (
entry.userState?.lockedCategories !== undefined &&
!isWellFormedParentalLockStalkerCategories(
entry.userState.lockedCategories
)
) {
throw new PlaylistBackupError(
`Stalker backup "${entry.title}" has invalid parental locks.`
);
}
break;
default:
throw new PlaylistBackupError(
@@ -41,8 +41,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
const backup = await service.exportBackup();
const entry = backup.manifest
.playlists[0] as XtreamPlaylistBackupEntry;
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
const expectedHiddenCategories = [
{ categoryType: 'live', xtreamId: 101 },
{ categoryType: 'movies', xtreamId: 201 },
@@ -92,9 +91,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
'xtream-1',
expect.objectContaining({
state: expect.objectContaining({
hiddenCategories: [
{ categoryType: 'live', xtreamId: 101 },
],
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
}),
}),
expect.any(Function)
@@ -107,11 +104,21 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
);
});
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService(collaborators);
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [{}];
await expect(
service.importBackup(JSON.stringify(manifest))
).rejects.toThrow(/invalid parental locks/);
expect(
collaborators.databaseService.updateCategoryVisibility
).not.toHaveBeenCalled();
});
it('rejects entries with missing user-state collections instead of wiping user data', async () => {
const collaborators = createRestoreCollaborators();
@@ -121,9 +128,8 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
// treated as an authoritative "empty" state: the merge path would
// unhide every category and delete favorites/recent/positions.
const manifest = createXtreamManifest([]);
delete (
manifest.playlists[0] as unknown as { userState?: unknown }
).userState;
delete (manifest.playlists[0] as unknown as { userState?: unknown })
.userState;
await expect(
service.importBackup(JSON.stringify(manifest))
@@ -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'])
);
}