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 getLocalVersion(): Promise<number>;47 48 buildEntryManifest(): Promise<Record<string, number>>;49 50 buildTombstoneManifest(): Promise<Record<string, number>>;51 52 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;53 54 applyEntries(entries: Record<string, string>): Promise<void>;55 56 applyTombstones(tombstones: Record<string, number>): Promise<void>;57 58596061626364656667 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;68 69 applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;70}7172export interface SyncOutcome {73 status: "completed" | "vault_mismatch" | "errored" | "timeout";74 entriesReceived: number;75 entriesSent: number;76 tombstonesReceived: number;77 tombstonesSent: number;78 error?: string;79}8081828384858687export async function runChirpSyncInitiator(88 session: ChirpSyncSession,89 vault: ChirpSyncVaultAdapter,90): Promise<SyncOutcome> {91 const result: SyncOutcome = {92 status: "errored",93 entriesReceived: 0,94 entriesSent: 0,95 tombstonesReceived: 0,96 tombstonesSent: 0,97 };9899 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);100 try {101 const ourVaultId = await vault.computeVaultId();102 const ourDeviceId = await vault.getOurDeviceId();103 const localVersion = await vault.getLocalVersion();104 const localTime = Date.now();105106 await sendMessage(session, {107 type: "SYNC_HELLO",108 vaultId: ourVaultId,109 deviceId: ourDeviceId,110 version: localVersion,111 deviceTime: localTime,112 });113 const helloIn = await receiveMessage(session, timer);114 if (helloIn.type !== "SYNC_HELLO") {115 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);116 }117 if (helloIn.vaultId !== ourVaultId) {118 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });119 result.status = "vault_mismatch";120 return result;121 }122123 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {124 console.warn(`[chirp-sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);125 }126127 128 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {129 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });130 const done = await receiveMessage(session, timer);131 if (done.type !== "SYNC_DONE") {132 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);133 }134 result.status = "completed";135 return result;136 }137138 const ourEntries = await vault.buildEntryManifest();139 const ourTombstones = await vault.buildTombstoneManifest();140 await sendMessage(session, {141 type: "SYNC_MANIFEST",142 entries: ourEntries,143 tombstones: ourTombstones,144 });145 const manifestIn = await receiveMessage(session, timer);146 if (manifestIn.type !== "SYNC_MANIFEST") {147 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);148 }149150 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);151 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });152153 const requestIn = await receiveMessage(session, timer);154 if (requestIn.type !== "SYNC_REQUEST") {155 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);156 }157 158 159 160161 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);162 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });163 result.entriesSent = Object.keys(ourPayload).length;164165 if (wantFromPeer.length > 0) {166 const entriesIn = await receiveMessage(session, timer);167 if (entriesIn.type !== "SYNC_ENTRIES") {168 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);169 }170 await vault.applyEntries(entriesIn.entries);171 result.entriesReceived = Object.keys(entriesIn.entries).length;172 }173174 175 176 const newerTombstonesForPeer: Record<string, number> = {};177 for (const [id, ts] of Object.entries(ourTombstones)) {178 const peerTs = manifestIn.tombstones[id] ?? 0;179 if (ts > peerTs) newerTombstonesForPeer[id] = ts;180 }181 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });182 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;183184 const tombstonesIn = await receiveMessage(session, timer);185 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {186 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);187 }188 await vault.applyTombstones(tombstonesIn.tombstones);189 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;190191 192 193 194 195 196 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {197 const ours = await vault.buildOutgoingDeviceManifest();198 if (ours) {199 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });200 } else {201 202 203 204 205 await sendMessage(session, {206 type: "SYNC_DEVICE_MANIFEST",207 manifest: EMPTY_MANIFEST_PLACEHOLDER,208 });209 }210 const manifestIn = await receiveMessage(session, timer);211 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {212 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);213 }214 if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {215 await vault.applyIncomingDeviceManifest(manifestIn.manifest);216 }217 }218219 await sendMessage(session, {220 type: "SYNC_DONE",221 entriesReceived: result.entriesReceived,222 entriesSent: result.entriesSent,223 });224 const done = await receiveMessage(session, timer);225 if (done.type !== "SYNC_DONE") {226 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);227 }228 result.status = "completed";229 return result;230 } catch (err) {231 if ((err as Error).message === "chirp_sync_timeout") {232 result.status = "timeout";233 result.error = "session timed out after 30s";234 return result;235 }236 return errorOutcome(result, (err as Error).message);237 } finally {238 239 240 241 timer.cancel();242 }243}244245function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {246 const want: string[] = [];247 for (const [id, ts] of Object.entries(remote)) {248 const ours = local[id];249 if (ours === undefined || ts > ours) want.push(id);250 }251 return want;252}253254interface Timer {255 signal: AbortSignal;256 cancel(): void;257}258259function scheduleTimeout(ms: number): Timer {260 const ctrl = new AbortController();261 const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms);262 return {263 signal: ctrl.signal,264 cancel: () => clearTimeout(t),265 };266}267268async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise<void> {269 session.send(new TextEncoder().encode(JSON.stringify(msg)));270}271272async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise<ChirpSyncMessage> {273 const pending = session.receive();274 const aborted = new Promise<never>((_, reject) => {275 if (timer.signal.aborted) reject(new Error("chirp_sync_timeout"));276 timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));277 });278 const bytes = await Promise.race([pending, aborted]);279 const parsed = JSON.parse(new TextDecoder().decode(bytes));280 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");281 return parsed as ChirpSyncMessage;282}283284function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {285 outcome.status = "errored";286 outcome.error = error;287 return outcome;288}