sync-alerts.ts

3.1 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// MV3 worker eviction between the sync round and the next popup open. The8// queue remains until the popup explicitly dismisses the corresponding alert9// group.1011import { capDeviceName } from "../shared/sync-limits";1213export interface SyncAlert {14  type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";15  /** VaultEntry.id — a UUID string in this codebase (spec deviation: the16   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).17   *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */18  entryId: string;19  entryTitle: string;20  /** Peer device name when known; "paired device" when the merge layer21   *  had no device context. */22  deviceName: string;23  timestamp: number;24}2526const SYNC_ALERTS_KEY = "peach_pending_sync_alerts_v1";27/** Bound the queue so a hostile peer cannot grow storage without limit. */28const MAX_PENDING_ALERTS = 200;2930export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {31  // Final display seam: cap whatever name reached the queue (including the32  // `knownName ?? entry.deviceName` fallback) at the shared bound.33  const securityAlerts = alerts.filter(isSyncAlert).map((a) => ({34    ...a,35    deviceName: capDeviceName(a.deviceName),36  }));37  if (securityAlerts.length === 0) return;38  const existing = await readAlerts();39  const merged = [...existing, ...securityAlerts].slice(-MAX_PENDING_ALERTS);40  try {41    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });42  } catch {43    // Alert persistence must never block or fail a sync merge.44  }45}4647export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {48  return readAlerts();49}50export async function clearPendingSyncAlerts(): Promise<void> {51  try {52    await chrome.storage.local.remove(SYNC_ALERTS_KEY);53  } catch {54    /* non-fatal */55  }56}5758export async function dismissPendingSyncAlerts(type: SyncAlert["type"]): Promise<void> {59  try {60    const remaining = (await readAlerts()).filter((alert) => alert.type !== type);61    if (remaining.length === 0) {62      await chrome.storage.local.remove(SYNC_ALERTS_KEY);63    } else {64      await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: remaining });65    }66  } catch {67    /* non-fatal */68  }69}7071async function readAlerts(): Promise<SyncAlert[]> {72  try {73    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);74    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];75    if (!Array.isArray(value)) return [];76    return value.filter(isSyncAlert);77  } catch {78    return [];79  }80}8182function isSyncAlert(x: unknown): x is SyncAlert {83  if (typeof x !== "object" || x === null) return false;84  const r = x as Record<string, unknown>;85  return (86    (r.type === "DEVICE_KEY_ROTATED" ||87      r.type === "DEVICE_REVOKED") &&88    typeof r.entryId === "string" &&89    typeof r.entryTitle === "string" &&90    typeof r.deviceName === "string" &&91    typeof r.timestamp === "number"92  );93}