123456789101112131415161718192021import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";22import type { ChirpSyncSession } from "../shared/chirp-sync-transport";23import { SYNC_LIMITS } from "../shared/sync-limits";2425const SESSION_TIMEOUT_MS = 30_000;26272829303132const EMPTY_MANIFEST_PLACEHOLDER: SignedDeviceManifest = {33 body: { manifestVersion: 1, issuerDeviceId: "", issuedAt: 0, devices: [] },34 signatureHex: "0".repeat(128),35};3637383940414243export interface ChirpSyncVaultAdapter {44 computeVaultId(): Promise<string>;45 getOurDeviceId(): Promise<string>;46 47 getLocalVersion(): Promise<number>;48 49 buildEntryManifest(): Promise<Record<string, number>>;50 51 buildTombstoneManifest(): Promise<Record<string, number>>;52 53 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;54 55 applyEntries(entries: Record<string, string>): Promise<void>;56 57 applyTombstones(tombstones: Record<string, number>): Promise<void>;58 59606162636465666768 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;69 70 applyIncomingDeviceManifest?(manifest: SignedDeviceManifest): Promise<void>;71}7273export interface SyncOutcome {74 status: "completed" | "vault_mismatch" | "errored" | "timeout";75 entriesReceived: number;76 entriesSent: number;77 tombstonesReceived: number;78 tombstonesSent: number;79 error?: string;80}8182838485868788export async function runChirpSyncInitiator(89 session: ChirpSyncSession,90 vault: ChirpSyncVaultAdapter,91): Promise<SyncOutcome> {92 const result: SyncOutcome = {93 status: "errored",94 entriesReceived: 0,95 entriesSent: 0,96 tombstonesReceived: 0,97 tombstonesSent: 0,98 };99100 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);101 try {102 const ourVaultId = await vault.computeVaultId();103 const ourDeviceId = await vault.getOurDeviceId();104 const localVersion = await vault.getLocalVersion();105 const localTime = Date.now();106107 await sendMessage(session, {108 type: "SYNC_HELLO",109 vaultId: ourVaultId,110 deviceId: ourDeviceId,111 version: localVersion,112 deviceTime: localTime,113 });114 const helloIn = await receiveMessage(session, timer);115 if (helloIn.type !== "SYNC_HELLO") {116 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);117 }118 if (helloIn.vaultId !== ourVaultId) {119 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });120 result.status = "vault_mismatch";121 return result;122 }123124 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {125 console.warn(`[chirp-sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);126 }127128 129 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {130 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });131 const done = await receiveMessage(session, timer);132 if (done.type !== "SYNC_DONE") {133 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);134 }135 result.status = "completed";136 return result;137 }138139 const ourEntries = await vault.buildEntryManifest();140 const ourTombstones = await vault.buildTombstoneManifest();141 await sendMessage(session, {142 type: "SYNC_MANIFEST",143 entries: ourEntries,144 tombstones: ourTombstones,145 });146 const manifestIn = await receiveMessage(session, timer);147 if (manifestIn.type !== "SYNC_MANIFEST") {148 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);149 }150151 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);152 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });153154 const requestIn = await receiveMessage(session, timer);155 if (requestIn.type !== "SYNC_REQUEST") {156 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);157 }158 159 160 161162 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);163 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });164 result.entriesSent = Object.keys(ourPayload).length;165166 if (wantFromPeer.length > 0) {167 const entriesIn = await receiveMessage(session, timer);168 if (entriesIn.type !== "SYNC_ENTRIES") {169 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);170 }171 await vault.applyEntries(entriesIn.entries);172 result.entriesReceived = Object.keys(entriesIn.entries).length;173 }174175 176 177 const newerTombstonesForPeer: Record<string, number> = {};178 for (const [id, ts] of Object.entries(ourTombstones)) {179 const peerTs = manifestIn.tombstones[id] ?? 0;180 if (ts > peerTs) newerTombstonesForPeer[id] = ts;181 }182 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });183 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;184185 const tombstonesIn = await receiveMessage(session, timer);186 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {187 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);188 }189 await vault.applyTombstones(tombstonesIn.tombstones);190 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;191192 193 194 195 196 197 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {198 const ours = await vault.buildOutgoingDeviceManifest();199 if (ours) {200 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });201 } else {202 203 204 205 206 await sendMessage(session, {207 type: "SYNC_DEVICE_MANIFEST",208 manifest: EMPTY_MANIFEST_PLACEHOLDER,209 });210 }211 const manifestIn = await receiveMessage(session, timer);212 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {213 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);214 }215 if (manifestIn.manifest && manifestIn.manifest.signatureHex !== EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {216 await vault.applyIncomingDeviceManifest(manifestIn.manifest);217 }218 }219220 await sendMessage(session, {221 type: "SYNC_DONE",222 entriesReceived: result.entriesReceived,223 entriesSent: result.entriesSent,224 });225 const done = await receiveMessage(session, timer);226 if (done.type !== "SYNC_DONE") {227 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);228 }229 result.status = "completed";230 return result;231 } catch (err) {232 if ((err as Error).message === "chirp_sync_timeout") {233 result.status = "timeout";234 result.error = "session timed out after 30s";235 return result;236 }237 return errorOutcome(result, (err as Error).message);238 } finally {239 240 241 242 timer.cancel();243 }244}245246function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {247 const want: string[] = [];248 for (const [id, ts] of Object.entries(remote)) {249 const ours = local[id];250 if (ours === undefined || ts > ours) want.push(id);251 }252 return want;253}254255interface Timer {256 signal: AbortSignal;257 cancel(): void;258}259260function scheduleTimeout(ms: number): Timer {261 const ctrl = new AbortController();262 const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms);263 return {264 signal: ctrl.signal,265 cancel: () => clearTimeout(t),266 };267}268269async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise<void> {270 session.send(new TextEncoder().encode(JSON.stringify(msg)));271}272273async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise<ChirpSyncMessage> {274 const pending = session.receive();275 const aborted = new Promise<never>((_, reject) => {276 if (timer.signal.aborted) reject(new Error("chirp_sync_timeout"));277 timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));278 });279 const bytes = await Promise.race([pending, aborted]);280 281 282 283 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {284 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);285 }286 const parsed = JSON.parse(new TextDecoder().decode(bytes));287 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");288 return parsed as ChirpSyncMessage;289}290291function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {292 outcome.status = "errored";293 outcome.error = error;294 return outcome;295}