1import { describe, expect, it, vi } from "vitest";2import { SYNC_LIMITS } from "../shared/sync-limits";3import {4 runSyncInitiator,5 runSyncResponder,6 type SyncVaultAdapter,7} from "./sync-coordinator";8import {9 DEVICE_MANIFEST_VERSION,10 SYNC_PROTOCOL_VERSION,11 type SyncMessage,12 type SignedDeviceManifest,13} from "../shared/sync-types";14import type { SyncSession } from "../shared/sync-transport";15import { buildSignedDeviceManifest } from "../shared/device-manifest";16import { deriveSyncSigningKeypair } from "../shared/legacy-identity-derivation.test-support";1718function inMemoryPair(): [SyncSession, FakePeer] {19 const a: Uint8Array[] = [];20 const b: Uint8Array[] = [];21 const aR: Array<(v: Uint8Array) => void> = [];22 const bR: Array<(v: Uint8Array) => void> = [];23 const aRej: Array<(e: Error) => void> = [];24 let closed = false;2526 const ourSession: SyncSession = {27 send(bytes) {28 const r = bR.shift();29 if (r) r(bytes); else b.push(bytes);30 },31 receive(): Promise<Uint8Array> {32 const v = a.shift();33 if (v) return Promise.resolve(v);34 if (closed) return Promise.reject(new Error("WebSocket closed"));35 return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });36 },37 async close() { },38 remoteStaticPublicKey: new Uint8Array(32),39 };4041 const peer: FakePeer = {42 async send(msg: SyncMessage) {43 const bytes = new TextEncoder().encode(JSON.stringify(msg));44 const r = aR.shift();45 if (r) r(bytes); else a.push(bytes);46 },47 async receive(): Promise<SyncMessage> {48 const v = b.shift();49 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));50 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;51 },52 5354555657 async closeInitiatorReceive() {58 if (closed) return;59 closed = true;60 while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));61 },62 };63 return [ourSession, peer];64}6566function symmetricSessionPair(): [SyncSession, SyncSession] {67 const queues: [Uint8Array[], Uint8Array[]] = [[], []];68 const readers: [69 Array<(value: Uint8Array) => void>,70 Array<(value: Uint8Array) => void>,71 ] = [[], []];72 const make = (side: 0 | 1): SyncSession => ({73 send(bytes) {74 const other = side === 0 ? 1 : 0;75 const reader = readers[other].shift();76 if (reader) reader(bytes);77 else queues[other].push(bytes);78 },79 receive() {80 const queued = queues[side].shift();81 if (queued) return Promise.resolve(queued);82 return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));83 },84 async close() {},85 remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),86 });87 return [make(0), make(1)];88}8990interface FakePeer {91 send(msg: SyncMessage): Promise<void>;92 receive(): Promise<SyncMessage>;93 94 closeInitiatorReceive(): Promise<void>;95}9697function makeVaultAdapter(opts: {98 vaultId?: string;99 deviceId?: string;100 localVersion?: number;101 entries?: Record<string, number>;102 tombstones?: Record<string, number>;103 ciphertext?: Record<string, string>;104}): SyncVaultAdapter & {105 appliedEntries: Record<string, string>[];106 appliedTombstones: Record<string, number>[];107} {108 const appliedEntries: Record<string, string>[] = [];109 const appliedTombstones: Record<string, number>[] = [];110 return {111 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },112 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },113 114 115 async getLocalVersion() { return opts.localVersion ?? 5; },116 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },117 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },118 async getEncryptedEntries(ids) {119 const out: Record<string, string> = {};120 const cipher = opts.ciphertext ?? {};121 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];122 return out;123 },124 async applyEntries(entries) { appliedEntries.push(entries); },125 async applyTombstones(t) { appliedTombstones.push(t); },126 appliedEntries,127 appliedTombstones,128 };129}130131132133134135136describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {137 it("rejects an oversized id list before any per-entry work", async () => {138 const [session, peer] = inMemoryPair();139 const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });140 let encryptedFor: string[] | null = null;141 const spied = Object.assign({}, vault, {142 async getEncryptedEntries(ids: string[]) {143 encryptedFor = ids;144 return {};145 },146 });147148 const initPromise = runSyncInitiator(session, spied);149 void (async () => {150 await peer.receive(); 151 await peer.send({152 type: "SYNC_HELLO",153 vaultId: "vault-abc",154 deviceId: "p",155 version: 0,156 deviceTime: Date.now(),157 protocolVersion: 3,158 });159 await peer.receive(); 160 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });161 await peer.receive(); 162 163 await peer.send({164 type: "SYNC_REQUEST",165 entryIds: Array.from(166 { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },167 (_, i) => `id-${i}`,168 ),169 });170 })();171172 const outcome = await initPromise;173 expect(outcome.status).toBe("errored");174 expect(outcome.error).toMatch(/entry count exceeds limit/);175 176 expect(encryptedFor).toBeNull();177 });178179 it("still serves a request at the limit", async () => {180 const [session, peer] = inMemoryPair();181 const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);182 const vault = makeVaultAdapter({183 vaultId: "vault-abc",184 entries: { "id-0": 1, "id-1": 1, "id-2": 1 },185 ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },186 });187188 const initPromise = runSyncInitiator(session, vault);189 void (async () => {190 await peer.receive(); 191 await peer.send({192 type: "SYNC_HELLO",193 vaultId: "vault-abc",194 deviceId: "p",195 version: 0,196 deviceTime: Date.now(),197 protocolVersion: 3,198 });199 await peer.receive(); 200 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });201 await peer.receive(); 202 await peer.send({ type: "SYNC_REQUEST", entryIds: ids });203 await peer.receive(); 204 await peer.receive(); 205 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });206 await peer.receive(); 207 await peer.send({ type: "SYNC_DONE" });208 })();209210 const outcome = await initPromise;211 expect(outcome.status).toBe("completed");212 expect(outcome.entriesSent).toBe(3);213 });214});215216describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {217 it("rejects oversized entry and tombstone manifests before diffing", async () => {218 const [session, peer] = inMemoryPair();219 const vault = makeVaultAdapter({ vaultId: "vault-abc" });220 const initPromise = runSyncInitiator(session, vault);221 void (async () => {222 await peer.receive();223 await peer.send({224 type: "SYNC_HELLO",225 vaultId: "vault-abc",226 deviceId: "p",227 version: 0,228 deviceTime: Date.now(),229 protocolVersion: 3,230 });231 await peer.receive();232 const entries = Object.fromEntries(233 Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),234 );235 await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });236 })();237 await expect(initPromise).resolves.toMatchObject({238 status: "errored",239 error: expect.stringMatching(/manifest entry count exceeds limit/),240 });241242 const [tombstoneSession, tombstonePeer] = inMemoryPair();243 const tombstonePromise = runSyncInitiator(tombstoneSession, vault);244 void (async () => {245 await tombstonePeer.receive();246 await tombstonePeer.send({247 type: "SYNC_HELLO",248 vaultId: "vault-abc",249 deviceId: "p",250 version: 0,251 deviceTime: Date.now(),252 protocolVersion: 3,253 });254 await tombstonePeer.receive();255 const tombstones = Object.fromEntries(256 Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),257 );258 await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });259 })();260 await expect(tombstonePromise).resolves.toMatchObject({261 status: "errored",262 error: expect.stringMatching(/manifest tombstone count exceeds limit/),263 });264 });265});266267describe("runSyncInitiator — HELLO fields and version optimisation", () => {268 it("sends HELLO with version and deviceTime", async () => {269 const [session, peer] = inMemoryPair();270 const vault = makeVaultAdapter({ localVersion: 7 });271272 const initPromise = runSyncInitiator(session, vault);273274 const sentHello = await peer.receive();275 expect(sentHello.type).toBe("SYNC_HELLO");276 if (sentHello.type === "SYNC_HELLO") {277 expect(sentHello.version).toBe(7);278 expect(typeof sentHello.deviceTime).toBe("number");279 expect(sentHello.deviceTime).toBeGreaterThan(0);280 }281 282 await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 3 });283 await peer.receive(); 284 await initPromise;285 });286287 it("exchanges entry manifests when vault versions are equal", async () => {288 const [dialerSession, waiterSession] = symmetricSessionPair();289 const dialer = makeVaultAdapter({290 deviceId: "dialer",291 localVersion: 10,292 entries: { a: 10 },293 ciphertext: { a: "cipher-a" },294 });295 const waiter = makeVaultAdapter({296 deviceId: "waiter",297 localVersion: 10,298 entries: { b: 11 },299 ciphertext: { b: "cipher-b" },300 });301302 const [dialerOutcome, waiterOutcome] = await Promise.all([303 runSyncInitiator(dialerSession, dialer),304 runSyncResponder(waiterSession, waiter),305 ]);306307 expect(dialerOutcome.status).toBe("completed");308 expect(waiterOutcome.status).toBe("completed");309 expect(dialer.appliedEntries).toEqual([{ b: "cipher-b" }]);310 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);311 });312313 it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {314 const [dialerSession, waiterSession] = symmetricSessionPair();315 const dialerManifest = {316 body: {317 manifestVersion: 5,318 issuerDeviceId: "dialer",319 issuedAt: 1,320 devices: [],321 revoked: [],322 },323 signatureHex: "11".repeat(64),324 } satisfies SignedDeviceManifest;325 const waiterManifest = {326 body: {327 manifestVersion: 5,328 issuerDeviceId: "waiter",329 issuedAt: 1,330 devices: [],331 revoked: [],332 },333 signatureHex: "22".repeat(64),334 } satisfies SignedDeviceManifest;335 const dialerApplied: SignedDeviceManifest[] = [];336 const waiterApplied: SignedDeviceManifest[] = [];337 const dialer: SyncVaultAdapter = {338 ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),339 async buildOutgoingDeviceManifest() { return dialerManifest; },340 async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },341 };342 const waiter: SyncVaultAdapter = {343 ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),344 async buildOutgoingDeviceManifest() { return waiterManifest; },345 async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },346 };347348 const [dialerOutcome, waiterOutcome] = await Promise.all([349 runSyncInitiator(dialerSession, dialer),350 runSyncResponder(waiterSession, waiter),351 ]);352353 expect(dialerOutcome.status).toBe("completed");354 expect(waiterOutcome.status).toBe("completed");355 expect(dialerApplied).toEqual([waiterManifest]);356 expect(waiterApplied).toEqual([dialerManifest]);357 });358359 it("negotiates the device-manifest step from both HELLOs in every role and version case", async () => {360 for (const initiatorSupports of [false, true]) {361 for (const responderSupports of [false, true]) {362 for (const equalVersions of [false, true]) {363 const [initiatorSession, responderSession] = symmetricSessionPair();364 const appliedByInitiator: SignedDeviceManifest[] = [];365 const appliedByResponder: SignedDeviceManifest[] = [];366 const manifest = (issuerDeviceId: string): SignedDeviceManifest => ({367 body: {368 manifestVersion: DEVICE_MANIFEST_VERSION,369 issuerDeviceId,370 issuedAt: 1,371 devices: [],372 revoked: [],373 },374 signatureHex: "11".repeat(64),375 });376 const adapter = (377 deviceId: string,378 localVersion: number,379 supports: boolean,380 applied: SignedDeviceManifest[],381 ): SyncVaultAdapter => ({382 ...makeVaultAdapter({ deviceId, localVersion }),383 ...(supports ? {384 async buildOutgoingDeviceManifest() { return manifest(deviceId); },385 async applyIncomingDeviceManifest(incoming: SignedDeviceManifest) {386 applied.push(incoming);387 },388 } : {}),389 });390 const initiator = adapter("initiator", 7, initiatorSupports, appliedByInitiator);391 const responder = adapter(392 "responder",393 equalVersions ? 7 : 9,394 responderSupports,395 appliedByResponder,396 );397398 const [initiatorOutcome, responderOutcome] = await Promise.all([399 runSyncInitiator(initiatorSession, initiator),400 runSyncResponder(responderSession, responder),401 ]);402 const bothSupport = initiatorSupports && responderSupports;403404 expect(initiatorOutcome.status).toBe("completed");405 expect(responderOutcome.status).toBe("completed");406 expect(appliedByInitiator).toHaveLength(bothSupport ? 1 : 0);407 expect(appliedByResponder).toHaveLength(bothSupport ? 1 : 0);408 }409 }410 }411 });412413 it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {414 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);415 const [session, peer] = inMemoryPair();416 const vault = makeVaultAdapter({ localVersion: 5 });417418 const initPromise = runSyncInitiator(session, vault);419420 await peer.receive(); 421 422 await peer.send({423 type: "SYNC_HELLO",424 vaultId: "vault-abc",425 deviceId: "p",426 version: 3, 427 deviceTime: Date.now() - 200_000,428 protocolVersion: 3,429 });430 431 await new Promise((r) => setTimeout(r, 0));432 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));433434 435 await peer.receive(); 436 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });437 await peer.receive(); 438 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });439 await peer.receive(); 440 await peer.receive(); 441 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });442 await peer.receive(); 443 await peer.send({ type: "SYNC_DONE" });444445 const outcome = await initPromise;446 expect(outcome.status).toBe("completed");447 warnSpy.mockRestore();448 });449});450451describe("runSyncInitiator", () => {452 it("syncs two mesh peers directly with their introducer absent", async () => {453 const [dialerSession, waiterSession] = symmetricSessionPair();454 const dialer = makeVaultAdapter({455 deviceId: "laptop-a",456 localVersion: 10,457 entries: { a: 10 },458 ciphertext: { a: "cipher-a" },459 });460 const waiter = makeVaultAdapter({461 deviceId: "laptop-c",462 localVersion: 11,463 entries: { c: 11 },464 ciphertext: { c: "cipher-c" },465 });466467 const [dialerOutcome, waiterOutcome] = await Promise.all([468 runSyncInitiator(dialerSession, dialer),469 runSyncResponder(waiterSession, waiter),470 ]);471472 expect(dialerOutcome.status).toBe("completed");473 expect(waiterOutcome.status).toBe("completed");474 expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);475 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);476 });477478 it("reports the privacy-safe protocol stage when a device stops answering", async () => {479 vi.useFakeTimers();480 try {481 const [session, peer] = inMemoryPair();482 const vault = makeVaultAdapter({});483 const initPromise = runSyncInitiator(session, vault);484485 const hello = await peer.receive();486 expect(hello.type).toBe("SYNC_HELLO");487488 await vi.advanceTimersByTimeAsync(30_000);489 await expect(initPromise).resolves.toMatchObject({490 status: "timeout",491 error: "sync_timeout:waiting_for_device_hello",492 });493 } finally {494 vi.useRealTimers();495 }496 });497498 it("completes a full sync with bi-directional updates", async () => {499 const [session, peer] = inMemoryPair();500 const vault = makeVaultAdapter({501 entries: { a: 100, b: 50 }, 502 tombstones: { x: 70 },503 ciphertext: { a: "ct-a", b: "ct-b" },504 });505506 const initPromise = runSyncInitiator(session, vault);507508 const peerProtocol = (async () => {509 const hello = await peer.receive();510 expect(hello.type).toBe("SYNC_HELLO");511 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 3 });512513 const ourManifest = await peer.receive();514 expect(ourManifest.type).toBe("SYNC_MANIFEST");515 await peer.send({516 type: "SYNC_MANIFEST",517 entries: { a: 80, b: 90, c: 200 }, 518 tombstones: { x: 60, y: 99 }, 519 });520521 const ourRequest = await peer.receive();522 expect(ourRequest.type).toBe("SYNC_REQUEST");523 524 if (ourRequest.type === "SYNC_REQUEST") {525 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);526 }527 528 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });529530 const ourEntries = await peer.receive();531 expect(ourEntries.type).toBe("SYNC_ENTRIES");532 if (ourEntries.type === "SYNC_ENTRIES") {533 expect(ourEntries.entries).toEqual({ a: "ct-a" });534 }535 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });536537 const ourTombs = await peer.receive();538 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");539 if (ourTombs.type === "SYNC_TOMBSTONES") {540 541 expect(ourTombs.tombstones).toEqual({ x: 70 });542 }543 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });544545 const ourDone = await peer.receive();546 expect(ourDone.type).toBe("SYNC_DONE");547 await peer.send({ type: "SYNC_DONE" });548 })();549550 const outcome = await initPromise;551 await peerProtocol;552553 expect(outcome.status).toBe("completed");554 expect(outcome.entriesSent).toBe(1);555 expect(outcome.entriesReceived).toBe(2);556 expect(outcome.tombstonesSent).toBe(1);557 expect(outcome.tombstonesReceived).toBe(1);558 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);559 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);560 });561562 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {563 const [session, peer] = inMemoryPair();564 const vault = makeVaultAdapter({ vaultId: "vault-A" });565566 const initPromise = runSyncInitiator(session, vault);567 const peerProtocol = (async () => {568 const hello = await peer.receive();569 expect(hello.type).toBe("SYNC_HELLO");570 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 3 });571 572 const err = await peer.receive();573 expect(err.type).toBe("SYNC_ERROR");574 })();575576 const outcome = await initPromise;577 await peerProtocol;578 expect(outcome.status).toBe("vault_mismatch");579 });580581 it("rejects a stale peer with PEER_PROTOCOL_OUTDATED", async () => {582 const [session, peer] = inMemoryPair();583 const vault = makeVaultAdapter({});584585 const initPromise = runSyncInitiator(session, vault);586 const peerProtocol = (async () => {587 await peer.receive(); 588 await peer.send({589 type: "SYNC_HELLO",590 vaultId: "vault-abc",591 deviceId: "p",592 version: 3,593 deviceTime: Date.now(),594 });595 const err = await peer.receive();596 expect(err.type).toBe("SYNC_ERROR");597 })();598599 const outcome = await initPromise;600 await peerProtocol;601 expect(outcome.status).toBe("errored");602 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");603 });604605 it("rejects a lower explicit protocol version as repair-required", async () => {606 const [session, peer] = inMemoryPair();607 const vault = makeVaultAdapter({});608609 const initPromise = runSyncInitiator(session, vault);610 const peerProtocol = (async () => {611 await peer.receive(); 612 await peer.send({613 type: "SYNC_HELLO",614 vaultId: "vault-abc",615 deviceId: "p",616 version: 3,617 deviceTime: Date.now(),618 protocolVersion: SYNC_PROTOCOL_VERSION - 1,619 });620 const err = await peer.receive();621 expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });622 })();623624 const outcome = await initPromise;625 await peerProtocol;626 expect(outcome.status).toBe("errored");627 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");628 });629630 it("rejects a future protocol version rather than failing open", async () => {631 const [session, peer] = inMemoryPair();632 const vault = makeVaultAdapter({});633634 const initPromise = runSyncInitiator(session, vault);635 const peerProtocol = (async () => {636 await peer.receive(); 637 await peer.send({638 type: "SYNC_HELLO",639 vaultId: "vault-abc",640 deviceId: "p",641 version: 3,642 deviceTime: Date.now(),643 protocolVersion: SYNC_PROTOCOL_VERSION + 1,644 });645 const err = await peer.receive();646 expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });647 })();648649 const outcome = await initPromise;650 await peerProtocol;651 expect(outcome.status).toBe("errored");652 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");653 });654655 it("responder rejects an incompatible peer before sending any manifest", async () => {656 const [session, peer] = inMemoryPair();657 const vault = makeVaultAdapter({});658659 const respondPromise = runSyncResponder(session, vault);660 await peer.send({661 type: "SYNC_HELLO",662 vaultId: "vault-abc",663 deviceId: "p",664 version: 3,665 deviceTime: Date.now(),666 protocolVersion: SYNC_PROTOCOL_VERSION + 1,667 });668669 const reply = await peer.receive();670 expect(reply).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });671 const outcome = await respondPromise;672 expect(outcome.status).toBe("errored");673 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");674 });675676 it("initiator surfaces repair-required when the responder rejects first", async () => {677 const [session, peer] = inMemoryPair();678 const vault = makeVaultAdapter({});679680 const initPromise = runSyncInitiator(session, vault);681 await peer.receive(); 682 await peer.send({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });683684 const outcome = await initPromise;685 expect(outcome.status).toBe("errored");686 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");687 });688689 it("exchanges device manifests when both adapters opt in", async () => {690 const [session, peer] = inMemoryPair();691 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");692 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");693694 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);695 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);696 const applied: SignedDeviceManifest[] = [];697698 const base = makeVaultAdapter({});699 const vault: SyncVaultAdapter = {700 ...base,701 async buildOutgoingDeviceManifest() { return outgoing; },702 async applyIncomingDeviceManifest(m, authenticatedKey) {703 expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);704 applied.push(m);705 },706 };707708 const initPromise = runSyncInitiator(session, vault);709 const peerProtocol = (async () => {710 await peer.receive(); 711 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });712 await peer.receive(); 713 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });714 await peer.receive(); 715 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });716 await peer.receive(); 717 await peer.receive(); 718 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });719 const ourManifest = await peer.receive();720 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");721 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {722 expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");723 }724 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });725 await peer.receive(); 726 await peer.send({ type: "SYNC_DONE" });727 })();728729 const outcome = await initPromise;730 await peerProtocol;731 expect(outcome.status).toBe("completed");732 expect(applied).toHaveLength(1);733 expect(applied[0].body.issuerDeviceId).toBe("them");734 });735736 it("uses null to represent an absent device manifest", async () => {737 const [session, peer] = inMemoryPair();738 const applyIncoming = vi.fn();739 const vault: SyncVaultAdapter = {740 ...makeVaultAdapter({}),741 async buildOutgoingDeviceManifest() { return null; },742 applyIncomingDeviceManifest: applyIncoming,743 };744745 const initPromise = runSyncInitiator(session, vault);746 const peerProtocol = (async () => {747 await peer.receive();748 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });749 await peer.receive();750 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });751 await peer.receive();752 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });753 await peer.receive();754 await peer.receive();755 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });756 const manifest = await peer.receive();757 expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });758 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });759 await peer.receive();760 await peer.send({ type: "SYNC_DONE" });761 })();762763 const outcome = await initPromise;764 await peerProtocol;765 expect(outcome.status).toBe("completed");766 expect(applyIncoming).not.toHaveBeenCalled();767 });768769 it("returns errored when peer sends a wrong frame", async () => {770 const [session, peer] = inMemoryPair();771 const vault = makeVaultAdapter({ vaultId: "v" });772773 const initPromise = runSyncInitiator(session, vault);774 void (async () => {775 await peer.receive(); 776 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3 });777 await peer.receive(); 778 await peer.send({ type: "SYNC_DONE" }); 779 })();780781 const outcome = await initPromise;782 expect(outcome.status).toBe("errored");783 expect(outcome.error).toMatch(/SYNC_MANIFEST/);784 });785786 it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {787 788 789 790 791 792 793 const [session, peer] = inMemoryPair();794 const vault = makeVaultAdapter({ localVersion: 5 });795796 const initPromise = runSyncInitiator(session, vault);797 void (async () => {798 await peer.receive(); 799 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 3 });800 await peer.receive(); 801 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });802 await peer.receive(); 803 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });804 await peer.receive(); 805 await peer.receive(); 806 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });807 await peer.receive(); 808 809 await peer.closeInitiatorReceive();810 })();811812 const outcome = await initPromise;813 expect(outcome.status).toBe("completed");814 });815});