12345678910import {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 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}