35252658ce1f
feat: Add stateless sync frame dispatcher (v2.13.21)
- time
- 2026-04-30 14:39 UTC · 6 months ago
- machine
- koh2
- parent
- 0795bfa40488
↓ peach-browser-35252658.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+177 −1src/background/sync-coordinator.ts+124 −1manifest.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": {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",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": {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 });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(