diff --git a/apps/readest-app/src/__tests__/libs/replicaSyncClient.test.ts b/apps/readest-app/src/__tests__/libs/replicaSyncClient.test.ts index 2dd94e02..52ae950a 100644 --- a/apps/readest-app/src/__tests__/libs/replicaSyncClient.test.ts +++ b/apps/readest-app/src/__tests__/libs/replicaSyncClient.test.ts @@ -151,3 +151,116 @@ describe('ReplicaSyncClient.pull', () => { expect(rows).toEqual([]); }); }); + +describe('ReplicaSyncClient.listReplicaKeys (cache + dedupe)', () => { + const sampleKey = { + saltId: 's1', + alg: 'pbkdf2-600k-sha256', + salt: 'AAAA', + createdAt: '2026-05-09T00:00:00Z', + }; + + test('coalesces concurrent in-flight calls into a single fetch', async () => { + let resolveFetch: (resp: Response) => void = () => {}; + mockFetch.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const client = new ReplicaSyncClient(); + // Kick off three concurrent calls; the first await-getAccessToken + // microtask hasn't resolved yet, so we need to let those microtasks + // drain before asserting fetch was called exactly once. + const all = Promise.all([ + client.listReplicaKeys(), + client.listReplicaKeys(), + client.listReplicaKeys(), + ]); + // Drain microtasks so the queued requireToken() awaits resolve and + // we reach the fetch call. Two ticks is enough for the three + // concurrent calls' first await to settle. + await Promise.resolve(); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(1); + resolveFetch(new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 })); + const [r1, r2, r3] = await all; + expect(r1).toEqual([sampleKey]); + expect(r2).toEqual([sampleKey]); + expect(r3).toEqual([sampleKey]); + }); + + test('subsequent calls return the cached value without a second fetch', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const client = new ReplicaSyncClient(); + await client.listReplicaKeys(); + await client.listReplicaKeys(); + await client.listReplicaKeys(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('returns a defensive copy so caller mutations do not poison the cache', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const client = new ReplicaSyncClient(); + const first = await client.listReplicaKeys(); + first.push({ ...sampleKey, saltId: 'mutated' }); + const second = await client.listReplicaKeys(); + expect(second).toEqual([sampleKey]); + }); + + test('createReplicaKey appends the new salt to the cache (no extra fetch)', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const client = new ReplicaSyncClient(); + await client.listReplicaKeys(); + const fresh = { ...sampleKey, saltId: 's2' }; + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ row: fresh }), { status: 201 })); + await client.createReplicaKey('pbkdf2-600k-sha256'); + const cached = await client.listReplicaKeys(); + expect(cached).toEqual([sampleKey, fresh]); + expect(mockFetch).toHaveBeenCalledTimes(2); // initial list + create; no re-list + }); + + test('forgetReplicaKeys clears the cache (next list re-fetches)', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const client = new ReplicaSyncClient(); + await client.listReplicaKeys(); + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + await client.forgetReplicaKeys(); + const empty = await client.listReplicaKeys(); + expect(empty).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('invalidateReplicaKeysCache forces a re-fetch on the next list', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const client = new ReplicaSyncClient(); + await client.listReplicaKeys(); + client.invalidateReplicaKeysCache(); + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ rows: [] }), { status: 200 })); + const refreshed = await client.listReplicaKeys(); + expect(refreshed).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('a failed fetch is not cached — next call retries', async () => { + mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500 })); + const client = new ReplicaSyncClient(); + await expect(client.listReplicaKeys()).rejects.toThrow(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ rows: [sampleKey] }), { status: 200 }), + ); + const rows = await client.listReplicaKeys(); + expect(rows).toEqual([sampleKey]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts new file mode 100644 index 00000000..2bdcf5b2 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'vitest'; +import { + SETTINGS_KIND, + SETTINGS_REPLICA_ID, + SETTINGS_SCHEMA_VERSION, + SETTINGS_WHITELIST, + readPath, + settingsAdapter, + writePath, + type SettingsRemoteRecord, +} from '@/services/sync/adapters/settings'; +import type { FieldEnvelope, Hlc, ReplicaRow } from '@/types/replica'; +import type { SystemSettings } from '@/types/settings'; + +const HLC = '00000000001-00000000-dev' as Hlc; +const env = (v: T): FieldEnvelope => ({ v, t: HLC, s: 'dev' }); + +const makeRow = (fields: Record>): ReplicaRow => ({ + user_id: 'u', + kind: SETTINGS_KIND, + replica_id: SETTINGS_REPLICA_ID, + fields_jsonb: fields, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: HLC, + schema_version: 1, +}); + +describe('settingsAdapter', () => { + test('kind + schemaVersion + replica id are stable', () => { + expect(settingsAdapter.kind).toBe('settings'); + expect(settingsAdapter.schemaVersion).toBe(1); + expect(SETTINGS_KIND).toBe('settings'); + expect(SETTINGS_SCHEMA_VERSION).toBe(1); + expect(SETTINGS_REPLICA_ID).toBe('singleton'); + }); + + test('declares no `binary` capability — bundled metadata only', () => { + expect(settingsAdapter.binary).toBeUndefined(); + }); + + test('computeId always returns the singleton id', async () => { + expect(await settingsAdapter.computeId({ name: 'singleton', patch: {} })).toBe('singleton'); + }); + + test('pack only emits whitelisted fields, dropping non-whitelist keys', () => { + const userColors = [{ name: 'mint', color: '#a8e6cf' }]; + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + globalReadSettings: { userHighlightColors: userColors }, + // not in whitelist: + telemetryEnabled: true, + screenBrightness: 0.5, + localBooksDir: '/should/not/sync', + } as unknown as Partial, + }; + const fields = settingsAdapter.pack(record); + expect(fields['globalReadSettings.userHighlightColors']).toEqual(userColors); + expect(fields['telemetryEnabled']).toBeUndefined(); + expect(fields['screenBrightness']).toBeUndefined(); + expect(fields['localBooksDir']).toBeUndefined(); + }); + + test('pack flattens dot-namespaced nested keys', () => { + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + kosync: { serverUrl: 'https://kosync.example' }, + } as Partial, + }; + const fields = settingsAdapter.pack(record); + expect(fields['kosync.serverUrl']).toBe('https://kosync.example'); + }); + + test('pack ∘ unpack round-trips object-valued nested fields (highlight palette)', () => { + const customColors = { yellow: '#ffeb3b', blue: '#2196f3' }; + const userColors = [{ name: 'mint', color: '#a8e6cf' }]; + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + globalReadSettings: { + customHighlightColors: customColors, + userHighlightColors: userColors, + }, + } as unknown as Partial, + }; + const fields = settingsAdapter.pack(record); + const out = settingsAdapter.unpack(fields); + expect(out.patch.globalReadSettings?.customHighlightColors).toEqual(customColors); + expect(out.patch.globalReadSettings?.userHighlightColors).toEqual(userColors); + }); + + test('declares encryptedFields covering kosync / readwise / hardcover credentials only (not serverUrl)', () => { + expect(settingsAdapter.encryptedFields).toEqual([ + 'kosync.username', + 'kosync.userkey', + 'kosync.password', + 'readwise.accessToken', + 'hardcover.accessToken', + ]); + }); + + test('kosync.serverUrl is plaintext (not in encryptedFields)', () => { + expect(settingsAdapter.encryptedFields).not.toContain('kosync.serverUrl'); + }); + + test('unpackRow reconstructs the patch from CRDT envelopes', () => { + const userColors = [{ name: 'mint', color: '#a8e6cf' }]; + const row = makeRow({ + 'globalReadSettings.userHighlightColors': env(userColors), + 'kosync.serverUrl': env('https://kosync.example'), + }); + const out = settingsAdapter.unpackRow(row, ''); + expect(out).not.toBeNull(); + expect(out!.name).toBe('singleton'); + expect(out!.patch.globalReadSettings?.userHighlightColors).toEqual(userColors); + expect(out!.patch.kosync?.serverUrl).toBe('https://kosync.example'); + }); + + test('unpackRow returns null when the row carries no whitelisted fields', () => { + const row = makeRow({ unknownField: env('garbage') }); + expect(settingsAdapter.unpackRow(row, '')).toBeNull(); + }); +}); + +describe('SETTINGS_WHITELIST', () => { + test('is non-empty (at least one field shipped in v1)', () => { + expect(SETTINGS_WHITELIST.length).toBeGreaterThan(0); + }); + + test('includes only string-typed dot-paths', () => { + for (const key of SETTINGS_WHITELIST) { + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(0); + } + }); +}); + +describe('readPath / writePath', () => { + test('reads top-level and nested values', () => { + const obj = { a: 1, nested: { b: 'x', deep: { c: true } } }; + expect(readPath(obj, 'a')).toBe(1); + expect(readPath(obj, 'nested.b')).toBe('x'); + expect(readPath(obj, 'nested.deep.c')).toBe(true); + expect(readPath(obj, 'nested.missing')).toBeUndefined(); + expect(readPath(obj, 'missing.path')).toBeUndefined(); + }); + + test('writes top-level and nested, creating intermediates', () => { + const obj: Record = {}; + writePath(obj, 'a', 1); + writePath(obj, 'nested.b', 'x'); + writePath(obj, 'nested.deep.c', true); + expect(obj).toEqual({ a: 1, nested: { b: 'x', deep: { c: true } } }); + }); + + test('writePath overwrites a non-object intermediate with a fresh object', () => { + const obj: Record = { foo: 'oops' }; + writePath(obj, 'foo.bar', 1); + expect(obj).toEqual({ foo: { bar: 1 } }); + }); + + test('writePath rejects __proto__ / constructor / prototype segments', () => { + const obj: Record = {}; + writePath(obj, '__proto__.polluted', 'bad'); + writePath(obj, 'constructor.prototype.polluted', 'bad'); + writePath(obj, 'a.prototype.b', 'bad'); + expect(({} as Record)['polluted']).toBeUndefined(); + expect(obj).toEqual({}); + }); + + test('readPath rejects __proto__ / constructor / prototype segments', () => { + const obj = { a: 1 }; + expect(readPath(obj, '__proto__')).toBeUndefined(); + expect(readPath(obj, 'constructor')).toBeUndefined(); + expect(readPath(obj, 'a.prototype')).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts index 0d678d71..c347f601 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts @@ -46,12 +46,12 @@ describe('bootstrapReplicaAdapters', () => { test('is idempotent: calling twice is a no-op (does not throw)', () => { bootstrapReplicaAdapters(); bootstrapReplicaAdapters(); - expect(listReplicaAdapters()).toHaveLength(4); + expect(listReplicaAdapters()).toHaveLength(5); }); - test('registers the current allowlist (dictionary, font, texture, opds_catalog)', () => { + test('registers the current allowlist (dictionary, font, texture, opds_catalog, settings)', () => { bootstrapReplicaAdapters(); const kinds = listReplicaAdapters().map((a) => a.kind); - expect(kinds).toEqual(['dictionary', 'font', 'texture', 'opds_catalog']); + expect(kinds).toEqual(['dictionary', 'font', 'texture', 'opds_catalog', 'settings']); }); }); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts new file mode 100644 index 00000000..55c355e2 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const publishMock = vi.fn(); +vi.mock('@/services/sync/replicaPublish', () => ({ + publishReplicaUpsert: (...args: unknown[]) => publishMock(...args), +})); + +let isUnlocked = true; +vi.mock('@/libs/crypto/session', () => ({ + cryptoSession: { isUnlocked: () => isUnlocked }, +})); + +// Default behavior: the gate "succeeds" by flipping isUnlocked to true +// (mimicking a successful passphrase setup/unlock). Individual tests +// override to simulate cancellation or a missing prompter. +const ensurePassphraseMock = vi.fn(async () => { + isUnlocked = true; +}); +vi.mock('@/services/sync/passphraseGate', () => ({ + ensurePassphraseUnlocked: () => ensurePassphraseMock(), +})); + +import { + __resetSettingsSyncForTests, + applyRemoteSettings, + publishSettingsIfChanged, +} from '@/services/sync/replicaSettingsSync'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { SystemSettings } from '@/types/settings'; +import type { EnvConfigType } from '@/services/environment'; + +const baseHighlight = { + customThemes: [], + customHighlightColors: { yellow: '#ffeb3b' }, + userHighlightColors: [], + defaultHighlightLabels: {}, + customTtsHighlightColors: [], +}; + +const makeSettings = (overrides: Partial = {}): SystemSettings => + ({ + globalReadSettings: { ...baseHighlight }, + kosync: { serverUrl: '', username: '', userkey: '', password: '' }, + readwise: { accessToken: '' }, + hardcover: { accessToken: '' }, + ...overrides, + }) as unknown as SystemSettings; + +const makeEnvConfig = (): EnvConfigType => ({ getAppService: vi.fn() }) as unknown as EnvConfigType; + +beforeEach(() => { + publishMock.mockReset(); + ensurePassphraseMock.mockReset(); + ensurePassphraseMock.mockImplementation(async () => { + isUnlocked = true; + }); + __resetSettingsSyncForTests(); + isUnlocked = true; + useSettingsStore.setState({ + settings: makeSettings(), + setSettings: (s: SystemSettings) => useSettingsStore.setState({ settings: s }), + saveSettings: vi.fn(), + applyUILanguage: vi.fn(), + } as unknown as ReturnType); +}); + +afterEach(() => { + __resetSettingsSyncForTests(); +}); + +describe('publishSettingsIfChanged', () => { + test('first call publishes every populated whitelisted field', async () => { + await publishSettingsIfChanged(makeSettings()); + expect(publishMock).toHaveBeenCalledTimes(1); + const [kind, record, replicaId] = publishMock.mock.calls[0]!; + expect(kind).toBe('settings'); + expect(replicaId).toBe('singleton'); + const patch = (record as { patch: Partial }).patch; + expect(patch.globalReadSettings?.customHighlightColors).toEqual( + baseHighlight.customHighlightColors, + ); + }); + + test('second call with no changes is a no-op', async () => { + await publishSettingsIfChanged(makeSettings()); + publishMock.mockReset(); + await publishSettingsIfChanged(makeSettings()); + expect(publishMock).not.toHaveBeenCalled(); + }); + + test('publishes only changed fields on subsequent calls', async () => { + await publishSettingsIfChanged(makeSettings()); + publishMock.mockReset(); + const next = makeSettings({ + globalReadSettings: { + ...baseHighlight, + userHighlightColors: [{ name: 'mint', color: '#a8e6cf' }], + } as unknown as SystemSettings['globalReadSettings'], + }); + await publishSettingsIfChanged(next); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + expect(patch.globalReadSettings?.userHighlightColors).toEqual([ + { name: 'mint', color: '#a8e6cf' }, + ]); + // Unchanged fields stay out of the diff + expect(patch.globalReadSettings?.customHighlightColors).toBeUndefined(); + expect(patch.kosync).toBeUndefined(); + }); + + test('detects nested changes (kosync.serverUrl)', async () => { + await publishSettingsIfChanged(makeSettings()); + publishMock.mockReset(); + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: 'https://kosync.example', + username: '', + userkey: '', + password: '', + } as SystemSettings['kosync'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + expect(patch.kosync?.serverUrl).toBe('https://kosync.example'); + }); + + test('triggers the passphrase gate when an encrypted field gets meaningful content while locked', async () => { + isUnlocked = false; + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: '', + username: '', + userkey: '', + password: 'hunter2', + } as SystemSettings['kosync'], + }), + ); + expect(ensurePassphraseMock).toHaveBeenCalledTimes(1); + expect(publishMock).toHaveBeenCalledTimes(1); + }); + + test('empty encrypted credential is dropped from publish entirely (no gate, no patch)', async () => { + isUnlocked = false; + // makeSettings has all kosync credentials as ''. Plaintext changes + // (highlight palette) trigger the publish; encrypted empty fields + // should NOT appear in the patch and should NOT trigger the gate. + await publishSettingsIfChanged(makeSettings()); + expect(ensurePassphraseMock).not.toHaveBeenCalled(); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + expect(patch.kosync?.password).toBeUndefined(); + expect(patch.readwise?.accessToken).toBeUndefined(); + expect(patch.hardcover?.accessToken).toBeUndefined(); + }); + + test('does NOT trigger the gate when only plaintext settings change', async () => { + isUnlocked = false; + await publishSettingsIfChanged( + makeSettings({ + globalReadSettings: { + ...baseHighlight, + userHighlightColors: [{ name: 'mint', color: '#a8e6cf' }], + } as unknown as SystemSettings['globalReadSettings'], + }), + ); + expect(ensurePassphraseMock).not.toHaveBeenCalled(); + expect(publishMock).toHaveBeenCalledTimes(1); + }); + + test('user cancels the gate prompt → encrypted hash NOT stored, next save retries', async () => { + isUnlocked = false; + ensurePassphraseMock.mockImplementationOnce(async () => { + throw new Error('user cancelled'); + }); + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: '', + username: '', + userkey: '', + password: 'hunter2', + } as SystemSettings['kosync'], + }), + ); + // Publish still fires — plaintext fields go through (none new this + // call) plus the encrypted field whose ciphertext the middleware + // will drop on the wire. + expect(publishMock).toHaveBeenCalledTimes(1); + publishMock.mockReset(); + + // Session still locked, same settings. Hash wasn't stored because + // we never unlocked, so the diff catches kosync.password again. + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: '', + username: '', + userkey: '', + password: 'hunter2', + } as SystemSettings['kosync'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + }); + + test('encrypted-field publish while unlocked records the value (no retry next save)', async () => { + isUnlocked = true; + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: '', + username: '', + userkey: '', + password: 'hunter2', + } as SystemSettings['kosync'], + }), + ); + publishMock.mockReset(); + await publishSettingsIfChanged( + makeSettings({ + kosync: { + serverUrl: '', + username: '', + userkey: '', + password: 'hunter2', + } as SystemSettings['kosync'], + }), + ); + expect(publishMock).not.toHaveBeenCalled(); + }); +}); + +describe('applyRemoteSettings', () => { + test('merges patch into useSettingsStore and persists', () => { + const env = makeEnvConfig(); + const userColors = [{ name: 'mint', color: '#a8e6cf' }]; + applyRemoteSettings(env, { + name: 'singleton', + patch: { + globalReadSettings: { userHighlightColors: userColors }, + } as unknown as Partial, + }); + const merged = useSettingsStore.getState().settings; + expect(merged.globalReadSettings.userHighlightColors).toEqual(userColors); + // Existing globalReadSettings fields preserved by the deep merge. + expect(merged.globalReadSettings.customHighlightColors).toEqual( + baseHighlight.customHighlightColors, + ); + expect(useSettingsStore.getState().saveSettings).toHaveBeenCalledTimes(1); + }); + + test('applying remote does NOT echo the remote field back on the next publish', async () => { + await publishSettingsIfChanged(useSettingsStore.getState().settings); + publishMock.mockReset(); + + const env = makeEnvConfig(); + const userColors = [{ name: 'mint', color: '#a8e6cf' }]; + applyRemoteSettings(env, { + name: 'singleton', + patch: { + globalReadSettings: { userHighlightColors: userColors }, + } as unknown as Partial, + }); + publishMock.mockReset(); + + await publishSettingsIfChanged(useSettingsStore.getState().settings); + expect(publishMock).not.toHaveBeenCalled(); + }); + + test('empty patch is a no-op', () => { + const env = makeEnvConfig(); + const before = useSettingsStore.getState().settings; + applyRemoteSettings(env, { name: 'singleton', patch: {} }); + expect(useSettingsStore.getState().settings).toBe(before); + expect(useSettingsStore.getState().saveSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 3da46ec8..39bf6dbe 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -117,10 +117,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const { isTransferQueueOpen } = useTransferStore(); // Library page pulls user replicas (dictionaries, custom fonts, - // background textures, OPDS catalogs). Deferred 10s; module-scoped - // dedup means a later navigation to the reader won't re-pull the - // same kind. - useReplicaPull({ kinds: ['dictionary', 'font', 'texture', 'opds_catalog'] }); + // background textures, OPDS catalogs, bundled settings). Deferred + // 10s; module-scoped dedup means a later navigation to the reader + // won't re-pull the same kind. + useReplicaPull({ + kinds: ['dictionary', 'font', 'texture', 'opds_catalog', 'settings'], + }); const [showCatalogManager, setShowCatalogManager] = useState( searchParams?.get('opds') === 'true', ); diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index d4d0774e..6b5908bb 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -25,6 +25,7 @@ import PassphrasePrompt from '@/components/PassphrasePrompt'; import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase'; import { cryptoSession } from '@/libs/crypto/session'; import { useAppLockStore } from '@/store/appLockStore'; +import { initSettingsSync } from '@/services/sync/replicaSettingsSync'; const Providers = ({ children }: { children: React.ReactNode }) => { const { envConfig, appService } = useEnv(); @@ -101,6 +102,15 @@ const Providers = ({ children }: { children: React.ReactNode }) => { })(); }, []); + // Subscribe the bundled-settings publisher to settingsStore changes. + // After this fires, every setSettings call diffs the whitelist + // against the last-published snapshot and emits a single replica + // upsert for changed fields (no-op if nothing whitelisted changed). + // Idempotent — safe to call on remount. + useEffect(() => { + initSettingsSync(); + }, []); + // Make sure appService is available in all children components if (!appService) return; diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts index 236d9b3e..6bb7a807 100644 --- a/apps/readest-app/src/hooks/useReplicaPull.ts +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -18,6 +18,14 @@ import { dictionaryAdapter } from '@/services/sync/adapters/dictionary'; import { fontAdapter } from '@/services/sync/adapters/font'; import { textureAdapter } from '@/services/sync/adapters/texture'; import { opdsCatalogAdapter } from '@/services/sync/adapters/opdsCatalog'; +import { settingsAdapter, type SettingsRemoteRecord } from '@/services/sync/adapters/settings'; +import { + applyRemoteSettings, + clearStoredEncryptedHashes, + getStoredLastSeenCipher, + publishSettingsIfChanged, +} from '@/services/sync/replicaSettingsSync'; +import { useSettingsStore } from '@/store/settingsStore'; import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; import { replicaPullAndApply, @@ -34,8 +42,9 @@ import type { ImportedDictionary } from '@/services/dictionaries/types'; import type { CustomFont } from '@/styles/fonts'; import type { CustomTexture } from '@/styles/textures'; import type { OPDSCatalog } from '@/types/opds'; +import type { SystemSettings } from '@/types/settings'; -export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog'; +export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog' | 'settings'; export interface UseReplicaPullOpts { /** Replica kinds this page wants pulled. */ @@ -70,6 +79,10 @@ interface ReplicaPullConfig { hydrateLocalStore?: (envConfig: EnvConfigType) => Promise; applyRemote: (record: T) => void; softDeleteByContentId: (id: string) => void; + /** Forwarded to PullAndApplyDeps; see that field for semantics. */ + silentDecrypt?: boolean; + /** Forwarded to PullAndApplyDeps; see that field for semantics. */ + onSaltNotFound?: (paths: readonly string[]) => void; } const buildReplicaPullDeps = ( @@ -89,6 +102,8 @@ const buildReplicaPullDeps = ( : undefined, applyRemote: config.applyRemote, softDeleteByContentId: config.softDeleteByContentId, + silentDecrypt: config.silentDecrypt, + onSaltNotFound: config.onSaltNotFound, // The bundle / binary callbacks below are only reached when the // adapter declares a `binary` capability — replicaPullAndApply // short-circuits metadata-only kinds before invoking them. The @@ -173,6 +188,41 @@ const opdsCatalogPullConfig: ReplicaPullConfig = { softDeleteByContentId: (id) => useCustomOPDSStore.getState().softDeleteByContentId(id), }; +const settingsPullConfig = (envConfig: EnvConfigType): ReplicaPullConfig => ({ + kind: 'settings', + // metadata-only — no baseDir + adapter: settingsAdapter, + // Synthesize a "local" record carrying the persisted cipher + // fingerprint so the orchestrator's cipher-fingerprint comparison + // works for settings the same way it does for OPDS: + // * fingerprint matches → skip prompt (already-decrypted ciphers + // unchanged); no spam on refresh + // * fingerprint differs (rotation / fresh device / new device A + // just set credentials) → prompt fires for the user to enter + // the passphrase + // The empty patch is fine: applyRow re-applies metadata-only kinds + // unconditionally for the actual data. + findByContentId: () => ({ + name: 'singleton' as const, + patch: {} as Partial, + lastSeenCipher: getStoredLastSeenCipher(), + }), + applyRemote: (record) => applyRemoteSettings(envConfig, record), + // Settings is a singleton — never tombstoned. The server-side + // forget-passphrase wipe doesn't touch this row. + softDeleteByContentId: () => {}, + // Auto-recovery for the orphan-cipher case: clear the persisted + // "already-published" hash so the next save re-encrypts under the + // current salt and overwrites the orphan. Then trigger an + // immediate re-publish so the user doesn't have to touch settings + // before the server heals itself. + onSaltNotFound: (paths) => { + clearStoredEncryptedHashes(paths); + const settings = useSettingsStore.getState().settings; + if (settings) void publishSettingsIfChanged(settings); + }, +}); + const runPullForKind = async ( kind: ReplicaKind, service: AppService, @@ -204,6 +254,11 @@ const runPullForKind = async ( buildReplicaPullDeps(ctx.manager, service, envConfig, opdsCatalogPullConfig), ); return; + case 'settings': + await replicaPullAndApply( + buildReplicaPullDeps(ctx.manager, service, envConfig, settingsPullConfig(envConfig)), + ); + return; } }; diff --git a/apps/readest-app/src/libs/replicaSchemas.ts b/apps/readest-app/src/libs/replicaSchemas.ts index 538833d5..f5c54b48 100644 --- a/apps/readest-app/src/libs/replicaSchemas.ts +++ b/apps/readest-app/src/libs/replicaSchemas.ts @@ -70,6 +70,14 @@ const opdsCatalogFieldsSchema = z }) .catchall(fieldEnvelopeWithCipher); +// Open-shaped: the bundled `settings` row stores arbitrary scalar +// preferences keyed by `` or `.` (for flat-map +// settings like providerEnabled., syncCategories., +// shortcut.). The whitelist of accepted field NAMES is +// enforced client-side by the adapter; the server only enforces the +// envelope shape and the 64-field / 64 KiB row caps. +const settingsFieldsSchema = z.record(z.string(), fieldEnvelopeWithCipher); + interface KindSpec { minSchemaVersion: number; maxSchemaVersion: number; @@ -107,6 +115,16 @@ export const KIND_ALLOWLIST: Record = { fields: opdsCatalogFieldsSchema, binary: false, }, + settings: { + // Singleton row per user (replica_id='singleton'). Holds scalar + // SystemSettings preferences plus flat-map settings encoded via + // namespaced field keys. + minSchemaVersion: 1, + maxSchemaVersion: 1, + maxRowsPerUser: 1, + fields: settingsFieldsSchema, + binary: false, + }, }; export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind); diff --git a/apps/readest-app/src/libs/replicaSyncClient.ts b/apps/readest-app/src/libs/replicaSyncClient.ts index 84510fcd..a2089dc3 100644 --- a/apps/readest-app/src/libs/replicaSyncClient.ts +++ b/apps/readest-app/src/libs/replicaSyncClient.ts @@ -98,7 +98,33 @@ export class ReplicaSyncClient { return data.rows ?? []; } - async listReplicaKeys(): Promise { + /** + * The replica_keys list rarely changes — only on `createReplicaKey` + * (passphrase setup / rotation) and `forgetReplicaKeys` (forgot- + * passphrase wipe), both of which we own and invalidate explicitly. + * Without a cache here, every consumer that needs the salt list + * (CryptoSession.unlock, tryRestoreFromStore, deriveKeyFor on a + * cache-miss saltId, passphraseGate.ensurePassphraseUnlocked, + * SyncPassphraseSection.refreshStatus) issues its own fetch in + * parallel — observed as 5+ identical concurrent GETs at boot. + * + * Two-layer dedupe: + * * `replicaKeysInflight` coalesces concurrent calls onto the same + * in-flight promise — no duplicate network round trips even + * before the first response lands. + * * `replicaKeysCache` holds the resolved value indefinitely for + * subsequent calls. Invalidated on every mutation we issue. + */ + private replicaKeysCache: ReplicaKeyRow[] | null = null; + private replicaKeysInflight: Promise | null = null; + + /** Discard the cached replica_keys list. Auth flows / sign-out should call this. */ + invalidateReplicaKeysCache(): void { + this.replicaKeysCache = null; + this.replicaKeysInflight = null; + } + + private async fetchReplicaKeys(): Promise { const token = await requireToken(); let response: Response; try { @@ -122,6 +148,29 @@ export class ReplicaSyncClient { return data.rows ?? []; } + async listReplicaKeys(): Promise { + if (this.replicaKeysCache !== null) { + // Defensive copy — callers occasionally mutate the returned array + // (e.g., `[...rows].sort(...)`). Returning the cache directly + // would let one caller's mutation poison another's view. + return [...this.replicaKeysCache]; + } + if (this.replicaKeysInflight) { + const rows = await this.replicaKeysInflight; + return [...rows]; + } + this.replicaKeysInflight = this.fetchReplicaKeys() + .then((rows) => { + this.replicaKeysCache = rows; + return rows; + }) + .finally(() => { + this.replicaKeysInflight = null; + }); + const rows = await this.replicaKeysInflight; + return [...rows]; + } + async forgetReplicaKeys(): Promise { const token = await requireToken(); let response: Response; @@ -142,6 +191,9 @@ export class ReplicaSyncClient { { status: response.status }, ); } + // Server side: every salt + every encrypted envelope is gone. + // Local cache is now lying — clear it. + this.replicaKeysCache = []; } async createReplicaKey(alg: string): Promise { @@ -172,6 +224,12 @@ export class ReplicaSyncClient { if (!data.row) { throw new SyncError('SERVER', 'replica-keys create returned no row'); } + // Splice the new salt into the cache so the next listReplicaKeys + // call sees it without another round trip. Rotation appends — old + // salts stay accessible for decrypting envelopes still under them. + if (this.replicaKeysCache !== null) { + this.replicaKeysCache = [...this.replicaKeysCache, data.row]; + } return data.row; } } diff --git a/apps/readest-app/src/services/sync/adapters/settings.ts b/apps/readest-app/src/services/sync/adapters/settings.ts new file mode 100644 index 00000000..e4a9e143 --- /dev/null +++ b/apps/readest-app/src/services/sync/adapters/settings.ts @@ -0,0 +1,179 @@ +import type { SystemSettings } from '@/types/settings'; +import type { ReplicaAdapter } from '@/services/sync/replicaRegistry'; +import type { FieldsObject, ReplicaRow } from '@/types/replica'; +import { unwrap } from './helpers'; + +export const SETTINGS_KIND = 'settings'; +export const SETTINGS_SCHEMA_VERSION = 1; + +/** + * Stable replica_id for the singleton settings row. The kind is + * capped at maxRowsPerUser=1 server-side so this id is unique per + * user. Kept short to limit fields_jsonb overhead. + */ +export const SETTINGS_REPLICA_ID = 'singleton'; + +/** + * Whitelist of SystemSettings keys that sync via the bundled + * `settings` row. Each entry is a dot-path into SystemSettings so + * nested values (`globalViewSettings.uiLanguage`) and flat-map + * settings (future: `providerEnabled.`) live alongside top-level + * scalars. Adding a new synced setting is a one-line addition. + * + * Notable exclusions: + * * Device-specific paths (`localBooksDir`, `lastOpenBooks`, + * `screenBrightness`, `customRootDir`) — wouldn't make sense + * across devices. + * * Collection settings already synced via dedicated kinds + * (`customFonts`, `customTextures`, `customDictionaries`, + * `opdsCatalogs`, `dictionarySettings`). + */ +export const SETTINGS_WHITELIST = [ + 'globalReadSettings.customThemes', + 'globalReadSettings.customHighlightColors', + 'globalReadSettings.userHighlightColors', + 'globalReadSettings.defaultHighlightLabels', + 'globalReadSettings.customTtsHighlightColors', + // External integrations. Server URL + identifiers sync as plaintext; + // the credential fields are listed in `encryptedFields` below so the + // publish/pull middleware wraps them in cipher envelopes. + 'kosync.serverUrl', + 'kosync.username', + 'kosync.userkey', + 'kosync.password', + 'readwise.accessToken', + 'hardcover.accessToken', +] as const; + +/** + * Whitelisted paths whose values are credentials. The publish/pull + * crypto middleware wraps these in cipher envelopes via the active + * CryptoSession; pack / unpack themselves only see plaintext. + * + * Best-effort encryption: when the session is locked we drop the + * field from the push (no plaintext leak) and skip applying it on + * pull. The user explicitly unlocks via the Sync passphrase panel + * (or via an OPDS prompt) to enable cross-device credential sync. + * Settings sync deliberately does NOT trigger the lazy passphrase + * prompt itself — credential sync is opt-in via that explicit + * unlock; the rest of the bundled settings keep syncing quietly. + */ +export const SETTINGS_ENCRYPTED_FIELDS = [ + 'kosync.username', + 'kosync.userkey', + 'kosync.password', + 'readwise.accessToken', + 'hardcover.accessToken', +] as const; + +export type SettingsWhitelistKey = (typeof SETTINGS_WHITELIST)[number]; + +// In practice every path comes from the compile-time SETTINGS_WHITELIST so +// these never appear, but readPath/writePath are exported helpers and the +// guard makes prototype pollution impossible if a future caller passes an +// untrusted path. +const isUnsafeKey = (k: string): boolean => + k === '__proto__' || k === 'constructor' || k === 'prototype'; + +/** Read a dot-path value from a deep object. Returns undefined for absent paths. */ +export const readPath = (obj: unknown, path: string): unknown => { + let cur: unknown = obj; + for (const part of path.split('.')) { + if (isUnsafeKey(part)) return undefined; + if (cur === null || cur === undefined || typeof cur !== 'object') return undefined; + cur = (cur as Record)[part]; + } + return cur; +}; + +/** + * Set a dot-path value on a deep object, creating intermediate + * objects as needed. Mutates in place. Used by the pull side to + * build a partial SystemSettings patch from the row's flat fields. + */ +export const writePath = (obj: Record, path: string, value: unknown): void => { + const parts = path.split('.'); + if (parts.some(isUnsafeKey)) return; + let cur: Record = obj; + for (let i = 0; i < parts.length - 1; i++) { + const k = parts[i]!; + const next = cur[k]; + if (next === undefined || next === null || typeof next !== 'object') { + cur[k] = {}; + } + cur = cur[k] as Record; + } + cur[parts[parts.length - 1]!] = value; +}; + +/** + * Unpacked settings patch returned by the adapter. The replica + * orchestrator constraint (`extends ReplicaLocalRecord`) requires + * a `name`; we set a stable placeholder so the orchestrator's + * displayName fallback works without touching the user-visible + * SystemSettings shape. + */ +export interface SettingsRemoteRecord { + name: 'singleton'; + patch: Partial; + /** + * Per-field cipher fingerprint of the last-decrypted pull. Populated + * from localStorage by the settings pull config so the orchestrator's + * cipher-fingerprint heuristic can decide whether to prompt — same + * pattern as OPDSCatalog.lastSeenCipher, just stored externally + * since the singleton settings row has no per-record local object. + */ + lastSeenCipher?: Record; +} + +const unwrapSettingsFields = (fields: FieldsObject): Record => { + const out: Record = {}; + for (const path of SETTINGS_WHITELIST) { + const v = unwrap(fields[path]); + if (v !== undefined) out[path] = v; + } + return out; +}; + +export const settingsAdapter: ReplicaAdapter = { + kind: SETTINGS_KIND, + schemaVersion: SETTINGS_SCHEMA_VERSION, + encryptedFields: SETTINGS_ENCRYPTED_FIELDS, + + pack(record: SettingsRemoteRecord): Record { + const fields: Record = {}; + for (const path of SETTINGS_WHITELIST) { + const value = readPath(record.patch, path); + if (value !== undefined) fields[path] = value; + } + return fields; + }, + + unpack(fields: Record): SettingsRemoteRecord { + const patch: Record = {}; + for (const path of SETTINGS_WHITELIST) { + const v = fields[path]; + if (v !== undefined) writePath(patch, path, v); + } + return { name: 'singleton', patch: patch as Partial }; + }, + + async computeId(): Promise { + return SETTINGS_REPLICA_ID; + }, + + unpackRow(row: ReplicaRow): SettingsRemoteRecord | null { + const flat = unwrapSettingsFields(row.fields_jsonb); + if (Object.keys(flat).length === 0) { + // Empty row (no whitelisted fields present yet) — nothing to apply. + return null; + } + const patch: Record = {}; + for (const [path, v] of Object.entries(flat)) { + writePath(patch, path, v); + } + return { name: 'singleton', patch: patch as Partial }; + }, + + // No `binary` capability — settings is metadata-only. +}; diff --git a/apps/readest-app/src/services/sync/replicaBootstrap.ts b/apps/readest-app/src/services/sync/replicaBootstrap.ts index 4ae51deb..c3175775 100644 --- a/apps/readest-app/src/services/sync/replicaBootstrap.ts +++ b/apps/readest-app/src/services/sync/replicaBootstrap.ts @@ -5,6 +5,7 @@ import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary'; import { fontAdapter, FONT_KIND } from './adapters/font'; import { textureAdapter, TEXTURE_KIND } from './adapters/texture'; import { opdsCatalogAdapter } from './adapters/opdsCatalog'; +import { settingsAdapter } from './adapters/settings'; import { getReplicaPersistEnv } from './replicaPersist'; import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry'; import { registerReplicaDownloadHandler } from './replicaTransferIntegration'; @@ -16,6 +17,8 @@ const KNOWN_ADAPTERS: ReplicaAdapter[] = [ textureAdapter as unknown as ReplicaAdapter, // Metadata-only — no binary download handler needed. opdsCatalogAdapter as unknown as ReplicaAdapter, + // Bundled scalar settings — singleton row, no binary. + settingsAdapter as unknown as ReplicaAdapter, ]; let didBootstrap = false; diff --git a/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts b/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts index d389a16d..b769a7a3 100644 --- a/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts +++ b/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts @@ -18,6 +18,8 @@ import { isSyncError, SyncError } from '@/libs/errors'; import { isCipherEnvelope } from '@/types/replica'; import type { CipherEnvelope, FieldsObject } from '@/types/replica'; import type { CryptoSession } from '@/libs/crypto/session'; +import { eventDispatcher } from '@/utils/event'; +import { stubTranslation as _ } from '@/utils/misc'; import { cryptoSession as defaultCryptoSession } from '@/libs/crypto/session'; /** @@ -145,14 +147,30 @@ export const collectDecryptSuccess = ( * this to the passphrase gate so a sync fresh device prompts the user * before silently dropping the encrypted creds. */ +export interface DecryptRowResult { + /** + * Field paths whose decrypt failed because the cipher envelope's + * `saltId` no longer exists server-side (the user / admin reset + * `replica_keys` out of band). The orchestrator hands these to the + * adapter so it can clear any persisted "already-published" + * fingerprint for the path — the next save then re-encrypts the + * (still locally-held) plaintext under the current salt and the + * orphaned cipher gets overwritten on the server. + */ + saltNotFound: string[]; +} + export const decryptRowFields = async ( fields: FieldsObject, encryptedFields: readonly string[] | undefined, session: CryptoSession = defaultCryptoSession, onLocked?: () => Promise, -): Promise => { - if (!encryptedFields || encryptedFields.length === 0) return; +): Promise => { + if (!encryptedFields || encryptedFields.length === 0) return { saltNotFound: [] }; + const saltNotFound: string[] = []; let promptAttempted = false; + let lastFailureCode: string | null = null; + let failedFieldCount = 0; for (const fieldName of encryptedFields) { const envelope = fields[fieldName]; if (!envelope || typeof envelope !== 'object' || !('v' in envelope)) continue; @@ -179,11 +197,47 @@ export const decryptRowFields = async ( (envelope as { v: unknown }).v = plaintext; } catch (err) { const code = isSyncError(err) ? (err as SyncError).code : 'unknown'; + // Loud + uniformly prefixed so it's easy to grep in production + // console output. AES-GCM failures (wrong passphrase) surface + // as DECRYPT; SHA-256 sidecar mismatches as INTEGRITY. console.warn( `[replicaCrypto] failed to decrypt field "${fieldName}" (${code}) — preserving local copy`, err, ); + lastFailureCode = code; + failedFieldCount += 1; + if (code === 'SALT_NOT_FOUND') saltNotFound.push(fieldName); delete fields[fieldName]; } } + + // One toast per call, surfaced after the loop so the user notices + // even if they don't watch the console. + // * DECRYPT / INTEGRITY: AES-GCM or sidecar verification failed — + // almost always "wrong sync passphrase entered on this device". + // Local plaintext copy is preserved. + // * SALT_NOT_FOUND: the row's cipher envelope references a salt + // that no longer exists in `replica_keys` server-side. This + // happens when the salts were deleted out-of-band (e.g., + // manually) without also wiping the cipher envelopes — the + // proper Forgot-passphrase RPC does both atomically. The orphan + // The orchestrator hands the saltNotFound list back to the + // adapter so it can clear any "already-published" snapshot for + // those paths — the next save then re-encrypts under the + // current salt and overwrites the orphan. + if (failedFieldCount > 0 && lastFailureCode !== null) { + let message: string; + if (lastFailureCode === 'DECRYPT' || lastFailureCode === 'INTEGRITY') { + message = _('Wrong sync passphrase — synced credentials could not be decrypted'); + } else if (lastFailureCode === 'SALT_NOT_FOUND') { + message = _( + 'Sync passphrase data on the server was reset. Re-encrypting your credentials under the new passphrase…', + ); + } else { + message = _('Failed to decrypt synced credentials'); + } + eventDispatcher.dispatch('toast', { type: 'error', message }); + } + + return { saltNotFound }; }; diff --git a/apps/readest-app/src/services/sync/replicaPullAndApply.ts b/apps/readest-app/src/services/sync/replicaPullAndApply.ts index 81eb581e..cb60800d 100644 --- a/apps/readest-app/src/services/sync/replicaPullAndApply.ts +++ b/apps/readest-app/src/services/sync/replicaPullAndApply.ts @@ -94,6 +94,28 @@ export interface PullAndApplyDeps { * orchestrator skips the entire pull (no network call, no warnings). */ isAuthenticated?(): Promise; + /** + * When true, encrypted-field cipher payloads are decrypted + * best-effort but the orchestrator NEVER triggers the passphrase + * gate for this kind. Cipher fields silently drop when the session + * is locked, leaving the local copy intact. Use for kinds where + * spam-prompting on every pull would be jarring (e.g., the bundled + * `settings` row, which pulls on every library mount). The user + * unlocks via an explicit Settings → Sync action; the next pull + * cycle then decrypts cleanly. + */ + silentDecrypt?: boolean; + /** + * Called when one or more cipher fields failed to decrypt because + * the cipher's `saltId` no longer exists in `replica_keys` (orphan + * after an out-of-band server reset). Adapters that persist a + * "previously published" fingerprint per encrypted path should + * clear it so the next save re-encrypts the still-locally-held + * plaintext under the current salt — overwriting the orphan + * cipher on the server. No-op when the kind has no such fingerprint + * to invalidate. + */ + onSaltNotFound?(paths: readonly string[]): void; } const MANIFEST_FILE_TO_TRANSFER = ( @@ -129,11 +151,20 @@ const applyRow = async ( const localLastSeen = local?.lastSeenCipher; const needsPrompt = + !deps.silentDecrypt && !cryptoSession.isUnlocked() && Object.keys(beforeDecrypt).length > 0 && cipherTextsChanged(beforeDecrypt, localLastSeen); const onLocked = needsPrompt ? () => ensurePassphraseUnlocked() : undefined; - await decryptRowFields(row.fields_jsonb, encryptedFields, undefined, onLocked); + const decryptResult = await decryptRowFields( + row.fields_jsonb, + encryptedFields, + undefined, + onLocked, + ); + if (decryptResult.saltNotFound.length > 0 && deps.onSaltNotFound) { + deps.onSaltNotFound(decryptResult.saltNotFound); + } // Build the lastSeenCipher fingerprint to attach to the unpacked // record. Only fields whose decrypt succeeded contribute — a failed diff --git a/apps/readest-app/src/services/sync/replicaSettingsSync.ts b/apps/readest-app/src/services/sync/replicaSettingsSync.ts new file mode 100644 index 00000000..7a350c0f --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaSettingsSync.ts @@ -0,0 +1,344 @@ +/** + * Settings publish/apply orchestration. Sits between useSettingsStore + * and the replica-sync pipeline so per-field LWW works on bundled + * SystemSettings preferences. + * + * Push side: `publishSettingsIfChanged(settings)` runs after every + * settingsStore save. It walks the whitelist, diffs against the + * snapshot, and emits a single replica upsert with only the changed + * fields. Encrypted paths use a SHA-256 hash of the value as the + * snapshot (persisted in localStorage so refresh-after-credential-set + * doesn't re-fire the prompt). When the diff includes a new + * non-empty encrypted value AND the CryptoSession is locked, we + * proactively trigger the passphrase gate — that's the moment the + * user is opting into encrypted-credential sync. + * + * Pull side: `applyRemoteSettings(record)` merges a remote partial + * into useSettingsStore, persists, and updates both snapshots so the + * post-save publish hook sees no diff and we don't echo the remote + * update back at the server. + * + * Users who never use credential-bearing features (kosync, readwise, + * hardcover, OPDS-with-creds) never see the passphrase prompt: every + * encrypted path's snapshot is empty / matches "" so the diff doesn't + * include them. + */ +import type { SystemSettings } from '@/types/settings'; +import type { EnvConfigType } from '@/services/environment'; +import { useSettingsStore } from '@/store/settingsStore'; +import { publishReplicaUpsert } from '@/services/sync/replicaPublish'; +import { + SETTINGS_ENCRYPTED_FIELDS, + SETTINGS_KIND, + SETTINGS_REPLICA_ID, + SETTINGS_WHITELIST, + readPath, + writePath, + type SettingsRemoteRecord, +} from '@/services/sync/adapters/settings'; +import { cryptoSession } from '@/libs/crypto/session'; +import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate'; + +const ENCRYPTED_PATHS: ReadonlySet = new Set(SETTINGS_ENCRYPTED_FIELDS); + +const HASH_KEY_PREFIX = 'readest_settings_pushed_hash_v1:'; +const CIPHER_KEY = 'readest_settings_last_seen_cipher_v1'; + +/** + * In-memory snapshot for plaintext (non-encrypted) whitelisted paths. + * Resets on tab close — the worst case is a redundant publish on the + * next save, which is harmless (server-side per-field LWW dedupes). + */ +const lastPublishedFields = new Map(); + +const equalShallow = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== typeof b) return false; + if (typeof a === 'object') { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } + } + return false; +}; + +const isMeaningful = (value: unknown): boolean => { + if (value === undefined || value === null) return false; + if (typeof value === 'string') return value.length > 0; + return true; +}; + +const sha256Hex = async (value: unknown): Promise => { + const str = typeof value === 'string' ? value : JSON.stringify(value); + if (typeof crypto === 'undefined' || !crypto.subtle) { + // SSR / test env without WebCrypto: fall back to the raw string. + // The diff still works (just no privacy benefit), and the only + // place we'd compare against persisted hashes is in a real browser. + return str; + } + const buf = new TextEncoder().encode(str); + const digest = await crypto.subtle.digest('SHA-256', buf); + let hex = ''; + for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, '0'); + return hex; +}; + +const safeLocalStorage = (): Storage | null => { + try { + return typeof localStorage !== 'undefined' ? localStorage : null; + } catch { + return null; + } +}; + +const getStoredEncryptedHash = (path: string): string | null => { + const ls = safeLocalStorage(); + if (!ls) return null; + try { + return ls.getItem(HASH_KEY_PREFIX + path); + } catch { + return null; + } +}; + +const setStoredEncryptedHash = (path: string, hash: string): void => { + const ls = safeLocalStorage(); + if (!ls) return; + try { + ls.setItem(HASH_KEY_PREFIX + path, hash); + } catch { + /* ignore quota / private mode */ + } +}; + +/** + * Auto-recovery hook for the orphaned-cipher case: when the orchestrator + * tells us a row's cipher referenced a saltId that's no longer in + * `replica_keys` (server-side reset out of band), clear the published + * hash for those paths. The next saveSettings will see the local + * plaintext as "never published" → re-encrypts under the current + * (post-reset) salt → overwrites the orphan cipher on the server. + * + * Safe no-op when the path isn't tracked or localStorage is + * unavailable. + */ +export const clearStoredEncryptedHashes = (paths: readonly string[]): void => { + const ls = safeLocalStorage(); + if (!ls) return; + for (const path of paths) { + try { + ls.removeItem(HASH_KEY_PREFIX + path); + } catch { + /* ignore */ + } + } +}; + +export const getStoredLastSeenCipher = (): Record => { + const ls = safeLocalStorage(); + if (!ls) return {}; + try { + const raw = ls.getItem(CIPHER_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +}; + +const setStoredLastSeenCipher = (val: Record): void => { + const ls = safeLocalStorage(); + if (!ls) return; + try { + ls.setItem(CIPHER_KEY, JSON.stringify(val)); + } catch { + /* ignore */ + } +}; + +export const publishSettingsIfChanged = async (settings: SystemSettings): Promise => { + // Pass 1: figure out what's changed. Plaintext paths use the + // in-memory snapshot; encrypted paths compare against the + // persisted SHA-256 hash so refresh-after-credential-set doesn't + // mistake a re-load for a fresh change. + const plainChanged: Record = {}; + const encryptedChanged: Array<{ path: string; value: unknown; hash: string }> = []; + let hasNewEncryptedContent = false; + + for (const path of SETTINGS_WHITELIST) { + const current = readPath(settings, path); + if (current === undefined) continue; + + if (ENCRYPTED_PATHS.has(path)) { + // Skip empty / cleared credentials entirely — there's nothing + // useful to encrypt and pushing a plaintext "" would make the + // server-side schema mix cipher envelopes with bare empty + // strings for the same field. Local-clear stays local. + if (!isMeaningful(current)) continue; + const currentHash = await sha256Hex(current); + if (currentHash === getStoredEncryptedHash(path)) continue; + encryptedChanged.push({ path, value: current, hash: currentHash }); + hasNewEncryptedContent = true; + } else { + const previous = lastPublishedFields.get(path); + if (equalShallow(current, previous)) continue; + plainChanged[path] = current; + lastPublishedFields.set(path, current); + } + } + + if (Object.keys(plainChanged).length === 0 && encryptedChanged.length === 0) return; + + // Proactive prompt: only fire when the user has actually entered a + // meaningful encrypted value (not just blanked out) AND we don't + // have an unlocked session to encrypt it. This is the user's first + // moment of opting into credential sync — show the modal. + // Plaintext-only changes never trigger the prompt. + if (hasNewEncryptedContent && !cryptoSession.isUnlocked()) { + try { + await ensurePassphraseUnlocked(); + } catch { + // User cancelled. We still proceed with the publish below — + // plaintext paths sync, encrypted paths get dropped on the + // wire by the middleware. We deliberately do NOT update the + // stored hash for encrypted paths in this case, so the next + // save retries the prompt. + } + } + + // Build the patch and fire one upsert. + const patch: Record = {}; + for (const [path, value] of Object.entries(plainChanged)) { + writePath(patch, path, value); + } + for (const { path, value } of encryptedChanged) { + writePath(patch, path, value); + } + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: patch as Partial, + }; + void publishReplicaUpsert(SETTINGS_KIND, record, SETTINGS_REPLICA_ID); + + // Persist hashes for encrypted paths only when the session is + // unlocked (the publish actually ships their values). Otherwise + // the middleware drops them on the wire and we want the next save + // to retry — so leave the stored hash stale. + if (cryptoSession.isUnlocked()) { + for (const { path, hash } of encryptedChanged) { + setStoredEncryptedHash(path, hash); + } + } +}; + +/** + * Merge a remote settings patch into the local store, persist it to + * disk, and update both the in-memory plaintext snapshot AND the + * persisted encrypted-hash snapshot so the post-save publish hook + * doesn't echo the change back at the server. Also persists the + * cipher fingerprint the orchestrator captured so the next pull + * doesn't re-prompt for unchanged ciphers. + */ +export const applyRemoteSettings = ( + envConfig: EnvConfigType, + record: SettingsRemoteRecord, +): void => { + const { settings, setSettings, saveSettings } = useSettingsStore.getState(); + + // Persist cipher fingerprint regardless of patch content — the + // orchestrator may attach a fingerprint update for fields that + // were already in sync but whose ciphers rotated server-side. + if (record.lastSeenCipher && Object.keys(record.lastSeenCipher).length > 0) { + setStoredLastSeenCipher(record.lastSeenCipher); + } + + if (!settings || Object.keys(record.patch).length === 0) return; + + // Mark the incoming values as "already published" so the + // post-save publish hook sees no diff and stays quiet. + for (const path of SETTINGS_WHITELIST) { + const v = readPath(record.patch, path); + if (v === undefined) continue; + if (ENCRYPTED_PATHS.has(path)) { + // Stored hash mirrors the just-applied plaintext. + void sha256Hex(v).then((h) => setStoredEncryptedHash(path, h)); + } else { + lastPublishedFields.set(path, v); + } + } + + const merged: SystemSettings = mergeSettings(settings, record.patch); + setSettings(merged); + saveSettings(envConfig, merged); +}; + +const mergeSettings = (current: SystemSettings, patch: Partial): SystemSettings => { + // Top-level shallow merge plus single-level deep merges for the + // nested groups the whitelist touches. If a future whitelist entry + // points into a new top-level group, add the corresponding deep + // merge here. + const out: SystemSettings = { ...current, ...patch }; + if (patch.globalViewSettings) { + out.globalViewSettings = { ...current.globalViewSettings, ...patch.globalViewSettings }; + } + if (patch.globalReadSettings) { + out.globalReadSettings = { ...current.globalReadSettings, ...patch.globalReadSettings }; + } + if (patch.kosync) { + out.kosync = { ...current.kosync, ...patch.kosync }; + } + if (patch.readwise) { + out.readwise = { ...current.readwise, ...patch.readwise }; + } + if (patch.hardcover) { + out.hardcover = { ...current.hardcover, ...patch.hardcover }; + } + return out; +}; + +/** + * Subscribe to settingsStore changes and run publishSettingsIfChanged + * on every settings mutation. The diff inside the helper short-circuits + * when no whitelisted field changed, so subscribing to the broad + * "any state changed" feed is cheap. Reference-equality check on + * `settings` filters out unrelated mutations (dialog open/close, + * activeSettingsItemId, etc.) before we even hit the diff. + * + * Idempotent — second call is a no-op. Mount once at the Providers + * root. + */ +let unsubscribe: (() => void) | null = null; +export const initSettingsSync = (): void => { + if (unsubscribe) return; + unsubscribe = useSettingsStore.subscribe((state, prev) => { + if (state.settings && state.settings !== prev?.settings) { + void publishSettingsIfChanged(state.settings); + } + }); +}; + +/** Test seam — drop the snapshot + subscription between specs. */ +export const __resetSettingsSyncForTests = (): void => { + lastPublishedFields.clear(); + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + const ls = safeLocalStorage(); + if (ls) { + try { + const keys: string[] = []; + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (k && (k.startsWith(HASH_KEY_PREFIX) || k === CIPHER_KEY)) keys.push(k); + } + for (const k of keys) ls.removeItem(k); + } catch { + /* ignore */ + } + } +}; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 324a9516..0acae761 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -45,9 +45,9 @@ export interface ReadSettings { autohideCursor: boolean; translationProvider: string; translateTargetLang: string; - highlightStyle: HighlightStyle; highlightStyles: Record; + customHighlightColors: Record; userHighlightColors: UserHighlightColor[]; defaultHighlightLabels: Partial>; @@ -96,6 +96,7 @@ export const SYNC_CATEGORIES: readonly SyncCategory[] = [ export interface SystemSettings { version: number; + migrationVersion: number; localBooksDir: string; customRootDir?: string; @@ -131,6 +132,9 @@ export interface SystemSettings { metadataSeriesCollapsed: boolean; metadataOthersCollapsed: boolean; metadataDescriptionCollapsed: boolean; + lastSyncedAtBooks: number; + lastSyncedAtConfigs: number; + lastSyncedAtNotes: number; /** * App-lock PIN. When `pinCodeEnabled` is true, the user must enter @@ -147,9 +151,7 @@ export interface SystemSettings { readwise: ReadwiseSettings; hardcover: HardcoverSettings; - lastSyncedAtBooks: number; - lastSyncedAtConfigs: number; - lastSyncedAtNotes: number; + aiSettings: AISettings; /** * Per-device id used as the deviceId portion of every HLC this device * mints. Lazy-generated on first sync init via uuidv4 (mirrors @@ -171,9 +173,6 @@ export interface SystemSettings { */ syncCategories?: Partial>; - migrationVersion: number; - - aiSettings: AISettings; // Global read settings that apply to the reader page globalReadSettings: ReadSettings; // Global view settings that apply to all books, and can be overridden by book-specific view settings diff --git a/docker/volumes/db/migrations/011_replica_settings.sql b/docker/volumes/db/migrations/011_replica_settings.sql new file mode 100644 index 00000000..892318ff --- /dev/null +++ b/docker/volumes/db/migrations/011_replica_settings.sql @@ -0,0 +1,12 @@ +-- Migration 011: Extend the replicas.kind allowlist with 'settings'. +-- Per tenet 8 of the replica-sync plan, scalar settings sync via a +-- single bundled row keyed by ('settings', 'singleton') instead of N +-- per-kind adapters. The DB CHECK is belt-and-suspenders; +-- src/libs/replicaSchemas.ts (KIND_ALLOWLIST) is the actual gate. + +ALTER TABLE public.replicas + DROP CONSTRAINT IF EXISTS replicas_kind_allowlist; + +ALTER TABLE public.replicas + ADD CONSTRAINT replicas_kind_allowlist + CHECK (kind IN ('dictionary', 'font', 'texture', 'opds_catalog', 'settings'));