8d53fdb48f58

remove dead sync code: sync-coordinator.{ts,test.ts}, sync-wire.{ts,test.ts}, and unused SyncMessage/PairingStep/NoiseHandshakeResult/SyncState types

time
2026-05-09 00:53 UTC · 5 months ago
machine
koh2
parent
2b0132a81b0b
5 files src/background/sync-coordinator.test.ts+0 −293src/background/sync-coordinator.ts+0 −240src/shared/sync-types.ts+6 −64src/shared/sync-wire.test.ts+0 −87src/shared/sync-wire.ts+0 −78
src/background/sync-coordinator.test.ts+0 −293
@@ -1,293 +0,0 @@1-/**2- * Blob sync session tests.3- *4- * Covers the v2.14.x rewrite that replaced per-entry SYNC_HELLO/MANIFEST/5- * REQUEST/ENTRIES/DONE with a single bidirectional blob exchange. Last-6- * write-wins is enforced via `sent_at` versus `peach_last_sync_at`.7- */8-9-import { beforeEach, describe, expect, it, vi } from "vitest";10-import { encodeSyncMessage, decodeSyncMessage } from "../shared/sync-wire";11-import type { SyncSessionTransport } from "./sync-coordinator";12-13-type StorageShape = Record<string, unknown>;14-15-interface MockTransport extends SyncSessionTransport {16-  sentFrames: string[];17-  pushIncoming: (frame: string) => void;18-  pushClose: () => void;19-  pushError: (msg: string) => void;20-}21-22-function makeMockTransport(): MockTransport {23-  const sentFrames: string[] = [];24-  const frameHandlers: Array<(frame: string) => void> = [];25-  const errorHandlers: Array<(msg: string) => void> = [];26-  const closeHandlers: Array<() => void> = [];27-28-  return {29-    sentFrames,30-    send: (frame: string) => {31-      sentFrames.push(frame);32-    },33-    onFrame: (handler) => {34-      frameHandlers.push(handler);35-      return () => {36-        const i = frameHandlers.indexOf(handler);37-        if (i >= 0) frameHandlers.splice(i, 1);38-      };39-    },40-    onError: (handler) => {41-      errorHandlers.push(handler);42-      return () => {43-        const i = errorHandlers.indexOf(handler);44-        if (i >= 0) errorHandlers.splice(i, 1);45-      };46-    },47-    onClose: (handler) => {48-      closeHandlers.push(handler);49-      return () => {50-        const i = closeHandlers.indexOf(handler);51-        if (i >= 0) closeHandlers.splice(i, 1);52-      };53-    },54-    close: () => {55-      // no-op for the mock — finish() in the coordinator will already56-      // have called us inside finish().57-    },58-    pushIncoming: (frame) => {59-      for (const h of frameHandlers) h(frame);60-    },61-    pushClose: () => {62-      for (const h of closeHandlers) h();63-    },64-    pushError: (msg) => {65-      for (const h of errorHandlers) h(msg);66-    },67-  };68-}69-70-describe("runBlobSyncSession + applyIncomingVault", () => {71-  let storage: StorageShape;72-73-  beforeEach(() => {74-    vi.resetModules();75-    storage = {};76-77-    const chromeMock = {78-      storage: {79-        local: {80-          get: vi.fn(async (key?: string | string[]) => {81-            if (!key) return { ...storage };82-            if (typeof key === "string") return { [key]: storage[key] };83-            return key.reduce<Record<string, unknown>>((acc, k) => {84-              acc[k] = storage[k];85-              return acc;86-            }, {});87-          }),88-          set: vi.fn(async (next: Record<string, unknown>) => {89-            Object.assign(storage, next);90-          }),91-          remove: vi.fn(async (keys: string | string[]) => {92-            for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];93-          }),94-        },95-      },96-      alarms: {97-        clear: vi.fn(),98-        create: vi.fn(),99-        onAlarm: { addListener: vi.fn() },100-      },101-      idle: {102-        setDetectionInterval: vi.fn(),103-        onStateChanged: { addListener: vi.fn() },104-      },105-    } as unknown as typeof chrome;106-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;107-  });108-109-  it("applyIncomingVault writes new blobs when sent_at beats lastSyncAt", async () => {110-    const { applyIncomingVault } = await import("./sync-coordinator");111-    storage.peach_last_sync_at = "100";112-    storage.peach_vault_a = "old-a";113-114-    const verdict = await applyIncomingVault("new-a", "new-b", 200);115-116-    expect(verdict).toBe("applied");117-    expect(storage.peach_vault_a).toBe("new-a");118-    expect(storage.peach_vault_b).toBe("new-b");119-    expect(storage.peach_last_sync_at).toBe("200");120-  });121-122-  it("applyIncomingVault ignores blobs whose sent_at is older", async () => {123-    const { applyIncomingVault } = await import("./sync-coordinator");124-    storage.peach_last_sync_at = "500";125-    storage.peach_vault_a = "current-a";126-127-    const verdict = await applyIncomingVault("stale-a", "stale-b", 100);128-129-    expect(verdict).toBe("ignored");130-    expect(storage.peach_vault_a).toBe("current-a");131-    expect(storage.peach_last_sync_at).toBe("500");132-  });133-134-  it("applyIncomingVault snapshots BEFORE writing with reason 'sync_received'", async () => {135-    // Pre-populate storage so takeVaultSnapshot has a slotA to snapshot.136-    const priorSlot = {137-      version: 1,138-      salt: "00".repeat(16),139-      nonce: "00".repeat(12),140-      ciphertext: "00".repeat(32),141-      tag: "00".repeat(16),142-    };143-    storage.peach_vault_a = priorSlot;144-    storage.peach_vault_b = priorSlot;145-    const priorJson = JSON.stringify(priorSlot);146-147-    const { applyIncomingVault } = await import("./sync-coordinator");148-    await applyIncomingVault("incoming-a", "incoming-b", Date.now() + 1000);149-150-    const history = storage.peach_vault_history as151-      | { snapshots: Array<{ reason: string; vault_a: string }> }152-      | undefined;153-    expect(history?.snapshots[0]?.reason).toBe("sync_received");154-    // History captures the old blob (JSON-stringified) before the sync write155-    // overwrote slotA with the incoming string.156-    expect(history?.snapshots[0]?.vault_a).toBe(priorJson);157-    // After apply, slotA holds the incoming bytes.158-    expect(storage.peach_vault_a).toBe("incoming-a");159-  });160-161-  it("initiator: pro_required when isPro returns false", async () => {162-    const { runBlobSyncSession } = await import("./sync-coordinator");163-    const transport = makeMockTransport();164-    const result = await runBlobSyncSession("initiator", transport, {165-      isPro: async () => false,166-      isUnlocked: () => true,167-      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),168-    });169-170-    expect(result.status).toBe("errored");171-    expect(result.error).toBe("pro_required");172-    expect(transport.sentFrames).toHaveLength(1);173-    const decoded = decodeSyncMessage(transport.sentFrames[0]!);174-    expect(decoded.type).toBe("SYNC_ERROR");175-    if (decoded.type === "SYNC_ERROR") {176-      expect(decoded.reason).toBe("pro_required");177-    }178-  });179-180-  it("initiator: vault_locked when isUnlocked returns false", async () => {181-    const { runBlobSyncSession } = await import("./sync-coordinator");182-    const transport = makeMockTransport();183-    const result = await runBlobSyncSession("initiator", transport, {184-      isPro: async () => true,185-      isUnlocked: () => false,186-      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),187-    });188-189-    expect(result.status).toBe("errored");190-    expect(result.error).toBe("vault_locked");191-  });192-193-  it("initiator sends SYNC_VAULT_SEND first and completes on peer ACK", async () => {194-    const { runBlobSyncSession } = await import("./sync-coordinator");195-    const transport = makeMockTransport();196-    const sessionPromise = runBlobSyncSession("initiator", transport, {197-      isPro: async () => true,198-      isUnlocked: () => true,199-      getVaultBlobs: async () => ({ vault_a: "blob-a", vault_b: "blob-b" }),200-    });201-202-    // Wait a microtask so the initiator's first send fires.203-    await Promise.resolve();204-    await Promise.resolve();205-206-    expect(transport.sentFrames).toHaveLength(1);207-    const sent = decodeSyncMessage(transport.sentFrames[0]!);208-    expect(sent.type).toBe("SYNC_VAULT_SEND");209-210-    transport.pushIncoming(211-      encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),212-    );213-214-    const result = await sessionPromise;215-    expect(result.status).toBe("completed");216-  });217-218-  it("responder applies incoming vault if newer and sends ACK + its own SYNC_VAULT_SEND", async () => {219-    const { runBlobSyncSession } = await import("./sync-coordinator");220-    const transport = makeMockTransport();221-222-    const sessionPromise = runBlobSyncSession("responder", transport, {223-      isPro: async () => true,224-      isUnlocked: () => true,225-      getVaultBlobs: async () => ({ vault_a: "responder-a", vault_b: "responder-b" }),226-    });227-228-    await Promise.resolve();229-    transport.pushIncoming(230-      encodeSyncMessage({231-        type: "SYNC_VAULT_SEND",232-        vault_a: "incoming-a",233-        vault_b: "incoming-b",234-        sent_at: Date.now() + 1000,235-      }),236-    );237-238-    // Settle the async work inside the frame handler.239-    await new Promise((r) => setTimeout(r, 5));240-241-    // Responder should have sent ACK + its own SEND.242-    expect(transport.sentFrames).toHaveLength(2);243-    const ack = decodeSyncMessage(transport.sentFrames[0]!);244-    const send = decodeSyncMessage(transport.sentFrames[1]!);245-    expect(ack.type).toBe("SYNC_VAULT_ACK");246-    expect(send.type).toBe("SYNC_VAULT_SEND");247-248-    // Now the initiator-side ACK arrives — session completes.249-    transport.pushIncoming(250-      encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),251-    );252-253-    const result = await sessionPromise;254-    expect(result.status).toBe("completed");255-    expect(storage.peach_vault_a).toBe("incoming-a");256-  });257-258-  it("SYNC_ERROR from peer propagates as session error", async () => {259-    const { runBlobSyncSession } = await import("./sync-coordinator");260-    const transport = makeMockTransport();261-    const sessionPromise = runBlobSyncSession("initiator", transport, {262-      isPro: async () => true,263-      isUnlocked: () => true,264-      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),265-    });266-267-    await Promise.resolve();268-    transport.pushIncoming(269-      encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),270-    );271-272-    const result = await sessionPromise;273-    expect(result.status).toBe("errored");274-    expect(result.error).toBe("vault_locked");275-  });276-277-  it("invalid frame triggers SYNC_ERROR + invalid_frame", async () => {278-    const { runBlobSyncSession } = await import("./sync-coordinator");279-    const transport = makeMockTransport();280-    const sessionPromise = runBlobSyncSession("responder", transport, {281-      isPro: async () => true,282-      isUnlocked: () => true,283-      getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),284-    });285-286-    await Promise.resolve();287-    transport.pushIncoming("@@@invalid-base64@@@");288-289-    const result = await sessionPromise;290-    expect(result.status).toBe("errored");291-    expect(result.error).toBe("invalid_frame");292-  });293-});

view file at the parent

src/background/sync-coordinator.ts+0 −240
@@ -1,240 +0,0 @@1-/**2- * Blob sync session coordinator.3- *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.14- */15-16-import { storageGet, storageSet } from "../shared/storage";17-import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";18-import type { SyncMessage } from "../shared/sync-types";19-import { takeVaultSnapshot } from "./vault";20-21-const LAST_SYNC_KEY = "peach_last_sync_at";22-const SYNC_TIMEOUT_MS = 30_000;23-24-export interface SyncCoordinatorDeps {25-  isPro: () => Promise<boolean>;26-  isUnlocked: () => boolean;27-  getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>;28-}29-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;36-}37-38-export type SyncRole = "initiator" | "responder";39-40-export interface SyncSessionResult {41-  status: "completed" | "errored" | "closed";42-  error?: string;43-}44-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";72-}73-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-  });240-}

