e4d716993d01

replace per-entry sync stack with blob protocol — applyIncomingVault + runBlobSyncSession (v2.14.1)

time
2026-04-30 20:28 UTC · 6 months ago
machine
koh2
parent
bb21a0e3dc56
12 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+0 −717src/background/sync-coordinator.ts+233 −423src/shared/sync-entry-payload.test.ts+0 −95src/shared/sync-entry-payload.ts+0 −118src/shared/sync-protocol.test.ts+0 −147src/shared/sync-protocol.ts+0 −167src/shared/sync-types.ts+37 −25src/shared/sync-wire.test.ts+42 −27src/shared/sync-wire.ts+9 −45
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.0",4+  "version": "2.14.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.0",4+  "version": "2.14.1",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.14.0",3+  "version": "2.14.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+0 −717
@@ -1,717 +0,0 @@1-import { describe, expect, it, vi } from "vitest";2-3-import {4-  applySyncEntriesMessage,5-  buildSyncEntriesMessage,6-  buildLocalSyncSnapshot,7-  cancelSyncIfVaultLocked,8-  checkSyncPreflight,9-  createNativeSyncTransport,10-  dispatchSyncFrame,11-  planManifestSync,12-  runSyncSession,13-  type NativeSyncTransportBindings,14-  type SyncSessionTransport,15-} from "./sync-coordinator";16-import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";17-import type { VaultEntry } from "../shared/types";18-19-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {20-  return {21-    id: "entry-1",22-    type: "login",23-    title: "GitHub",24-    username: "june",25-    password: "secret",26-    url: "https://github.com",27-    createdAt: 1_000,28-    modifiedAt: 1_000,29-    ...overrides,30-  };31-}32-33-describe("sync coordinator", () => {34-  it("blocks sync for free users before inspecting the vault", async () => {35-    const getEntries = vi.fn(() => [entry()]);36-37-    await expect(38-      checkSyncPreflight({39-        isProUser: async () => false,40-        isUnlocked: () => true,41-      })42-    ).resolves.toEqual({ ok: false, error: "pro_required" });43-44-    expect(getEntries).not.toHaveBeenCalled();45-  });46-47-  it("blocks sync when the vault is locked", async () => {48-    await expect(49-      checkSyncPreflight({50-        isProUser: async () => true,51-        isUnlocked: () => false,52-      })53-    ).resolves.toEqual({ ok: false, error: "vault_locked" });54-  });55-56-  it("builds a local sync snapshot only after preflight passes", async () => {57-    const snapshot = await buildLocalSyncSnapshot({58-      isProUser: async () => true,59-      isUnlocked: () => true,60-      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],61-    });62-63-    expect(snapshot.entries).toHaveLength(2);64-    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);65-  });66-67-  it("plans manifest sync without sending entry payloads", async () => {68-    const plan = await planManifestSync(69-      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],70-      {71-        isProUser: async () => true,72-        isUnlocked: () => true,73-        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],74-      }75-    );76-77-    expect(plan.diff).toEqual({78-      requestFromRemote: ["remote-only"],79-      offerToRemote: ["local-only"],80-      conflicts: [],81-    });82-  });83-84-  it("builds encrypted entry messages for requested ids", async () => {85-    const rawEntryKey = new Uint8Array(32).fill(5);86-    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {87-      isProUser: async () => true,88-      isUnlocked: () => true,89-      getEntries: () => [90-        entry({ id: "a" }),91-        entry({92-          id: "b",93-          passkey: {94-            rpId: "github.com",95-            rpName: "GitHub",96-            credentialId: "credential",97-            publicKeyCose: "public",98-            privateKeyJwk: { kty: "EC", d: "secret" },99-            userHandle: "user",100-            userName: "june",101-            userDisplayName: "June",102-            algorithm: -7,103-            signCount: 1,104-            aaguid: "aaguid",105-            backupEligible: true,106-            backupState: true,107-            createdAt: 1_000,108-          },109-        }),110-      ],111-    });112-113-    expect(message.type).toBe("SYNC_ENTRIES");114-    expect(message.entries).toHaveLength(1);115-    expect(message.entries[0]).toMatchObject({ id: "b" });116-    expect(message.entries[0]?.ciphertext).not.toContain("secret");117-  });118-119-  it("decrypts and applies incoming entry messages through vault merge", async () => {120-    const rawEntryKey = new Uint8Array(32).fill(6);121-    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {122-      isProUser: async () => true,123-      isUnlocked: () => true,124-      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],125-    });126-    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));127-128-    await expect(129-      applySyncEntriesMessage(message, rawEntryKey, {130-        isProUser: async () => true,131-        isUnlocked: () => true,132-        restoreEntries,133-      })134-    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });135-136-    expect(restoreEntries).toHaveBeenCalledWith(137-      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],138-      "merge"139-    );140-  });141-142-  it("sends a stable sync error when a peer asks while the vault is locked", async () => {143-    const sendSyncData = vi.fn();144-145-    const cancelled = await cancelSyncIfVaultLocked("conn-1", {146-      isUnlocked: () => false,147-      sendSyncData,148-    });149-150-    expect(cancelled).toBe(true);151-    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));152-    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({153-      type: "SYNC_ERROR",154-      code: "vault_locked",155-      message: "Vault is locked; unlock Peach before syncing.",156-    });157-  });158-159-  it("does not cancel an unlocked vault sync", async () => {160-    const sendSyncData = vi.fn();161-162-    expect(163-      await cancelSyncIfVaultLocked("conn-1", {164-        isUnlocked: () => true,165-        sendSyncData,166-      })167-    ).toBe(false);168-    expect(sendSyncData).not.toHaveBeenCalled();169-  });170-171-  describe("dispatchSyncFrame", () => {172-    const passDeps = (entries: VaultEntry[]) => ({173-      isProUser: async () => true,174-      isUnlocked: () => true,175-      getEntries: () => entries,176-      restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),177-    });178-179-    it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {180-      const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);181-      const result = await dispatchSyncFrame(182-        encodeSyncMessage({183-          type: "SYNC_HELLO",184-          deviceId: "device",185-          clockMs: 1,186-          vaultVersion: 1,187-        }),188-        new Uint8Array(32).fill(1),189-        deps190-      );191-      expect(result.outbound).toHaveLength(1);192-      const decoded = decodeSyncMessage(result.outbound[0]!);193-      expect(decoded.type).toBe("SYNC_MANIFEST");194-      if (decoded.type === "SYNC_MANIFEST") {195-        expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);196-      }197-    });198-199-    it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {200-      const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);201-      const result = await dispatchSyncFrame(202-        encodeSyncMessage({203-          type: "SYNC_MANIFEST",204-          entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],205-        }),206-        new Uint8Array(32).fill(2),207-        deps208-      );209-      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));210-      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({211-        type: "SYNC_REQUEST",212-        ids: ["remote"],213-      });214-      const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");215-      expect(entriesMessage?.type).toBe("SYNC_ENTRIES");216-      if (entriesMessage?.type === "SYNC_ENTRIES") {217-        expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);218-      }219-    });220-221-    it("resolves equal-timestamp conflicts deterministically: local wins → offer", async () => {222-      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];223-      const restoreEntries = vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 }));224-      const localManifest = (225-        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)226-      )[0]!;227-      // Pick a remote hash that lex-compares larger so local wins.228-      const remoteHash =229-        localManifest.hash < "f".repeat(64) ? "f".repeat(64) : "0".repeat(64);230-231-      const result = await dispatchSyncFrame(232-        encodeSyncMessage({233-          type: "SYNC_MANIFEST",234-          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],235-        }),236-        new Uint8Array(32).fill(20),237-        {238-          isProUser: async () => true,239-          isUnlocked: () => true,240-          getEntries: () => localEntries,241-          restoreEntries,242-        }243-      );244-245-      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));246-      // Local wins → offer "shared", no request, no archive.247-      expect(restoreEntries).not.toHaveBeenCalled();248-      const offer = messages.find((m) => m.type === "SYNC_ENTRIES");249-      expect(offer?.type).toBe("SYNC_ENTRIES");250-      if (offer?.type === "SYNC_ENTRIES") {251-        expect(offer.entries.map((e) => e.id)).toEqual(["shared"]);252-      }253-      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toBeUndefined();254-    });255-256-    it("resolves equal-timestamp conflicts deterministically: remote wins → request + archive", async () => {257-      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];258-      const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));259-      const localManifest = (260-        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)261-      )[0]!;262-      // Pick a remote hash that lex-compares smaller so remote wins.263-      const remoteHash =264-        localManifest.hash > "0".repeat(64) ? "0".repeat(64) : "f".repeat(64);265-266-      const result = await dispatchSyncFrame(267-        encodeSyncMessage({268-          type: "SYNC_MANIFEST",269-          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],270-        }),271-        new Uint8Array(32).fill(21),272-        {273-          isProUser: async () => true,274-          isUnlocked: () => true,275-          getEntries: () => localEntries,276-          restoreEntries,277-        }278-      );279-280-      // Remote wins → request "shared" from peer, archive our local copy under281-      // a derived id, no offer.282-      expect(restoreEntries).toHaveBeenCalledTimes(1);283-      const archived = (restoreEntries.mock.calls[0] as unknown as [VaultEntry[]])[0];284-      expect(archived).toHaveLength(1);285-      expect(archived[0]!.id).toBe(`shared-conflict-${localManifest.hash.slice(0, 8)}`);286-      expect(archived[0]!.title).toBe("LocalEdit (conflicted copy)");287-288-      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));289-      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({290-        type: "SYNC_REQUEST",291-        ids: ["shared"],292-      });293-      expect(messages.find((m) => m.type === "SYNC_ENTRIES")).toBeUndefined();294-      expect(result.merge).toEqual({ added: 1, updated: 0, skipped: 0 });295-    });296-297-    it("returns SYNC_DONE when manifests already agree", async () => {298-      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);299-      const localManifest = encodeSyncMessage({300-        type: "SYNC_MANIFEST",301-        entries: [302-          {303-            id: "shared",304-            modifiedAt: 1_000,305-            hash: (306-              await (await import("../shared/sync-protocol")).buildSyncManifest(307-                deps.getEntries()308-              )309-            )[0]!.hash,310-          },311-        ],312-      });313-      const result = await dispatchSyncFrame(314-        localManifest,315-        new Uint8Array(32).fill(3),316-        deps317-      );318-      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));319-      expect(messages).toHaveLength(1);320-      expect(messages[0]!.type).toBe("SYNC_DONE");321-    });322-323-    it("encrypts only the requested ids on SYNC_REQUEST", async () => {324-      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);325-      const result = await dispatchSyncFrame(326-        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),327-        new Uint8Array(32).fill(4),328-        deps329-      );330-      expect(result.outbound).toHaveLength(1);331-      const decoded = decodeSyncMessage(result.outbound[0]!);332-      expect(decoded.type).toBe("SYNC_ENTRIES");333-      if (decoded.type === "SYNC_ENTRIES") {334-        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);335-      }336-    });337-338-    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {339-      const rawEntryKey = new Uint8Array(32).fill(9);340-      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {341-        isProUser: async () => true,342-        isUnlocked: () => true,343-        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],344-      });345-      const deps = passDeps([]);346-      const result = await dispatchSyncFrame(347-        encodeSyncMessage(offered),348-        rawEntryKey,349-        deps350-      );351-      expect(deps.restoreEntries).toHaveBeenCalledWith(352-        [expect.objectContaining({ id: "remote", title: "Remote" })],353-        "merge"354-      );355-      const decoded = decodeSyncMessage(result.outbound[0]!);356-      expect(decoded.type).toBe("SYNC_DONE");357-    });358-359-    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {360-      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {361-        isProUser: async () => true,362-        isUnlocked: () => true,363-        getEntries: () => [],364-        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),365-      });366-      expect(result.done).toBe(true);367-      expect(result.error?.code).toBe("invalid_message");368-      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");369-    });370-371-    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {372-      const result = await dispatchSyncFrame(373-        encodeSyncMessage({374-          type: "SYNC_HELLO",375-          deviceId: "device",376-          clockMs: 0,377-          vaultVersion: 0,378-        }),379-        new Uint8Array(32),380-        {381-          isProUser: async () => true,382-          isUnlocked: () => false,383-          getEntries: () => [],384-          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),385-        }386-      );387-      expect(result.done).toBe(true);388-      expect(result.error?.code).toBe("vault_locked");389-      const decoded = decodeSyncMessage(result.outbound[0]!);390-      expect(decoded.type).toBe("SYNC_ERROR");391-      if (decoded.type === "SYNC_ERROR") {392-        expect(decoded.code).toBe("vault_locked");393-      }394-    });395-396-    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {397-      const result = await dispatchSyncFrame(398-        encodeSyncMessage({ type: "SYNC_DONE" }),399-        new Uint8Array(32).fill(7),400-        passDeps([])401-      );402-      expect(result.outbound).toEqual([]);403-      expect(result.done).toBe(true);404-    });405-406-    it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {407-      const result = await dispatchSyncFrame(408-        encodeSyncMessage({409-          type: "SYNC_ERROR",410-          code: "peer_failed",411-          message: "Peer reported error.",412-        }),413-        new Uint8Array(32).fill(8),414-        passDeps([])415-      );416-      expect(result.outbound).toEqual([]);417-      expect(result.done).toBe(true);418-      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });419-    });420-  });421-422-  describe("runSyncSession", () => {423-    interface TransportHarness {424-      transport: SyncSessionTransport;425-      sent: string[];426-      emitFrame: (frame: string) => Promise<void>;427-      emitError: (message: string) => Promise<void>;428-      emitClose: () => Promise<void>;429-      isClosed: () => boolean;430-      ready: () => Promise<void>;431-    }432-433-    function makeTransport(rawKey: Uint8Array): TransportHarness {434-      const sent: string[] = [];435-      const frameQueue: string[] = [];436-      const errorQueue: string[] = [];437-      let pendingClose = false;438-      let frameHandler: ((frame: string) => void) | null = null;439-      let errorHandler: ((message: string) => void) | null = null;440-      let closeHandler: (() => void) | null = null;441-      let resolveReady: (() => void) | null = null;442-      const ready = new Promise<void>((resolve) => {443-        resolveReady = resolve;444-      });445-      let closed = false;446-447-      const checkReady = (): void => {448-        if (frameHandler && errorHandler && closeHandler && resolveReady) {449-          resolveReady();450-          resolveReady = null;451-          for (const queued of frameQueue.splice(0)) frameHandler(queued);452-          for (const queued of errorQueue.splice(0)) errorHandler(queued);453-          if (pendingClose && closeHandler) {454-            pendingClose = false;455-            closeHandler();456-          }457-        }458-      };459-460-      const transport: SyncSessionTransport = {461-        send: (frame) => {462-          sent.push(frame);463-        },464-        onFrame: (handler) => {465-          frameHandler = handler;466-          checkReady();467-          return () => {468-            if (frameHandler === handler) frameHandler = null;469-          };470-        },471-        onError: (handler) => {472-          errorHandler = handler;473-          checkReady();474-          return () => {475-            if (errorHandler === handler) errorHandler = null;476-          };477-        },478-        onClose: (handler) => {479-          closeHandler = handler;480-          checkReady();481-          return () => {482-            if (closeHandler === handler) closeHandler = null;483-          };484-        },485-        close: () => {486-          closed = true;487-        },488-        getSessionKey: async () => rawKey,489-      };490-491-      const flush = async (): Promise<void> => {492-        for (let i = 0; i < 50; i += 1) {493-          await new Promise<void>((resolve) => setTimeout(resolve, 0));494-        }495-      };496-497-      return {498-        transport,499-        sent,500-        emitFrame: async (frame) => {501-          if (frameHandler) frameHandler(frame);502-          else frameQueue.push(frame);503-          await flush();504-        },505-        emitError: async (message) => {506-          if (errorHandler) errorHandler(message);507-          else errorQueue.push(message);508-          await flush();509-        },510-        emitClose: async () => {511-          if (closeHandler) closeHandler();512-          else pendingClose = true;513-          await flush();514-        },515-        isClosed: () => closed,516-        ready: () => ready,517-      };518-    }519-520-    it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {521-      const harness = makeTransport(new Uint8Array(32).fill(11));522-523-      const sessionPromise = runSyncSession({524-        role: "initiator",525-        deviceId: "device-a",526-        vaultVersion: 1,527-        transport: harness.transport,528-        deps: {529-          isProUser: async () => true,530-          isUnlocked: () => true,531-          getEntries: () => [],532-          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),533-        },534-      });535-536-      await harness.ready();537-      expect(harness.sent).toHaveLength(1);538-      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");539-540-      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));541-542-      const result = await sessionPromise;543-      expect(result.status).toBe("completed");544-      expect(harness.isClosed()).toBe(true);545-    });546-547-    it("does not send SYNC_HELLO when responder", async () => {548-      const harness = makeTransport(new Uint8Array(32));549-      const sessionPromise = runSyncSession({550-        role: "responder",551-        deviceId: "device-b",552-        vaultVersion: 1,553-        transport: harness.transport,554-        deps: {555-          isProUser: async () => true,556-          isUnlocked: () => true,557-          getEntries: () => [],558-          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),559-        },560-      });561-      await harness.ready();562-      expect(harness.sent).toEqual([]);563-564-      await harness.emitClose();565-      const result = await sessionPromise;566-      expect(result.status).toBe("closed");567-    });568-569-    it("aborts immediately with SYNC_ERROR when preflight fails", async () => {570-      const harness = makeTransport(new Uint8Array(32));571-      const result = await runSyncSession({572-        role: "initiator",573-        deviceId: "device",574-        vaultVersion: 1,575-        transport: harness.transport,576-        deps: {577-          isProUser: async () => false,578-          isUnlocked: () => true,579-          getEntries: () => [],580-          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),581-        },582-      });583-584-      expect(result.status).toBe("errored");585-      expect(result.error?.code).toBe("pro_required");586-      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");587-      expect(harness.isClosed()).toBe(true);588-    });589-590-    it("propagates transport errors as a session result", async () => {591-      const harness = makeTransport(new Uint8Array(32));592-      const sessionPromise = runSyncSession({593-        role: "responder",594-        deviceId: "device",595-        vaultVersion: 1,596-        transport: harness.transport,597-        deps: {598-          isProUser: async () => true,599-          isUnlocked: () => true,600-          getEntries: () => [],601-          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),602-        },603-      });604-      await harness.ready();605-      await harness.emitError("native host disconnected");606-      const result = await sessionPromise;607-      expect(result.status).toBe("errored");608-      expect(result.error).toEqual({609-        code: "transport_error",610-        message: "native host disconnected",611-      });612-    });613-614-    it("createNativeSyncTransport routes only matching frames and errors", async () => {615-      const sendSyncData = vi.fn();616-      const disconnectFromPeer = vi.fn();617-      const handlers: {618-        frame: ((id: string, data: string) => void) | null;619-        error: ((msg: string, id?: string) => void) | null;620-        disconnect: ((id?: string) => void) | null;621-      } = { frame: null, error: null, disconnect: null };622-623-      const bindings: NativeSyncTransportBindings = {624-        sendSyncData,625-        disconnectFromPeer,626-        onSyncMessage: (h) => {627-          handlers.frame = h;628-          return () => {629-            if (handlers.frame === h) handlers.frame = null;630-          };631-        },632-        onError: (h) => {633-          handlers.error = h;634-          return () => {635-            if (handlers.error === h) handlers.error = null;636-          };637-        },638-        onDisconnected: (h) => {639-          handlers.disconnect = h;640-          return () => {641-            if (handlers.disconnect === h) handlers.disconnect = null;642-          };643-        },644-        getNativeSessionKey: async () => new Uint8Array(32).fill(7),645-      };646-647-      const transport = await createNativeSyncTransport("target", bindings);648-649-      const frames: string[] = [];650-      const errors: string[] = [];651-      let closes = 0;652-      transport.onFrame((frame) => frames.push(frame));653-      transport.onError((message) => errors.push(message));654-      transport.onClose(() => {655-        closes += 1;656-      });657-658-      handlers.frame?.("other", "ignored");659-      handlers.frame?.("target", "kept");660-      expect(frames).toEqual(["kept"]);661-662-      handlers.error?.("scoped error", "other");663-      handlers.error?.("scoped error", "target");664-      handlers.error?.("global error", undefined);665-      expect(errors).toEqual(["scoped error", "global error"]);666-667-      handlers.disconnect?.("other");668-      handlers.disconnect?.("target");669-      handlers.disconnect?.(undefined);670-      expect(closes).toBe(2);671-672-      transport.send("frame");673-      expect(sendSyncData).toHaveBeenCalledWith("target", "frame");674-675-      transport.close();676-      expect(disconnectFromPeer).toHaveBeenCalledWith("target");677-678-      const key = await transport.getSessionKey();679-      expect(key).toEqual(new Uint8Array(32).fill(7));680-    });681-682-    it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {683-      const rawKey = new Uint8Array(32).fill(12);684-      const harness = makeTransport(rawKey);685-      const restoreEntries = vi686-        .fn()687-        .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });688-689-      const sessionPromise = runSyncSession({690-        role: "responder",691-        deviceId: "device",692-        vaultVersion: 1,693-        transport: harness.transport,694-        deps: {695-          isProUser: async () => true,696-          isUnlocked: () => true,697-          getEntries: () => [],698-          restoreEntries,699-        },700-      });701-      await harness.ready();702-703-      const incoming = await buildSyncEntriesMessage(["a"], rawKey, {704-        isProUser: async () => true,705-        isUnlocked: () => true,706-        getEntries: () => [entry({ id: "a", title: "First" })],707-      });708-      await harness.emitFrame(encodeSyncMessage(incoming));709-      // Peer signals end of its side; session can resolve.710-      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));711-712-      const result = await sessionPromise;713-      expect(result.status).toBe("completed");714-      expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });715-    });716-  });717-});

