Skip to content

e2e encryption for Grails chat - #137

Open
encryptedDegen wants to merge 94 commits into
mainfrom
e2e
Open

e2e encryption for Grails chat#137
encryptedDegen wants to merge 94 commits into
mainfrom
e2e

Conversation

@encryptedDegen

Copy link
Copy Markdown
Collaborator

View PR#135 for implementation & technical details

Quantumlyy added 30 commits May 6, 2026 20:57
Quantumlyy and others added 29 commits May 12, 2026 22:18
…back against reverse-ordering

Two P1s from the second review pass.

P1.A — restoration-gate stale value on the first unlocked render.

The previous gate (restoredFromDisk: boolean, set true in the locked
branch as a 'no-op restore' signal) left restoredFromDisk=true sitting
in state from before unlock. On the first render AFTER setUnlocked(true)
commits, the React batch order is:

  1. Render (isUnlocked flips, restoredFromDisk still true from prior).
  2. useEffect for dmKey effect runs, calls setRestoredFromDisk(false).
  3. useEffect for banner cache-scan effect ALSO runs in the same pass —
     closure-captured restoredFromDisk is the prior value (true), gate
     passes, scan runs against not-yet-loaded refs.

setState is asynchronous; the scheduled (false) doesn't apply until the
next render. The same race surfaces on chat switch.

