3b8b0dd8f317

feat: Keep routine sync quiet (v4.3.2)

time
2026-07-27 20:12 UTC · 3 months ago
machine
koh2
parent
c700871e2778
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-alerts.ts+9 −16src/background/vault.sync-clamp.test.ts+2 −33src/background/vault.ts+1 −33src/popup/App.tsx+6 −10
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.1",4+  "version": "4.3.2",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.1",4+  "version": "4.3.2",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.3.1",3+  "version": "4.3.2",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-alerts.ts+9 −16
@@ -1,10 +1,8 @@1-// Pending sync alerts — audit fix HV-1b.1+// Pending sync security alerts.22 //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).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.86 //97 // Persistence: chrome.storage.local under a dedicated key so alerts survive108 // MV3 worker eviction between the sync round and the next popup open. The@@ -12,11 +10,7 @@1210 // with `consume: true`).1311 1412 export interface SyncAlert {15-  type:16-    | "REMOTE_PASSWORD_CHANGE"17-    | "REMOTE_DELETE"18-    | "DEVICE_KEY_ROTATED"19-    | "DEVICE_REVOKED";13+  type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";2014   /** VaultEntry.id — a UUID string in this codebase (spec deviation: the2115    *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).2216    *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */@@ -33,9 +27,10 @@3327 const MAX_PENDING_ALERTS = 200;3428 3529 export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {36-  if (alerts.length === 0) return;30+  const securityAlerts = alerts.filter(isSyncAlert);31+  if (securityAlerts.length === 0) return;3732   const existing = await readAlerts();38-  const merged = [...existing, ...alerts].slice(-MAX_PENDING_ALERTS);33+  const merged = [...existing, ...securityAlerts].slice(-MAX_PENDING_ALERTS);3934   try {4035     await chrome.storage.local.set({ [SYNC_ALERTS_KEY]: merged });4136   } catch {@@ -70,9 +65,7 @@7065   if (typeof x !== "object" || x === null) return false;7166   const r = x as Record<string, unknown>;7267   return (73-    (r.type === "REMOTE_PASSWORD_CHANGE" ||74-      r.type === "REMOTE_DELETE" ||75-      r.type === "DEVICE_KEY_ROTATED" ||68+    (r.type === "DEVICE_KEY_ROTATED" ||7669       r.type === "DEVICE_REVOKED") &&7770     typeof r.entryId === "string" &&7871     typeof r.entryTitle === "string" &&

view file

src/background/vault.sync-clamp.test.ts+2 −33
@@ -142,7 +142,7 @@142142     );143143   });144144 145-  it("records a REMOTE_PASSWORD_CHANGE alert when a remote replaces a local password", async () => {145+  it("applies remote entry changes quietly without queuing activity alerts", async () => {146146     const vault = await import("./vault");147147     const { getPendingSyncAlerts, clearPendingSyncAlerts } = await import(148148       "./sync-alerts"@@ -168,43 +168,12 @@168168       {},169169       { deviceName: "Pixel 9" },170170     );171-    const alerts = await getPendingSyncAlerts();172-    expect(alerts).toHaveLength(1);173-    expect(alerts[0]).toMatchObject({174-      type: "REMOTE_PASSWORD_CHANGE",175-      entryId: local.id,176-      entryTitle: "Email",177-      deviceName: "Pixel 9",178-    });179-  });180-181-  it("records a REMOTE_DELETE alert when a remote tombstone deletes a live entry", async () => {182-    const vault = await import("./vault");183-    const { getPendingSyncAlerts, clearPendingSyncAlerts } = await import(184-      "./sync-alerts"185-    );186-    await vault.createVault("correct horse battery staple");187-    await clearPendingSyncAlerts();188-189-    const local = await vault.addEntry({190-      title: "Shop",191-      username: "june",192-      password: "pw",193-      url: "https://shop.example.com",194-      type: "login",195-    });196171     await vault.applyMergedRemoteEntries(197172       [],198173       { [local.id]: Date.now() + 1000 },199174       { deviceName: "Pixel 9" },200175     );201176     const alerts = await getPendingSyncAlerts();202-    expect(alerts).toHaveLength(1);203-    expect(alerts[0]).toMatchObject({204-      type: "REMOTE_DELETE",205-      entryId: local.id,206-      entryTitle: "Shop",207-      deviceName: "Pixel 9",208-    });177+    expect(alerts).toEqual([]);209178   });210179 });

view file

