35252658ce1f

feat: Add stateless sync frame dispatcher (v2.13.21)

- Add dispatchSyncFrame: decode one inbound base64url SyncMessage and produce outbound frames plus merge/done/error effects - SYNC_HELLO → SYNC_MANIFEST; SYNC_MANIFEST → SYNC_REQUEST + encrypted SYNC_ENTRIES; SYNC_REQUEST → encrypted SYNC_ENTRIES; SYNC_ENTRIES → restoreEntries(merge) + SYNC_DONE - SYNC_DONE/SYNC_ERROR end the session; pairing messages are out-of-band - Malformed frames and locked-vault dispatches emit a stable SYNC_ERROR - Cover all branches in sync-coordinator.test.ts (9 new cases, 17 total) Verification: npm typecheck, focused coordinator 17/17, npm test 269/269, npm run build clean.

time
2026-04-30 14:39 UTC · 6 months ago
machine
koh2
parent
0795bfa40488
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+177 −1src/background/sync-coordinator.ts+124 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.20",4+  "version": "2.13.21",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.13.20",4+  "version": "2.13.21",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.13.20",3+  "version": "2.13.21",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+177 −1
@@ -6,9 +6,10 @@66   buildLocalSyncSnapshot,77   cancelSyncIfVaultLocked,88   checkSyncPreflight,9+  dispatchSyncFrame,910   planManifestSync,1011 } from "./sync-coordinator";11-import { decodeSyncMessage } from "../shared/sync-wire";12+import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";1213 import type { VaultEntry } from "../shared/types";1314 1415 function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {@@ -161,5 +162,180 @@161162       })162163     ).toBe(false);163164     expect(sendSyncData).not.toHaveBeenCalled();165+  });166+167+  describe("dispatchSyncFrame", () => {168+    const passDeps = (entries: VaultEntry[]) => ({169+      isProUser: async () => true,170+      isUnlocked: () => true,171+      getEntries: () => entries,172+      restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),173+    });174+175+    it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {176+      const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);177+      const result = await dispatchSyncFrame(178+        encodeSyncMessage({179+          type: "SYNC_HELLO",180+          deviceId: "device",181+          clockMs: 1,182+          vaultVersion: 1,183+        }),184+        new Uint8Array(32).fill(1),185+        deps186+      );187+      expect(result.outbound).toHaveLength(1);188+      const decoded = decodeSyncMessage(result.outbound[0]!);189+      expect(decoded.type).toBe("SYNC_MANIFEST");190+      if (decoded.type === "SYNC_MANIFEST") {191+        expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);192+      }193+    });194+195+    it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {196+      const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);197+      const result = await dispatchSyncFrame(198+        encodeSyncMessage({199+          type: "SYNC_MANIFEST",200+          entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],201+        }),202+        new Uint8Array(32).fill(2),203+        deps204+      );205+      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));206+      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({207+        type: "SYNC_REQUEST",208+        ids: ["remote"],209+      });210+      const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");211+      expect(entriesMessage?.type).toBe("SYNC_ENTRIES");212+      if (entriesMessage?.type === "SYNC_ENTRIES") {213+        expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);214+      }215+    });216+217+    it("returns SYNC_DONE when manifests already agree", async () => {218+      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);219+      const localManifest = encodeSyncMessage({220+        type: "SYNC_MANIFEST",221+        entries: [222+          {223+            id: "shared",224+            modifiedAt: 1_000,225+            hash: (226+              await (await import("../shared/sync-protocol")).buildSyncManifest(227+                deps.getEntries()228+              )229+            )[0]!.hash,230+          },231+        ],232+      });233+      const result = await dispatchSyncFrame(234+        localManifest,235+        new Uint8Array(32).fill(3),236+        deps237+      );238+      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));239+      expect(messages).toHaveLength(1);240+      expect(messages[0]!.type).toBe("SYNC_DONE");241+    });242+243+    it("encrypts only the requested ids on SYNC_REQUEST", async () => {244+      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);245+      const result = await dispatchSyncFrame(246+        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),247+        new Uint8Array(32).fill(4),248+        deps249+      );250+      expect(result.outbound).toHaveLength(1);251+      const decoded = decodeSyncMessage(result.outbound[0]!);252+      expect(decoded.type).toBe("SYNC_ENTRIES");253+      if (decoded.type === "SYNC_ENTRIES") {254+        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);255+      }256+    });257+258+    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {259+      const rawEntryKey = new Uint8Array(32).fill(9);260+      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {261+        isProUser: async () => true,262+        isUnlocked: () => true,263+        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],264+      });265+      const deps = passDeps([]);266+      const result = await dispatchSyncFrame(267+        encodeSyncMessage(offered),268+        rawEntryKey,269+        deps270+      );271+      expect(deps.restoreEntries).toHaveBeenCalledWith(272+        [expect.objectContaining({ id: "remote", title: "Remote" })],273+        "merge"274+      );275+      const decoded = decodeSyncMessage(result.outbound[0]!);276+      expect(decoded.type).toBe("SYNC_DONE");277+    });278+279+    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {280+      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {281+        isProUser: async () => true,282+        isUnlocked: () => true,283+        getEntries: () => [],284+        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),285+      });286+      expect(result.done).toBe(true);287+      expect(result.error?.code).toBe("invalid_message");288+      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");289+    });290+291+    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {292+      const result = await dispatchSyncFrame(293+        encodeSyncMessage({294+          type: "SYNC_HELLO",295+          deviceId: "device",296+          clockMs: 0,297+          vaultVersion: 0,298+        }),299+        new Uint8Array(32),300+        {301+          isProUser: async () => true,302+          isUnlocked: () => false,303+          getEntries: () => [],304+          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),305+        }306+      );307+      expect(result.done).toBe(true);308+      expect(result.error?.code).toBe("vault_locked");309+      const decoded = decodeSyncMessage(result.outbound[0]!);310+      expect(decoded.type).toBe("SYNC_ERROR");311+      if (decoded.type === "SYNC_ERROR") {312+        expect(decoded.code).toBe("vault_locked");313+      }314+    });315+316+    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {317+      const result = await dispatchSyncFrame(318+        encodeSyncMessage({ type: "SYNC_DONE" }),319+        new Uint8Array(32).fill(7),320+        passDeps([])321+      );322+      expect(result.outbound).toEqual([]);323+      expect(result.done).toBe(true);324+    });325+326+    it("treats SYNC_ERROR from peer as session end with no outbound frames", async () => {327+      const result = await dispatchSyncFrame(328+        encodeSyncMessage({329+          type: "SYNC_ERROR",330+          code: "peer_failed",331+          message: "Peer reported error.",332+        }),333+        new Uint8Array(32).fill(8),334+        passDeps([])335+      );336+      expect(result.outbound).toEqual([]);337+      expect(result.done).toBe(true);338+      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });339+    });164340   });165341 });

