/** * Dormant SYN-001 coordinator helpers. * * This module owns extension-side sync preflight and cancellation rules before * any live background path enables native messaging. Entry payload exchange is * intentionally left to the next phase so the encrypted-entry envelope can be * reviewed on its own. */ import { buildSyncManifest, diffSyncManifests, selectEntriesForSync, } from "../shared/sync-protocol"; import type { SyncDiff } from "../shared/sync-protocol"; import { decryptSyncEntry, encryptSyncEntry, importSyncEntryKey, } from "../shared/sync-entry-payload"; import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types"; import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire"; import type { VaultEntry } from "../shared/types"; export type SyncPreflightError = "pro_required" | "vault_locked"; export interface SyncPreflightResult { ok: boolean; error?: SyncPreflightError; } export interface SyncSnapshot { entries: VaultEntry[]; manifest: SyncManifestEntry[]; } export interface SyncPlan { localManifest: SyncManifestEntry[]; diff: SyncDiff; } export interface SyncCoordinatorDeps { isProUser: () => Promise; isUnlocked: () => boolean; getEntries: () => VaultEntry[]; restoreEntries: ( entries: VaultEntry[], mode: "replace" | "merge" ) => Promise<{ added: number; updated: number; skipped: number }>; sendSyncData: (connectionId: string, data: string) => void; } async function loadDefaultDeps(): Promise { const [license, sync, vault] = await Promise.all([ import("./license"), import("./sync"), import("./vault"), ]); return { isProUser: license.isProUser, isUnlocked: vault.isUnlocked, getEntries: vault.getEntries, restoreEntries: vault.restoreEntries, sendSyncData: sync.sendSyncData, }; } export async function checkSyncPreflight( deps?: Pick ): Promise { const readyDeps = deps ?? (await loadDefaultDeps()); if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" }; if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" }; return { ok: true }; } export async function buildLocalSyncSnapshot( deps?: Pick ): Promise { const readyDeps = deps ?? (await loadDefaultDeps()); const preflight = await checkSyncPreflight(readyDeps); if (!preflight.ok) throw new Error(preflight.error); const entries = readyDeps.getEntries(); return { entries, manifest: await buildSyncManifest(entries), }; } export async function planManifestSync( remoteManifest: SyncManifestEntry[], deps?: Pick ): Promise { const snapshot = await buildLocalSyncSnapshot(deps); return { localManifest: snapshot.manifest, diff: diffSyncManifests(snapshot.manifest, remoteManifest), }; } export async function buildSyncEntriesMessage( ids: string[], rawEntryKey: Uint8Array, deps?: Pick ): Promise> { const readyDeps = deps ?? (await loadDefaultDeps()); const preflight = await checkSyncPreflight(readyDeps); if (!preflight.ok) throw new Error(preflight.error); const key = await importSyncEntryKey(rawEntryKey); const selected = selectEntriesForSync(readyDeps.getEntries(), ids); return { type: "SYNC_ENTRIES", entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))), }; } export async function applySyncEntriesMessage( message: Extract, rawEntryKey: Uint8Array, deps?: Pick ): Promise<{ added: number; updated: number; skipped: number }> { const readyDeps = deps ?? (await loadDefaultDeps()); const preflight = await checkSyncPreflight(readyDeps); if (!preflight.ok) throw new Error(preflight.error); const key = await importSyncEntryKey(rawEntryKey); const entries = await Promise.all( message.entries.map((payload) => decryptSyncEntry(payload, key)) ); return readyDeps.restoreEntries(entries, "merge"); } export interface SyncFrameDispatchResult { outbound: string[]; merge?: { added: number; updated: number; skipped: number }; done?: boolean; error?: { code: string; message: string }; } export async function dispatchSyncFrame( frame: string, rawEntryKey: Uint8Array, deps?: Pick< SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries" > ): Promise { const readyDeps = deps ?? (await loadDefaultDeps()); const preflight = await checkSyncPreflight(readyDeps); if (!preflight.ok) { const message = preflight.error === "pro_required" ? "Sync requires a Pro license." : "Vault is locked; unlock Peach before syncing."; return { outbound: [ encodeSyncMessage({ type: "SYNC_ERROR", code: preflight.error ?? "preflight_failed", message, }), ], done: true, error: { code: preflight.error ?? "preflight_failed", message }, }; } let message: SyncMessage; try { message = decodeSyncMessage(frame); } catch { return { outbound: [ encodeSyncMessage({ type: "SYNC_ERROR", code: "invalid_message", message: "Could not decode sync frame.", }), ], done: true, error: { code: "invalid_message", message: "Could not decode sync frame." }, }; } const key = await importSyncEntryKey(rawEntryKey); switch (message.type) { case "SYNC_HELLO": { const manifest = await buildSyncManifest(readyDeps.getEntries()); return { outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })], }; } case "SYNC_MANIFEST": { const localEntries = readyDeps.getEntries(); const localManifest = await buildSyncManifest(localEntries); const diff = diffSyncManifests(localManifest, message.entries); const offered = selectEntriesForSync(localEntries, diff.offerToRemote); const outbound: string[] = []; if (diff.requestFromRemote.length > 0) { outbound.push( encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote }) ); } if (offered.length > 0) { outbound.push( encodeSyncMessage({ type: "SYNC_ENTRIES", entries: await Promise.all( offered.map((entry) => encryptSyncEntry(entry, key)) ), }) ); } if (outbound.length === 0) { outbound.push(encodeSyncMessage({ type: "SYNC_DONE" })); } return { outbound }; } case "SYNC_REQUEST": { const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids); return { outbound: [ encodeSyncMessage({ type: "SYNC_ENTRIES", entries: await Promise.all( offered.map((entry) => encryptSyncEntry(entry, key)) ), }), ], }; } case "SYNC_ENTRIES": { const decrypted = await Promise.all( message.entries.map((payload) => decryptSyncEntry(payload, key)) ); const merge = await readyDeps.restoreEntries(decrypted, "merge"); return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge }; } case "SYNC_DONE": return { outbound: [], done: true }; case "SYNC_ERROR": return { outbound: [], done: true, error: { code: message.code, message: message.message }, }; case "PAIR_REQUEST": case "PAIR_ACCEPT": case "PAIR_REJECT": // Pairing flow is handled outside the data-sync state machine. return { outbound: [] }; } } export async function cancelSyncIfVaultLocked( connectionId: string, deps?: Pick ): Promise { const readyDeps = deps ?? (await loadDefaultDeps()); if (readyDeps.isUnlocked()) return false; readyDeps.sendSyncData( connectionId, encodeSyncMessage({ type: "SYNC_ERROR", code: "vault_locked", message: "Vault is locked; unlock Peach before syncing.", }) ); return true; }