/** * Blob sync session coordinator. * * Replaces the per-entry SYNC_HELLO/MANIFEST/REQUEST/ENTRIES/DONE state machine * with a single bidirectional vault-blob exchange. The encrypted vault blob is * already AES-256-GCM ciphertext under the user's master password, so the * Noise NK transport adds a second layer and the sync layer never sees * plaintext. * * Last-write-wins at the vault level: an incoming blob is applied only if its * `sent_at` is strictly newer than the local `peach_last_sync_at`. Vault * history snapshots before every apply so the user can restore an older state * if a sync overwrites the wrong vault. */ import { storageGet, storageSet } from "../shared/storage"; import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire"; import type { SyncMessage } from "../shared/sync-types"; import { takeVaultSnapshot } from "./vault"; const LAST_SYNC_KEY = "peach_last_sync_at"; const SYNC_TIMEOUT_MS = 30_000; export interface SyncCoordinatorDeps { isPro: () => Promise; isUnlocked: () => boolean; getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>; } export interface SyncSessionTransport { send: (frame: string) => void | Promise; onFrame: (handler: (frame: string) => void) => () => void; onError: (handler: (message: string) => void) => () => void; onClose: (handler: () => void) => () => void; close: () => void; } export type SyncRole = "initiator" | "responder"; export interface SyncSessionResult { status: "completed" | "errored" | "closed"; error?: string; } /** * Persist an incoming vault blob if its `sent_at` beats the local last-sync * timestamp. Returns 'applied' when the blob was written, 'ignored' when the * incoming was older. Always snapshots BEFORE writing so the user can roll * back via vault history if a sync overwrites the wrong state. */ export async function applyIncomingVault( incomingVaultA: string, incomingVaultB: string, sentAt: number, ): Promise<"applied" | "ignored"> { const lastRaw = await storageGet(LAST_SYNC_KEY); const lastTs = lastRaw ? Number.parseInt(lastRaw, 10) : 0; if (!Number.isFinite(sentAt) || sentAt <= lastTs) { return "ignored"; } // History capture happens BEFORE the write. Failures are swallowed inside // takeVaultSnapshot — sync must never block on history. await takeVaultSnapshot("sync_received"); await storageSet("peach_vault_a", incomingVaultA); await storageSet("peach_vault_b", incomingVaultB); await storageSet(LAST_SYNC_KEY, String(sentAt)); return "applied"; } /** * Run one bidirectional sync session over an authenticated transport. * * Wire flow: * 1. Initiator sends SYNC_VAULT_SEND with its blobs + sent_at * 2. Responder receives, applies if newer, replies with SYNC_VAULT_ACK * 3. Responder then sends ITS own SYNC_VAULT_SEND so the initiator gets * whichever vault is newer * 4. Initiator receives, applies if newer, replies with SYNC_VAULT_ACK * 5. Both sides close * * Either side can short-circuit with SYNC_ERROR (pro_required, vault_locked, * timeout). The session times out after 30 seconds total. */ export async function runBlobSyncSession( role: SyncRole, transport: SyncSessionTransport, deps: SyncCoordinatorDeps, ): Promise { if (!(await deps.isPro())) { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "pro_required" }), ); transport.close(); return { status: "errored", error: "pro_required" }; } if (!deps.isUnlocked()) { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }), ); transport.close(); return { status: "errored", error: "vault_locked" }; } return new Promise((resolve) => { let settled = false; const unsubscribers: Array<() => void> = []; const finish = (result: SyncSessionResult): void => { if (settled) return; settled = true; clearTimeout(timeoutHandle); for (const fn of unsubscribers) { try { fn(); } catch { // unsubscribe must not throw } } try { transport.close(); } catch { // close must not throw } resolve(result); }; const timeoutHandle = setTimeout( () => finish({ status: "errored", error: "timeout" }), SYNC_TIMEOUT_MS, ); unsubscribers.push(transport.onClose(() => finish({ status: "closed" }))); unsubscribers.push( transport.onError((message) => finish({ status: "errored", error: message || "transport_error" }), ), ); unsubscribers.push( transport.onFrame((frame) => { void (async () => { let msg: SyncMessage; try { msg = decodeSyncMessage(frame); } catch { await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason: "invalid_frame" }), ); finish({ status: "errored", error: "invalid_frame" }); return; } if (msg.type === "SYNC_VAULT_SEND") { try { await applyIncomingVault(msg.vault_a, msg.vault_b, msg.sent_at); } catch (err) { const reason = err instanceof Error ? err.message : "apply_failed"; await transport.send( encodeSyncMessage({ type: "SYNC_ERROR", reason }), ); finish({ status: "errored", error: reason }); return; } await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now(), }), ); if (role === "responder") { // Send our blob back so the initiator ends up with whichever is newer. const blobs = await deps.getVaultBlobs(); await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: blobs.vault_a, vault_b: blobs.vault_b, sent_at: Date.now(), }), ); // Now wait for initiator's ACK. } else { // Initiator received responder's vault and ACK'd — done. finish({ status: "completed" }); } } else if (msg.type === "SYNC_VAULT_ACK") { // Peer received our vault. If we are the responder we still need // to wait for the initiator's ACK after we send our blob back; in // practice that flow ends via the initiator's SYNC_VAULT_SEND // branch above. The simple case: we sent first, peer ACK'd, we // can close. if (role === "initiator") { // Wait — initiator only completes after receiving responder's // SYNC_VAULT_SEND. A naked ACK without the responder vault means // the responder chose not to send anything back; treat as done. finish({ status: "completed" }); } else { finish({ status: "completed" }); } } else if (msg.type === "SYNC_ERROR") { finish({ status: "errored", error: msg.reason }); } // PAIR_* messages are out-of-band for the data-sync session. })().catch((err) => { finish({ status: "errored", error: err instanceof Error ? err.message : String(err), }); }); }), ); if (role === "initiator") { void (async () => { try { const blobs = await deps.getVaultBlobs(); await transport.send( encodeSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: blobs.vault_a, vault_b: blobs.vault_b, sent_at: Date.now(), }), ); } catch (err) { finish({ status: "errored", error: err instanceof Error ? err.message : String(err), }); } })(); } }); } /** * Build a transport adapter that bridges a native-host connection (via sync.ts) * into the SyncSessionTransport interface runBlobSyncSession consumes. The * adapter scopes frame/error/disconnect events to a single connection id so * concurrent sync sessions on different connections don't cross-talk. */ export interface NativeSyncTransportBindings { sendSyncData: (connectionId: string, data: string) => void; onSyncMessage: ( handler: (connectionId: string, data: string) => void, ) => () => void; onError: ( handler: (message: string, connectionId?: string) => void, ) => () => void; onDisconnected: (handler: (connectionId?: string) => void) => () => void; disconnectFromPeer: (connectionId: string) => void; } async function loadNativeSyncBindings(): Promise { const sync = await import("./sync"); return { sendSyncData: sync.sendSyncData, onSyncMessage: sync.onSyncMessage, onError: sync.onError, onDisconnected: sync.onDisconnected, disconnectFromPeer: sync.disconnectFromPeer, }; } /** * Wait for the next sync frame on a specific connection or reject after * `timeoutMs`. Used by the pairing flow to await a single PAIR_ACCEPT / * PAIR_REJECT response. Resolves with the decoded SyncMessage; throws on * timeout, decode failure, or transport disconnect. */ export async function waitForPairingResponse( connectionId: string, timeoutMs: number, bindings?: NativeSyncTransportBindings, ): Promise { const ready = bindings ?? (await loadNativeSyncBindings()); return new Promise((resolve, reject) => { let settled = false; const cleanup: Array<() => void> = []; const finish = (action: () => void) => { if (settled) return; settled = true; for (const fn of cleanup) { try { fn(); } catch { // unsubscribe must not throw } } action(); }; const handle = setTimeout( () => finish(() => reject(new Error("pairing_response_timeout"))), timeoutMs, ); cleanup.push(() => clearTimeout(handle)); cleanup.push( ready.onSyncMessage((id, frame) => { if (id !== connectionId) return; try { const msg = decodeSyncMessage(frame); finish(() => resolve(msg)); } catch (err) { finish(() => reject(err instanceof Error ? err : new Error(String(err)))); } }), ); cleanup.push( ready.onDisconnected((id) => { if (id === undefined || id === connectionId) { finish(() => reject(new Error("pairing_connection_closed"))); } }), ); cleanup.push( ready.onError((message, id) => { if (id === undefined || id === connectionId) { finish(() => reject(new Error(message || "pairing_transport_error"))); } }), ); }); } export async function createNativeSyncTransport( connectionId: string, bindings?: NativeSyncTransportBindings, ): Promise { const ready = bindings ?? (await loadNativeSyncBindings()); return { send: (frame) => ready.sendSyncData(connectionId, frame), onFrame: (handler) => ready.onSyncMessage((id, frame) => { if (id === connectionId) handler(frame); }), onError: (handler) => ready.onError((message, id) => { if (id === undefined || id === connectionId) handler(message); }), onClose: (handler) => ready.onDisconnected((id) => { if (id === undefined || id === connectionId) handler(); }), close: () => ready.disconnectFromPeer(connectionId), }; }