sync-coordinator.ts

8.3 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 { decodeSyncMessage, 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 interface SyncFrameDispatchResult {136  outbound: string[];137  merge?: { added: number; updated: number; skipped: number };138  done?: boolean;139  error?: { code: string; message: string };140}141142export async function dispatchSyncFrame(143  frame: string,144  rawEntryKey: Uint8Array,145  deps?: Pick<146    SyncCoordinatorDeps,147    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"148  >149): Promise<SyncFrameDispatchResult> {150  const readyDeps = deps ?? (await loadDefaultDeps());151  const preflight = await checkSyncPreflight(readyDeps);152  if (!preflight.ok) {153    const message =154      preflight.error === "pro_required"155        ? "Sync requires a Pro license."156        : "Vault is locked; unlock Peach before syncing.";157    return {158      outbound: [159        encodeSyncMessage({160          type: "SYNC_ERROR",161          code: preflight.error ?? "preflight_failed",162          message,163        }),164      ],165      done: true,166      error: { code: preflight.error ?? "preflight_failed", message },167    };168  }169170  let message: SyncMessage;171  try {172    message = decodeSyncMessage(frame);173  } catch {174    return {175      outbound: [176        encodeSyncMessage({177          type: "SYNC_ERROR",178          code: "invalid_message",179          message: "Could not decode sync frame.",180        }),181      ],182      done: true,183      error: { code: "invalid_message", message: "Could not decode sync frame." },184    };185  }186187  const key = await importSyncEntryKey(rawEntryKey);188189  switch (message.type) {190    case "SYNC_HELLO": {191      const manifest = await buildSyncManifest(readyDeps.getEntries());192      return {193        outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],194      };195    }196    case "SYNC_MANIFEST": {197      const localEntries = readyDeps.getEntries();198      const localManifest = await buildSyncManifest(localEntries);199      const diff = diffSyncManifests(localManifest, message.entries);200      const offered = selectEntriesForSync(localEntries, diff.offerToRemote);201      const outbound: string[] = [];202      if (diff.requestFromRemote.length > 0) {203        outbound.push(204          encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })205        );206      }207      if (offered.length > 0) {208        outbound.push(209          encodeSyncMessage({210            type: "SYNC_ENTRIES",211            entries: await Promise.all(212              offered.map((entry) => encryptSyncEntry(entry, key))213            ),214          })215        );216      }217      if (outbound.length === 0) {218        outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));219      }220      return { outbound };221    }222    case "SYNC_REQUEST": {223      const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);224      return {225        outbound: [226          encodeSyncMessage({227            type: "SYNC_ENTRIES",228            entries: await Promise.all(229              offered.map((entry) => encryptSyncEntry(entry, key))230            ),231          }),232        ],233      };234    }235    case "SYNC_ENTRIES": {236      const decrypted = await Promise.all(237        message.entries.map((payload) => decryptSyncEntry(payload, key))238      );239      const merge = await readyDeps.restoreEntries(decrypted, "merge");240      return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };241    }242    case "SYNC_DONE":243      return { outbound: [], done: true };244    case "SYNC_ERROR":245      return {246        outbound: [],247        done: true,248        error: { code: message.code, message: message.message },249      };250    case "PAIR_REQUEST":251    case "PAIR_ACCEPT":252    case "PAIR_REJECT":253      // Pairing flow is handled outside the data-sync state machine.254      return { outbound: [] };255  }256}257258export async function cancelSyncIfVaultLocked(259  connectionId: string,260  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">261): Promise<boolean> {262  const readyDeps = deps ?? (await loadDefaultDeps());263  if (readyDeps.isUnlocked()) return false;264265  readyDeps.sendSyncData(266    connectionId,267    encodeSyncMessage({268      type: "SYNC_ERROR",269      code: "vault_locked",270      message: "Vault is locked; unlock Peach before syncing.",271    })272  );273  return true;274}