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

@@ -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))