import { getAccessToken } from '@/utils/access'; import { getAPIBaseUrl } from '@/services/environment'; import { SyncError } from '@/libs/errors'; import type { Hlc, ReplicaRow } from '@/types/replica'; import type { SyncErrorCode } from '@/libs/errors'; const ENDPOINT = () => `${getAPIBaseUrl()}/sync/replicas`; const KEYS_ENDPOINT = () => `${getAPIBaseUrl()}/sync/replica-keys`; export interface ReplicaKeyRow { saltId: string; alg: string; salt: string; createdAt: string; } interface ErrorBody { error?: string; code?: SyncErrorCode; offendingIndex?: number; } const statusToDefaultCode = (status: number): SyncErrorCode => { if (status === 401 || status === 403) return 'AUTH'; if (status === 402 || status === 507) return 'QUOTA_EXCEEDED'; if (status === 409) return 'CLOCK_SKEW'; if (status === 413) return 'VALIDATION'; if (status === 422) return 'VALIDATION'; if (status >= 500) return 'SERVER'; return 'VALIDATION'; }; const parseErrorBody = async (response: Response): Promise => { try { return (await response.json()) as ErrorBody; } catch { return {}; } }; const requireToken = async (): Promise => { const token = await getAccessToken(); if (!token) throw new SyncError('AUTH', 'Not authenticated'); return token; }; export class ReplicaSyncClient { async push(rows: ReplicaRow[]): Promise { if (rows.length === 0) return []; const token = await requireToken(); let response: Response; try { response = await fetch(ENDPOINT(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rows }), }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during push', { cause }); } if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError(code, body.error ?? `Push failed with status ${response.status}`, { status: response.status, }); } const data = (await response.json()) as { rows: ReplicaRow[] }; return data.rows ?? []; } async pull(kind: string, since: Hlc | null): Promise { const token = await requireToken(); const params = new URLSearchParams({ kind }); if (since) params.set('since', since); const url = `${ENDPOINT()}?${params.toString()}`; let response: Response; try { response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${token}` }, }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during pull', { cause }); } if (response.status === 404) return []; if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError(code, body.error ?? `Pull failed with status ${response.status}`, { status: response.status, }); } const data = (await response.json()) as { rows: ReplicaRow[] }; return data.rows ?? []; } /** * Batched pull for the incremental auto-sync path. Collapses what * used to be N parallel `GET /api/sync/replicas?kind=…&since=…` * requests (one per replica kind, fired on every focus / online / * periodic trigger) into a single POST round-trip. The boot path * still uses `pull()` per kind to preserve the settings-first * ordering invariant. * * The endpoint is the same `/sync/replicas` route — `{ cursors: [...] }` * in the body discriminates batched-pull from `{ rows: [...] }` push. */ async pullBatch( cursors: { kind: string; since: Hlc | null }[], ): Promise<{ kind: string; rows: ReplicaRow[] }[]> { if (cursors.length === 0) return []; const token = await requireToken(); let response: Response; try { response = await fetch(ENDPOINT(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ cursors }), }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during batch pull', { cause }); } if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError(code, body.error ?? `Batch pull failed with status ${response.status}`, { status: response.status, }); } const data = (await response.json()) as { results: { kind: string; rows: ReplicaRow[] }[] }; return data.results ?? []; } /** * The replica_keys list rarely changes — only on `createReplicaKey` * (passphrase setup / rotation) and `forgetReplicaKeys` (forgot- * passphrase wipe), both of which we own and invalidate explicitly. * Without a cache here, every consumer that needs the salt list * (CryptoSession.unlock, tryRestoreFromStore, deriveKeyFor on a * cache-miss saltId, passphraseGate.ensurePassphraseUnlocked, * SyncPassphraseSection.refreshStatus) issues its own fetch in * parallel — observed as 5+ identical concurrent GETs at boot. * * Two-layer dedupe: * * `replicaKeysInflight` coalesces concurrent calls onto the same * in-flight promise — no duplicate network round trips even * before the first response lands. * * `replicaKeysCache` holds the resolved value indefinitely for * subsequent calls. Invalidated on every mutation we issue. */ private replicaKeysCache: ReplicaKeyRow[] | null = null; private replicaKeysInflight: Promise | null = null; /** Discard the cached replica_keys list. Auth flows / sign-out should call this. */ invalidateReplicaKeysCache(): void { this.replicaKeysCache = null; this.replicaKeysInflight = null; } private async fetchReplicaKeys(): Promise { const token = await requireToken(); let response: Response; try { response = await fetch(KEYS_ENDPOINT(), { method: 'GET', headers: { Authorization: `Bearer ${token}` }, }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during replica-keys list', { cause }); } if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError( code, body.error ?? `replica-keys list failed with status ${response.status}`, { status: response.status }, ); } const data = (await response.json()) as { rows: ReplicaKeyRow[] }; return data.rows ?? []; } async listReplicaKeys(): Promise { if (this.replicaKeysCache !== null) { // Defensive copy — callers occasionally mutate the returned array // (e.g., `[...rows].sort(...)`). Returning the cache directly // would let one caller's mutation poison another's view. return [...this.replicaKeysCache]; } if (this.replicaKeysInflight) { const rows = await this.replicaKeysInflight; return [...rows]; } this.replicaKeysInflight = this.fetchReplicaKeys() .then((rows) => { this.replicaKeysCache = rows; return rows; }) .finally(() => { this.replicaKeysInflight = null; }); const rows = await this.replicaKeysInflight; return [...rows]; } async forgetReplicaKeys(): Promise { const token = await requireToken(); let response: Response; try { response = await fetch(KEYS_ENDPOINT(), { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during replica-keys forget', { cause }); } if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError( code, body.error ?? `replica-keys forget failed with status ${response.status}`, { 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 { const token = await requireToken(); let response: Response; try { response = await fetch(KEYS_ENDPOINT(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ alg }), }); } catch (cause) { throw new SyncError('SERVER', 'Network failure during replica-keys create', { cause }); } if (!response.ok) { const body = await parseErrorBody(response); const code = body.code ?? statusToDefaultCode(response.status); throw new SyncError( code, body.error ?? `replica-keys create failed with status ${response.status}`, { status: response.status }, ); } const data = (await response.json()) as { row: ReplicaKeyRow }; 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; } } export const replicaSyncClient = new ReplicaSyncClient();