sync-coordinator.ts

11.6 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 interface SyncSessionTransport {259  send: (frame: string) => void;260  onFrame: (handler: (frame: string) => void) => () => void;261  onError: (handler: (message: string) => void) => () => void;262  onClose: (handler: () => void) => () => void;263  close: () => void;264  getSessionKey: () => Promise<Uint8Array>;265}266267export interface SyncSessionResult {268  status: "completed" | "errored" | "closed";269  merges: { added: number; updated: number; skipped: number };270  error?: { code: string; message: string };271}272273export type SyncRole = "initiator" | "responder";274275export async function runSyncSession(options: {276  role: SyncRole;277  deviceId: string;278  vaultVersion: number;279  transport: SyncSessionTransport;280  deps?: Pick<281    SyncCoordinatorDeps,282    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"283  >;284}): Promise<SyncSessionResult> {285  const readyDeps = options.deps ?? (await loadDefaultDeps());286  const merges = { added: 0, updated: 0, skipped: 0 };287288  const preflight = await checkSyncPreflight(readyDeps);289  if (!preflight.ok) {290    const message =291      preflight.error === "pro_required"292        ? "Sync requires a Pro license."293        : "Vault is locked; unlock Peach before syncing.";294    options.transport.send(295      encodeSyncMessage({296        type: "SYNC_ERROR",297        code: preflight.error ?? "preflight_failed",298        message,299      })300    );301    options.transport.close();302    return {303      status: "errored",304      merges,305      error: { code: preflight.error ?? "preflight_failed", message },306    };307  }308309  const sessionKey = await options.transport.getSessionKey();310311  return new Promise<SyncSessionResult>((resolve) => {312    let settled = false;313    const unsubscribers: Array<() => void> = [];314315    const finish = (result: SyncSessionResult): void => {316      if (settled) return;317      settled = true;318      for (const fn of unsubscribers) fn();319      options.transport.close();320      resolve(result);321    };322323    unsubscribers.push(324      options.transport.onClose(() =>325        finish({ status: "closed", merges })326      )327    );328    unsubscribers.push(329      options.transport.onError((message) =>330        finish({331          status: "errored",332          merges,333          error: { code: "transport_error", message },334        })335      )336    );337338    unsubscribers.push(339      options.transport.onFrame((frame) => {340        void (async () => {341          const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);342          if (settled) return;343          if (result.merge) {344            merges.added += result.merge.added;345            merges.updated += result.merge.updated;346            merges.skipped += result.merge.skipped;347          }348          for (const outbound of result.outbound) options.transport.send(outbound);349          if (result.error) {350            finish({ status: "errored", merges, error: result.error });351            return;352          }353          if (result.done) {354            finish({ status: "completed", merges });355          }356        })();357      })358    );359360    if (options.role === "initiator") {361      options.transport.send(362        encodeSyncMessage({363          type: "SYNC_HELLO",364          deviceId: options.deviceId,365          clockMs: Date.now(),366          vaultVersion: options.vaultVersion,367        })368      );369    }370  });371}372373export async function cancelSyncIfVaultLocked(374  connectionId: string,375  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">376): Promise<boolean> {377  const readyDeps = deps ?? (await loadDefaultDeps());378  if (readyDeps.isUnlocked()) return false;379380  readyDeps.sendSyncData(381    connectionId,382    encodeSyncMessage({383      type: "SYNC_ERROR",384      code: "vault_locked",385      message: "Vault is locked; unlock Peach before syncing.",386    })387  );388  return true;389}