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:
Huang Xin
2026-05-09 01:03:23 +08:00
committed by GitHub
parent 2d5590ec1f
commit 6e7c9d1395
16 changed files with 1357 additions and 19 deletions
@@ -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();
});
});
+6 -4
View File
@@ -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;
+56 -1
View File
@@ -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);
+59 -1
View File
@@ -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 */
}
}
};
+6 -7
View File
@@ -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'));