src/background/vault.ts+1 −33
@@ -13,7 +13,6 @@1313 import { extractDomain } from "../shared/domain";1414 import { clampSyncTimestamp } from "../shared/sync-limits";1515 import { computeVaultId } from "../shared/sync-identity";16-import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";1716 // Static, not `await import()`: runtime import() throws in an MV3 service1817 // worker. Neither module imports vault.ts back, so there is no cycle to break.1918 import { triggerSyncIfReachable } from "./sync-trigger";@@ -2144,7 +2143,7 @@21442143 export async function applyMergedRemoteEntries(21452144   remoteEntries: VaultEntry[],21462145   remoteTombstones: Record<string, number>,2147-  context: { deviceName?: string } = {},2146+  _context: { deviceName?: string } = {},21482147 ): Promise<{ received: number; applied: number; conflicts: number }> {21492148   // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices21502149   // concurrently edit different fields of the same entry, the one with the@@ -2161,8 +2160,6 @@21612160   const byId = new Map<string, VaultEntry>();21622161   for (const e of entries) byId.set(e.id, e);21632162 2164-  const alerts: SyncAlert[] = [];2165-  const deviceName = context.deviceName ?? "paired device";21662163   const mergeNow = Date.now();21672164 21682165   for (const remote of remoteEntries) {@@ -2187,22 +2184,6 @@21872184     } else if (sanitized.modifiedAt > local.modifiedAt) {21882185       const idx = entries.findIndex((e) => e.id === local.id);21892186       if (idx !== -1) {2190-        // HV-1b: surface remote password REPLACEMENTS (existing id, password2191-        // differs) so a compromised peer can't silently rewrite credentials.2192-        if (2193-          !local.deletedAt &&2194-          typeof sanitized.password === "string" &&2195-          typeof local.password === "string" &&2196-          sanitized.password !== local.password2197-        ) {2198-          alerts.push({2199-            type: "REMOTE_PASSWORD_CHANGE",2200-            entryId: sanitized.id,2201-            entryTitle: sanitized.title,2202-            deviceName,2203-            timestamp: mergeNow,2204-          });2205-        }22062187         entries[idx] = sanitized;22072188         byId.set(sanitized.id, sanitized);22082189         applied += 1;@@ -2221,16 +2202,6 @@22212202     if (deletedAt < local.modifiedAt) continue;22222203     const idx = entries.findIndex((e) => e.id === id);22232204     if (idx !== -1) {2224-      // HV-1b: surface remote deletes of live local entries.2225-      if (!local.deletedAt) {2226-        alerts.push({2227-          type: "REMOTE_DELETE",2228-          entryId: id,2229-          entryTitle: local.title,2230-          deviceName,2231-          timestamp: mergeNow,2232-        });2233-      }22342205       entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };22352206       applied += 1;22362207     }@@ -2240,9 +2211,6 @@22402211     await persistEntries("sync_received");22412212     buildDomainIndexSync(entries);22422213   }2243-  // Queued after the merge committed — an alert must describe a mutation2244-  // that actually landed. recordSyncAlerts never throws.2245-  await recordSyncAlerts(alerts);22462214   return { received, applied, conflicts };22472215 }22482216 

view file

src/popup/App.tsx+6 −10
@@ -149,10 +149,9 @@149149   const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);150150   const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);151151 152-  // UI-1 / HV-1b + MV-8d: remote-mutation alerts queued by the sync merge,153-  // shown as dismissible banners on popup open.152+  // Rare device-trust alerts stay visible; routine entry sync is silent.154153   interface SyncAlert {155-    type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";154+    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";156155     entryId: string;157156     entryTitle: string;158157     deviceName: string;@@ -175,12 +174,9 @@175174     setSyncAlerts((arr) => arr.filter((_, i) => i !== index));176175   }177176   function syncAlertText(a: SyncAlert): string {178-    if (a.type === "REMOTE_PASSWORD_CHANGE") {179-      return `${a.deviceName} changed the password for ${a.entryTitle}`;177+    if (a.type === "DEVICE_REVOKED") {178+      return `${a.deviceName} revoked a paired device — review your devices in Settings`;180179     }181-    if (a.type === "REMOTE_DELETE") {182-      return `${a.deviceName} deleted ${a.entryTitle}`;183-    }184180     return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;185181   }186182   const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");@@ -410,7 +406,7 @@410406         payload: { reason: "foreground" },411407       });412408       void loadSyncIndicator();413-      // UI-1 / HV-1b: surface any remote-mutation alerts the sync merge queued.409+      // Rare device-trust alerts only; routine entry sync stays quiet.414410       void loadSyncAlerts();415411     }416412 @@ -1609,7 +1605,7 @@16091605             {/* Desktop: shown on demand via Show. Native sheet is rendered16101606                 via Portal below at the document root so its position:fixed16111607                 isn't trapped by .peach-shell's transform-based animations. */}1612-            {/* UI-1 / HV-1b: dismissible remote-mutation alert banners. */}1608+            {/* Rare device-trust alerts; normal sync changes are silent. */}16131609             <Show when={syncAlerts().length > 0}>16141610               <div class="peach-sync-alerts">16151611                 <For each={syncAlerts()}>

view file