Fix: replace the boolean with restoredForDmKey: string | null. Banner
gates on e2e.restoredForDmKey === dmKey. A stale value (null, or a
prior chat's dmKey) cannot match — by construction. The setter only
flips at the END of a successful load, and uses the dmKey captured at
effect-start so a mid-flight chat switch can't mis-write.

The locked / no-dmKey branches no longer eager-set the gate; the
banner gates on isUnlocked + isEligibleChat separately, so those paths
don't need a restore signal.

P1.B — consumePeerBundle disk-fallback reverse-ordering leak.

The await loadSession runs INSIDE the per-peer bundle/<did> queue, but
the dmKey-load effect runs OUTSIDE it. Reverse ordering:

  T1: consumePeerBundle: ref.get → undefined
  T2: dmKey effect: targetMap.has → false → targetMap.set(did, A)
  T1: await loadSession resolves → session B
  T1: ref.set(did, B)                              ← overwrites A, leaks

Same byte-identical pickle on both sides, but Olm.Session is a distinct
Wasm allocation per unpickle, so the unfreed one stays in the Wasm
heap until next chat unmount. Fix: re-check the ref after the await
and free the duplicate (theirs OR ours, whoever raced second) instead
of overwriting.

Also: parallelize roster + published-flag + sessions loads via
Promise.all. They all hit the same encrypted IndexedDB store, no
ordering dependency. Saves two round-trips in the unlock-to-ready
window — exactly the window the gate is keeping the banner waiting on.
The preload was installing globalThis.document = {...} unconditionally,
which silently flipped any future module under test that branches on
typeof document !== 'undefined' into the browser code path. Currently
benign (only src/lib/e2e/olm.ts consumes it, and the consumer path —
loadOlmScript — is short-circuited by window.Olm being pre-populated),
but a foot-gun for any future 'use client' module imported into a Bun
test.

Move the stub into installBrowserDocumentShim(), returning a cleanup
function. Callers that need it (currently none) opt in with
beforeAll/afterAll, leaving document === undefined as the honest Node
default for everything else. All existing tests still pass — none
actually reached the document branch.
…nation invariant

Three Synpress-spec polish items from review.

1. bootstrapToReady previously did expect.poll(...).toBeGreaterThanOrEqual(1)
   followed by a separate expect(count).toBe(1). A second POST landing
   between the two assertions (exactly the bug we'd want to catch) would
   slip the poll's >= 1 then fail the toBe(1) at a confusing spot. Fold
   both into a single expect.poll(...).toBe(1) so the assertion is
   monotonic over the polling window.

2. wipeBrowserStorage's indexedDB.deleteDatabase had onblocked silently
   resolving. If a previous test's page held an open grails-e2e
   connection, the database wouldn't actually be deleted and the next
   test would see stale state (e.g. a stored published-flag would
   suppress the legitimate first-publish). Playwright's per-test context
   isolation makes this unlikely today, but a future Synpress upgrade
   with context-reuse semantics would flip the suite into silent
   order-dependence. Reject loudly instead.

3. Document what the pagination scenario actually exercises. After
   pushPaddingAfterUserSend(60), reversed[0..49] is the 60 post-padding
   rows (slice cap at 50), and BOTH the user's POST and the peer's
   handshake end up outside the visible window. The 'own paginated,
   peer visible' framing from the PR body is unreachable under a
   chronologically-ordered feed; the test exercises the strictly
   stronger 'neither own nor peer visible' invariant, which is the
   correct property to pin. Inline a comment so a future reader doesn't
   misread it as weaker than the bug description.
The Synpress CLI writes its wallet-cache directory based on where it's
invoked from, which on some machines lands at the repo root rather than
under test/e2e/. Drop the path prefix so the entry catches the cache
wherever it ends up.
shouldAutoPublishHandshake had isReady listed as a suppression signal,
which conflated two independent facts:

  - isReady = we have a peer session in memory (built from THEIR
    published bundle via consumePeerBundle).
  - hasPublishedForChat = we have successfully POSTed our bundle.

These are not the same. If consumePeerBundle persisted peer sessions
and roster but the subsequent sendMessage POST failed (network drop,
5xx, etc.), markOwnHandshakePublished is never called and the
persisted flag stays false. On next mount the dmKey loader restores
the peer session, isReady flips true, the manual 'Setting up
encryption…' banner doesn't render (state.kind === 'ready' early-
return), and the auto-publish fallback would have suppressed via
isReady — leaving the peer permanently without our bundle and unable
to initiate their inbound channel to us.

Drop isReady from the helper's input. The remaining signals
(sawOwnHandshake, alreadyAttemptedThisMount, hasPersistedPublishedFlag,
plus the restoredForDmKey gate on the effect itself) correctly cover
every case where we shouldn't publish — and crucially do NOT cover
the post-fail-retry case.

The restoration race that the earlier isReady gate was belt-and-
suspendering against is handled correctly by restoredForDmKey, so this
removal is a pure correctness fix with no race regression.

Tests: dropped the isReady cases, added an explicit regression test
pinning that an isReady-style suppression must NOT come back.
Extract openChatAndUnlock(page, metamask) and call it both after the
initial wallet-connect AND after each page.reload(). The Redux
chat-sidebar slice isn't in the redux-persist whitelist, so a reload
lands on the inbox-closed root view — the chat thread (and the
'Unlock encryption' button) aren't mounted until we re-open Messages
and click the chat row.

Without this, the existing reload paths fail at
page.getByRole('button', { name: /unlock encryption/i }).click()
because the button doesn't exist yet. The Synpress regression
assertion (handshake POST count unchanged) is never reached.

Uses the navbar 'Messages' button's aria-label as the stable selector
(the icon-only button has no visible text).
… lint

Two paper-cuts that surfaced on the first end-to-end Synpress run.

1. Cache-key mismatch — 'Cache for <hash> does not exist'.

   Synpress derives the cache key from defineWalletSetup's callback
   function body. The CLI (`bun run test:e2e:setup`) reads the raw
   .ts source and regex-extracts the arrow function; the Playwright
   runtime calls fn.toString() on the compiled-and-loaded module. When
   the function body references a top-level ES import like
   `new MetaMask(...)`, Playwright's TS compiler rewrites it to a
   namespace member access (`new _ns.MetaMask(...)`) — the CLI's
   raw-source view and the runtime's fn.toString() then produce
   different bytes through esbuild minify, and the cache keys diverge.

   Fix: dynamic-import MetaMask inside the callback. The
   `await import('@synthetixio/synpress-metamask/playwright')` is
   identical bytes at both extraction paths, so the cache key matches.
   Locally reproduced via the CLI's extractor + esbuild pipeline; new
   stable hash is fa138dac27156d029410.

2. ESLint walking .cache-synpress.

   The Synpress CLI unpacks MetaMask's bundled background-*.js sources
   into .cache-synpress/. .gitignore covers them, but ESLint's
   directory walk doesn't read .gitignore — running lint after a setup
   run produced 14 errors + 73 warnings from minified MetaMask
   internals. Add .cache-synpress/, playwright-report/, and test-results/
   to the ignores list in eslint.config.mjs.
The Synpress-MetaMask path hit three paper cuts in rapid succession:
cache-key hash mismatch (different transpilers between CLI extract and
runtime fn.toString), wallet password not entered on test start after
setup, and the handshake test hanging because the dApp uses
RainbowKit's modal + ethereum-identity-kit's auto-SIWE — the test
needs to click through the modal AND handle a second signature
prompt, neither of which the previous spec did.

Switch to @synthetixio/synpress's ethereum-wallet-mock fixture
(same package, different export). It works by addInitScript-injecting
@depay/web3-mock into every page so window.ethereum is mocked before
the app loads. No MetaMask binary, no .cache-synpress, no password,
no extension to unlock. Mocked account is a fixed constant
(0xd73b04b0e696b0945283defa3eee453814758f1a) which the mock backend
already accepts as the SIWE-verified user.

Tradeoff:
  - Lose: real-MetaMask-UI integration coverage.
  - Keep: every line of e2e code we ACTUALLY want to test —
    consumePeerBundle, the cache-scan auto-publish decision, the
    restoredForDmKey gate, the persisted-flag round-trip, the cache
    scan's behavior across page.reload(). The bug is post-auth; the
    wallet provider being mocked doesn't reduce coverage of the
    regression.

Drops:
  - test/e2e/wallet-setup/basic.setup.ts (no setup phase needed).
  - test:e2e:setup script (no cache to build).
  - test:e2e:headless script (default is now headless; --headed for
    debugging via test:e2e:headed).

playwright.config.ts: removes the headed-mode requirement now that
there's no extension to render.
markOwnHandshakePublished was awaiting the IDB persist BEFORE calling
setHasPublishedForChat(true). If the persist throws (quota exhausted,
private-window quirks, transient errors), the in-memory state stays
false for the rest of the mount. The banner's catch-and-log around
republishOurHandshake swallows the rejection, the autoPublishedChatsRef
latch bounds republish to one per mount — but on the NEXT mount we
re-publish again, and if the IDB error is persistent we'd emit one
duplicate handshake per page-load forever.

Flip the order: in-memory state first, IDB second. On persist failure,
the running session correctly suppresses republish; the next refresh
sees the missing flag and retries one more time, which is the right
end-state (peer already has our bundle from the original successful
POST, so the retry is observably a no-op on their consumePeerBundle).
Disk and memory diverge briefly, but only on the failure path, and
the divergence self-heals on next successful persist.
Two mock-backend bugs surfaced by the first end-to-end run of the
wallet-mock spec.

1. verify payload shape. src/api/siwe/verifySignature.ts reads
   (await response.json()).data — it expects the verify payload to be
   wrapped under a top-level 'data' key. The mock returned token/user
   flat at the top level; the client got undefined, the SIWE flow
   silently failed, and the dApp parked on the Sign-in screen. Both
   tests then timed out waiting for the Messages button. Wrap the
   payload under data.

2. Catch-all route ordering. Playwright evaluates page.route handlers
   in REVERSE order of registration (LIFO) and stops at the first one
   that calls route.fulfill(). The catch-all was registered LAST, which
   means it fired FIRST — every /chats, /chats/:id, /messages request
   got { success: true, data: null } before the specific handlers had
   a chance. React Query then read 'no chats, no messages.'

   Fix: register the catch-all FIRST so it's evaluated LAST, becoming
   a true fallback for requests that don't match any specific handler.
   Specific handlers (registered after) are evaluated first and
   fulfill on match.
Two issues from the latest end-to-end run.

1. Test hung waiting for the 'Messages' button. The screenshot showed
   the RainbowKit Connect-a-Wallet modal sitting open with all the
   connectors listed. ethereumWalletMock.connectToDapp() only re-mocks
   window.ethereum — it does not interact with the UI, so the modal
   never closes and the SIWE flow never starts. Add an explicit click
   on the 'MetaMask' tile inside the dialog after opening the modal.
   Scope the lookup to page.getByRole('dialog') so we don't match
   sibling 'MetaMask' text elsewhere on the page.

   Sequence is now:
     - Click 'SIGN IN' (RainbowKit modal opens)
     - Click 'MetaMask' tile inside modal (wagmi → eth_requestAccounts)
     - wallet-mock answers → RainbowKit closes modal
     - ethereum-identity-kit auto-fires personal_sign → wallet-mock
       signs → /api/auth/verify (mocked) → JWT cookie → authenticated

2. Homepage queries were producing noisy console errors:

   - fetchDomains hits API_URL/search?... — previously fell through to
     the catch-all returning data: null, which client code then
     dereferenced as json.data.names → TypeError. Add a specific /search
     handler returning an empty result set.

   - getEtherPrice hits eth-mainnet.g.alchemy.com/v2/undefined (env var
     unset in test). Alchemy returns text-not-JSON. Abort the request
     so getEtherPrice's try/catch resolves cleanly. Also abort the
     optimism/base/quiknode RPC endpoints for the same reason.

   These weren't blocking the test, but the failed requests added to
   the network-idle wait and polluted the test report.
…orting

Previous commit aborted the unauthenticated Alchemy requests so
getEtherPrice's try/catch would resolve cleanly. That kept the noise
out of the test report but left the in-app price feed permanently
unavailable, which would surface as either undefined values in the
UI or a slow retry loop.

Route the Alchemy and QuickNode URLs to the matching publicnode.com
RPC endpoints via route.continue({ url }). The browser's network
layer rewrites the destination; method, headers, and POST body are
preserved. publicnode serves the standard JSON-RPC interface with no
API key and CORS Allow-Origin: *.

Routing:
  - eth-mainnet.g.alchemy.com → ethereum-rpc.publicnode.com
  - opt-mainnet.g.alchemy.com → optimism-rpc.publicnode.com
  - base-mainnet.g.alchemy.com → base-rpc.publicnode.com
  - *.quiknode.pro            → ethereum-rpc.publicnode.com (safety net;
    wagmi only hits QuickNode if Alchemy fails, which it won't now)
…pletion

Two issues blocking the wallet-mock test from completing.

1. /api/users/me wasn't mocked. checkAuthentication() reads
   document.cookie for the token and then GETs /api/users/me — a
   Next.js route that server-side proxies to api.grails.app/auth/me.
   page.route catches the browser-side request before Next.js's handler
   runs, but we never registered a handler for it. After the verify-
   cookie landed, useAuthStatus's refetch hit real network on the
   forwarded path, authStatus stayed 'unauthenticated', and the
   Messages button (gated on authenticated) never mounted.

   Add a /api/users/me handler returning APIResponseType<ProfileResponseType>
   — the wrapper shape the client reads via response.json() and
   checks .success on. Removed the dead /auth/check mock at the same
   time; no caller in the app hits that path.

2. networkidle isn't a reliable auth-completed signal. It resolves
   after 500ms of request silence, which fires before /api/users/me
   has even been called when verify is mocked, and if users/me ever
   fails, networkidle resolves anyway and the test then waits 120s
   for the Messages button — surfacing the failure as a vague
   timeout instead of an obvious auth issue.

   Replace the post-modal-click networkidle wait with an explicit
   page.getByRole('button', { name: 'Messages' }).waitFor — the
   button is the canonical authenticated-state UI signal. A 30s
   timeout catches auth failures fast with a clear error pointing at
   the right spot.
Synpress's ethereum-wallet-mock fixture's importWallet/connectToDapp
configure window.ethereum's accounts but never register a signature
mock. Any personal_sign request from the page (SIWE auto-fire from
ethereum-identity-kit, HANDSHAKE_MSG unlock signature from
useE2ESession) then crashes inside Web3Mock with viem's
UnknownRpcError.

Add a mockPersonalSign(page) helper that calls Web3Mock.mock() with
signature: { return } in addition to the existing accounts mock. The
fixed-value return is intentional — the refresh-then-unlock
regression depends on deriveStorageKey(sig) producing the SAME
IndexedDB encryption key both times, so determinism matters.

Apply it after the initial connectToDapp and re-apply after each
page.reload(): the fixture's addInitScript re-runs on navigation but
only mocks accounts, so the signature handler would be gone after a
reload and the second unlock would crash the same way.

The signature content is arbitrary (Web3Mock doesn't verify shape,
/api/auth/verify is mocked to accept any payload, HANDSHAKE_MSG only
needs a stable input to deriveStorageKey).
…icipants

After login completed successfully, the test was hanging somewhere in
the Messages → chat row → Unlock sequence without surfacing which
step broke. Playwright's auto-wait would burn the full 120s budget
on whichever click failed and report the error at the click line
instead of the underlying 'sidebar never opened' / 'inbox row never
rendered' cause.

Add a visibility waitFor at each step with a tight timeout:
  1. Messages button (15s) — gated on authStatus === 'authenticated';
     failure means SIWE didn't complete.
  2. Chat sidebar <aside aria-label='Chat sidebar'> (10s) — the
     sidebar slides in over 250ms; this catches animation issues
     and confirms the dispatch fired.
  3. data-testid='chat-inbox-row' inside the sidebar (15s) —
     useChatsInbox fetches /chats while the sidebar animates in;
     failure here means the inbox-list mock didn't fire or
     authStatus regressed.
  4. 'Unlock encryption' button (15s) — gated on isEligibleChat AND
     !e2e.isUnlocked AND the E2E flag (which ?e2e=1 forces on);
     failure here means the chat-detail mock didn't fire or the
     feature flag dropped.

Also fill in 'participants' on the inbox-list chat object. ChatRow
renders without them (peer falls back to a placeholder circle), but
the real backend always sends them and matching the shape keeps the
mock honest.
…lick

The MetaMask tile inside RainbowKit's connect modal is unstable
under Playwright's default click semantics. Clicking it triggers
wagmi.connect → eth_requestAccounts (instant via wallet-mock) →
RainbowKit transitions the modal from 'list' to 'connecting' to
'success' and detaches the tile element mid-animation. Playwright's
actionability check (element must be stable for 100ms) then keeps
retrying against a freshly-rendered or detached element, burning
the full 120s test timeout before any click actually fires —
even though the page does eventually reach a signed-in state on
those rare runs where one click slips through.

Fix: click with force:true to bypass the stability check, plus
noWaitAfter to skip Playwright's post-click navigation wait (the
modal close is an internal animation, not a route change). The
click is recorded and dispatched immediately; wagmi receives it,
connects, and the subsequent DOM detach no longer aborts the
action.

Belt-and-suspenders: an explicit waitFor({ state: 'visible' })
before the click ensures we're not force-clicking a fresh element
that hasn't rendered yet.
…le for sidebar

Two Playwright issues breaking the regression suite from reaching its
assertions.

1. MetaMask tile resolves as visible but sits below the viewport.

   RainbowKit's wallet list overflows the modal; on the default
   1280x720 viewport MetaMask is past the visible scroll position.
   force:true skips Playwright's actionability check but does NOT
   scroll the element into view — the click coordinates resolve to
   an off-screen point and the event never dispatches.

   Fix: call locator.scrollIntoViewIfNeeded() before the force-click.
   force:true still bypasses the stability check (the tile detaches
   mid-animation when wagmi connects), so both failure modes are
   handled.

2. getByLabel('Chat sidebar') matched two elements (strict mode
   violation).

   The <aside> carries aria-label='Chat sidebar' and contains an
   inner resize handle with aria-label='Resize chat sidebar'.
   getByLabel does substring matching, so both candidates pass and
   Playwright throws. The first test happened to land on the right
   one; the second test failed immediately on strict-mode.

   Fix: switch to getByRole('complementary', { name: 'Chat sidebar' }).
   <aside>'s implicit ARIA role is 'complementary' and the resize
   handle (a div with role='separator') is excluded from the match.
The previous fixed-order helper (waitFor tile → scrollIntoView →
force-click) kept failing at scrollIntoViewIfNeeded with 'Element
is not attached to the DOM.' Root cause: there are two paths after
the Sign In click, and we were only handling one:

  A. Modal opens → click MetaMask → wagmi connects → SIWE.
  B. wagmi auto-reconnects from window.ethereum.eth_accounts (the
     wallet-mock has the account loaded by the time the modal would
     render) and SIWE auto-fires without the modal opening. The
     modal flashes in the DOM and detaches before our next call.

Replace the fixed sequence with a race:

  - signedInPromise: waitFor the Messages button (the canonical
    authenticated-state signal; gated on authStatus === 'authenticated'
    AND userAddress in src/components/navigation/chats.tsx).
  - modalPromise:    waitFor [role='dialog'] visible, catching the
    timeout-rejection so the race doesn't reject if path (B) wins.

Whichever wins first tells us if the modal is in play. If yes, click
the MetaMask tile via page.evaluate — the click fires the instant
the button is in the DOM, no Playwright locator-resolution retries
against a detaching element. Then await signedInPromise to gate the
rest of the test on auth completion. Either path converges on the
same downstream wait.
The hook is reused across chat switches (same banner instance, dmKey
prop changes). If a publish that started in chat A is still inflight
when the user switches to chat B, the publish-success callback
(captured at call-time with dmKey=A) would call setHasPublishedForChat(true)
on the current hook instance — which is now mounted for B. B's
in-memory flag flips true even though B's on-disk flag is false,
and shouldAutoPublishHandshake then suppresses B's first legitimate
publish, stranding B's peer without our bundle.

Fix: capture dmKey at the start of markOwnHandshakePublished and
compare against dmKeyRef.current (a live mirror of the current
dmKey prop, updated on every render). If they differ — the user has
switched chats — skip both writes. The disk write is also bailed
because writing to A's flag when the user is on B is wasted work
(B's banner won't read it, and A doesn't need it: sendMessage has
already POSTed the bundle and the peer's consumePeerBundle is
idempotent on retry).

The chat that A's publish was for still gets its disk flag written
on the NEXT mount of chat A: the autoPublishedChatsRef latch is
per-mount, so a future visit to A will see no flag, scan messages
for the visible own-handshake row, and skip auto-publish via
sawOwnHandshake. If the row is paginated out, the auto-publish
fallback fires once and lands a duplicate — observably a no-op on
the peer side. Acceptable trade vs the current bug of stranding B.
…t SDK)

RainbowKit's MetaMask tile uses @metamask/sdk-react-ui's connector
which expects to talk to the real MetaMask browser extension via
the SDK's own postMessage channel. Even though our wallet-mock
injects window.ethereum with isMetaMask=true (which makes RainbowKit
SHOW the MetaMask tile as 'Installed'), the connector's connect
path waits on the SDK's response and never gets one — RainbowKit
parks on 'Opening MetaMask... Confirm connection in the extension'
and the test times out waiting for the Messages button.

Switch the click target to the 'Browser Wallet' tile, which uses
wagmi's generic injected() connector. injected() just calls
window.ethereum.request({ method: 'eth_requestAccounts' }) directly
— exactly what the wallet-mock answers. wagmi gets the connection,
ethereum-identity-kit's auto-SIWE fires, and the navbar Messages
button mounts.

Same in-page click via page.evaluate (avoids Playwright locator-
resolution retries against the tile detaching mid-animation), same
modal-vs-authenticated race wrapping it (so path B — wagmi auto-
reconnect from eth_accounts — also works without a click).
…HTTP rejection

Best-effort attempt at the lingering Messages-button regression. The
useAuthStatus.verify() success path runs:

  document.cookie = 'token=...; SameSite=None; Secure'

The Secure attribute is rejected on http://localhost, and in some
browser versions that rejection transiently clears the existing
cookie (set via the mocked /api/auth/verify Set-Cookie response).
A refetch in that window reads no token, flips authStatus to
'unauthenticated', and the Messages button unmounts mid-test even
though it was visible moments earlier.

Plant the token cookie at the Playwright context level in beforeEach
so the browser's cookie jar holds a fallback the page's writes can't
durably remove. Same name + path + sameSite as the mock backend's
Set-Cookie; only the context-level vs document-level distinction
matters here.
…coverage

The browser-level regression suite never reached the handshake
assertions reliably. Each round surfaced a new compatibility issue
in the wallet-mock ↔ RainbowKit ↔ ethereum-identity-kit chain:
extension cache hash mismatches, MetaMask SDK hanging on a
non-existent extension, RainbowKit's MetaMask tile detaching mid-
animation, Web3Mock's missing signature handler, /api/users/me not
being mocked, Set-Cookie races with verify()'s Secure-on-HTTP
write, the Messages button unmounting after first appearing. Each
patch landed cleanly; together the surface area outgrew the value
the canary was providing.

The regression itself remains pinned by the Bun unit tier (51 tests
under src/) that gates every PR:

  - src/lib/e2e/__tests__/storage.test.ts: encrypted IDB round-trip,
    null-on-missing, prefix scan, wrong-key reject.
  - src/lib/e2e/__tests__/olm-restoration.test.ts: identity preserved
    across refresh, session persist→load, stored pickle bytes
    unchanged (the core regression), markHandshakePublished /
    loadHandshakePublished round-trip and namespacing, flag survives
    refresh.
  - src/components/chat/components/chat/__tests__/shouldAutoPublishHandshake.test.ts:
    truth-table of the suppression-decision helper.

These exercise the actual race-and-restoration code paths against
real fake-indexeddb + real Olm crypto running Node-side. They run
in ~120ms.

Removed:
  - test/e2e/ (mock-backend, peer-bundle fixture, spec).
  - playwright.config.ts.
  - test:e2e + test:e2e:headed scripts in package.json.
  - devDeps: @playwright/test, @synthetixio/synpress + cache + metamask.
  - data-testid='chat-inbox-row' from ChatRow (only used by the spec).
  - ESLint + .gitignore Synpress-artifact entries (kept the
    .cache-synpress ignore defensively in case a dev still has one
    on disk from an earlier run).

Kept:
  - test/setup.ts + bunfig.toml — Bun unit tests preload them for
    fake-indexeddb + Node-side Olm.
  - fake-indexeddb devDep — same reason.
Previous fix guarded BOTH the in-memory setState and the IDB write
behind `dmKeyRef.current === callForDmKey`. The in-memory guard is
correct (setHasPublishedForChat shares state across chat switches,
flipping it for the wrong chat suppresses that chat's legitimate
first publish). The disk guard was wrong: the peer received our
bundle for callForDmKey regardless of where the user is now, so the
on-disk flag for THAT chat must reflect it.

Without writing the disk flag, the next visit to the chat we sent
the handshake from — once the handshake row is paginated past the
first 50 messages — would auto-publish a duplicate via the
sawOwnHandshake=false fallback path. The persisted-flag suppression
exists specifically to defeat that case, and skipping the write
strands it.

Fix: only guard the in-memory setHasPublishedForChat on the current-
chat match; the disk write always runs, keyed by callForDmKey.
…m cache scan

Two related fixes hardening the persisted-flag flow.

1. markOwnHandshakePublished was reading storageKeyRef.current LIVE
   at the IDB-write site, but the closure captured userAddress and
   dmKey from the call time. If the user switched wallets between
   the publish starting and resolving, we'd write
   `wallet/<old-address>/published/<old-dmKey>` encrypted with the
   NEW wallet's storageKey. The old wallet's next restore then fails
   with 'wrong wallet?' from storage.ts:41, breaking handshake-flag
   restoration for that chat under the old wallet.

   Fix: snapshot storageKeyRef.current at call entry alongside
   userAddress and dmKey. Pass the captured triple to
   markHandshakePublished — the live ref is never dereferenced
   inside the await. The on-disk blob is consistent with the wallet
   that actually sent the bundle.

2. The cache scan in e2eHandshakeBanner marked sawOwnHandshake when
   our own handshake row was visible but never backfilled the
   persisted flag. Two cases this stranded:

     - Chats whose handshake was sent before the persisted-flag
       mechanism existed — the flag stays missing forever.
     - Chats where a prior markOwnHandshakePublished failed
       transiently (IDB quota, private-window quirks) — the publish
       landed on the peer but our flag never persisted.

   Once the visible row aged past the first 50 messages,
   sawOwnHandshake flipped back to false and the auto-publish
   fallback fired a duplicate. The persisted-flag suppression was
   precisely there to defeat this case, but it never got the chance.

   Fix: after the scan loop, if we saw an own handshake AND the
   persisted flag is still false, call markOwnHandshakePublished
   to backfill. Idempotent — markOwnHandshakePublished writes a
   single-byte blob and the wallet-key capture from fix #1 above
   makes it race-safe even if a wallet switch is in flight.
…ndshake suite

The 'regression: must NOT suppress on ready alone' test was added when
isReady was still an input to the helper. After isReady was removed
from the helper's signature (commit 0abb567), the test's assertion
collapsed into a duplicate of the happy-path case — same inputs, same
expected output. TypeScript would already reject any attempt to add
an isReady input back at the call sites, so the test no longer
guards anything the type system doesn't.

Drop it; the remaining six suppression-signal tests fully cover the
truth table.
… be tracked

Same defensive logic as the .cache-synpress entry above it: the
Synpress + Playwright infrastructure was removed in f701ea5, but
dev machines that ran the test before the removal may still have
a test-results/ directory from those runs. Ignore it at the repo
root so it can't accidentally end up tracked.
…-build time

Previous attempt captured storageKey at markOwnHandshakePublished's
ENTRY (i.e. after sendMessage resolves). That's still too late: a
wallet switch between sendMessage starting and resolving leaves
storageKeyRef.current pointing at the NEW wallet's key by the time
markOwnHandshakePublished snapshots it, and we then write
wallet/<old-address>/published/<old-dmKey> encrypted with the new
key. The old wallet's next restore throws 'wrong wallet?' from
storage.ts:41 and leaves E2E stuck.

Replace buildHandshakeBundle + markOwnHandshakePublished (in the
publish path) with a two-phase beginHandshakePublish() that returns
{ bundle, commit }. The (userAddress, dmKey, storageKey) triple is
snapshotted INSIDE the enqueue('account', ...) critical section at
bundle-build time and baked into the commit closure. commit() writes
the flag against that fixed triple, immune to any wallet/chat switch
that lands between begin and commit.

Banner.republishOurHandshake now:
  const handle = await e2e.beginHandshakePublish()
  if (!handle) return
  await sendMessage({ ..., bundle: handle.bundle })
  await handle.commit()

Kept markOwnHandshakePublished on the hook for the cache-scan
backfill path — that effect's cleanup runs synchronously on dep
change (wallet/chat switch flips cancelled=true before any deferred
async work resumes), so the backfill is naturally race-safe via the
post-call cancelled guard.
A wallet switch mid-publish on the same chat (dmKey unchanged) would
otherwise pass the dmKey-only gate and flip wallet B's in-memory state
from wallet A's snapshot. Disk write was already snapshot-scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(e2e): prevent key-share republish on refresh + unlock
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants