123456789101112131415161718192021import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";22import type { SyncSession } from "../shared/sync-transport";23import { SYNC_LIMITS } from "../shared/sync-limits";2425const SESSION_TIMEOUT_MS = 30_000;2627282930313233export interface SyncVaultAdapter {34 computeVaultId(): Promise<string>;35 getOurDeviceId(): Promise<string>;36 37 getLocalVersion(): Promise<number>;38 39 buildEntryManifest(): Promise<Record<string, number>>;40 41 buildTombstoneManifest(): Promise<Record<string, number>>;42 43 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;44 45 applyEntries(entries: Record<string, string>): Promise<void>;46 47 applyTombstones(tombstones: Record<string, number>): Promise<void>;48 49505152535455565758 buildOutgoingDeviceManifest?(): Promise<SignedDeviceManifest | null>;59 6061626364 applyIncomingDeviceManifest?(65 manifest: SignedDeviceManifest,66 authenticatedRemoteStaticPublicKey: Uint8Array,67 ): 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 runSyncInitiator(86 session: SyncSession,87 vault: SyncVaultAdapter,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();101 const localVersion = await vault.getLocalVersion();102 const localTime = Date.now();103104 await sendMessage(session, {105 type: "SYNC_HELLO",106 vaultId: ourVaultId,107 deviceId: ourDeviceId,108 version: localVersion,109 deviceTime: localTime,110 });111 const helloIn = await receiveMessage(session, timer);112 if (helloIn.type !== "SYNC_HELLO") {113 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);114 }115 if (helloIn.vaultId !== ourVaultId) {116 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });117 result.status = "vault_mismatch";118 return result;119 }120121 if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {122 console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);123 }124125 126 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {127 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });128 const done = await receiveMessage(session, timer);129 if (done.type !== "SYNC_DONE") {130 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);131 }132 result.status = "completed";133 return result;134 }135136 const ourEntries = await vault.buildEntryManifest();137 const ourTombstones = await vault.buildTombstoneManifest();138 await sendMessage(session, {139 type: "SYNC_MANIFEST",140 entries: ourEntries,141 tombstones: ourTombstones,142 });143 const manifestIn = await receiveMessage(session, timer);144 if (manifestIn.type !== "SYNC_MANIFEST") {145 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);146 }147148 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);149 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });150151 const requestIn = await receiveMessage(session, timer);152 if (requestIn.type !== "SYNC_REQUEST") {153 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);154 }155 156 157 158159 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);160 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });161 result.entriesSent = Object.keys(ourPayload).length;162163 if (wantFromPeer.length > 0) {164 const entriesIn = await receiveMessage(session, timer);165 if (entriesIn.type !== "SYNC_ENTRIES") {166 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);167 }168 await vault.applyEntries(entriesIn.entries);169 result.entriesReceived = Object.keys(entriesIn.entries).length;170 }171172 173 174 const newerTombstonesForPeer: Record<string, number> = {};175 for (const [id, ts] of Object.entries(ourTombstones)) {176 const peerTs = manifestIn.tombstones[id] ?? 0;177 if (ts > peerTs) newerTombstonesForPeer[id] = ts;178 }179 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });180 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;181182 const tombstonesIn = await receiveMessage(session, timer);183 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {184 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);185 }186 await vault.applyTombstones(tombstonesIn.tombstones);187 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;188189 190 191 192 193 194 if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {195 const ours = await vault.buildOutgoingDeviceManifest();196 await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });197 const manifestIn = await receiveMessage(session, timer);198 if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {199 return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);200 }201 if (manifestIn.manifest) {202 await vault.applyIncomingDeviceManifest(203 manifestIn.manifest,204 session.remoteStaticPublicKey,205 );206 }207 }208209 await sendMessage(session, {210 type: "SYNC_DONE",211 entriesReceived: result.entriesReceived,212 entriesSent: result.entriesSent,213 });214 const done = await receiveMessage(session, timer);215 if (done.type !== "SYNC_DONE") {216 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);217 }218 result.status = "completed";219 return result;220 } catch (err) {221 if ((err as Error).message === "sync_timeout") {222 result.status = "timeout";223 result.error = "session timed out after 30s";224 return result;225 }226 return errorOutcome(result, (err as Error).message);227 } finally {228 229 230 231 timer.cancel();232 }233}234235function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {236 const want: string[] = [];237 for (const [id, ts] of Object.entries(remote)) {238 const ours = local[id];239 if (ours === undefined || ts > ours) want.push(id);240 }241 return want;242}243244interface Timer {245 signal: AbortSignal;246 cancel(): void;247}248249function scheduleTimeout(ms: number): Timer {250 const ctrl = new AbortController();251 const t = setTimeout(() => ctrl.abort(new Error("sync_timeout")), ms);252 return {253 signal: ctrl.signal,254 cancel: () => clearTimeout(t),255 };256}257258async function sendMessage(session: SyncSession, msg: SyncMessage): Promise<void> {259 session.send(new TextEncoder().encode(JSON.stringify(msg)));260}261262async function receiveMessage(session: SyncSession, timer: Timer): Promise<SyncMessage> {263 const pending = session.receive();264 const aborted = new Promise<never>((_, reject) => {265 if (timer.signal.aborted) reject(new Error("sync_timeout"));266 timer.signal.addEventListener("abort", () => reject(new Error("sync_timeout")));267 });268 const bytes = await Promise.race([pending, aborted]);269 270 271 272 if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {273 throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);274 }275 const parsed = JSON.parse(new TextDecoder().decode(bytes));276 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");277 return parsed as SyncMessage;278}279280function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {281 outcome.status = "errored";282 outcome.error = error;283 return outcome;284}