view file at the parent

src/shared/sync-types.ts+6 −64
@@ -1,16 +1,11 @@11 /**2- * Types for Peach sync — full encrypted vault blob transfer.2+ * Types for Peach sync — full encrypted vault blob transfer over Noise NK.33  *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.4+ * The wire protocol (SYNC_VAULT_SEND / SYNC_VAULT_ACK / SYNC_ERROR /5+ * PAIR_REQUEST / PAIR_ACCEPT / PAIR_REJECT) lives on the native peer6+ * (peach-android SyncPlugin) and is no longer modeled in the extension —7+ * the desktop side only owns the in-popup pairing UI plus the device8+ * registry.149  */1510 1611 export interface TrustedNetwork {@@ -29,60 +24,7 @@2924   lastSyncAt?: number;3025 }3126 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- */47-export type SyncMessage =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 }58-  | { type: "PAIR_REJECT"; reason: string };59-6027 export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";61-62-export interface SyncState {63-  status: SyncStatus;64-  lastSyncAt?: number;65-  pairedDevices: PairedDevice[];66-  trustedNetworks: TrustedNetwork[];67-  isPro: boolean;68-}69-70-export type PairingStep =71-  | { stage: "idle" }72-  | { stage: "generating_code"; code: string; expiresAt: number }73-  | { stage: "waiting_for_peer" }74-  | { stage: "entering_code" }75-  | { stage: "connecting"; peerName: string }76-  | { stage: "verifying" }77-  | { stage: "done"; device: PairedDevice }78-  | { stage: "error"; message: string };79-80-export interface NoiseHandshakeResult {81-  sessionKey: Uint8Array;82-  sendNonce: number;83-  receiveNonce: number;84-  isInitiator: boolean;85-}8628 8729 /**8830  * Pairing request received from a peer that needs user confirmation.

