1234567891011121314151617181920import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";21import type { SyncSession } from "../shared/sync-transport";22import { SYNC_LIMITS } from "../shared/sync-limits";2324const SESSION_TIMEOUT_MS = 30_000;2526272829303132export interface SyncVaultAdapter {33 computeVaultId(): Promise<string>;34 getOurDeviceId(): Promise<string>;35 36 getLocalVersion(): Promise<number>;37 38 buildEntryManifest(): Promise<Record<string, number>>;39 40 buildTombstoneManifest(): Promise<Record<string, number>>;41 42 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;43 44 applyEntries(entries: Record<string, string>): Promise<void>;45 46 applyTombstones(tombstones: Record<string, number>): Promise<void>;47 48495051525354555657 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;58 5960616263 applyIncomingDeviceManifest?(64 manifest: SignedDeviceManifest,65 authenticatedRemoteStaticPublicKey: Uint8Array,66 ): Promise<void>;67}6869export interface SyncOutcome {70 status: "completed" | "vault_mismatch" | "errored" | "timeout";71 entriesReceived: number;72 entriesSent: number;73 tombstonesReceived: number;74 tombstonesSent: number;75 error?: string;76}7778798081828384export async function runSyncInitiator(85 session: SyncSession,86 vault: SyncVaultAdapter,87): Promise<SyncOutcome> {88 const result: SyncOutcome = {89 status: "errored",90 entriesReceived: 0,91 entriesSent: 0,92 tombstonesReceived: 0,93 tombstonesSent: 0,94 };9596 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);97 try {98 const ourVaultId = await vault.computeVaultId();99 const ourDeviceId = await vault.getOurDeviceId();100 const localVersion = await vault.getLocalVersion();101 const localTime = Date.now();102103 await sendMessage(session, {104 type: "SYNC_HELLO",105 vaultId: ourVaultId,106 deviceId: ourDeviceId,107 version: localVersion,108 deviceTime: localTime,109 });110 const helloIn = await receiveMessage(session, timer);111 if (helloIn.type !== "SYNC_HELLO") {112 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);113 }114 if (helloIn.vaultId !== ourVaultId) {115 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });116 result.status = "vault_mismatch";117 return result;118 }119120 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {121 console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);122 }123124 125 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {126 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });127 const done = await receiveMessage(session, timer);128 if (done.type !== "SYNC_DONE") {129 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);130 }131 result.status = "completed";132 return result;133 }134135 const ourEntries = await vault.buildEntryManifest();136 const ourTombstones = await vault.buildTombstoneManifest();137 await sendMessage(session, {138 type: "SYNC_MANIFEST",139 entries: ourEntries,140 tombstones: ourTombstones,141 });142 const manifestIn = await receiveMessage(session, timer);143 if (manifestIn.type !== "SYNC_MANIFEST") {144 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);145 }146147 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);148 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });149150 const requestIn = await receiveMessage(session, timer);151 if (requestIn.type !== "SYNC_REQUEST") {152 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);153 }154 155 156 157158 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);159 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });160 result.entriesSent = Object.keys(ourPayload).length;161162 if (wantFromPeer.length > 0) {163 const entriesIn = await receiveMessage(session, timer);164 if (entriesIn.type !== "SYNC_ENTRIES") {165 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);166 }167 await vault.applyEntries(entriesIn.entries);168 result.entriesReceived = Object.keys(entriesIn.entries).length;169 }170171 172 173 const newerTombstonesForPeer: Record<string, number> = {};174 for (const [id, ts] of Object.entries(ourTombstones)) {175 const peerTs = manifestIn.tombstones[id] ?? 0;176 if (ts > peerTs) newerTombstonesForPeer[id] = ts;177 }178 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });179 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;180181 const tombstonesIn = await receiveMessage(session, timer);182 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {183 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);184 }185 await vault.applyTombstones(tombstonesIn.tombstones);186 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;187188 189 190 191 192 193 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {194 const ours = await vault.buildOutgoingDeviceManifest();195 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });196 const manifestIn = await receiveMessage(session, timer);197 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {198 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);199 }200 if (manifestIn.manifest) {201 await vault.applyIncomingDeviceManifest(202 manifestIn.manifest,203 session.remoteStaticPublicKey,204 );205 }206 }207208 await sendMessage(session, {209 type: "SYNC_DONE",210 entriesReceived: result.entriesReceived,211 entriesSent: result.entriesSent,212 });213 const done = await receiveMessage(session, timer);214 if (done.type !== "SYNC_DONE") {215 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);216 }217 result.status = "completed";218 return result;219 } catch (err) {220 if ((err as Error).message === "sync_timeout") {221 result.status = "timeout";222 result.error = "session timed out after 30s";223 return result;224 }225 return errorOutcome(result, (err as Error).message);226 } finally {227 228 229 230 timer.cancel();231 }232}233234function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {235 const want: string[] = [];236 for (const [id, ts] of Object.entries(remote)) {237 const ours = local[id];238 if (ours === undefined || ts > ours) want.push(id);239 }240 return want;241}242243interface Timer {244 signal: AbortSignal;245 cancel(): void;246}247248function scheduleTimeout(ms: number): Timer {249 const ctrl = new AbortController();250 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);251 return {252 signal: ctrl.signal,253 cancel: () => clearTimeout(t),254 };255}256257async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {258 session.send(new TextEncoder().encode(JSON.stringify(msg)));259}260261async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {262 const pending = session.receive();263 const aborted = new Promise<never>((_, reject) => {264 if (timer.signal.aborted) reject(new Error("sync_timeout"));265 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));266 });267 const bytes = await Promise.race([pending, aborted]);268 269 270 271 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {272 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);273 }274 const parsed = JSON.parse(new TextDecoder().decode(bytes));275 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");276 return parsed as SyncMessage;277}278279function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {280 outcome.status = "errored";281 outcome.error = error;282 return outcome;283}