forked from akai/readest
feat(sync): bundled settings replica kind for cross-device prefs and credentials (#4094)
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials Adds a single-row `settings` replica that syncs a whitelist of `SystemSettings` fields across devices via per-field LWW (one entry per dot-namespaced path). Plaintext for theme / highlight colour / TTS configuration; encrypted (AES-GCM under the user's sync passphrase) for kosync / Readwise / Hardcover credentials. Highlights: - Push-side diff against an in-memory snapshot for plaintext paths and a localStorage SHA-256 hash for encrypted paths, so a refresh doesn't re-publish or re-prompt for the passphrase. - Pull-side cipher-fingerprint dedupe + per-row passphrase gate; decryption failures surface as toasts (wrong passphrase / orphan cipher) instead of silent drops. - Auto-recovery for orphaned ciphers: when a row references a saltId no longer in `replica_keys`, clear the local hash and re-encrypt under the current salt on the next save. - Single in-flight `/sync/replica-keys` fetch with a value cache to coalesce the boot-time burst of concurrent unlock callers. * fix(sync): guard settings dot-path helpers against prototype-polluting keys Reject `__proto__`, `constructor`, and `prototype` segments in the settings adapter's `readPath` / `writePath`. Every caller currently passes a constant from `SETTINGS_WHITELIST`, so the guard is purely defensive — but it silences the CodeQL prototype-pollution warning on PR #4094 and keeps the helpers safe if a future call site ever forwards an untrusted path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Response>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = <T>(v: T): FieldEnvelope<T> => ({ v, t: HLC, s: 'dev' });
|
||||
|
||||
const makeRow = (fields: Record<string, FieldEnvelope<unknown>>): 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<SystemSettings>,
|
||||
};
|
||||
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<SystemSettings>,
|
||||
};
|
||||
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<SystemSettings>,
|
||||
};
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = { foo: 'oops' };
|
||||
writePath(obj, 'foo.bar', 1);
|
||||
expect(obj).toEqual({ foo: { bar: 1 } });
|
||||
});
|
||||
|
||||
test('writePath rejects __proto__ / constructor / prototype segments', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
writePath(obj, '__proto__.polluted', 'bad');
|
||||
writePath(obj, 'constructor.prototype.polluted', 'bad');
|
||||
writePath(obj, 'a.prototype.b', 'bad');
|
||||
expect(({} as Record<string, unknown>)['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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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<typeof useSettingsStore.getState>);
|
||||
});
|
||||
|
||||
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<SystemSettings> }).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<SystemSettings>;
|
||||
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<SystemSettings>;
|
||||
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<SystemSettings>;
|
||||
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<SystemSettings>,
|
||||
});
|
||||
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<SystemSettings>,
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<T extends ReplicaLocalRecord> {
|
||||
hydrateLocalStore?: (envConfig: EnvConfigType) => Promise<void>;
|
||||
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 = <T extends ReplicaLocalRecord>(
|
||||
@@ -89,6 +102,8 @@ const buildReplicaPullDeps = <T extends ReplicaLocalRecord>(
|
||||
: 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<OPDSCatalog> = {
|
||||
softDeleteByContentId: (id) => useCustomOPDSStore.getState().softDeleteByContentId(id),
|
||||
};
|
||||
|
||||
const settingsPullConfig = (envConfig: EnvConfigType): ReplicaPullConfig<SettingsRemoteRecord> => ({
|
||||
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<SystemSettings>,
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ const opdsCatalogFieldsSchema = z
|
||||
})
|
||||
.catchall(fieldEnvelopeWithCipher);
|
||||
|
||||
// Open-shaped: the bundled `settings` row stores arbitrary scalar
|
||||
// preferences keyed by `<setting>` or `<group>.<id>` (for flat-map
|
||||
// settings like providerEnabled.<id>, syncCategories.<id>,
|
||||
// shortcut.<action>). 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<string, KindSpec> = {
|
||||
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);
|
||||
|
||||
@@ -98,7 +98,33 @@ export class ReplicaSyncClient {
|
||||
return data.rows ?? [];
|
||||
}
|
||||
|
||||
async listReplicaKeys(): Promise<ReplicaKeyRow[]> {
|
||||
/**
|
||||
* 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<ReplicaKeyRow[]> | 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<ReplicaKeyRow[]> {
|
||||
const token = await requireToken();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -122,6 +148,29 @@ export class ReplicaSyncClient {
|
||||
return data.rows ?? [];
|
||||
}
|
||||
|
||||
async listReplicaKeys(): Promise<ReplicaKeyRow[]> {
|
||||
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<void> {
|
||||
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<ReplicaKeyRow> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.<id>`) 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<string, unknown>)[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<string, unknown>, path: string, value: unknown): void => {
|
||||
const parts = path.split('.');
|
||||
if (parts.some(isUnsafeKey)) return;
|
||||
let cur: Record<string, unknown> = 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<string, unknown>;
|
||||
}
|
||||
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<SystemSettings>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
}
|
||||
|
||||
const unwrapSettingsFields = (fields: FieldsObject): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const path of SETTINGS_WHITELIST) {
|
||||
const v = unwrap(fields[path]);
|
||||
if (v !== undefined) out[path] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const settingsAdapter: ReplicaAdapter<SettingsRemoteRecord> = {
|
||||
kind: SETTINGS_KIND,
|
||||
schemaVersion: SETTINGS_SCHEMA_VERSION,
|
||||
encryptedFields: SETTINGS_ENCRYPTED_FIELDS,
|
||||
|
||||
pack(record: SettingsRemoteRecord): Record<string, unknown> {
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const path of SETTINGS_WHITELIST) {
|
||||
const value = readPath(record.patch, path);
|
||||
if (value !== undefined) fields[path] = value;
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
|
||||
unpack(fields: Record<string, unknown>): SettingsRemoteRecord {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const path of SETTINGS_WHITELIST) {
|
||||
const v = fields[path];
|
||||
if (v !== undefined) writePath(patch, path, v);
|
||||
}
|
||||
return { name: 'singleton', patch: patch as Partial<SystemSettings> };
|
||||
},
|
||||
|
||||
async computeId(): Promise<string> {
|
||||
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<string, unknown> = {};
|
||||
for (const [path, v] of Object.entries(flat)) {
|
||||
writePath(patch, path, v);
|
||||
}
|
||||
return { name: 'singleton', patch: patch as Partial<SystemSettings> };
|
||||
},
|
||||
|
||||
// No `binary` capability — settings is metadata-only.
|
||||
};
|
||||
@@ -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<unknown>[] = [
|
||||
textureAdapter as unknown as ReplicaAdapter<unknown>,
|
||||
// Metadata-only — no binary download handler needed.
|
||||
opdsCatalogAdapter as unknown as ReplicaAdapter<unknown>,
|
||||
// Bundled scalar settings — singleton row, no binary.
|
||||
settingsAdapter as unknown as ReplicaAdapter<unknown>,
|
||||
];
|
||||
|
||||
let didBootstrap = false;
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> => {
|
||||
if (!encryptedFields || encryptedFields.length === 0) return;
|
||||
): Promise<DecryptRowResult> => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -94,6 +94,28 @@ export interface PullAndApplyDeps<T extends ReplicaLocalRecord> {
|
||||
* orchestrator skips the entire pull (no network call, no warnings).
|
||||
*/
|
||||
isAuthenticated?(): Promise<boolean>;
|
||||
/**
|
||||
* 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 <T extends ReplicaLocalRecord>(
|
||||
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
|
||||
|
||||
@@ -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<string> = 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<string, unknown>();
|
||||
|
||||
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<string> => {
|
||||
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<string, string> => {
|
||||
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<string, string>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const setStoredLastSeenCipher = (val: Record<string, string>): void => {
|
||||
const ls = safeLocalStorage();
|
||||
if (!ls) return;
|
||||
try {
|
||||
ls.setItem(CIPHER_KEY, JSON.stringify(val));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
export const publishSettingsIfChanged = async (settings: SystemSettings): Promise<void> => {
|
||||
// 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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<SystemSettings>,
|
||||
};
|
||||
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>): 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 */
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -45,9 +45,9 @@ export interface ReadSettings {
|
||||
autohideCursor: boolean;
|
||||
translationProvider: string;
|
||||
translateTargetLang: string;
|
||||
|
||||
highlightStyle: HighlightStyle;
|
||||
highlightStyles: Record<HighlightStyle, HighlightColor>;
|
||||
|
||||
customHighlightColors: Record<HighlightColor, string>;
|
||||
userHighlightColors: UserHighlightColor[];
|
||||
defaultHighlightLabels: Partial<Record<HighlightColor, string>>;
|
||||
@@ -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<Record<SyncCategory, boolean>>;
|
||||
|
||||
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
|
||||
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user