19c5b50deb77
feat: Add sync coordinator preflight (v2.13.16)
- time
- 2026-04-30 14:09 UTC · 6 months ago
- machine
- koh2
- parent
- 0607c6c271d5
↓ peach-browser-19c5b50d.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+105 −0src/background/sync-coordinator.ts+104 −0manifest.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": {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",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": {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+});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+}