123456789101112131415161718192021import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";22import type { ChirpSyncSession } from "../shared/chirp-sync-transport";2324const SESSION_TIMEOUT_MS = 30_000;25262728293031const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {32 body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },33 signatureHex: "0".repeat(128),34};3536373839404142export interface ChirpSyncVaultAdapter {43 computeVaultId(): Promise<string>;44 getOurDeviceId(): Promise<string>;45 46 buildEntryManifest(): Promise<Record<string, number>>;47 48 buildTombstoneManifest(): Promise<Record<string, number>>;49 50 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;51 52 applyEntries(entries: Record<string, string>): Promise<void>;53 54 applyTombstones(tombstones: Record<string, number>): Promise<void>;55 56575859606162636465 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;66 67 applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;68}6970export interface SyncOutcome {71 status: "completed" | "vault_mismatch" | "errored" | "timeout";72 entriesReceived: number;73 entriesSent: number;74 tombstonesReceived: number;75 tombstonesSent: number;76 error?: string;77}7879808182838485export async function runChirpSyncInitiator(86 session: ChirpSyncSession,87 vault: ChirpSyncVaultAdapter,88): Promise<SyncOutcome> {89 const result: SyncOutcome = {90 status: "errored",91 entriesReceived: 0,92 entriesSent: 0,93 tombstonesReceived: 0,94 tombstonesSent: 0,95 };9697 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);98 try {99 const ourVaultId = await vault.computeVaultId();100 const ourDeviceId = await vault.getOurDeviceId();101102 await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId });103 const helloIn = await receiveMessage(session, timer);104 if (helloIn.type !== "SYNC_HELLO") {105 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);106 }107 if (helloIn.vaultId !== ourVaultId) {108 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });109 result.status = "vault_mismatch";110 return result;111 }112113 const ourEntries = await vault.buildEntryManifest();114 const ourTombstones = await vault.buildTombstoneManifest();115 await sendMessage(session, {116 type: "SYNC_MANIFEST",117 entries: ourEntries,118 tombstones: ourTombstones,119 });120 const manifestIn = await receiveMessage(session, timer);121 if (manifestIn.type !== "SYNC_MANIFEST") {122 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);123 }124125 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);126 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });127128 const requestIn = await receiveMessage(session, timer);129 if (requestIn.type !== "SYNC_REQUEST") {130 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);131 }132 133 134 135136 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);137 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });138 result.entriesSent = Object.keys(ourPayload).length;139140 if (wantFromPeer.length > 0) {141 const entriesIn = await receiveMessage(session, timer);142 if (entriesIn.type !== "SYNC_ENTRIES") {143 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);144 }145 await vault.applyEntries(entriesIn.entries);146 result.entriesReceived = Object.keys(entriesIn.entries).length;147 }148149 150 151 const newerTombstonesForPeer: Record<string, number> = {};152 for (const [id, ts] of Object.entries(ourTombstones)) {153 const peerTs = manifestIn.tombstones[id] ?? 0;154 if (ts > peerTs) newerTombstonesForPeer[id] = ts;155 }156 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });157 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;158159 const tombstonesIn = await receiveMessage(session, timer);160 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {161 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);162 }163 await vault.applyTombstones(tombstonesIn.tombstones);164 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;165166 167 168 169 170 171 172 173 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {174 const ours = await vault.buildOutgoingDeviceManifest();175 if (ours) {176 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });177 } else {178 179 180 181 182 await sendMessage(session, {183 type: "SYNC_DEVICE_MANIFEST",184 manifest: EMPTY_MANIFEST_PLACEHOLDER,185 });186 }187 const manifestIn = await receiveMessage(session, timer);188 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {189 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);190 }191 if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {192 await vault.applyIncomingDeviceManifest(manifestIn.manifest);193 }194 }195196 await sendMessage(session, { type: "SYNC_DONE" });197 const done = await receiveMessage(session, timer);198 if (done.type !== "SYNC_DONE") {199 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);200 }201 result.status = "completed";202 return result;203 } catch (err) {204 if ((err as Error).message === "chirp_sync_timeout") {205 result.status = "timeout";206 result.error = "session timed out after 30s";207 return result;208 }209 return errorOutcome(result, (err as Error).message);210 } finally {211 timer.cancel();212 await session.close().catch(() => undefined);213 }214}215216function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {217 const want: string[] = [];218 for (const [id, ts] of Object.entries(remote)) {219 const ours = local[id];220 if (ours === undefined || ts > ours) want.push(id);221 }222 return want;223}224225interface Timer {226 signal: AbortSignal;227 cancel(): void;228}229230function scheduleTimeout(ms: number): Timer {231 const ctrl = new AbortController();232 const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms);233 return {234 signal: ctrl.signal,235 cancel: () => clearTimeout(t),236 };237}238239async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise<void> {240 session.send(new TextEncoder().encode(JSON.stringify(msg)));241}242243async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise<ChirpSyncMessage> {244 const pending = session.receive();245 const aborted = new Promise<never>((_, reject) => {246 if (timer.signal.aborted) reject(new Error("chirp_sync_timeout"));247 timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));248 });249 const bytes = await Promise.race([pending, aborted]);250 const parsed = JSON.parse(new TextDecoder().decode(bytes));251 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");252 return parsed as ChirpSyncMessage;253}254255function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {256 outcome.status = "errored";257 outcome.error = error;258 return outcome;259}