fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" 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 06:57:27 +02:00
1 parent 02f5b09201
commit 4f931030e1
9 files changed
+195 -23

No files matched your search

+11 -2
View File
@@ -45,7 +45,12 @@ 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.
process. A hash that could not be READ is not an absent PIN:
`ParentalLockStorageService.readPinHash()` reports the failure as `null`
(an absent PIN is `{hash: null}`), the session then stays locked (`enabled`
treats an unreadable PIN as set while the switch is unknown) and every
PIN-protected step — unlock, change PIN, disable — re-reads it first, so
storage answering later is enough to recover without a restart.
The lock store (`ParentalLockStore` in `parental-lock.util.ts`) is keyed by
playlist id and holds, per playlist, Xtream `{categoryType, xtreamId}`
@@ -269,7 +274,11 @@ on either side.
as locked while Electron reads, which filter by the index alone, still
serve it); if the rollback write or its re-stamp fails too, the playlist is
re-stamped on the next store access, and every launch re-derives the
index from the store for each playlist that has locks — awaited inside
index from the store for each playlist that has locks — which is why a
write that removes a playlist's LAST lock clears the index first and
drops the key afterwards (the reconcile finds playlists only through
their key; an interruption then leaves store-with-lock and index-without,
which the next reconcile repairs toward locked) — awaited inside
the store's `load()`, so `readable` (and with it every catalog read the
renderer gates) stays false until the index agrees with the store; a
re-stamp that keeps failing keeps the session fail-closed.
@@ -90,6 +90,44 @@ describe('ParentalLockLockStore', () => {
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'series', []);
});
it('clears the index before the last lock leaves the store', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
storage.writeLocks.mockClear();
const order: string[] = [];
setCategoryLocks.mockImplementation(async () => {
order.push('stamp');
return true;
});
storage.writeLocks.mockImplementation(async () => {
order.push('persist');
return true;
});
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
true
);
expect(order).toEqual(['stamp', 'persist']);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', []);
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
});
it('does not drop the key when clearing the index fails', async () => {
await store.load();
await store.ensureReadable();
storage.writeLocks.mockClear();
setCategoryLocks.mockResolvedValue(false);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
false
);
expect(storage.writeLocks).not.toHaveBeenCalled();
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
});
it('rolls the store back when the index re-stamp fails', async () => {
await store.load();
setCategoryLocks.mockResolvedValue(false);
@@ -183,14 +183,7 @@ export class ParentalLockLockStore {
): Promise<boolean> {
const previous = this.locksFor(playlistId);
const next = withXtreamLocks(previous, categoryType, xtreamIds);
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
if (await this.stampXtreamLocks(playlistId, [categoryType])) {
return true;
}
await this.rollBack(playlistId, previous, [categoryType]);
return false;
return this.commitLocks(playlistId, previous, next, [categoryType]);
}
async setStalkerLocks(
@@ -223,14 +216,58 @@ export class ParentalLockLockStore {
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
const previous = this.locksFor(playlistId);
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
return this.commitLocks(
playlistId,
this.locksFor(playlistId),
locks,
XTREAM_CATEGORY_TYPES
);
}
/**
* Persists `next` and re-stamps the touched types. Order matters for a
* playlist whose LAST lock goes away: its key leaves the store, and the
* startup reconcile finds playlists only through their key — a crash
* between the two writes would strand stamped rows for good. That case
* clears the index first (from `next`) and persists afterwards, so an
* interruption leaves the store with the lock and the index without it,
* which the next reconcile repairs toward locked. Every other write
* persists first and rolls back on a failed re-stamp.
*/
private async commitLocks(
playlistId: string,
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<boolean> {
const normalizedNext = normalizeParentalLockPlaylistLocks(next);
if (isParentalLockPlaylistLocksEmpty(normalizedNext)) {
if (!(await this.ensureReadable())) {
return false;
}
if (
!(await this.stampXtreamLocks(
playlistId,
categoryTypes,
normalizedNext
))
) {
return false;
}
if (await this.persistPlaylistLocks(playlistId, normalizedNext)) {
return true;
}
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
return false;
}
if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) {
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
if (await this.stampXtreamLocks(playlistId, categoryTypes)) {
return true;
}
await this.rollBack(playlistId, previous, XTREAM_CATEGORY_TYPES);
await this.rollBack(playlistId, previous, categoryTypes);
return false;
}
@@ -266,7 +303,8 @@ export class ParentalLockLockStore {
*/
async stampXtreamLocks(
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES,
locks: ParentalLockPlaylistLocks = this.locksFor(playlistId)
): Promise<boolean> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
return true;
@@ -277,7 +315,7 @@ export class ParentalLockLockStore {
(await this.databaseService.setCategoryLocks(
playlistId,
categoryType,
this.lockedXtreamIds(playlistId, categoryType)
lockedXtreamCategoryIds(locks, categoryType)
)) && success;
}
return success;
@@ -26,9 +26,18 @@ export class ParentalLockStorageService {
return this.runtime.supportsAppStateStorage;
}
async readPinHash(): Promise<string | null> {
const value = (await this.read(PARENTAL_LOCK_PIN_KEY))?.value;
return value && value.trim() !== '' ? value : null;
/**
* The stored PIN hash (`hash: null` when none was set up), or `null`
* when the read itself failed — the service then retries before every
* PIN-protected operation instead of treating the PIN as absent.
*/
async readPinHash(): Promise<{ hash: string | null } | null> {
const result = await this.read(PARENTAL_LOCK_PIN_KEY);
if (result === null) {
return null;
}
const value = result.value;
return { hash: value && value.trim() !== '' ? value : null };
}
async writePinHash(hash: string): Promise<boolean> {
@@ -57,7 +57,7 @@ describe('ParentalLockService', () => {
storage = {
pinHash: null,
locks: {},
readPinHash: jest.fn(async () => storage.pinHash),
readPinHash: jest.fn(async () => ({ hash: storage.pinHash })),
writePinHash: jest.fn(async (hash: string) => {
storage.pinHash = hash;
return true;
@@ -226,6 +226,25 @@ describe('ParentalLockService', () => {
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('keeps the session locked on a failed PIN read and retries before unlocking', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
storage.readPinHash.mockResolvedValueOnce(null);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
(await request.verify?.('1234')) ? '1234' : null
);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.hasPin()).toBe(false);
await expect(service.requestUnlock()).resolves.toBe(true);
expect(service.hasPin()).toBe(true);
expect(service.unlocked()).toBe(true);
});
it('rolls the relock timeout back when it cannot be persisted', async () => {
updateSettings.mockImplementationOnce(async () => {
parentalLockRelockMinutes.set(30);
@@ -51,6 +51,8 @@ export class ParentalLockService {
private readonly unlockedState = signal(false);
private readonly pinHash = signal<string | null>(null);
/** The PIN hash could not be read; retried before PIN-protected steps. */
private readonly pinUnreadable = signal(false);
private readonly versionState = signal(0);
// The main process starts LOCKED whenever the feature is on (mirrored
// setting). Reporting our state before settings have loaded would send a
@@ -80,7 +82,7 @@ export class ParentalLockService {
*/
readonly enabled = computed(() =>
this.switchUnknown()
? this.pinHash() !== null
? this.pinHash() !== null || this.pinUnreadable()
: this.settingsStore.parentalLockEnabled?.() === true
);
/** Feature on and the PIN has been entered this session. */
@@ -89,6 +91,8 @@ export class ParentalLockService {
readonly active = computed(() => this.enabled() && !this.unlockedState());
/** A PIN exists; enabling is only possible once this is true. */
readonly hasPin = computed(() => this.pinHash() !== null);
/** The lock store is read and trustworthy; backups must not run without it. */
readonly locksReadable = computed(() => this.locks.readable());
/**
* Locked, and the lock store could not be read: which categories are
* locked is unknown, so EVERY category is withheld — the renderer-side
@@ -145,12 +149,12 @@ export class ParentalLockService {
initialize(): Promise<void> {
if (!this.initialization) {
this.initialization = (async () => {
const [pinHash] = await Promise.all([
const [pinRead] = await Promise.all([
this.storage.readPinHash(),
this.locks.load(),
this.settingsStore.loadSettings(),
]);
this.pinHash.set(pinHash);
this.applyPinRead(pinRead);
this.versionState.update((value) => value + 1);
this.settingsReady.set(true);
})().catch((error) => {
@@ -202,6 +206,7 @@ export class ParentalLockService {
private async promptForUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'>
): Promise<boolean> {
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
@@ -281,6 +286,7 @@ export class ParentalLockService {
/** Verifies the current PIN, then replaces it. */
async changePin(): Promise<boolean> {
await this.initialize();
await this.ensurePin();
if (!this.prompt || !this.hasPin()) {
return false;
}
@@ -317,6 +323,7 @@ export class ParentalLockService {
*/
private async verifyCurrentPin(): Promise<boolean> {
await this.initialize();
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
@@ -470,6 +477,29 @@ export class ParentalLockService {
return this.locks.stampXtreamLocks(playlistId, categoryTypes);
}
private applyPinRead(read: { hash: string | null } | null): void {
if (read === null) {
console.error('The parental lock PIN could not be read.');
this.pinUnreadable.set(true);
return;
}
this.pinHash.set(read.hash);
this.pinUnreadable.set(false);
}
/**
* Retries a PIN read that failed at startup. A failed read is not an
* absent PIN: the session stays locked (`enabled` treats it as set),
* and every PIN-protected step re-reads first so the parent can still
* unlock, change the PIN or switch the feature off once storage answers.
*/
private async ensurePin(): Promise<void> {
if (!this.pinUnreadable()) {
return;
}
this.applyPinRead(await this.storage.readPinHash());
}
private async storePin(pin: string): Promise<boolean> {
try {
const hash = await hashParentalLockPin(pin);
@@ -477,6 +507,7 @@ export class ParentalLockService {
return false;
}
this.pinHash.set(hash);
this.pinUnreadable.set(false);
return true;
} catch (error) {
console.error('Failed to store the parental lock PIN.', error);
@@ -14,6 +14,23 @@ describe('PlaylistBackupService', () => {
localStorage.clear();
});
it('refuses to export while the parental lock store is not readable', async () => {
const initialize = jest.fn().mockResolvedValue(undefined);
const service = createPlaylistBackupService({
parentalLock: {
initialize,
locksReadable: jest.fn(() => false),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
});
await expect(service.exportBackup()).rejects.toThrow(
/parental lock store/
);
expect(initialize).toHaveBeenCalled();
});
it('exports self-contained M3U data and strips Stalker session fields', async () => {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
@@ -121,6 +121,8 @@ export function createPlaylistBackupService(
},
pendingRestoreService,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
@@ -76,6 +76,15 @@ export class PlaylistBackupService {
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
// Locks are written only when present, and an absent field means
// "no opinion" on restore — so an export must never run against an
// empty in-memory store that merely has not loaded, or could not.
await this.parentalLock.initialize();
if (!this.parentalLock.locksReadable()) {
throw new Error(
'The parental lock store could not be read; the backup would omit its locks.'
);
}
const playlists = await firstValueFrom(
this.playlistsService.getAllData()
);