sync-alerts.ts

2.7 KB · raw

1// Pending sync security alerts.2//3// Routine entry changes are intentionally silent. Only rare device-trust4// events enter this queue so a reliable sync does not turn the vault into an5// activity feed.6//7// Persistence: chrome.storage.local under a dedicated key so alerts survive8// MV3 worker eviction between the sync round and the next popup open. The9// queue is cleared after the popup acknowledges display (GET_SYNC_ALERTS10// with `consume: true`).1112import { capDeviceName } from "../shared/sync-limits";1314export interface SyncAlert {15  type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";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  // Final display seam: cap whatever name reached the queue (including the33  // `knownName ?? entry.deviceName` fallback) at the shared bound.34  const securityAlerts = alerts.filter(isSyncAlert).map((a) => ({35    ...a,36    deviceName: capDeviceName(a.deviceName),37  }));38  if (securityAlerts.length === 0) return;39  const existing = await readAlerts();40  const merged = [...existing, ...securityAlerts].slice(-MAX_PENDING_ALERTS);41  try {42    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });43  } catch {44    // Alert persistence must never block or fail a sync merge.45  }46}4748export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {49  return readAlerts();50}5152export async function clearPendingSyncAlerts(): Promise<void> {53  try {54    await chrome.storage.local.remove(SYNC_ALERTS_KEY);55  } catch {56    /* non-fatal */57  }58}5960async function readAlerts(): Promise<SyncAlert[]> {61  try {62    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);63    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];64    if (!Array.isArray(value)) return [];65    return value.filter(isSyncAlert);66  } catch {67    return [];68  }69}7071function isSyncAlert(x: unknown): x is SyncAlert {72  if (typeof x !== "object" || x === null) return false;73  const r = x as Record<string, unknown>;74  return (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}