3b8b0dd8f317
feat: Keep routine sync quiet (v4.3.2)
- time
- 2026-07-27 20:12 UTC · 3 months ago
- machine
- koh2
- parent
- c700871e2778
↓ peach-browser-3b8b0dd8.tar.gzatlas at this save
files changed7 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 −10manifest.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": {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",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",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" &&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 });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 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()}>