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
↓ peach-browser-8d53fdb4.tar.gzatlas at this save
files changed5 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 −78src/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-});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-}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.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-});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-}