view file at the parent

src/background/sync-coordinator.ts+233 −423
@@ -1,83 +1,260 @@11 /**2- * Dormant SYN-001 coordinator helpers.2+ * Blob sync session coordinator.33  *4- * This module owns extension-side sync preflight and cancellation rules before5- * any live background path enables native messaging. Entry payload exchange is6- * intentionally left to the next phase so the encrypted-entry envelope can be7- * reviewed on its own.4+ * Replaces the per-entry SYNC_HELLO/MANIFEST/REQUEST/ENTRIES/DONE state machine5+ * with a single bidirectional vault-blob exchange. The encrypted vault blob is6+ * already AES-256-GCM ciphertext under the user's master password, so the7+ * Noise NK transport adds a second layer and the sync layer never sees8+ * plaintext.9+ *10+ * Last-write-wins at the vault level: an incoming blob is applied only if its11+ * `sent_at` is strictly newer than the local `peach_last_sync_at`. Vault12+ * history snapshots before every apply so the user can restore an older state13+ * if a sync overwrites the wrong vault.814  */915 10-import {11-  buildConflictedCopy,12-  buildSyncManifest,13-  chooseConflictWinner,14-  diffSyncManifests,15-  selectEntriesForSync,16-} from "../shared/sync-protocol";17-import type { SyncDiff } from "../shared/sync-protocol";18-import {19-  decryptSyncEntry,20-  encryptSyncEntry,21-  importSyncEntryKey,22-} from "../shared/sync-entry-payload";23-import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";16+import { storageGet, storageSet } from "../shared/storage";2417 import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";25-import type { VaultEntry } from "../shared/types";18+import type { SyncMessage } from "../shared/sync-types";19+import { takeVaultSnapshot } from "./vault";2620 27-export type SyncPreflightError = "pro_required" | "vault_locked";21+const LAST_SYNC_KEY = "peach_last_sync_at";22+const SYNC_TIMEOUT_MS = 30_000;2823 29-export interface SyncPreflightResult {30-  ok: boolean;31-  error?: SyncPreflightError;24+export interface SyncCoordinatorDeps {25+  isPro: () => Promise<boolean>;26+  isUnlocked: () => boolean;27+  getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>;3228 }3329 34-export interface SyncSnapshot {35-  entries: VaultEntry[];36-  manifest: SyncManifestEntry[];30+export interface SyncSessionTransport {31+  send: (frame: string) => void | Promise<void>;32+  onFrame: (handler: (frame: string) => void) => () => void;33+  onError: (handler: (message: string) => void) => () => void;34+  onClose: (handler: () => void) => () => void;35+  close: () => void;3736 }3837 39-export interface SyncPlan {40-  localManifest: SyncManifestEntry[];41-  diff: SyncDiff;38+export type SyncRole = "initiator" | "responder";39+40+export interface SyncSessionResult {41+  status: "completed" | "errored" | "closed";42+  error?: string;4243 }4344 44-export interface SyncCoordinatorDeps {45-  isProUser: () => Promise<boolean>;46-  isUnlocked: () => boolean;47-  getEntries: () => VaultEntry[];48-  restoreEntries: (49-    entries: VaultEntry[],50-    mode: "replace" | "merge"51-  ) => Promise<{ added: number; updated: number; skipped: number }>;52-  sendSyncData: (connectionId: string, data: string) => void;45+/**46+ * Persist an incoming vault blob if its `sent_at` beats the local last-sync47+ * timestamp. Returns 'applied' when the blob was written, 'ignored' when the48+ * incoming was older. Always snapshots BEFORE writing so the user can roll49+ * back via vault history if a sync overwrites the wrong state.50+ */51+export async function applyIncomingVault(52+  incomingVaultA: string,53+  incomingVaultB: string,54+  sentAt: number,55+): Promise<"applied" | "ignored"> {56+  const lastRaw = await storageGet(LAST_SYNC_KEY);57+  const lastTs = lastRaw ? Number.parseInt(lastRaw, 10) : 0;58+59+  if (!Number.isFinite(sentAt) || sentAt <= lastTs) {60+    return "ignored";61+  }62+63+  // History capture happens BEFORE the write. Failures are swallowed inside64+  // takeVaultSnapshot — sync must never block on history.65+  await takeVaultSnapshot("sync_received");66+67+  await storageSet("peach_vault_a", incomingVaultA);68+  await storageSet("peach_vault_b", incomingVaultB);69+  await storageSet(LAST_SYNC_KEY, String(sentAt));70+71+  return "applied";5372 }5473 55-async function loadDefaultDeps(): Promise<SyncCoordinatorDeps> {56-  const [license, sync, vault] = await Promise.all([57-    import("./license"),58-    import("./sync"),59-    import("./vault"),60-  ]);61-  return {62-    isProUser: license.isProUser,63-    isUnlocked: vault.isUnlocked,64-    getEntries: vault.getEntries,65-    restoreEntries: vault.restoreEntries,66-    sendSyncData: sync.sendSyncData,67-  };74+/**75+ * Run one bidirectional sync session over an authenticated transport.76+ *77+ * Wire flow:78+ *   1. Initiator sends SYNC_VAULT_SEND with its blobs + sent_at79+ *   2. Responder receives, applies if newer, replies with SYNC_VAULT_ACK80+ *   3. Responder then sends ITS own SYNC_VAULT_SEND so the initiator gets81+ *      whichever vault is newer82+ *   4. Initiator receives, applies if newer, replies with SYNC_VAULT_ACK83+ *   5. Both sides close84+ *85+ * Either side can short-circuit with SYNC_ERROR (pro_required, vault_locked,86+ * timeout). The session times out after 30 seconds total.87+ */88+export async function runBlobSyncSession(89+  role: SyncRole,90+  transport: SyncSessionTransport,91+  deps: SyncCoordinatorDeps,92+): Promise<SyncSessionResult> {93+  if (!(await deps.isPro())) {94+    await transport.send(95+      encodeSyncMessage({ type: "SYNC_ERROR", reason: "pro_required" }),96+    );97+    transport.close();98+    return { status: "errored", error: "pro_required" };99+  }100+  if (!deps.isUnlocked()) {101+    await transport.send(102+      encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),103+    );104+    transport.close();105+    return { status: "errored", error: "vault_locked" };106+  }107+108+  return new Promise<SyncSessionResult>((resolve) => {109+    let settled = false;110+    const unsubscribers: Array<() => void> = [];111+112+    const finish = (result: SyncSessionResult): void => {113+      if (settled) return;114+      settled = true;115+      clearTimeout(timeoutHandle);116+      for (const fn of unsubscribers) {117+        try {118+          fn();119+        } catch {120+          // unsubscribe must not throw121+        }122+      }123+      try {124+        transport.close();125+      } catch {126+        // close must not throw127+      }128+      resolve(result);129+    };130+131+    const timeoutHandle = setTimeout(132+      () => finish({ status: "errored", error: "timeout" }),133+      SYNC_TIMEOUT_MS,134+    );135+136+    unsubscribers.push(transport.onClose(() => finish({ status: "closed" })));137+    unsubscribers.push(138+      transport.onError((message) =>139+        finish({ status: "errored", error: message || "transport_error" }),140+      ),141+    );142+143+    unsubscribers.push(144+      transport.onFrame((frame) => {145+        void (async () => {146+          let msg: SyncMessage;147+          try {148+            msg = decodeSyncMessage(frame);149+          } catch {150+            await transport.send(151+              encodeSyncMessage({ type: "SYNC_ERROR", reason: "invalid_frame" }),152+            );153+            finish({ status: "errored", error: "invalid_frame" });154+            return;155+          }156+157+          if (msg.type === "SYNC_VAULT_SEND") {158+            try {159+              await applyIncomingVault(msg.vault_a, msg.vault_b, msg.sent_at);160+            } catch (err) {161+              const reason = err instanceof Error ? err.message : "apply_failed";162+              await transport.send(163+                encodeSyncMessage({ type: "SYNC_ERROR", reason }),164+              );165+              finish({ status: "errored", error: reason });166+              return;167+            }168+169+            await transport.send(170+              encodeSyncMessage({171+                type: "SYNC_VAULT_ACK",172+                received_at: Date.now(),173+              }),174+            );175+176+            if (role === "responder") {177+              // Send our blob back so the initiator ends up with whichever is newer.178+              const blobs = await deps.getVaultBlobs();179+              await transport.send(180+                encodeSyncMessage({181+                  type: "SYNC_VAULT_SEND",182+                  vault_a: blobs.vault_a,183+                  vault_b: blobs.vault_b,184+                  sent_at: Date.now(),185+                }),186+              );187+              // Now wait for initiator's ACK.188+            } else {189+              // Initiator received responder's vault and ACK'd — done.190+              finish({ status: "completed" });191+            }192+          } else if (msg.type === "SYNC_VAULT_ACK") {193+            // Peer received our vault. If we are the responder we still need194+            // to wait for the initiator's ACK after we send our blob back; in195+            // practice that flow ends via the initiator's SYNC_VAULT_SEND196+            // branch above. The simple case: we sent first, peer ACK'd, we197+            // can close.198+            if (role === "initiator") {199+              // Wait — initiator only completes after receiving responder's200+              // SYNC_VAULT_SEND. A naked ACK without the responder vault means201+              // the responder chose not to send anything back; treat as done.202+              finish({ status: "completed" });203+            } else {204+              finish({ status: "completed" });205+            }206+          } else if (msg.type === "SYNC_ERROR") {207+            finish({ status: "errored", error: msg.reason });208+          }209+          // PAIR_* messages are out-of-band for the data-sync session.210+        })().catch((err) => {211+          finish({212+            status: "errored",213+            error: err instanceof Error ? err.message : String(err),214+          });215+        });216+      }),217+    );218+219+    if (role === "initiator") {220+      void (async () => {221+        try {222+          const blobs = await deps.getVaultBlobs();223+          await transport.send(224+            encodeSyncMessage({225+              type: "SYNC_VAULT_SEND",226+              vault_a: blobs.vault_a,227+              vault_b: blobs.vault_b,228+              sent_at: Date.now(),229+            }),230+          );231+        } catch (err) {232+          finish({233+            status: "errored",234+            error: err instanceof Error ? err.message : String(err),235+          });236+        }237+      })();238+    }239+  });68240 }69241 242+/**243+ * Build a transport adapter that bridges a native-host connection (via sync.ts)244+ * into the SyncSessionTransport interface runBlobSyncSession consumes. The245+ * adapter scopes frame/error/disconnect events to a single connection id so246+ * concurrent sync sessions on different connections don't cross-talk.247+ */70248 export interface NativeSyncTransportBindings {71249   sendSyncData: (connectionId: string, data: string) => void;72250   onSyncMessage: (73-    handler: (connectionId: string, data: string) => void251+    handler: (connectionId: string, data: string) => void,74252   ) => () => void;75253   onError: (76-    handler: (message: string, connectionId?: string) => void254+    handler: (message: string, connectionId?: string) => void,77255   ) => () => void;78256   onDisconnected: (handler: (connectionId?: string) => void) => () => void;79257   disconnectFromPeer: (connectionId: string) => void;80-  getNativeSessionKey: (connectionId: string) => Promise<Uint8Array>;81258 }82259 83260 async function loadNativeSyncBindings(): Promise<NativeSyncTransportBindings> {@@ -88,13 +265,12 @@88265     onError: sync.onError,89266     onDisconnected: sync.onDisconnected,90267     disconnectFromPeer: sync.disconnectFromPeer,91-    getNativeSessionKey: sync.getNativeSessionKey,92268   };93269 }94270 95271 export async function createNativeSyncTransport(96272   connectionId: string,97-  bindings?: NativeSyncTransportBindings273+  bindings?: NativeSyncTransportBindings,98274 ): Promise<SyncSessionTransport> {99275   const ready = bindings ?? (await loadNativeSyncBindings());100276   return {@@ -112,371 +288,5 @@112288         if (id === undefined || id === connectionId) handler();113289       }),114290     close: () => ready.disconnectFromPeer(connectionId),115-    getSessionKey: () => ready.getNativeSessionKey(connectionId),116291   };117-}118-119-export async function checkSyncPreflight(120-  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">121-): Promise<SyncPreflightResult> {122-  const readyDeps = deps ?? (await loadDefaultDeps());123-  if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };124-  if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };125-  return { ok: true };126-}127-128-export async function buildLocalSyncSnapshot(129-  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">130-): Promise<SyncSnapshot> {131-  const readyDeps = deps ?? (await loadDefaultDeps());132-  const preflight = await checkSyncPreflight(readyDeps);133-  if (!preflight.ok) throw new Error(preflight.error);134-135-  const entries = readyDeps.getEntries();136-  return {137-    entries,138-    manifest: await buildSyncManifest(entries),139-  };140-}141-142-export async function planManifestSync(143-  remoteManifest: SyncManifestEntry[],144-  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">145-): Promise<SyncPlan> {146-  const snapshot = await buildLocalSyncSnapshot(deps);147-  return {148-    localManifest: snapshot.manifest,149-    diff: diffSyncManifests(snapshot.manifest, remoteManifest),150-  };151-}152-153-export async function buildSyncEntriesMessage(154-  ids: string[],155-  rawEntryKey: Uint8Array,156-  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">157-): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {158-  const readyDeps = deps ?? (await loadDefaultDeps());159-  const preflight = await checkSyncPreflight(readyDeps);160-  if (!preflight.ok) throw new Error(preflight.error);161-162-  const key = await importSyncEntryKey(rawEntryKey);163-  const selected = selectEntriesForSync(readyDeps.getEntries(), ids);164-  return {165-    type: "SYNC_ENTRIES",166-    entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),167-  };168-}169-170-export async function applySyncEntriesMessage(171-  message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,172-  rawEntryKey: Uint8Array,173-  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">174-): Promise<{ added: number; updated: number; skipped: number }> {175-  const readyDeps = deps ?? (await loadDefaultDeps());176-  const preflight = await checkSyncPreflight(readyDeps);177-  if (!preflight.ok) throw new Error(preflight.error);178-179-  const key = await importSyncEntryKey(rawEntryKey);180-  const entries = await Promise.all(181-    message.entries.map((payload) => decryptSyncEntry(payload, key))182-  );183-  return readyDeps.restoreEntries(entries, "merge");184-}185-186-export interface SyncFrameDispatchResult {187-  outbound: string[];188-  merge?: { added: number; updated: number; skipped: number };189-  done?: boolean;190-  error?: { code: string; message: string };191-}192-193-export async function dispatchSyncFrame(194-  frame: string,195-  rawEntryKey: Uint8Array,196-  deps?: Pick<197-    SyncCoordinatorDeps,198-    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"199-  >200-): Promise<SyncFrameDispatchResult> {201-  const readyDeps = deps ?? (await loadDefaultDeps());202-  const preflight = await checkSyncPreflight(readyDeps);203-  if (!preflight.ok) {204-    const message =205-      preflight.error === "pro_required"206-        ? "Sync requires a Pro license."207-        : "Vault is locked; unlock Peach before syncing.";208-    return {209-      outbound: [210-        encodeSyncMessage({211-          type: "SYNC_ERROR",212-          code: preflight.error ?? "preflight_failed",213-          message,214-        }),215-      ],216-      done: true,217-      error: { code: preflight.error ?? "preflight_failed", message },218-    };219-  }220-221-  let message: SyncMessage;222-  try {223-    message = decodeSyncMessage(frame);224-  } catch {225-    return {226-      outbound: [227-        encodeSyncMessage({228-          type: "SYNC_ERROR",229-          code: "invalid_message",230-          message: "Could not decode sync frame.",231-        }),232-      ],233-      done: true,234-      error: { code: "invalid_message", message: "Could not decode sync frame." },235-    };236-  }237-238-  const key = await importSyncEntryKey(rawEntryKey);239-240-  switch (message.type) {241-    case "SYNC_HELLO": {242-      const manifest = await buildSyncManifest(readyDeps.getEntries());243-      return {244-        outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],245-      };246-    }247-    case "SYNC_MANIFEST": {248-      const localEntries = readyDeps.getEntries();249-      const localManifest = await buildSyncManifest(localEntries);250-      const diff = diffSyncManifests(localManifest, message.entries);251-252-      const requestIds = new Set(diff.requestFromRemote);253-      const offerIds = new Set(diff.offerToRemote);254-      const archiveCopies: VaultEntry[] = [];255-256-      if (diff.conflicts.length > 0) {257-        const localManifestById = new Map(258-          localManifest.map((entry) => [entry.id, entry])259-        );260-        const remoteManifestById = new Map(261-          message.entries.map((entry) => [entry.id, entry])262-        );263-        const localEntryById = new Map(localEntries.map((entry) => [entry.id, entry]));264-265-        for (const id of diff.conflicts) {266-          const localManifestEntry = localManifestById.get(id);267-          const remoteManifestEntry = remoteManifestById.get(id);268-          const localEntry = localEntryById.get(id);269-          if (!localManifestEntry || !remoteManifestEntry || !localEntry) continue;270-271-          const winner = chooseConflictWinner(272-            localManifestEntry.hash,273-            remoteManifestEntry.hash274-          );275-          if (winner === "local") {276-            offerIds.add(id);277-          } else {278-            requestIds.add(id);279-            archiveCopies.push(buildConflictedCopy(localEntry, localManifestEntry.hash));280-          }281-        }282-      }283-284-      // Persist any losing-side conflicted copies before the peer's winning285-      // entries overwrite the original ids on the next SYNC_ENTRIES message.286-      let merge: { added: number; updated: number; skipped: number } | undefined;287-      if (archiveCopies.length > 0) {288-        merge = await readyDeps.restoreEntries(archiveCopies, "merge");289-      }290-291-      const sortedRequestIds = [...requestIds].sort();292-      const sortedOfferIds = [...offerIds].sort();293-      const offered = selectEntriesForSync(localEntries, sortedOfferIds);294-      const outbound: string[] = [];295-      if (sortedRequestIds.length > 0) {296-        outbound.push(297-          encodeSyncMessage({ type: "SYNC_REQUEST", ids: sortedRequestIds })298-        );299-      }300-      if (offered.length > 0) {301-        outbound.push(302-          encodeSyncMessage({303-            type: "SYNC_ENTRIES",304-            entries: await Promise.all(305-              offered.map((entry) => encryptSyncEntry(entry, key))306-            ),307-          })308-        );309-      }310-      if (outbound.length === 0) {311-        outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));312-      }313-      return merge ? { outbound, merge } : { outbound };314-    }315-    case "SYNC_REQUEST": {316-      const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);317-      return {318-        outbound: [319-          encodeSyncMessage({320-            type: "SYNC_ENTRIES",321-            entries: await Promise.all(322-              offered.map((entry) => encryptSyncEntry(entry, key))323-            ),324-          }),325-        ],326-      };327-    }328-    case "SYNC_ENTRIES": {329-      const decrypted = await Promise.all(330-        message.entries.map((payload) => decryptSyncEntry(payload, key))331-      );332-      const merge = await readyDeps.restoreEntries(decrypted, "merge");333-      return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };334-    }335-    case "SYNC_DONE":336-      return { outbound: [], done: true };337-    case "SYNC_ERROR":338-      return {339-        outbound: [],340-        done: true,341-        error: { code: message.code, message: message.message },342-      };343-    case "PAIR_REQUEST":344-    case "PAIR_ACCEPT":345-    case "PAIR_REJECT":346-      // Pairing flow is handled outside the data-sync state machine.347-      return { outbound: [] };348-  }349-}350-351-export interface SyncSessionTransport {352-  send: (frame: string) => void;353-  onFrame: (handler: (frame: string) => void) => () => void;354-  onError: (handler: (message: string) => void) => () => void;355-  onClose: (handler: () => void) => () => void;356-  close: () => void;357-  getSessionKey: () => Promise<Uint8Array>;358-}359-360-export interface SyncSessionResult {361-  status: "completed" | "errored" | "closed";362-  merges: { added: number; updated: number; skipped: number };363-  error?: { code: string; message: string };364-}365-366-export type SyncRole = "initiator" | "responder";367-368-export async function runSyncSession(options: {369-  role: SyncRole;370-  deviceId: string;371-  vaultVersion: number;372-  transport: SyncSessionTransport;373-  deps?: Pick<374-    SyncCoordinatorDeps,375-    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"376-  >;377-}): Promise<SyncSessionResult> {378-  const readyDeps = options.deps ?? (await loadDefaultDeps());379-  const merges = { added: 0, updated: 0, skipped: 0 };380-381-  const preflight = await checkSyncPreflight(readyDeps);382-  if (!preflight.ok) {383-    const message =384-      preflight.error === "pro_required"385-        ? "Sync requires a Pro license."386-        : "Vault is locked; unlock Peach before syncing.";387-    options.transport.send(388-      encodeSyncMessage({389-        type: "SYNC_ERROR",390-        code: preflight.error ?? "preflight_failed",391-        message,392-      })393-    );394-    options.transport.close();395-    return {396-      status: "errored",397-      merges,398-      error: { code: preflight.error ?? "preflight_failed", message },399-    };400-  }401-402-  const sessionKey = await options.transport.getSessionKey();403-404-  return new Promise<SyncSessionResult>((resolve) => {405-    let settled = false;406-    const unsubscribers: Array<() => void> = [];407-408-    const finish = (result: SyncSessionResult): void => {409-      if (settled) return;410-      settled = true;411-      for (const fn of unsubscribers) fn();412-      options.transport.close();413-      resolve(result);414-    };415-416-    unsubscribers.push(417-      options.transport.onClose(() =>418-        finish({ status: "closed", merges })419-      )420-    );421-    unsubscribers.push(422-      options.transport.onError((message) =>423-        finish({424-          status: "errored",425-          merges,426-          error: { code: "transport_error", message },427-        })428-      )429-    );430-431-    unsubscribers.push(432-      options.transport.onFrame((frame) => {433-        void (async () => {434-          const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);435-          if (settled) return;436-          if (result.merge) {437-            merges.added += result.merge.added;438-            merges.updated += result.merge.updated;439-            merges.skipped += result.merge.skipped;440-          }441-          for (const outbound of result.outbound) options.transport.send(outbound);442-          if (result.error) {443-            finish({ status: "errored", merges, error: result.error });444-            return;445-          }446-          if (result.done) {447-            finish({ status: "completed", merges });448-          }449-        })();450-      })451-    );452-453-    if (options.role === "initiator") {454-      options.transport.send(455-        encodeSyncMessage({456-          type: "SYNC_HELLO",457-          deviceId: options.deviceId,458-          clockMs: Date.now(),459-          vaultVersion: options.vaultVersion,460-        })461-      );462-    }463-  });464-}465-466-export async function cancelSyncIfVaultLocked(467-  connectionId: string,468-  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">469-): Promise<boolean> {470-  const readyDeps = deps ?? (await loadDefaultDeps());471-  if (readyDeps.isUnlocked()) return false;472-473-  readyDeps.sendSyncData(474-    connectionId,475-    encodeSyncMessage({476-      type: "SYNC_ERROR",477-      code: "vault_locked",478-      message: "Vault is locked; unlock Peach before syncing.",479-    })480-  );481-  return true;482292 }

view file

src/shared/sync-entry-payload.test.ts+0 −95
@@ -1,95 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import {4-  decryptSyncEntry,5-  encryptSyncEntry,6-  importSyncEntryKey,7-  stripNonPortableSecrets,8-} from "./sync-entry-payload";9-import type { VaultEntry } from "./types";10-11-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {12-  return {13-    id: "entry-1",14-    type: "login",15-    title: "GitHub",16-    username: "june",17-    password: "secret",18-    url: "https://github.com",19-    createdAt: 1_000,20-    modifiedAt: 1_000,21-    ...overrides,22-  };23-}24-25-describe("sync entry payloads", () => {26-  it("encrypts and decrypts vault entries through an AES-GCM envelope", async () => {27-    const key = await importSyncEntryKey(new Uint8Array(32).fill(7));28-    const source = entry({ deletedAt: 2_000, notes: "sync me" });29-30-    const encrypted = await encryptSyncEntry(source, key);31-32-    expect(encrypted).toMatchObject({33-      id: "entry-1",34-      modifiedAt: 1_000,35-      deletedAt: 2_000,36-      ciphertext: expect.stringMatching(/^[A-Za-z0-9_-]+$/),37-      nonce: expect.stringMatching(/^[A-Za-z0-9_-]+$/),38-      tag: expect.stringMatching(/^[A-Za-z0-9_-]+$/),39-    });40-    expect(await decryptSyncEntry(encrypted, key)).toEqual(source);41-  });42-43-  it("strips passkey private keys before encryption", async () => {44-    const key = await importSyncEntryKey(new Uint8Array(32).fill(9));45-    const source = entry({46-      passkey: {47-        rpId: "github.com",48-        rpName: "GitHub",49-        credentialId: "credential",50-        publicKeyCose: "public",51-        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "secret" },52-        userHandle: "user",53-        userName: "june",54-        userDisplayName: "June",55-        algorithm: -7,56-        signCount: 3,57-        aaguid: "aaguid",58-        backupEligible: true,59-        backupState: true,60-        browser: "chrome",61-        createdAt: 1_000,62-      },63-      privateKeyJwk: { kty: "EC", d: "legacy-secret" },64-    });65-66-    const encrypted = await encryptSyncEntry(source, key);67-    const decrypted = await decryptSyncEntry(encrypted, key);68-69-    expect(decrypted.passkey?.privateKeyJwk).toBeUndefined();70-    expect(decrypted.privateKeyJwk).toBeUndefined();71-    expect(stripNonPortableSecrets(source).passkey?.publicKeyCose).toBe("public");72-  });73-74-  it("rejects invalid key sizes", async () => {75-    await expect(importSyncEntryKey(new Uint8Array(31))).rejects.toThrow(76-      "sync entry key must be 32 bytes"77-    );78-  });79-80-  it("rejects tampered ciphertext or mismatched envelope metadata", async () => {81-    const key = await importSyncEntryKey(new Uint8Array(32).fill(11));82-    const encrypted = await encryptSyncEntry(entry(), key);83-84-    const replacement = encrypted.ciphertext.endsWith("A") ? "B" : "A";85-    await expect(86-      decryptSyncEntry(87-        { ...encrypted, ciphertext: `${encrypted.ciphertext.slice(0, -1)}${replacement}` },88-        key89-      )90-    ).rejects.toThrow();91-    await expect(decryptSyncEntry({ ...encrypted, id: "other" }, key)).rejects.toThrow(92-      "invalid sync entry payload"93-    );94-  });95-});

view file at the parent

src/shared/sync-entry-payload.ts+0 −118
@@ -1,118 +0,0 @@1-import type { EncryptedSyncEntry } from "./sync-types";2-import type { VaultEntry } from "./types";3-4-const NONCE_BYTES = 12;5-const GCM_TAG_BYTES = 16;6-const textEncoder = new TextEncoder();7-const textDecoder = new TextDecoder("utf-8", { fatal: true });8-9-export async function importSyncEntryKey(rawKey: Uint8Array): Promise<CryptoKey> {10-  if (rawKey.length !== 32) {11-    throw new Error("sync entry key must be 32 bytes");12-  }13-  return crypto.subtle.importKey("raw", asArrayBuffer(rawKey), "AES-GCM", false, [14-    "encrypt",15-    "decrypt",16-  ]);17-}18-19-export async function encryptSyncEntry(20-  entry: VaultEntry,21-  key: CryptoKey22-): Promise<EncryptedSyncEntry> {23-  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));24-  const syncEntry = stripNonPortableSecrets(entry);25-  const encrypted = new Uint8Array(26-    await crypto.subtle.encrypt(27-      { name: "AES-GCM", iv: nonce, tagLength: GCM_TAG_BYTES * 8 },28-      key,29-      textEncoder.encode(JSON.stringify(syncEntry))30-    )31-  );32-  const tagOffset = encrypted.length - GCM_TAG_BYTES;33-  if (tagOffset < 0) throw new Error("invalid encrypted sync entry");34-35-  return {36-    id: syncEntry.id,37-    modifiedAt: syncEntry.modifiedAt,38-    ...(syncEntry.deletedAt !== undefined ? { deletedAt: syncEntry.deletedAt } : {}),39-    ciphertext: base64urlEncode(encrypted.slice(0, tagOffset)),40-    nonce: base64urlEncode(nonce),41-    tag: base64urlEncode(encrypted.slice(tagOffset)),42-  };43-}44-45-export async function decryptSyncEntry(46-  payload: EncryptedSyncEntry,47-  key: CryptoKey48-): Promise<VaultEntry> {49-  const ciphertext = base64urlDecode(payload.ciphertext);50-  const tag = base64urlDecode(payload.tag);51-  const combined = new Uint8Array(ciphertext.length + tag.length);52-  combined.set(ciphertext);53-  combined.set(tag, ciphertext.length);54-55-  const plain = textDecoder.decode(56-    await crypto.subtle.decrypt(57-      {58-        name: "AES-GCM",59-        iv: asArrayBuffer(base64urlDecode(payload.nonce)),60-        tagLength: GCM_TAG_BYTES * 8,61-      },62-      key,63-      combined64-    )65-  );66-  const entry = JSON.parse(plain) as unknown;67-  if (!isVaultEntryEnvelope(entry, payload)) {68-    throw new Error("invalid sync entry payload");69-  }70-  return entry;71-}72-73-export function stripNonPortableSecrets(entry: VaultEntry): VaultEntry {74-  const clone = structuredClone(entry);75-  if (clone.passkey) {76-    delete clone.passkey.privateKeyJwk;77-  }78-  delete clone.privateKeyJwk;79-  return clone;80-}81-82-function isVaultEntryEnvelope(value: unknown, envelope: EncryptedSyncEntry): value is VaultEntry {83-  if (!value || typeof value !== "object" || Array.isArray(value)) return false;84-  const entry = value as Partial<VaultEntry>;85-  return (86-    entry.id === envelope.id &&87-    entry.modifiedAt === envelope.modifiedAt &&88-    entry.deletedAt === envelope.deletedAt &&89-    typeof entry.title === "string" &&90-    typeof entry.createdAt === "number"91-  );92-}93-94-function base64urlEncode(bytes: Uint8Array): string {95-  let binary = "";96-  for (const byte of bytes) binary += String.fromCharCode(byte);97-  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");98-}99-100-function base64urlDecode(value: string): Uint8Array {101-  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {102-    throw new Error("invalid base64url");103-  }104-  const padded = value105-    .replace(/-/g, "+")106-    .replace(/_/g, "/")107-    .padEnd(Math.ceil(value.length / 4) * 4, "=");108-  const binary = atob(padded);109-  const out = new Uint8Array(binary.length);110-  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);111-  return out;112-}113-114-function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {115-  const copy = new Uint8Array(bytes.length);116-  copy.set(bytes);117-  return copy.buffer;118-}

view file at the parent

src/shared/sync-protocol.test.ts+0 −147
@@ -1,147 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import {4-  buildConflictedCopy,5-  buildSyncManifest,6-  chooseConflictWinner,7-  diffSyncManifests,8-  hashVaultEntry,9-  mergeSyncEntries,10-  selectEntriesForSync,11-} from "./sync-protocol";12-import type { VaultEntry } from "./types";13-14-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {15-  return {16-    id: "entry-1",17-    type: "login",18-    title: "GitHub",19-    username: "june",20-    password: "secret",21-    url: "https://github.com",22-    createdAt: 1_000,23-    modifiedAt: 1_000,24-    ...overrides,25-  };26-}27-28-describe("sync protocol helpers", () => {29-  it("hashes entries canonically regardless of object key insertion order", async () => {30-    const a = entry({ tags: ["work", "code"] });31-    const b = {32-      tags: ["work", "code"],33-      modifiedAt: 1_000,34-      createdAt: 1_000,35-      url: "https://github.com",36-      password: "secret",37-      username: "june",38-      title: "GitHub",39-      type: "login",40-      id: "entry-1",41-    } as VaultEntry;42-43-    await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b));44-  });45-46-  it("builds a stable manifest sorted by entry id", async () => {47-    const manifest = await buildSyncManifest([48-      entry({ id: "b", modifiedAt: 20 }),49-      entry({ id: "a", modifiedAt: 10, deletedAt: 30 }),50-    ]);51-52-    expect(manifest).toEqual([53-      {54-        id: "a",55-        modifiedAt: 10,56-        deletedAt: 30,57-        hash: expect.stringMatching(/^[0-9a-f]{64}$/),58-      },59-      {60-        id: "b",61-        modifiedAt: 20,62-        hash: expect.stringMatching(/^[0-9a-f]{64}$/),63-      },64-    ]);65-  });66-67-  it("diffs manifests into request, offer, and conflict buckets", () => {68-    const diff = diffSyncManifests(69-      [70-        { id: "local-only", modifiedAt: 10, hash: "a" },71-        { id: "newer-local", modifiedAt: 20, hash: "b" },72-        { id: "newer-remote", modifiedAt: 5, hash: "c" },73-        { id: "same-time", modifiedAt: 7, hash: "d" },74-      ],75-      [76-        { id: "remote-only", modifiedAt: 10, hash: "e" },77-        { id: "newer-local", modifiedAt: 12, hash: "f" },78-        { id: "newer-remote", modifiedAt: 30, hash: "g" },79-        { id: "same-time", modifiedAt: 7, hash: "h" },80-      ]81-    );82-83-    expect(diff).toEqual({84-      requestFromRemote: ["newer-remote", "remote-only"],85-      offerToRemote: ["local-only", "newer-local"],86-      conflicts: ["same-time"],87-    });88-  });89-90-  it("selects sync entries by id without sharing mutable references", () => {91-    const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })];92-    const selected = selectEntriesForSync(source, ["b"]);93-94-    expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);95-    selected[0]!.title = "Changed";96-    expect(source[1]!.title).toBe("Bank");97-  });98-99-  it("chooses the same conflict winner from either peer's perspective", () => {100-    // Both peers run chooseConflictWinner against the same hash pair but in101-    // opposite local/remote slots — the resulting winner-side must point at102-    // the same physical hash on both peers.103-    const peerA = chooseConflictWinner("aaaa", "bbbb");104-    const peerB = chooseConflictWinner("bbbb", "aaaa");105-    expect(peerA).toBe("local");106-    expect(peerB).toBe("remote");107-  });108-109-  it("breaks identical hashes by keeping local (no-op merge)", () => {110-    expect(chooseConflictWinner("xx", "xx")).toBe("local");111-  });112-113-  it("builds an idempotent conflicted copy id from the loser hash", () => {114-    const loser = entry({ id: "shared", title: "Edits" });115-    const archived = buildConflictedCopy(loser, "abcdef0123456789");116-    expect(archived.id).toBe("shared-conflict-abcdef01");117-    expect(archived.title).toBe("Edits (conflicted copy)");118-    // Re-running with the same loser hash returns the same id (no duplicate copies on re-sync)119-    expect(buildConflictedCopy(loser, "abcdef0123456789").id).toBe(archived.id);120-  });121-122-  it("conflicted copy does not mutate the source entry", () => {123-    const loser = entry({ id: "shared", title: "Original" });124-    buildConflictedCopy(loser, "deadbeef");125-    expect(loser.id).toBe("shared");126-    expect(loser.title).toBe("Original");127-  });128-129-  it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {130-    const local = [131-      entry({ id: "same", modifiedAt: 10, title: "old" }),132-      entry({ id: "local-newer", modifiedAt: 50, title: "keep" }),133-    ];134-    const incoming = [135-      entry({ id: "same", modifiedAt: 20, title: "new" }),136-      entry({ id: "local-newer", modifiedAt: 40, title: "skip" }),137-      entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }),138-    ];139-140-    const merged = mergeSyncEntries(local, incoming);141-142-    expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 });143-    expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new");144-    expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep");145-    expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30);146-  });147-});

view file at the parent

src/shared/sync-protocol.ts+0 −167
@@ -1,167 +0,0 @@1-import type { VaultEntry } from "./types";2-import type { SyncManifestEntry } from "./sync-types";3-4-export interface SyncDiff {5-  requestFromRemote: string[];6-  offerToRemote: string[];7-  conflicts: string[];8-}9-10-export interface SyncMergeResult {11-  entries: VaultEntry[];12-  added: number;13-  updated: number;14-  skipped: number;15-}16-17-export async function buildSyncManifest(entries: VaultEntry[]): Promise<SyncManifestEntry[]> {18-  const manifest = await Promise.all(19-    entries.map(async (entry) => ({20-      id: entry.id,21-      modifiedAt: entry.modifiedAt ?? entry.createdAt ?? 0,22-      ...(entry.deletedAt !== undefined ? { deletedAt: entry.deletedAt } : {}),23-      hash: await hashVaultEntry(entry),24-    }))25-  );26-27-  return manifest.sort((a, b) => a.id.localeCompare(b.id));28-}29-30-export async function hashVaultEntry(entry: VaultEntry): Promise<string> {31-  const encoded = new TextEncoder().encode(canonicalJson(entry));32-  const digest = await crypto.subtle.digest("SHA-256", encoded);33-  return hex(new Uint8Array(digest));34-}35-36-export function diffSyncManifests(37-  local: SyncManifestEntry[],38-  remote: SyncManifestEntry[]39-): SyncDiff {40-  const localById = new Map(local.map((entry) => [entry.id, entry]));41-  const remoteById = new Map(remote.map((entry) => [entry.id, entry]));42-  const ids = [...new Set([...localById.keys(), ...remoteById.keys()])].sort();43-44-  const requestFromRemote: string[] = [];45-  const offerToRemote: string[] = [];46-  const conflicts: string[] = [];47-48-  for (const id of ids) {49-    const ours = localById.get(id);50-    const theirs = remoteById.get(id);51-52-    if (!ours && theirs) {53-      requestFromRemote.push(id);54-      continue;55-    }56-    if (ours && !theirs) {57-      offerToRemote.push(id);58-      continue;59-    }60-    if (!ours || !theirs || ours.hash === theirs.hash) continue;61-62-    if (ours.modifiedAt > theirs.modifiedAt) {63-      offerToRemote.push(id);64-    } else if (theirs.modifiedAt > ours.modifiedAt) {65-      requestFromRemote.push(id);66-    } else {67-      conflicts.push(id);68-    }69-  }70-71-  return { requestFromRemote, offerToRemote, conflicts };72-}73-74-export type ConflictWinner = "local" | "remote";75-76-/**77- * Equal-timestamp/different-hash conflict resolver.78- *79- * Both peers see the same pair of hashes for the conflicting entry, so80- * sorting them lexicographically yields the same winner on each side81- * without any extra round-trip — the peer holding the loser knows to82- * archive its copy and accept the peer's payload.83- */84-export function chooseConflictWinner(85-  localHash: string,86-  remoteHash: string87-): ConflictWinner {88-  if (localHash === remoteHash) return "local";89-  return localHash < remoteHash ? "local" : "remote";90-}91-92-/**93- * A losing entry is preserved as a new vault entry rather than overwritten,94- * so the user can still recover the rejected edit. The id is derived from95- * the loser's id + content hash so re-running the same sync produces the96- * same archive id (idempotent — never duplicates the conflicted copy).97- */98-export function buildConflictedCopy(loser: VaultEntry, loserHash: string): VaultEntry {99-  const archived = structuredClone(loser);100-  archived.id = `${loser.id}-conflict-${loserHash.slice(0, 8)}`;101-  archived.title = `${loser.title ?? "Untitled"} (conflicted copy)`;102-  return archived;103-}104-105-export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] {106-  const wanted = new Set(ids);107-  return entries108-    .filter((entry) => wanted.has(entry.id))109-    .map((entry) => structuredClone(entry));110-}111-112-export function mergeSyncEntries(113-  local: VaultEntry[],114-  incoming: VaultEntry[]115-): SyncMergeResult {116-  const merged = local.map((entry) => structuredClone(entry));117-  const byId = new Map(merged.map((entry, index) => [entry.id, { entry, index }]));118-  let added = 0;119-  let updated = 0;120-  let skipped = 0;121-122-  for (const next of incoming) {123-    const existing = byId.get(next.id);124-    const cloned = structuredClone(next);125-126-    if (!existing) {127-      byId.set(cloned.id, { entry: cloned, index: merged.length });128-      merged.push(cloned);129-      added += 1;130-      continue;131-    }132-133-    const existingModified = existing.entry.modifiedAt ?? existing.entry.createdAt ?? 0;134-    const nextModified = cloned.modifiedAt ?? cloned.createdAt ?? 0;135-    if (nextModified > existingModified) {136-      merged[existing.index] = cloned;137-      byId.set(cloned.id, { entry: cloned, index: existing.index });138-      updated += 1;139-    } else {140-      skipped += 1;141-    }142-  }143-144-  return { entries: merged, added, updated, skipped };145-}146-147-function canonicalJson(value: unknown): string {148-  return JSON.stringify(canonicalize(value));149-}150-151-function canonicalize(value: unknown): unknown {152-  if (Array.isArray(value)) return value.map(canonicalize);153-  if (!value || typeof value !== "object") return value;154-155-  const sorted: Record<string, unknown> = {};156-  for (const key of Object.keys(value).sort()) {157-    const next = (value as Record<string, unknown>)[key];158-    if (next !== undefined) sorted[key] = canonicalize(next);159-  }160-  return sorted;161-}162-163-function hex(bytes: Uint8Array): string {164-  return Array.from(bytes)165-    .map((byte) => byte.toString(16).padStart(2, "0"))166-    .join("");167-}

view file at the parent

src/shared/sync-types.ts+37 −25
@@ -1,5 +1,16 @@11 /**2- * Types for Peach Sync - encrypted cross-device credential synchronization2+ * Types for Peach sync — full encrypted vault blob transfer.3+ *4+ * The protocol used to be per-entry (manifest / diff / conflict), but at5+ * 200KB for an 800-entry vault local-WiFi transfer takes under 200ms, so6+ * the simpler model wins: the sender ships the whole encrypted blob, the7+ * receiver applies it if its `sent_at` is newer than the last sync, and8+ * vault history is the safety net for last-write-wins.9+ *10+ * The `vault_a` / `vault_b` fields are JSON-stringified `StoredVault`11+ * records — already AES-256-GCM encrypted under the user's master password.12+ * The Noise NK transport adds a second encryption layer; the sync layer13+ * never sees plaintext vault contents.314  */415 516 export interface TrustedNetwork {@@ -18,32 +29,33 @@1829   lastSyncAt?: number;1930 }2031 21-export interface SyncManifestEntry {22-  id: string;23-  modifiedAt: number;24-  deletedAt?: number;25-  hash: string;26-}27-32+/**33+ * Wire protocol for an active Noise NK session.34+ *35+ * SYNC_VAULT_SEND  — sender pushes the full encrypted vault blobs36+ * SYNC_VAULT_ACK   — receiver acknowledges; receipt is non-fatal but lets37+ *                    the sender measure round-trip and disconnect cleanly38+ * SYNC_ERROR       — preflight or session error (vault locked, pro_required,39+ *                    invalid frame, timeout)40+ * PAIR_REQUEST     — initiator asks an unpaired responder to add it as a41+ *                    trusted device (sent only when the responder is in42+ *                    pairing mode)43+ * PAIR_ACCEPT      — responder confirms; both sides add each other to their44+ *                    trust stores45+ * PAIR_REJECT      — user declined the pair, or the request expired46+ */2847 export type SyncMessage =29-  | { type: "SYNC_HELLO"; deviceId: string; clockMs: number; vaultVersion: number }30-  | { type: "SYNC_MANIFEST"; entries: SyncManifestEntry[] }31-  | { type: "SYNC_REQUEST"; ids: string[] }32-  | { type: "SYNC_ENTRIES"; entries: EncryptedSyncEntry[] }33-  | { type: "SYNC_DONE" }34-  | { type: "SYNC_ERROR"; code: string; message: string }35-  | { type: "PAIR_REQUEST"; edPublicKey: string; name: string; code: string }36-  | { type: "PAIR_ACCEPT"; edPublicKey: string; name: string }48+  | {49+      type: "SYNC_VAULT_SEND";50+      vault_a: string;51+      vault_b: string;52+      sent_at: number;53+    }54+  | { type: "SYNC_VAULT_ACK"; received_at: number }55+  | { type: "SYNC_ERROR"; reason: string }56+  | { type: "PAIR_REQUEST"; public_key_hex: string; device_name: string }57+  | { type: "PAIR_ACCEPT"; public_key_hex: string; device_name: string }3758   | { type: "PAIR_REJECT"; reason: string };38-39-export interface EncryptedSyncEntry {40-  id: string;41-  modifiedAt: number;42-  deletedAt?: number;43-  ciphertext: string;44-  nonce: string;45-  tag: string;46-}4759 4860 export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";4961 

view file

src/shared/sync-wire.test.ts+42 −27
@@ -4,12 +4,12 @@44 import type { SyncMessage } from "./sync-types";55 66 describe("sync wire codec", () => {7-  it("round-trips sync messages through base64url JSON", () => {7+  it("round-trips SYNC_VAULT_SEND through base64url JSON", () => {88     const message: SyncMessage = {9-      type: "SYNC_HELLO",10-      deviceId: "phone-\u2603",11-      clockMs: 1_778_000_000_000,12-      vaultVersion: 4,9+      type: "SYNC_VAULT_SEND",10+      vault_a: "ciphertext-a",11+      vault_b: "ciphertext-b",12+      sent_at: 1_778_000_000_000,1313     };1414 1515     const encoded = encodeSyncMessage(message);@@ -19,24 +19,21 @@1919     expect(decodeSyncMessage(encoded)).toEqual(message);2020   });2121 22-  it("round-trips manifest and encrypted entry payloads", () => {22+  it("round-trips ACK / ERROR / pairing messages", () => {2323     const messages: SyncMessage[] = [24+      { type: "SYNC_VAULT_ACK", received_at: 42 },25+      { type: "SYNC_ERROR", reason: "vault_locked" },2426       {25-        type: "SYNC_MANIFEST",26-        entries: [{ id: "entry-1", modifiedAt: 42, deletedAt: 45, hash: "abc123" }],27+        type: "PAIR_REQUEST",28+        public_key_hex: "ab".repeat(32),29+        device_name: "June's Laptop",2730       },2831       {29-        type: "SYNC_ENTRIES",30-        entries: [31-          {32-            id: "entry-1",33-            modifiedAt: 42,34-            ciphertext: "ciphertext",35-            nonce: "nonce",36-            tag: "tag",37-          },38-        ],32+        type: "PAIR_ACCEPT",33+        public_key_hex: "cd".repeat(32),34+        device_name: "June's Phone",3935       },36+      { type: "PAIR_REJECT", reason: "user_declined" },4037     ];4138 4239     for (const message of messages) {@@ -45,15 +42,33 @@4542   });4643 4744   it("validates message-specific required fields", () => {48-    expect(isSyncMessage({ type: "SYNC_REQUEST", ids: ["a", "b"] })).toBe(true);49-    expect(isSyncMessage({ type: "PAIR_ACCEPT", edPublicKey: "key", name: "Laptop" })).toBe(50-      true51-    );52-    expect(isSyncMessage({ type: "SYNC_REQUEST", ids: [7] })).toBe(false);53-    expect(isSyncMessage({ type: "SYNC_HELLO", deviceId: "phone", clockMs: "now" })).toBe(54-      false55-    );56-    expect(isSyncMessage({ type: "SYNC_MANIFEST", entries: [{ id: "a" }] })).toBe(false);45+    expect(46+      isSyncMessage({47+        type: "SYNC_VAULT_SEND",48+        vault_a: "a",49+        vault_b: "b",50+        sent_at: 1,51+      }),52+    ).toBe(true);53+    expect(54+      isSyncMessage({55+        type: "PAIR_ACCEPT",56+        public_key_hex: "deadbeef",57+        device_name: "Phone",58+      }),59+    ).toBe(true);60+61+    // Missing sent_at62+    expect(63+      isSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: "a", vault_b: "b" }),64+    ).toBe(false);65+    // Wrong type for received_at66+    expect(isSyncMessage({ type: "SYNC_VAULT_ACK", received_at: "now" })).toBe(false);67+    // Missing reason68+    expect(isSyncMessage({ type: "SYNC_ERROR" })).toBe(false);69+    // Missing device_name70+    expect(isSyncMessage({ type: "PAIR_REQUEST", public_key_hex: "abc" })).toBe(false);71+    // Unknown variant5772     expect(isSyncMessage({ type: "UNKNOWN" })).toBe(false);5873   });5974 

view file

src/shared/sync-wire.ts+9 −45
@@ -24,26 +24,20 @@2424   if (!isRecord(value) || typeof value.type !== "string") return false;2525 2626   switch (value.type) {27-    case "SYNC_HELLO":27+    case "SYNC_VAULT_SEND":2828       return (29-        isString(value.deviceId) &&30-        isFiniteNumber(value.clockMs) &&31-        isFiniteNumber(value.vaultVersion)29+        isString(value.vault_a) &&30+        isString(value.vault_b) &&31+        isFiniteNumber(value.sent_at)3232       );33-    case "SYNC_MANIFEST":34-      return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry);35-    case "SYNC_REQUEST":36-      return isStringArray(value.ids);37-    case "SYNC_ENTRIES":38-      return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry);39-    case "SYNC_DONE":40-      return true;33+    case "SYNC_VAULT_ACK":34+      return isFiniteNumber(value.received_at);4135     case "SYNC_ERROR":42-      return isString(value.code) && isString(value.message);36+      return isString(value.reason);4337     case "PAIR_REQUEST":44-      return isString(value.edPublicKey) && isString(value.name) && isString(value.code);38+      return isString(value.public_key_hex) && isString(value.device_name);4539     case "PAIR_ACCEPT":46-      return isString(value.edPublicKey) && isString(value.name);40+      return isString(value.public_key_hex) && isString(value.device_name);4741     case "PAIR_REJECT":4842       return isString(value.reason);4943     default:@@ -51,28 +45,6 @@5145   }5246 }5347 54-function isSyncManifestEntry(value: unknown): boolean {55-  return (56-    isRecord(value) &&57-    isString(value.id) &&58-    isFiniteNumber(value.modifiedAt) &&59-    isOptionalNumber(value.deletedAt) &&60-    isString(value.hash)61-  );62-}63-64-function isEncryptedSyncEntry(value: unknown): boolean {65-  return (66-    isRecord(value) &&67-    isString(value.id) &&68-    isFiniteNumber(value.modifiedAt) &&69-    isOptionalNumber(value.deletedAt) &&70-    isString(value.ciphertext) &&71-    isString(value.nonce) &&72-    isString(value.tag)73-  );74-}75-7648 function isRecord(value: unknown): value is Record<string, unknown> {7749   return Boolean(value) && typeof value === "object" && !Array.isArray(value);7850 }@@ -81,16 +53,8 @@8153   return typeof value === "string";8254 }8355 84-function isStringArray(value: unknown): value is string[] {85-  return Array.isArray(value) && value.every(isString);86-}87-8856 function isFiniteNumber(value: unknown): value is number {8957   return typeof value === "number" && Number.isFinite(value);90-}91-92-function isOptionalNumber(value: unknown): boolean {93-  return value === undefined || isFiniteNumber(value);9458 }9559 9660 function base64urlEncode(bytes: Uint8Array): string {

view file