Auth: multiple browser tabs break refresh (rotation race / no 401 recovery) #44
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while diagnosing the #36 browser 401. The browser harness opened a second page in the same
BrowserContext, so the two pages shared the refresh cookie. That page's refresh rotated the cookie, and the main page was left with a revoked access token.GET /api/v1/containersthen returned 401 and nothing recovered it. The harness has been changed, but real users have the same setup: several infraternal tabs in one browser.Current behavior:
packages/web/src/lib/auth.svelte.ts) callsPOST /api/v1/auth/refreshon load.docs/contracts.md§1, around lines 92–97) says reuse of any consumed refresh credential revokes every access and refresh credential in the family.Expected problems:
It needs a design that keeps reuse detection secure, for example:
This is an auth surface, so it runs on Sol.
Started issue #44 on branch multi-tab at base
d9197837c5. Reading the refresh contract and implementation; next I will reproduce both tab failures before changing behavior.Issue #44 design finding:
SqliteStorage::refresh_sessionmarks the old refresh credential used and updates its backing access token torevokedin one immediate transaction. That explains tab A's 401 after tab B refreshes. A second use of the old refresh credential revokes every active access token in its family and auditsauth.refresh.reuse.I updated
docs/contracts.md§1.1 before implementation: same-origin web tabs serialize cookie refresh with Web Locks, share the new access token through BroadcastChannel, and an access-authenticated request gets one 401 recovery and retry. The old access token remains revoked on rotation. There is no server grace: cookie plus matching Origin cannot prove that a presenter is another tab rather than another browser holding a stolen cookie. RFC 9700 §4.14.2 requires replay detection for public clients and explains that the server cannot tell which presenter is legitimate after a reused refresh token: https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2 .Red/green evidence for #44: with
packages/web/src/lib/api.tstemporarily restored fromorigin/devand the newapi.test.tsretained,bun run --filter @infraternal/web test -- src/lib/api.test.tsexited 1 (3 failed;setUnauthorizedRecovery is not a function). Restored fixed source; the same focused test exited 0 (3 passed).bun run --filter @infraternal/web checkfound 0 errors and 0 warnings. The server-level concurrent test usestokio::join!on one refresh secret and verifies one rotation and one reuse revoke the winner's access credential; this documents why cross-tab serialization is required and preserves the true-reuse security behavior. The browser check now creates multiple pages in one context, deliberately suppresses BroadcastChannel on a stale tab to exercise 401 recovery, then starts two tabs together with delayed refresh HTTP requests to exercise the rotation race.fastgate at headb3e9617plus the subsequent access-revocation assertion (the gate read the current working tree):Integration/browser gate scheduling:
scripts/verify fastpassed. At 20:27 local time, load5 was 8.77 and another job'scargo build --release -p infraternalwas still active. The job contract prohibits overlapping full release builds and asks for load5 below 8 before integration/browser, so those gates are waiting; I am polling without touching the other job's process or files.Real-browser red proof against
origin/devweb auth/api/inventory code: I rebuilt the base web app withscripts/build-web, cleaned and rebuilt this worktree's debug server package so the embedded assets were actually replaced, and ran the new Playwright check with two pages in one BrowserContext against a fresh local server. It exited 1 at the intended stale-tab assertion (before any later browser checks):Focused real-browser green proof: rebuilt the fixed web app with
scripts/build-web, cleaned and rebuilt this worktree's debug server package to embed it, and ran the same Playwright check against a fresh local server. The multi-tab check printed:I stopped this targeted debug run after that check; it is not the required production browser gate. The corresponding base-client run exited 1 at the stale-tab assertion (comment above). The production
scripts/verify browsergate is still pending the other job's active release build.Additional focused real-browser result against the fixed debug server: the check now runs two simultaneous pages both with Web Locks and with
navigator.locksdisabled to force BroadcastChannel fallback. Both modes completed with a single refresh HTTP request:This was a targeted debug run stopped after those assertions; production browser gate remains pending.
Server-level HTTP race test now passes:
CARGO_BUILD_JOBS=2 cargo test -p infraternal-server --test auth_http concurrent_cookie_refreshes_detect_reuse_without_browser_serialization -- --nocaptureexited 0 with1 passed. It sends two concurrent refresh requests with the same cookie and exact Origin but distinct idempotency keys. Exactly one rotates; the other is rejected as reuse, and the winner's successor cookie is then rejected because the family was revoked. This is the server behavior preserved while browser tabs serialize refresh.Final source head
3b0bfbepassedscripts/verify fast(verbatim output):Second real-browser red proof against
origin/devweb client, using two pages in one BrowserContext and the real server. The focused concurrent mode sent both refresh requests through Playwright'sroute.fetch()before fulfilling either response, so the browser cookie jar did not advance between them. The check exited 1:The strengthened Playwright concurrency check is green against the fixed real debug server after the baseline run failed. It uses
route.fetch()and delayedroute.fulfill()so both uncoordinated requests can reach the server while the browser still has the original cookie; Playwright documents thatroute.continue()cannot overrideCookie, which is why the earlier delay-only version was insufficient: https://playwright.dev/docs/api/class-route . Targeted output:The targeted debug run continued into unrelated browser checks afterward; the required production browser gate still waits for the release build slot.
At final head
11ff44e,scripts/verify fastpassed through Clippy but its full cargo test stage failed in the unchanged engine allocation test while tests ran in parallel. Relevant verbatim output:Focused rerun with one test thread passed:
I will rerun the full fast gate once at the unchanged source head and report its actual result. This is outside the owned auth files, so I am not changing the engine test here.
Full
scripts/verify fastrerun at head11ff44epassed after the preceding unchanged engine allocation-test failure. Verbatim gate output:Gate blocker after the requested 45-minute polling window (started about 20:26 CEST; now 21:11 CEST): load5 is 7.30, but another worktree still has
cargo build --release -p infraternalactive (PID 3116828, elapsed 1:53:22). The shared job rule says not to start more than one full release build at a time and forbids signaling processes owned by another job. Bothscripts/verify integrationandscripts/verify browserbegin with their own full release build, so neither gate can start while that process remains active. I have not touched it. Final head is11ff44e;scripts/verify fastrerun passed and both focused browser regressions passed. Integration and production browser gates remain outstanding.Job handoff at head
11ff44ef5a8218ba5071512757889a894cefa1caon branchmulti-tab(clean worktree; not pushed). The contract and implementation are committed. Both browser regressions were demonstrated red against base web assets in a real browser, and fixed Web Locks and forced BroadcastChannel checks each issued one refresh request for two simultaneous tabs. The same-cookie HTTP concurrency test and focused web tests pass. Finalscripts/verify fastrerun passed verbatim as posted above; its earlier unrelated engine allocation-test failure and focused pass are also posted.integrationand productionbrowsergates remain unrun. After the full 45-minute load polling window and extra waiting, the other worktree'scargo build --release -p infraternalis still active (PID 3116828, over 2 hours elapsed). This job's shared rule forbids overlapping full release builds and signaling another job's process. I did not push, close the issue, or start either prohibited overlapping gate.progress.mdat the worktree root is excluded from git and holds the continuation instructions.Independent review started on branch
multi-tab, head11ff44ef5a8218ba5071512757889a894cefa1ca, against based9197837c5adb20ad0da2a6b8872ed2826a92aee. Scope: refresh rotation contract and changed web/server tests. I will report source-level findings and run the requested integration/browser gates after the load threshold, plus regression checks in a throwaway worktree.Source review finding:
packages/web/src/lib/auth.svelte.ts:120-148uses an 80 ms BroadcastChannel leader election. The channel delivery has no 80 ms bound; if both tabs' intent events arrive after their timers, each sees only its own owner id and sends a refresh with the shared cookie. The unchanged server path (crates/server/src/storage/mod.rs:2057-2074) treats the loser as reuse and revokes the family. The browser test exercises ordinary timing but does not delay intent delivery. This blocks the claimed fallback guarantee; the fallback needs a coordinator that can guarantee exclusivity, or must fail closed when it cannot coordinate.Regression evidence: in the throwaway worktree, I restored the three changed web implementation files from
d919783while retaining the new tests.bun run test src/lib/api.test.tsfailed 3/3 (TypeError: setUnauthorizedRecovery is not a function). Both new server concurrency tests still passed (1 passed; 0 failedeach), as expected:git diff d919783 11ff44e -- crates/server/src/auth.rs crates/server/src/storage/mod.rsis empty. The server tests characterize the existing no-grace reuse rule; they cannot fail on the base client. Thescripts/verify fastlog has 16 PASS lines and no FAIL lines. Integration/browser remain pending the load5 < 8 condition.Source review finding:
packages/web/src/lib/auth.svelte.ts:34-45accepts any same-origin BroadcastChannel{type: "session", accessToken: <nonempty string>}and setssignedIn = trueeven after explicit sign-out.setSession()sends the access credential to every listener (:60-65), including an open tab on the signed-out screen;signOut()/clear()has no generation guard against late session messages or refresh responses (:232-253). The HTML Standard scopes the channel by storage key/name and queues delivery, but supplies no sender authentication. The client should gate token adoption to an active matching session or pending refresh, and discard late messages/results after sign-out. A delayed-refresh/sign-out browser check is missing.scripts/verify integrationcompleted with exit 0 on11ff44e, after starting at load5=6.65. Verbatim gate output:Browser gate is pending load5 < 8.
scripts/verify browsercompleted with exit 0 on11ff44e. Load5 at the prestart poll was 7.98; the script's startup warning sampled 8.18. Verbatim gate output:Browser screenshots are under
/mnt/hdd/infraternal/jobs/review-44/artifacts/, including 1440×900 and 390×844. The throwaway worktree with base web source is now running the same browser checks to confirm regression failure.Red-before-green browser evidence in the throwaway worktree: restored
packages/web/src/lib/api.ts,auth.svelte.ts, andinventory.tsfromd919783while retaining the newscripts/browser-check.mjschecks. The first baselinescripts/verify browserattempt stopped before the app checks because port 4175 was already occupied (FAIL browser: terminal fixture Playwright); I reran withINFRATERMINAL_TERMINAL_TEST_PORT=47144. That run built the base web app and release binary, then failed at the new stale-tab assertion:INFRATERNAL_MULTI_TAB_CONCURRENT_ONLY=1is now running separately so the first failure cannot mask the simultaneous-tabs check.Review verdict: request changes on
11ff44ef5a8218ba5071512757889a894cefa1caagainstd9197837c5adb20ad0da2a6b8872ed2826a92aee. Full review:/mnt/hdd/infraternal/jobs/review-44/findings.md.Blocking findings:
packages/web/src/lib/auth.svelte.ts:120-148). The 80 ms BroadcastChannel election has no delivery guarantee. Two tabs can both decide they own refresh, and the unchanged server correctly revokes the family on reuse. Use guaranteed exclusion or fail closed; test delayed intent delivery.auth.svelte.ts:34-45,60-65,242-253). Any same-origin channel participant can send a nonemptysessiontoken; the recipient setssignedIn=trueeven after sign-out. Broadcasts also disclose tokens to same-origin listeners, including signed-out tabs. Add session binding and a sign-out generation guard against late responses/messages; bound and validate message shapes. See the HTML Standard's queued, storage-key-scoped delivery: https://html.spec.whatwg.org/multipage/web-messaging.html#broadcasting-to-other-browsing-contexts.auth.svelte.ts:23-24,120-121,152-165). Web Locks serialize cookie rotations but cannot share the new access token without a channel; a later tab revokes an earlier tab's token. With neither API, concurrent refresh can trigger reuse revocation. Sign-out cannot immediately clear other tabs without the channel. Handle unsupported environments explicitly and test both tabs after recovery.Lower-severity findings are in
findings.md: the generic 401 helper has no key guard for unkeyed mutating calls, and the server concurrency tests are invariants rather than red-before-green regressions. Confirmed: normal rotation revokes the old access token immediately; the server added no grace window; access tokens are not written to persistent browser storage; a keyed request reuses its Idempotency-Key and retries at most once, excluding refresh itself.Gate evidence:
scripts/verify integrationexited 0 with 16 PASS lines (quoted in the earlier comment).scripts/verify browserexited 0 with six PASS lines (also quoted earlier), including the two-tab checks.scripts/verify fastprinted the following verbatim output; my outer zsh capture wrapper then errored by assigning its read-onlystatusvariable, after the gate itself had finished:Red-before-green proof in the removed throwaway worktree, retaining the head tests with base web code: API recovery unit tests failed 3/3; the browser stale-tab check failed because the first tab got no successful container response; the separate concurrent-only run failed because simultaneous Web Locks tab 1 showed the setup screen instead of the dashboard. The first base browser attempt stopped on an unrelated port 4175 collision; rerun used port 47144. Both new server concurrency tests passed with base web code (
1 passed; 0 failedeach), because server refresh code is unchanged. Therefore the requested claim that the server test fails without this client fix is refuted.The review worktree has no tracked changes. Screenshots and complete gate logs are under
/mnt/hdd/infraternal/jobs/review-44/.Started fix-44 on branch
multi-tabat base head11ff44ef5a8218ba5071512757889a894cefa1cain/home/kayg/wt/multi-tab. Reviewing findings 1–5 and implementing the agreed Web Locks + BroadcastChannel design. Compatibility sources: MDN Web Locks, MDN BroadcastChannel, caniuse Web Locks, caniuse BroadcastChannel. Both APIs support Safari/iOS Safari 15.4+, Chrome and Firefox; Web Locks requires a secure context.Started fix-44 on branch
multi-tabat base head11ff44ef5a8218ba5071512757889a894cefa1cain/home/kayg/wt/multi-tab. Reviewing findings 1–5 and implementing the agreed Web Locks + BroadcastChannel design. Compatibility sources: MDN Web Locks, MDN BroadcastChannel, caniuse Web Locks, caniuse BroadcastChannel. Both APIs support Safari/iOS Safari 15.4+, Chrome and Firefox; Web Locks requires a secure context.Design and finding fixes at
6563cf6:Targeted evidence:
bun run --cwd packages/web checkprintedsvelte-check found 0 errors and 0 warnings;bun run --cwd packages/web test -- src/lib/auth.test.ts src/lib/api.test.tsprintedTest Files 2 passed (2)andTests 15 passed (15)before the additional two sign-out tests;bun run --cwd packages/web test -- src/lib/auth.test.tsprintedTest Files 1 passed (1)andTests 8 passed (8)afterward. Full gates pending.Verification update through
bd1694b:Red-before-green proof: with only
packages/web/src/lib/auth.svelte.tstemporarily restored from reviewed head11ff44e, the newauth.test.tssuite printed verbatim:The failures included a signed-out tab becoming signed in from a forged channel message, malformed token acceptance, and missing unsupported-browser notice. The source was restored and
git status --shortwas clean. At the fixed head, targeted client tests printed:First integration gate (exit 1) failed in the unchanged engine PTY cleanup test:
The exact test rerun with
--ignored --exact --nocapture --test-threads=1printed:Full integration gate rerun (exit 0) printed:
Latest fast gate (exit 0, before the small channel-close correction, which has targeted typecheck and tests) printed:
The channel-close correction means signed-out infraternal tabs stop receiving channel payloads at all. Browser gate and final-head fast gate remain pending.
Browser evidence at
e3f4498:First
scripts/verify browser(exit 1) completed the new checks, then found two expected 401 console entries on the main page after unsupported tabs had rotated the shared cookie. Its relevant output was:The browser check now refreshes that main page before resetting its console collector; the 401s remain observable inside the multi-tab exercise. The full rerun (exit 0) printed verbatim:
Notice screenshots inspected at desktop and 390×844:
/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-locks-only-1440x900.png,/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-locks-only-390x844.png,/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-neither-1440x900.png,/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-neither-390x844.png. The notice sits above the app content without clipping or overlap. UI rules applied: use shared spacing, type, color and surface tokens; keep the notice in flow at narrow widths; use a politerole=status; keep copy plain and action-oriented; avoid animation for this persistent compatibility state.Finished fix-44 on branch
multi-tabat heade3f4498c8435406006ff12478481e9971bac603e(base11ff44ef5a8218ba5071512757889a894cefa1ca). Committed in seven small commits; did not push. Worktree is clean. Findings 1–5 and their evidence are in the earlier issue comments. The final checks below all ran on this head.scripts/verify fast(exit 0), verbatim output:scripts/verify integration(exit 0; prestart load5=6.99), verbatim output:scripts/verify browser(exit 0; prestart load5=3.24), verbatim output:Additional evidence:
node --check scripts/browser-check.mjspassed;git diff --check 11ff44e..HEADpassed; code-review-graph updated. The browser check loggedPASS multi-tab supported: simultaneous tabs (1 refresh HTTP request),PASS multi-tab locks-only: one notice per tab and sequential stale-access recovery, andPASS multi-tab neither: one notice per tab and sequential stale-access recoveryduring its first run, then completed the same cases in the successful full rerun. The notice screenshots are at/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-locks-only-{1440x900,390x844}.pngand/mnt/hdd/infraternal/artifacts/shadcn/multi-tab-neither-{1440x900,390x844}.png; both widths were inspected with no overlap or clipping. Safari/iOS remain for the owner's checklist per CLAUDE.md.Starting verify-44 on branch
multi-tab, base heade3f4498c8435406006ff12478481e9971bac603e. I will verify findings 1–5, reproduce the specified regressions in a throwaway worktree, integrateorigin/dev, then run the required gates under the resource constraints.Verify-44 findings check on merge input head
e3f4498c8435406006ff12478481e9971bac603e:packages/web/src/lib/auth.svelte.tsenables shared refresh only when Web Locks and BroadcastChannel are both available and usesnavigator.locks.request; the 80 ms owner election is gone. In the throwaway worktree, a focused timer regression test passed one3f4498and failed with the old11ff44eauth source (expected [80] to not include 80).openChannel()accepts a session only with an active session or a current pending refresh, a valid token shape and a newer safe generation.clear()closes the channel and increments the sign-out epoch; refresh/setup/sign-in responses also check the epoch. The tests cover forged post-sign-out messages, late refresh responses and stale/malformed generations. Stable signed-out tabs close their channel; active session restoration is the only state that adopts a shared credential.authStore.singleTabNoticeis true when either coordination API is missing.AppShell.svelterenders a plainrole="status"notice advising one tab, without blocking the app. Browser checks assert one notice per unsupported tab and the issue's prior run includes desktop and 390×844 screenshots.api.tsretries only GET/HEAD or requests with a nonblankIdempotency-Key, retaining the same key. With old11ff44eAPI code, the new API suite failed 4/10, including the unkeyed POST, PUT and DELETE assertions; fixed code passed.docs/contracts.md§1.1 describes the same Web Locks + BroadcastChannel path, per-tab unsupported fallback and notice, sign-out fencing, and one-time retry restrictions implemented inauth.svelte.tsandapi.ts.The fixed client regressions passed 19/19 in the throwaway worktree.
origin/devmerged cleanly atebf8ab27a9dd46781abd79f1785bca107f3649d0; no generated API/client files were in the merge diff. Mergedscripts/verify fastpassed. Browser and integration are pending the other job's active integration gate and the required resource check.Correction to my previous finding-2 status: the broader stated guarantee is not closed. A new tab starts with
sessionGeneration = 0even iflocalStoragehas a later session generation. In a focused temporary reproduction againste3f4498, I seeded storage generation5, left refresh pending, then delivered a well-shapedsessionmessage with generation1; the tab adopted that token and setsignedIn = true. The temporary test failed with the adopted token where it expectednull.Evidence:
auth.svelte.tschecksdata.generation > sessionGenerationbut does not compare the message againststoredGeneration().auth.test.tsonly rejects an equal generation after it has already accepted one in the same instance. The existing sign-out test covers a stable signed-out tab afterclear()closes its channel, but does not cover stale/forged messages during pending restoration. In that pending statesignedInis still false, andsetSession()broadcasts its token to all open listeners. This conflicts with the requested unqualified guarantees that stale/forged messages cannot sign a tab in and that no token is sent to a signed-out tab.No code change was made because this verification job permits only the
devmerge and fixes for failures caused by that merge. Findings 1, 3, 4 and 5 remain closed by the evidence in the previous comment. This verification will be reported not verified unless the owner clarifies that pending cookie-backed restoration is excluded from the signed-out condition.Starting verify-44 on branch
multi-tab. The requested fix-round head ise3f4498c8435406006ff12478481e9971bac603e; this checkout is clean atebf8ab27a9dd46781abd79f1785bca107f3649d0, a merge commit after that fix-round head. The localorigin/devtarget is9d4876ae814fea4d0cf0c6b5d420fc33654ad726. I will verify the fix-round and establish whether this existing merge commit is the requested integration.Verification evidence at merged checkout
ebf8ab27a9dd46781abd79f1785bca107f3649d0(parentse3f4498and9d4876ae814fea4d0cf0c6b5d420fc33654ad726):packages/web/src/lib/auth.svelte.tsuses Web Locks only when both Web Locks and BroadcastChannel exist; otherwise it callsperformRefresh()per tab and setssingleTabNotice. There is norefresh-intentelection or 80 ms timer. The 2 s timeout only bounds waiting for a generation change while holding the lock. A temporary channel-only test passed at e3 and failed against11ff44ebecause the pre-fix code opened a channel without Web Locks. Contract §1.1 andauth.test.tsmatch the unsupported per-tab behavior.auth.svelte.ts, thesignouthandler is guarded bysignedIn(line 68); a signed-out tab with an in-flight refresh therefore ignores that message. Thesessionhandler accepts a valid-shaped newer token whenever a refresh is pending in the current sign-out epoch (lines 56–64). A temporary test in/home/kayg/wt/verify-44-tmpdeliveredsignoutand then a forged newersessionwhile refresh was pending; it observedsignedIn: trueand the supplied token. The committed test atauth.test.ts:105-119also deliberately accepts a channel token in this signed-out/pending state. This leaves the requested forged/late/stale signed-out-tab guarantee unproven and reproducibly false.role="status"notice inAppShell.svelte; it appears above the shell and does not block input.scripts/browser-check.mjschecks one notice and stale-access recovery in bothlocks-onlyandneithermodes at desktop and 390×844.api.ts:31-34retries only GET/HEAD or requests with a nonblankIdempotency-Key; the sameHeadersobject is reused.api.test.ts:46-83tests POST/PUT/DELETE unkeyed and keyed calls. In the throwaway worktree withapi.tsrestored from11ff44e, the unkeyed POST/PUT/DELETE tests failed because each sent 2 fetches instead of 1; the blank-key test failed for the same reason.Focused tests passed at the integrated checkout:
2files,18tests. At fix-round head e3 in the throwaway worktree the same2files /18tests passed before restoring pre-fix client files. Provisional verdict: not verified because finding 2 remains open. Gates follow.Verdict: not verified. Finding 2 remains open on the merged head
ebf8ab27a9dd46781abd79f1785bca107f3649d0.The head is a merge commit with parents
e3f4498c8435406006ff12478481e9971bac603eand the requestedorigin/devhead9d4876ae814fea4d0cf0c6b5d420fc33654ad726.git fetch origin devconfirmed thatorigin/devis still that SHA and already an ancestor of HEAD. The merge existed when this job started, so no additional commit was made.Finding results
auth.svelte.ts:141-164uses Web Locks only when both required APIs exist; otherwise it refreshes per tab. There is no timed election orrefresh-intent. The 2 s timer at lines 126-139 only bounds waiting for the generation announcement while holding the lock. A temporary channel-only test passed at e3 and failed against pre-fix11ff44eauth, which opened BroadcastChannel when Web Locks were absent.auth.svelte.ts:68ignoressignoutwhensignedInis false. The session handler at lines 56-64 accepts a newer shaped token while a refresh is pending.setSession()at line 80 broadcasts the access token to all open channel listeners. A temporary test in the required e3 throwaway worktree sentsignout, then a forged newer session while refresh was pending in a signed-out context; it observedsignedIn: trueand the injected token. The throwaway test and worktree were removed afterward. The committedauth.test.ts:105-119also explicitly expects session-message adoption while refresh is pending. This contradicts the signed-out-tab guarantee indocs/contracts.md§1.1 and does not prove that signed-out tabs avoid receiving access tokens.AppShell.svelte:77-80renders arole="status"notice above the shell. The browser check exercises locks-only and neither-API modes; the channel-only temporary check also passed with the fix.api.ts:31-39retries only GET/HEAD or a request with a nonblankIdempotency-Key, reusing the sameHeaders. In the throwaway worktree, restoring pre-fix11ff44eAPI code made the POST, PUT, DELETE, and blank-key regression tests fail; each observed two fetches instead of one. The keyed mutation tests passed.Focused auth/API tests passed on the merged head and at e3: 2 files, 18 tests.
scripts/verify fastandscripts/verify integrationpassed. The final browser run passed after one complete attempt failed during the neither-API navigation step with a local-server connection refusal. A separate earlier browser attempt was interrupted before Playwright when another job's browser sweep started; it is not a gate result. The final run did not fail at Jump-to-latest, so no targeted rerun was needed.scripts/verify fast(exit 0):scripts/verify browserfinal run (exit 0):Earlier complete browser attempt (exit 1; relevant output):
scripts/verify integration(exit 0):fix-44b started on branch
multi-tab, base headebf8ab27a9dd46781abd79f1785bca107f3649d0. Scope: pending-refresh sign-out fencing in web auth, contract text, regression tests, fast and browser gates. The current channel handler checkssignedInbefore honoringsignout; a tab with a pending refresh and no access credential therefore ignores it.fix-44b started on branch
multi-tab, base headebf8ab27a9dd46781abd79f1785bca107f3649d0. Scope: pending-refresh sign-out fencing in web auth, contract text, regression tests, fast and browser gates. The current channel handler checkssignedInbefore honoringsignout; a tab with a pending refresh and no access credential therefore ignores it.Finding and fix evidence for fix-44b:
openChannelignoredsignoutwhenever the receiving tab was not yetsignedIn, even when its refresh HTTP request was in flight. The test atauth.test.ts:105now sends sign-out before a forged newer session; with the implementation temporarily reverted toebf8ab2, the focused Vitest run exited 1 with two failures:rejects a forged session after sign-out during a pending refresh(receivedsignedIn=true) anddiscards a late refresh response after channel sign-out(refresh resolvedtrue). The fixed implementation increments the sign-out epoch for local and channel sign-out, rejects later session messages, and rejects the outstanding refresh result. A later refresh call remains blocked until a local passkey ceremony. Focused test run on the fix: 13/13 passed.scripts/verify fastpassed all steps, including cargo test, svelte-check, Vitest and OpenAPI/client drift. Browser gate awaits the specified load/process window.fix-44b finished on
multi-tabat head102ec66091d1ca4bddf3adac1e4750f4e8903cd7(baseebf8ab27a9dd46781abd79f1785bca107f3649d0); not pushed. Commits:9a7f88fauth/contract/unit tests and102ec66production browser check.The receiving tab now honors broadcast sign-out during pending refresh, advances its sign-out epoch, discards both a forged newer channel session and the late refresh result, and stays signed out until its own passkey ceremony. Broadcast session adoption requires an active pending refresh in the current epoch, no sign-out in that epoch, and a newer session generation. Local sign-out without a credential also fences a pending refresh.
docs/contracts.mdrecords these rules.Red/green evidence: with only the auth implementation temporarily reverted to
ebf8ab2,bun --cwd packages/web test src/lib/auth.test.tsexited 1:rejects a forged session after sign-out during a pending refreshobservedsignedIn=true;discards a late refresh response after channel sign-outobserved refreshtrueinstead offalse. The fixed run passed 13/13 tests.node --check scripts/browser-check.mjsandgit diff --checkpassed. Production Playwright loggedPASS multi-tab sign-out: pending refresh response cannot restore tab Bwith tab A signing out while tab B's refresh response was held.Verbatim
scripts/verify fastoutput:Verbatim
scripts/verify browseroutput:Architecture-review follow-up (2026-10-04): this review did not reproduce the multi-tab bug. Its history analysis found browser refresh, pagehide, replay and terminal-workspace scenarios among the highest-churn lifecycle areas. It recommends short, independent fixtures with named readiness/lifecycle events, while retaining the full production-browser flow. This supports isolating the refresh handoff case here; it does not replace the production-browser gate.
Source: arch-review 2026-10-04; /mnt/hdd/infraternal/jobs/arch-review/report.md.