19c5b50deb77

feat: Add sync coordinator preflight (v2.13.16)

time
2026-04-30 14:09 UTC · 6 months ago
machine
koh2
parent
0607c6c271d5
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+105 −0src/background/sync-coordinator.ts+104 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.15",4+  "version": "2.13.16",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.15",4+  "version": "2.13.16",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.15",3+  "version": "2.13.16",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+105 −0
@@ -0,0 +1,105 @@1+import { describe, expect, it, vi } from "vitest";2+3+import {4+  buildLocalSyncSnapshot,5+  cancelSyncIfVaultLocked,6+  checkSyncPreflight,7+  planManifestSync,8+} from "./sync-coordinator";9+import { decodeSyncMessage } from "../shared/sync-wire";10+import type { VaultEntry } from "../shared/types";11+12+function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {13+  return {14+    id: "entry-1",15+    type: "login",16+    title: "GitHub",17+    username: "june",18+    password: "secret",19+    url: "https://github.com",20+    createdAt: 1_000,21+    modifiedAt: 1_000,22+    ...overrides,23+  };24+}25+26+describe("sync coordinator", () => {27+  it("blocks sync for free users before inspecting the vault", async () => {28+    const getEntries = vi.fn(() => [entry()]);29+30+    await expect(31+      checkSyncPreflight({32+        isProUser: async () => false,33+        isUnlocked: () => true,34+      })35+    ).resolves.toEqual({ ok: false, error: "pro_required" });36+37+    expect(getEntries).not.toHaveBeenCalled();38+  });39+40+  it("blocks sync when the vault is locked", async () => {41+    await expect(42+      checkSyncPreflight({43+        isProUser: async () => true,44+        isUnlocked: () => false,45+      })46+    ).resolves.toEqual({ ok: false, error: "vault_locked" });47+  });48+49+  it("builds a local sync snapshot only after preflight passes", async () => {50+    const snapshot = await buildLocalSyncSnapshot({51+      isProUser: async () => true,52+      isUnlocked: () => true,53+      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],54+    });55+56+    expect(snapshot.entries).toHaveLength(2);57+    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);58+  });59+60+  it("plans manifest sync without sending entry payloads", async () => {61+    const plan = await planManifestSync(62+      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],63+      {64+        isProUser: async () => true,65+        isUnlocked: () => true,66+        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],67+      }68+    );69+70+    expect(plan.diff).toEqual({71+      requestFromRemote: ["remote-only"],72+      offerToRemote: ["local-only"],73+      conflicts: [],74+    });75+  });76+77+  it("sends a stable sync error when a peer asks while the vault is locked", async () => {78+    const sendSyncData = vi.fn();79+80+    const cancelled = await cancelSyncIfVaultLocked("conn-1", {81+      isUnlocked: () => false,82+      sendSyncData,83+    });84+85+    expect(cancelled).toBe(true);86+    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));87+    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({88+      type: "SYNC_ERROR",89+      code: "vault_locked",90+      message: "Vault is locked; unlock Peach before syncing.",91+    });92+  });93+94+  it("does not cancel an unlocked vault sync", async () => {95+    const sendSyncData = vi.fn();96+97+    expect(98+      await cancelSyncIfVaultLocked("conn-1", {99+        isUnlocked: () => true,100+        sendSyncData,101+      })102+    ).toBe(false);103+    expect(sendSyncData).not.toHaveBeenCalled();104+  });105+});

view file

src/background/sync-coordinator.ts+104 −0
@@ -0,0 +1,104 @@1+/**2+ * Dormant SYN-001 coordinator helpers.3+ *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.8+ */9+10+import { buildSyncManifest, diffSyncManifests } from "../shared/sync-protocol";11+import type { SyncDiff } from "../shared/sync-protocol";12+import type { SyncManifestEntry } from "../shared/sync-types";13+import { encodeSyncMessage } from "../shared/sync-wire";14+import type { VaultEntry } from "../shared/types";15+16+export type SyncPreflightError = "pro_required" | "vault_locked";17+18+export interface SyncPreflightResult {19+  ok: boolean;20+  error?: SyncPreflightError;21+}22+23+export interface SyncSnapshot {24+  entries: VaultEntry[];25+  manifest: SyncManifestEntry[];26+}27+28+export interface SyncPlan {29+  localManifest: SyncManifestEntry[];30+  diff: SyncDiff;31+}32+33+export interface SyncCoordinatorDeps {34+  isProUser: () => Promise<boolean>;35+  isUnlocked: () => boolean;36+  getEntries: () => VaultEntry[];37+  sendSyncData: (connectionId: string, data: string) => void;38+}39+40+async function loadDefaultDeps(): Promise<SyncCoordinatorDeps> {41+  const [license, sync, vault] = await Promise.all([42+    import("./license"),43+    import("./sync"),44+    import("./vault"),45+  ]);46+  return {47+    isProUser: license.isProUser,48+    isUnlocked: vault.isUnlocked,49+    getEntries: vault.getEntries,50+    sendSyncData: sync.sendSyncData,51+  };52+}53+54+export async function checkSyncPreflight(55+  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">56+): Promise<SyncPreflightResult> {57+  const readyDeps = deps ?? (await loadDefaultDeps());58+  if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };59+  if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };60+  return { ok: true };61+}62+63+export async function buildLocalSyncSnapshot(64+  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">65+): Promise<SyncSnapshot> {66+  const readyDeps = deps ?? (await loadDefaultDeps());67+  const preflight = await checkSyncPreflight(readyDeps);68+  if (!preflight.ok) throw new Error(preflight.error);69+70+  const entries = readyDeps.getEntries();71+  return {72+    entries,73+    manifest: await buildSyncManifest(entries),74+  };75+}76+77+export async function planManifestSync(78+  remoteManifest: SyncManifestEntry[],79+  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">80+): Promise<SyncPlan> {81+  const snapshot = await buildLocalSyncSnapshot(deps);82+  return {83+    localManifest: snapshot.manifest,84+    diff: diffSyncManifests(snapshot.manifest, remoteManifest),85+  };86+}87+88+export async function cancelSyncIfVaultLocked(89+  connectionId: string,90+  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">91+): Promise<boolean> {92+  const readyDeps = deps ?? (await loadDefaultDeps());93+  if (readyDeps.isUnlocked()) return false;94+95+  readyDeps.sendSyncData(96+    connectionId,97+    encodeSyncMessage({98+      type: "SYNC_ERROR",99+      code: "vault_locked",100+      message: "Vault is locked; unlock Peach before syncing.",101+    })102+  );103+  return true;104+}

view file