view file

src/background/sync-coordinator.ts+124 −1
@@ -19,7 +19,7 @@1919   importSyncEntryKey,2020 } from "../shared/sync-entry-payload";2121 import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";22-import { encodeSyncMessage } from "../shared/sync-wire";22+import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";2323 import type { VaultEntry } from "../shared/types";2424 2525 export type SyncPreflightError = "pro_required" | "vault_locked";@@ -130,6 +130,129 @@130130     message.entries.map((payload) => decryptSyncEntry(payload, key))131131   );132132   return readyDeps.restoreEntries(entries, "merge");133+}134+135+export interface SyncFrameDispatchResult {136+  outbound: string[];137+  merge?: { added: number; updated: number; skipped: number };138+  done?: boolean;139+  error?: { code: string; message: string };140+}141+142+export async function dispatchSyncFrame(143+  frame: string,144+  rawEntryKey: Uint8Array,145+  deps?: Pick<146+    SyncCoordinatorDeps,147+    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"148+  >149+): Promise<SyncFrameDispatchResult> {150+  const readyDeps = deps ?? (await loadDefaultDeps());151+  const preflight = await checkSyncPreflight(readyDeps);152+  if (!preflight.ok) {153+    const message =154+      preflight.error === "pro_required"155+        ? "Sync requires a Pro license."156+        : "Vault is locked; unlock Peach before syncing.";157+    return {158+      outbound: [159+        encodeSyncMessage({160+          type: "SYNC_ERROR",161+          code: preflight.error ?? "preflight_failed",162+          message,163+        }),164+      ],165+      done: true,166+      error: { code: preflight.error ?? "preflight_failed", message },167+    };168+  }169+170+  let message: SyncMessage;171+  try {172+    message = decodeSyncMessage(frame);173+  } catch {174+    return {175+      outbound: [176+        encodeSyncMessage({177+          type: "SYNC_ERROR",178+          code: "invalid_message",179+          message: "Could not decode sync frame.",180+        }),181+      ],182+      done: true,183+      error: { code: "invalid_message", message: "Could not decode sync frame." },184+    };185+  }186+187+  const key = await importSyncEntryKey(rawEntryKey);188+189+  switch (message.type) {190+    case "SYNC_HELLO": {191+      const manifest = await buildSyncManifest(readyDeps.getEntries());192+      return {193+        outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],194+      };195+    }196+    case "SYNC_MANIFEST": {197+      const localEntries = readyDeps.getEntries();198+      const localManifest = await buildSyncManifest(localEntries);199+      const diff = diffSyncManifests(localManifest, message.entries);200+      const offered = selectEntriesForSync(localEntries, diff.offerToRemote);201+      const outbound: string[] = [];202+      if (diff.requestFromRemote.length > 0) {203+        outbound.push(204+          encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })205+        );206+      }207+      if (offered.length > 0) {208+        outbound.push(209+          encodeSyncMessage({210+            type: "SYNC_ENTRIES",211+            entries: await Promise.all(212+              offered.map((entry) => encryptSyncEntry(entry, key))213+            ),214+          })215+        );216+      }217+      if (outbound.length === 0) {218+        outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));219+      }220+      return { outbound };221+    }222+    case "SYNC_REQUEST": {223+      const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);224+      return {225+        outbound: [226+          encodeSyncMessage({227+            type: "SYNC_ENTRIES",228+            entries: await Promise.all(229+              offered.map((entry) => encryptSyncEntry(entry, key))230+            ),231+          }),232+        ],233+      };234+    }235+    case "SYNC_ENTRIES": {236+      const decrypted = await Promise.all(237+        message.entries.map((payload) => decryptSyncEntry(payload, key))238+      );239+      const merge = await readyDeps.restoreEntries(decrypted, "merge");240+      return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };241+    }242+    case "SYNC_DONE":243+      return { outbound: [], done: true };244+    case "SYNC_ERROR":245+      return {246+        outbound: [],247+        done: true,248+        error: { code: message.code, message: message.message },249+      };250+    case "PAIR_REQUEST":251+    case "PAIR_ACCEPT":252+    case "PAIR_REJECT":253+      // Pairing flow is handled outside the data-sync state machine.254+      return { outbound: [] };255+  }133256 }134257 135258 export async function cancelSyncIfVaultLocked(

view file