view file

src/shared/sync-wire.test.ts+0 −87
@@ -1,87 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import { decodeSyncMessage, encodeSyncMessage, isSyncMessage } from "./sync-wire";4-import type { SyncMessage } from "./sync-types";5-6-describe("sync wire codec", () => {7-  it("round-trips SYNC_VAULT_SEND through base64url JSON", () => {8-    const message: SyncMessage = {9-      type: "SYNC_VAULT_SEND",10-      vault_a: "ciphertext-a",11-      vault_b: "ciphertext-b",12-      sent_at: 1_778_000_000_000,13-    };14-15-    const encoded = encodeSyncMessage(message);16-17-    expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);18-    expect(encoded).not.toMatch(/[+/=]/);19-    expect(decodeSyncMessage(encoded)).toEqual(message);20-  });21-22-  it("round-trips ACK / ERROR / pairing messages", () => {23-    const messages: SyncMessage[] = [24-      { type: "SYNC_VAULT_ACK", received_at: 42 },25-      { type: "SYNC_ERROR", reason: "vault_locked" },26-      {27-        type: "PAIR_REQUEST",28-        public_key_hex: "ab".repeat(32),29-        device_name: "June's Laptop",30-      },31-      {32-        type: "PAIR_ACCEPT",33-        public_key_hex: "cd".repeat(32),34-        device_name: "June's Phone",35-      },36-      { type: "PAIR_REJECT", reason: "user_declined" },37-    ];38-39-    for (const message of messages) {40-      expect(decodeSyncMessage(encodeSyncMessage(message))).toEqual(message);41-    }42-  });43-44-  it("validates message-specific required fields", () => {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 variant72-    expect(isSyncMessage({ type: "UNKNOWN" })).toBe(false);73-  });74-75-  it("rejects malformed wire payloads with a stable error", () => {76-    const malformedJson = btoa("{").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");77-    const unknownType = btoa(JSON.stringify({ type: "BOGUS" }))78-      .replace(/\+/g, "-")79-      .replace(/\//g, "_")80-      .replace(/=+$/g, "");81-82-    expect(() => decodeSyncMessage("@@@")).toThrow("invalid sync message");83-    expect(() => decodeSyncMessage("A")).toThrow("invalid sync message");84-    expect(() => decodeSyncMessage(malformedJson)).toThrow("invalid sync message");85-    expect(() => decodeSyncMessage(unknownType)).toThrow("invalid sync message");86-  });87-});

view file at the parent

src/shared/sync-wire.ts+0 −78
@@ -1,78 +0,0 @@1-import type { SyncMessage } from "./sync-types";2-3-const textEncoder = new TextEncoder();4-const textDecoder = new TextDecoder("utf-8", { fatal: true });5-6-export function encodeSyncMessage(message: SyncMessage): string {7-  return base64urlEncode(textEncoder.encode(JSON.stringify(message)));8-}9-10-export function decodeSyncMessage(data: string): SyncMessage {11-  try {12-    const raw = textDecoder.decode(base64urlDecode(data));13-    const parsed = JSON.parse(raw) as unknown;14-    if (isSyncMessage(parsed)) {15-      return parsed;16-    }17-  } catch {18-    // Normalize malformed transport payloads so callers do not branch on parser details.19-  }20-  throw new Error("invalid sync message");21-}22-23-export function isSyncMessage(value: unknown): value is SyncMessage {24-  if (!isRecord(value) || typeof value.type !== "string") return false;25-26-  switch (value.type) {27-    case "SYNC_VAULT_SEND":28-      return (29-        isString(value.vault_a) &&30-        isString(value.vault_b) &&31-        isFiniteNumber(value.sent_at)32-      );33-    case "SYNC_VAULT_ACK":34-      return isFiniteNumber(value.received_at);35-    case "SYNC_ERROR":36-      return isString(value.reason);37-    case "PAIR_REQUEST":38-      return isString(value.public_key_hex) && isString(value.device_name);39-    case "PAIR_ACCEPT":40-      return isString(value.public_key_hex) && isString(value.device_name);41-    case "PAIR_REJECT":42-      return isString(value.reason);43-    default:44-      return false;45-  }46-}47-48-function isRecord(value: unknown): value is Record<string, unknown> {49-  return Boolean(value) && typeof value === "object" && !Array.isArray(value);50-}51-52-function isString(value: unknown): value is string {53-  return typeof value === "string";54-}55-56-function isFiniteNumber(value: unknown): value is number {57-  return typeof value === "number" && Number.isFinite(value);58-}59-60-function base64urlEncode(bytes: Uint8Array): string {61-  let binary = "";62-  for (const byte of bytes) binary += String.fromCharCode(byte);63-  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");64-}65-66-function base64urlDecode(value: string): Uint8Array {67-  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {68-    throw new Error("invalid sync message");69-  }70-  const padded = value71-    .replace(/-/g, "+")72-    .replace(/_/g, "/")73-    .padEnd(Math.ceil(value.length / 4) * 4, "=");74-  const binary = atob(padded);75-  const out = new Uint8Array(binary.length);76-  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);77-  return out;78-}

view file at the parent