Files
readest/apps/readest-app/src/services/sync/replicaSettingsSync.ts
T
Huang Xin 6e7c9d1395 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>
2026-05-08 19:03:23 +02:00

345 lines
12 KiB
TypeScript

/**
* 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 */
}
}
};