sync-alerts.ts

2.6 KB · raw

1// Pending sync alerts — audit fix HV-1b.2//3// When a sync round accepts a remote entry that REPLACES an existing local4// password, or soft-deletes a local entry via a remote tombstone, the user5// gets no signal today. This queue records those remote mutations so the6// popup can display them on next open (non-blocking — the sync itself is7// not interrupted).8//9// Persistence: chrome.storage.local under a dedicated key so alerts survive10// MV3 worker eviction between the sync round and the next popup open. The11// queue is cleared after the popup acknowledges display (GET_SYNC_ALERTS12// with `consume: true`).1314export interface SyncAlert {15  type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";16  /** VaultEntry.id — a UUID string in this codebase (spec deviation: the17   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).18   *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */19  entryId: string;20  entryTitle: string;21  /** Peer device name when known; "paired device" when the merge layer22   *  had no device context. */23  deviceName: string;24  timestamp: number;25}2627const SYNC_ALERTS_KEY = "peach_pending_sync_alerts_v1";28/** Bound the queue so a hostile peer cannot grow storage without limit. */29const MAX_PENDING_ALERTS = 200;3031export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {32  if (alerts.length === 0) return;33  const existing = await readAlerts();34  const merged = [...existing, ...alerts].slice(-MAX_PENDING_ALERTS);35  try {36    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });37  } catch {38    // Alert persistence must never block or fail a sync merge.39  }40}4142export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {43  return readAlerts();44}4546export async function clearPendingSyncAlerts(): Promise<void> {47  try {48    await chrome.storage.local.remove(SYNC_ALERTS_KEY);49  } catch {50    /* non-fatal */51  }52}5354async function readAlerts(): Promise<SyncAlert[]> {55  try {56    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);57    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];58    if (!Array.isArray(value)) return [];59    return value.filter(isSyncAlert);60  } catch {61    return [];62  }63}6465function isSyncAlert(x: unknown): x is SyncAlert {66  if (typeof x !== "object" || x === null) return false;67  const r = x as Record<string, unknown>;68  return (69    (r.type === "REMOTE_PASSWORD_CHANGE" ||70      r.type === "REMOTE_DELETE" ||71      r.type === "DEVICE_KEY_ROTATED") &&72    typeof r.entryId === "string" &&73    typeof r.entryTitle === "string" &&74    typeof r.deviceName === "string" &&75    typeof r.timestamp === "number"76  );77}