fix: merge state per field instead of overwriting the whole blob (closes #304)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m13s
e2e / e2e-firefox (push) Successful in 24s

saveState() is now a read-modify-write that merges only the fields this page
changed, diffed against a deep-cloned per-page baseline. wallets, allowedSites,
deniedSites and networkEndpoints merge structurally by identity, so membership
comes from fresh storage except for this page's own adds and deletes.

Fixes a second extension page silently deleting a wallet, the background balance
refresh clobbering a concurrent add or resurrecting a delete, and a stale page
resurrecting a revoked site permission.

Colliding wallet identities keep both records and log rather than silently
dropping an encryptedSecret. Concurrent writers of the same leaf remain
last-writer-wins by design.
This commit was merged in pull request #337.
This commit is contained in:
2026-08-20 16:41:19 +02:00
parent 20e911059a
commit cef6aaab11
3 changed files with 987 additions and 123 deletions

26
TODO.md
View File

@@ -44,6 +44,32 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: A second extension page can no longer silently delete a wallet
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
the entire state blob, and every extension page — the toolbar popup, a dApp
approval window, `backgroundRefresh()` — holds its own in-memory `state`,
loaded once, with `showView()` saving on every navigation; a second page that
saved after a first had written something new overwrote it, no attacker or
unusual input required. `saveState()` is now a read-modify-write: it re-reads
storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
taken at the last `loadState()`/`saveState()` on that page, and writes only
the fields that actually changed — everything else is carried forward from
storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
with `loadState()`), so a legacy or malformed record a load has always
self-healed in memory keeps getting written back even on a save that touched
something else entirely. `showView()` fires `saveState()` on every navigation
without awaiting it, so two saves from the same page can be in flight at once;
a FIFO queue serializes them rather than letting a slow one finish after a
later one and re-derive a stale answer. Deliberately not done: the live
`state` of a field this page does not own is not rehydrated from what another
page wrote, only the persisted record is — adopting a concurrently-written
value into `state` reintroduced the same clobber one page later, caught by
`tests/txStatus.test.js` red. Two writers of the same field still resolve
last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
covers the two-page save and the approval-window reproduction from the issue —
add a wallet in one page, force a save from a second page loaded before it,
both wallets survive — each demonstrated failing against the unfixed full-blob
write.
- 2026-08-20: A forgotten password no longer wedges the wallet
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
was password-gated and importing its recovery phrase again was refused as a