sync-coordinator.ts

13.2 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 interface NativeSyncTransportBindings {69  sendSyncData: (connectionId: string, data: string) => void;70  onSyncMessage: (71    handler: (connectionId: string, data: string) => void72  ) => () => void;73  onError: (74    handler: (message: string, connectionId?: string) => void75  ) => () => void;76  onDisconnected: (handler: (connectionId?: string) => void) => () => void;77  disconnectFromPeer: (connectionId: string) => void;78  getNativeSessionKey: (connectionId: string) => Promise<Uint8Array>;79}8081async function loadNativeSyncBindings(): Promise<NativeSyncTransportBindings> {82  const sync = await import("./sync");83  return {84    sendSyncData: sync.sendSyncData,85    onSyncMessage: sync.onSyncMessage,86    onError: sync.onError,87    onDisconnected: sync.onDisconnected,88    disconnectFromPeer: sync.disconnectFromPeer,89    getNativeSessionKey: sync.getNativeSessionKey,90  };91}9293export async function createNativeSyncTransport(94  connectionId: string,95  bindings?: NativeSyncTransportBindings96): Promise<SyncSessionTransport> {97  const ready = bindings ?? (await loadNativeSyncBindings());98  return {99    send: (frame) => ready.sendSyncData(connectionId, frame),100    onFrame: (handler) =>101      ready.onSyncMessage((id, frame) => {102        if (id === connectionId) handler(frame);103      }),104    onError: (handler) =>105      ready.onError((message, id) => {106        if (id === undefined || id === connectionId) handler(message);107      }),108    onClose: (handler) =>109      ready.onDisconnected((id) => {110        if (id === undefined || id === connectionId) handler();111      }),112    close: () => ready.disconnectFromPeer(connectionId),113    getSessionKey: () => ready.getNativeSessionKey(connectionId),114  };115}116117export async function checkSyncPreflight(118  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">119): Promise<SyncPreflightResult> {120  const readyDeps = deps ?? (await loadDefaultDeps());121  if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };122  if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };123  return { ok: true };124}125126export async function buildLocalSyncSnapshot(127  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">128): Promise<SyncSnapshot> {129  const readyDeps = deps ?? (await loadDefaultDeps());130  const preflight = await checkSyncPreflight(readyDeps);131  if (!preflight.ok) throw new Error(preflight.error);132133  const entries = readyDeps.getEntries();134  return {135    entries,136    manifest: await buildSyncManifest(entries),137  };138}139140export async function planManifestSync(141  remoteManifest: SyncManifestEntry[],142  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">143): Promise<SyncPlan> {144  const snapshot = await buildLocalSyncSnapshot(deps);145  return {146    localManifest: snapshot.manifest,147    diff: diffSyncManifests(snapshot.manifest, remoteManifest),148  };149}150151export async function buildSyncEntriesMessage(152  ids: string[],153  rawEntryKey: Uint8Array,154  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">155): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {156  const readyDeps = deps ?? (await loadDefaultDeps());157  const preflight = await checkSyncPreflight(readyDeps);158  if (!preflight.ok) throw new Error(preflight.error);159160  const key = await importSyncEntryKey(rawEntryKey);161  const selected = selectEntriesForSync(readyDeps.getEntries(), ids);162  return {163    type: "SYNC_ENTRIES",164    entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),165  };166}167168export async function applySyncEntriesMessage(169  message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,170  rawEntryKey: Uint8Array,171  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">172): Promise<{ added: number; updated: number; skipped: number }> {173  const readyDeps = deps ?? (await loadDefaultDeps());174  const preflight = await checkSyncPreflight(readyDeps);175  if (!preflight.ok) throw new Error(preflight.error);176177  const key = await importSyncEntryKey(rawEntryKey);178  const entries = await Promise.all(179    message.entries.map((payload) => decryptSyncEntry(payload, key))180  );181  return readyDeps.restoreEntries(entries, "merge");182}183184export interface SyncFrameDispatchResult {185  outbound: string[];186  merge?: { added: number; updated: number; skipped: number };187  done?: boolean;188  error?: { code: string; message: string };189}190191export async function dispatchSyncFrame(192  frame: string,193  rawEntryKey: Uint8Array,194  deps?: Pick<195    SyncCoordinatorDeps,196    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"197  >198): Promise<SyncFrameDispatchResult> {199  const readyDeps = deps ?? (await loadDefaultDeps());200  const preflight = await checkSyncPreflight(readyDeps);201  if (!preflight.ok) {202    const message =203      preflight.error === "pro_required"204        ? "Sync requires a Pro license."205        : "Vault is locked; unlock Peach before syncing.";206    return {207      outbound: [208        encodeSyncMessage({209          type: "SYNC_ERROR",210          code: preflight.error ?? "preflight_failed",211          message,212        }),213      ],214      done: true,215      error: { code: preflight.error ?? "preflight_failed", message },216    };217  }218219  let message: SyncMessage;220  try {221    message = decodeSyncMessage(frame);222  } catch {223    return {224      outbound: [225        encodeSyncMessage({226          type: "SYNC_ERROR",227          code: "invalid_message",228          message: "Could not decode sync frame.",229        }),230      ],231      done: true,232      error: { code: "invalid_message", message: "Could not decode sync frame." },233    };234  }235236  const key = await importSyncEntryKey(rawEntryKey);237238  switch (message.type) {239    case "SYNC_HELLO": {240      const manifest = await buildSyncManifest(readyDeps.getEntries());241      return {242        outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],243      };244    }245    case "SYNC_MANIFEST": {246      const localEntries = readyDeps.getEntries();247      const localManifest = await buildSyncManifest(localEntries);248      const diff = diffSyncManifests(localManifest, message.entries);249      const offered = selectEntriesForSync(localEntries, diff.offerToRemote);250      const outbound: string[] = [];251      if (diff.requestFromRemote.length > 0) {252        outbound.push(253          encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })254        );255      }256      if (offered.length > 0) {257        outbound.push(258          encodeSyncMessage({259            type: "SYNC_ENTRIES",260            entries: await Promise.all(261              offered.map((entry) => encryptSyncEntry(entry, key))262            ),263          })264        );265      }266      if (outbound.length === 0) {267        outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));268      }269      return { outbound };270    }271    case "SYNC_REQUEST": {272      const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);273      return {274        outbound: [275          encodeSyncMessage({276            type: "SYNC_ENTRIES",277            entries: await Promise.all(278              offered.map((entry) => encryptSyncEntry(entry, key))279            ),280          }),281        ],282      };283    }284    case "SYNC_ENTRIES": {285      const decrypted = await Promise.all(286        message.entries.map((payload) => decryptSyncEntry(payload, key))287      );288      const merge = await readyDeps.restoreEntries(decrypted, "merge");289      return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };290    }291    case "SYNC_DONE":292      return { outbound: [], done: true };293    case "SYNC_ERROR":294      return {295        outbound: [],296        done: true,297        error: { code: message.code, message: message.message },298      };299    case "PAIR_REQUEST":300    case "PAIR_ACCEPT":301    case "PAIR_REJECT":302      // Pairing flow is handled outside the data-sync state machine.303      return { outbound: [] };304  }305}306307export interface SyncSessionTransport {308  send: (frame: string) => void;309  onFrame: (handler: (frame: string) => void) => () => void;310  onError: (handler: (message: string) => void) => () => void;311  onClose: (handler: () => void) => () => void;312  close: () => void;313  getSessionKey: () => Promise<Uint8Array>;314}315316export interface SyncSessionResult {317  status: "completed" | "errored" | "closed";318  merges: { added: number; updated: number; skipped: number };319  error?: { code: string; message: string };320}321322export type SyncRole = "initiator" | "responder";323324export async function runSyncSession(options: {325  role: SyncRole;326  deviceId: string;327  vaultVersion: number;328  transport: SyncSessionTransport;329  deps?: Pick<330    SyncCoordinatorDeps,331    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"332  >;333}): Promise<SyncSessionResult> {334  const readyDeps = options.deps ?? (await loadDefaultDeps());335  const merges = { added: 0, updated: 0, skipped: 0 };336337  const preflight = await checkSyncPreflight(readyDeps);338  if (!preflight.ok) {339    const message =340      preflight.error === "pro_required"341        ? "Sync requires a Pro license."342        : "Vault is locked; unlock Peach before syncing.";343    options.transport.send(344      encodeSyncMessage({345        type: "SYNC_ERROR",346        code: preflight.error ?? "preflight_failed",347        message,348      })349    );350    options.transport.close();351    return {352      status: "errored",353      merges,354      error: { code: preflight.error ?? "preflight_failed", message },355    };356  }357358  const sessionKey = await options.transport.getSessionKey();359360  return new Promise<SyncSessionResult>((resolve) => {361    let settled = false;362    const unsubscribers: Array<() => void> = [];363364    const finish = (result: SyncSessionResult): void => {365      if (settled) return;366      settled = true;367      for (const fn of unsubscribers) fn();368      options.transport.close();369      resolve(result);370    };371372    unsubscribers.push(373      options.transport.onClose(() =>374        finish({ status: "closed", merges })375      )376    );377    unsubscribers.push(378      options.transport.onError((message) =>379        finish({380          status: "errored",381          merges,382          error: { code: "transport_error", message },383        })384      )385    );386387    unsubscribers.push(388      options.transport.onFrame((frame) => {389        void (async () => {390          const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);391          if (settled) return;392          if (result.merge) {393            merges.added += result.merge.added;394            merges.updated += result.merge.updated;395            merges.skipped += result.merge.skipped;396          }397          for (const outbound of result.outbound) options.transport.send(outbound);398          if (result.error) {399            finish({ status: "errored", merges, error: result.error });400            return;401          }402          if (result.done) {403            finish({ status: "completed", merges });404          }405        })();406      })407    );408409    if (options.role === "initiator") {410      options.transport.send(411        encodeSyncMessage({412          type: "SYNC_HELLO",413          deviceId: options.deviceId,414          clockMs: Date.now(),415          vaultVersion: options.vaultVersion,416        })417      );418    }419  });420}421422export async function cancelSyncIfVaultLocked(423  connectionId: string,424  deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">425): Promise<boolean> {426  const readyDeps = deps ?? (await loadDefaultDeps());427  if (readyDeps.isUnlocked()) return false;428429  readyDeps.sendSyncData(430    connectionId,431    encodeSyncMessage({432      type: "SYNC_ERROR",433      code: "vault_locked",434      message: "Vault is locked; unlock Peach before syncing.",435    })436  );437  return true;438}