// Chirp Sync — Phase 2 manifest-diff coordinator. // // After Noise XX is open and the responder's static key has been verified // against the paired-device registry, this coordinator drives the // manifest exchange: // // -> SYNC_HELLO { vaultId, deviceId } // <- SYNC_HELLO { vaultId, deviceId } (mismatch → SYNC_ERROR) // <-> SYNC_MANIFEST { entries, tombstones } (each side sends one) // -> SYNC_REQUEST { entryIds } (entries newer on the peer) // <- SYNC_ENTRIES { entries } // <- SYNC_REQUEST { entryIds } (entries newer on us) // -> SYNC_ENTRIES { entries } // <-> SYNC_TOMBSTONES { tombstones } // <-> SYNC_DONE // // Entries are opaque base64 ciphertext — the coordinator never decrypts. // Last-write-wins at the entry level by mtime; tombstones override updates // with a later deletion timestamp. import type { ChirpSyncMessage } from "../shared/sync-types"; import type { ChirpSyncSession } from "../shared/chirp-sync-transport"; const SESSION_TIMEOUT_MS = 30_000; /** * Vault-side adapter — the coordinator's dependency on the vault layer. * The vault holds the canonical entry/tombstone tables; the coordinator * pulls a manifest, requests entries by id, applies remote entries, and * applies remote tombstones. */ export interface ChirpSyncVaultAdapter { computeVaultId(): Promise; getOurDeviceId(): Promise; /** Returns the per-entry mtimes for our active entries. */ buildEntryManifest(): Promise>; /** Returns the per-entry deletion timestamps for our tombstones. */ buildTombstoneManifest(): Promise>; /** Returns base64 ciphertext for each requested entry id. */ getEncryptedEntries(entryIds: string[]): Promise>; /** Apply incoming encrypted entries; vault decides per-entry overwrite. */ applyEntries(entries: Record): Promise; /** Apply incoming tombstones; vault retains the latest deletion ts. */ applyTombstones(tombstones: Record): Promise; } export interface SyncOutcome { status: "completed" | "vault_mismatch" | "errored" | "timeout"; entriesReceived: number; entriesSent: number; tombstonesReceived: number; tombstonesSent: number; error?: string; } /** * Run one sync round as the initiator. The extension is always the * initiator (it is the WebSocket client). The peer's role is symmetric * after SYNC_HELLO, so a corresponding responder helper exists for the * mobile-side coordinator that calls this same protocol the other way. */ export async function runChirpSyncInitiator( session: ChirpSyncSession, vault: ChirpSyncVaultAdapter, ): Promise { const result: SyncOutcome = { status: "errored", entriesReceived: 0, entriesSent: 0, tombstonesReceived: 0, tombstonesSent: 0, }; const timer = scheduleTimeout(SESSION_TIMEOUT_MS); try { const ourVaultId = await vault.computeVaultId(); const ourDeviceId = await vault.getOurDeviceId(); await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId }); const helloIn = await receiveMessage(session, timer); if (helloIn.type !== "SYNC_HELLO") { return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`); } if (helloIn.vaultId !== ourVaultId) { await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" }); result.status = "vault_mismatch"; return result; } const ourEntries = await vault.buildEntryManifest(); const ourTombstones = await vault.buildTombstoneManifest(); await sendMessage(session, { type: "SYNC_MANIFEST", entries: ourEntries, tombstones: ourTombstones, }); const manifestIn = await receiveMessage(session, timer); if (manifestIn.type !== "SYNC_MANIFEST") { return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`); } const wantFromPeer = diffNewer(manifestIn.entries, ourEntries); await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer }); const requestIn = await receiveMessage(session, timer); if (requestIn.type !== "SYNC_REQUEST") { return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`); } // Sanity: the peer's request must be a subset of what its manifest // declared as newer. A malformed/over-eager request is a protocol // violation but not a data-safety problem — we just send what they ask. const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds); await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload }); result.entriesSent = Object.keys(ourPayload).length; if (wantFromPeer.length > 0) { const entriesIn = await receiveMessage(session, timer); if (entriesIn.type !== "SYNC_ENTRIES") { return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`); } await vault.applyEntries(entriesIn.entries); result.entriesReceived = Object.keys(entriesIn.entries).length; } // Tombstones: each side ships the tombstones that the other does not // yet have a newer record for. const newerTombstonesForPeer: Record = {}; for (const [id, ts] of Object.entries(ourTombstones)) { const peerTs = manifestIn.tombstones[id] ?? 0; if (ts > peerTs) newerTombstonesForPeer[id] = ts; } await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer }); result.tombstonesSent = Object.keys(newerTombstonesForPeer).length; const tombstonesIn = await receiveMessage(session, timer); if (tombstonesIn.type !== "SYNC_TOMBSTONES") { return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`); } await vault.applyTombstones(tombstonesIn.tombstones); result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length; await sendMessage(session, { type: "SYNC_DONE" }); const done = await receiveMessage(session, timer); if (done.type !== "SYNC_DONE") { return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`); } result.status = "completed"; return result; } catch (err) { if ((err as Error).message === "chirp_sync_timeout") { result.status = "timeout"; result.error = "session timed out after 30s"; return result; } return errorOutcome(result, (err as Error).message); } finally { timer.cancel(); await session.close().catch(() => undefined); } } function diffNewer(remote: Record, local: Record): string[] { const want: string[] = []; for (const [id, ts] of Object.entries(remote)) { const ours = local[id]; if (ours === undefined || ts > ours) want.push(id); } return want; } interface Timer { signal: AbortSignal; cancel(): void; } function scheduleTimeout(ms: number): Timer { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms); return { signal: ctrl.signal, cancel: () => clearTimeout(t), }; } async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise { session.send(new TextEncoder().encode(JSON.stringify(msg))); } async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise { const pending = session.receive(); const aborted = new Promise((_, reject) => { if (timer.signal.aborted) reject(new Error("chirp_sync_timeout")); timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout"))); }); const bytes = await Promise.race([pending, aborted]); const parsed = JSON.parse(new TextDecoder().decode(bytes)); if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage"); return parsed as ChirpSyncMessage; } function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome { outcome.status = "errored"; outcome.error = error; return outcome; }