sync-alerts.ts

2.5 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";16  /** VaultEntry.id — a UUID string in this codebase (spec deviation: the17   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`). */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  if (alerts.length === 0) return;32  const existing = await readAlerts();33  const merged = [...existing, ...alerts].slice(-MAX_PENDING_ALERTS);34  try {35    await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });36  } catch {37    // Alert persistence must never block or fail a sync merge.38  }39}4041export async function getPendingSyncAlerts(): Promise<SyncAlert[]> {42  return readAlerts();43}4445export async function clearPendingSyncAlerts(): Promise<void> {46  try {47    await chrome.storage.local.remove(SYNC_ALERTS_KEY);48  } catch {49    /* non-fatal */50  }51}5253async function readAlerts(): Promise<SyncAlert[]> {54  try {55    const result = await chrome.storage.local.get(SYNC_ALERTS_KEY);56    const value = (result as Record<string, unknown>)[SYNC_ALERTS_KEY];57    if (!Array.isArray(value)) return [];58    return value.filter(isSyncAlert);59  } catch {60    return [];61  }62}6364function isSyncAlert(x: unknown): x is SyncAlert {65  if (typeof x !== "object" || x === null) return false;66  const r = x as Record<string, unknown>;67  return (68    (r.type === "REMOTE_PASSWORD_CHANGE" || r.type === "REMOTE_DELETE") &&69    typeof r.entryId === "string" &&70    typeof r.entryTitle === "string" &&71    typeof r.deviceName === "string" &&72    typeof r.timestamp === "number"73  );74}