sync-coordinator.ts

4.7 KB · raw

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 */910import {11  buildSyncManifest,12  diffSyncManifests,13  selectEntriesForSync,14} from "../shared/sync-protocol";15import type { SyncDiff } from "../shared/sync-protocol";16import {17  decryptSyncEntry,18  encryptSyncEntry,19  importSyncEntryKey,20} from "../shared/sync-entry-payload";21import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";22import { encodeSyncMessage } from "../shared/sync-wire";23import type { VaultEntry } from "../shared/types";2425export type SyncPreflightError = "pro_required" | "vault_locked";2627export interface SyncPreflightResult {28  ok: boolean;29  error?: SyncPreflightError;30}3132export interface SyncSnapshot {33  entries: VaultEntry[];34  manifest: SyncManifestEntry[];35}3637export interface SyncPlan {38  localManifest: SyncManifestEntry[];39  diff: SyncDiff;40}4142export interface SyncCoordinatorDeps {43  isProUser: () => Promise<boolean>;44  isUnlocked: () => boolean;45  getEntries: () => VaultEntry[];46  restoreEntries: (47    entries: VaultEntry[],48    mode: "replace" | "merge"49  ) => Promise<{ added: number; updated: number; skipped: number }>;50  sendSyncData: (connectionId: string, data: string) => void;51}5253async function loadDefaultDeps(): Promise<SyncCoordinatorDeps> {54  const [license, sync, vault] = await Promise.all([55    import("./license"),56    import("./sync"),57    import("./vault"),58  ]);59  return {60    isProUser: license.isProUser,61    isUnlocked: vault.isUnlocked,62    getEntries: vault.getEntries,63    restoreEntries: vault.restoreEntries,64    sendSyncData: sync.sendSyncData,65  };66}6768export async function checkSyncPreflight(69  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">70): Promise<SyncPreflightResult> {71  const readyDeps = deps ?? (await loadDefaultDeps());72  if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };73  if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };74  return { ok: true };75}7677export async function buildLocalSyncSnapshot(78  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">79): Promise<SyncSnapshot> {80  const readyDeps = deps ?? (await loadDefaultDeps());81  const preflight = await checkSyncPreflight(readyDeps);82  if (!preflight.ok) throw new Error(preflight.error);8384  const entries = readyDeps.getEntries();85  return {86    entries,87    manifest: await buildSyncManifest(entries),88  };89}9091export async function planManifestSync(92  remoteManifest: SyncManifestEntry[],93  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">94): Promise<SyncPlan> {95  const snapshot = await buildLocalSyncSnapshot(deps);96  return {97    localManifest: snapshot.manifest,98    diff: diffSyncManifests(snapshot.manifest, remoteManifest),99  };100}101102export async function buildSyncEntriesMessage(103  ids: string[],104  rawEntryKey: Uint8Array,105  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">106): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {107  const readyDeps = deps ?? (await loadDefaultDeps());108  const preflight = await checkSyncPreflight(readyDeps);109  if (!preflight.ok) throw new Error(preflight.error);110111  const key = await importSyncEntryKey(rawEntryKey);112  const selected = selectEntriesForSync(readyDeps.getEntries(), ids);113  return {114    type: "SYNC_ENTRIES",115    entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),116  };117}118119export async function applySyncEntriesMessage(120  message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,121  rawEntryKey: Uint8Array,122  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">123): Promise<{ added: number; updated: number; skipped: number }> {124  const readyDeps = deps ?? (await loadDefaultDeps());125  const preflight = await checkSyncPreflight(readyDeps);126  if (!preflight.ok) throw new Error(preflight.error);127128  const key = await importSyncEntryKey(rawEntryKey);129  const entries = await Promise.all(130    message.entries.map((payload) => decryptSyncEntry(payload, key))131  );132  return readyDeps.restoreEntries(entries, "merge");133}134135export async function cancelSyncIfVaultLocked(136  connectionId: string,137  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">138): Promise<boolean> {139  const readyDeps = deps ?? (await loadDefaultDeps());140  if (readyDeps.isUnlocked()) return false;141142  readyDeps.sendSyncData(143    connectionId,144    encodeSyncMessage({145      type: "SYNC_ERROR",146      code: "vault_locked",147      message: "Vault is locked; unlock Peach before syncing.",148    })149  );150  return true;151}