sync-alerts.ts

2.7 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:16    | "REMOTE_PASSWORD_CHANGE"17    | "REMOTE_DELETE"18    | "DEVICE_KEY_ROTATED"19    | "DEVICE_REVOKED";20  /** VaultEntry.id — a UUID string in this codebase (spec deviation: the21   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).22   *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */23  entryId: string;24  entryTitle: string;25  /** Peer device name when known; "paired device" when the merge layer26   *  had no device context. */27  deviceName: string;28  timestamp: number;29}3031const SYNC_ALERTS_KEY = "peach_pending_sync_alerts_v1";32/** Bound the queue so a hostile peer cannot grow storage without limit. */33const MAX_PENDING_ALERTS = 200;3435export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {36  if (alerts.length === 0) return;37  const existing = await readAlerts();38  const merged = [...existing, ...alerts].slice(-MAX_PENDING_ALERTS);39  try {40    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });41  } catch {42    // Alert persistence must never block or fail a sync merge.43  }44}4546export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {47  return readAlerts();48}4950export async function clearPendingSyncAlerts(): Promise<void> {51  try {52    await chrome.storage.local.remove(SYNC_ALERTS_KEY);53  } catch {54    /* non-fatal */55  }56}5758async function readAlerts(): Promise<SyncAlert[]> {59  try {60    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);61    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];62    if (!Array.isArray(value)) return [];63    return value.filter(isSyncAlert);64  } catch {65    return [];66  }67}6869function isSyncAlert(x: unknown): x is SyncAlert {70  if (typeof x !== "object" || x === null) return false;71  const r = x as Record<string, unknown>;72  return (73    (r.type === "REMOTE_PASSWORD_CHANGE" ||74      r.type === "REMOTE_DELETE" ||75      r.type === "DEVICE_KEY_ROTATED" ||76      r.type === "DEVICE_REVOKED") &&77    typeof r.entryId === "string" &&78    typeof r.entryTitle === "string" &&79    typeof r.deviceName === "string" &&80    typeof r.timestamp === "number"81  );82}