fix(session): one platform credential, one 401 verdict, one handler (#2791) - #2811
Conversation
…2791) Log out and log back in on the main app with a Workspace tab open from the previous session, and the NEW session dies within seconds. That tab holds the old JWT, its 20s poll 401s, and the handler calls `authStore.logout()` — which removes `localStorage['token']`, i.e. the token the re-login had just written. The handler never asked whether the credential that failed was still the current one. Underneath it, one browser held the platform JWT in two places that could disagree (the in-memory `axios.defaults` copy vs localStorage re-read per request), with no `storage` listener anywhere under `src/frontend/src`, and three separate 401 implementations that had each drifted. `utils/platformSession.js` makes all three singular. **One source.** `readStoredToken()` is the only reader. The `axios.defaults` copy is no longer written (`setupAxiosAuth` is a documented no-op); `main.js` installs a global axios REQUEST interceptor that rebuilds the header per request, so ~368 bare-`axios` call sites get the current credential without being rewritten and a new one cannot forget to opt in. This is the AC's second half ("or is provably never read in preference to the store") and it is the stronger of the two. An explicit header still wins, and exactly one caller needs that: the logout revoke. #2258 clears local state BEFORE the revoke, so with the defaults copy gone the revoke would have gone out unauthenticated and #187 would have silently stopped revoking anything. The token is captured before the clear and passed after it. **One verdict.** `sessionLostVerdict()` → `ignore | stale | logout`, pure so a node-env spec can reach it. `stale` — the failed token is not the stored one — is the fix for the report: adopt the current session instead of destroying it. The Workspace veto closes AC #5: a client whose browser holds a DEAD operator JWT is no longer thrown onto the operator login by `initializeAuth`'s `fetchUserProfile`. It stays scoped by path as well as by portal token, so an expired operator JWT still bounces off an operator surface. **One handler.** `setPlatformUnauthorizedHandler` / `notifyPlatformUnauthorized`. `main.js` registers the reaction; `api.js`, the global interceptor and `portalHttp` report to it. `api.js` no longer hard-reloads, no longer leaves `auth0_user` behind, and carries no private predicate. **Cross-tab sync.** A `storage` listener adopts a sibling's login and drops the mirror on a sibling's logout — without a second server revoke and without writing to storage, since N background tabs reacting to one event would each clear it again. Neither branch navigates: a background tab pushing /login is the noise this issue reports. `workspaceSession.spec.js`'s predicate block asserted its own hand-copied `shouldBounce` helper — which is why it stayed green while the three real predicates drifted, and would have stayed green through this change too. It now asserts the real function. Verified: 131 files / 2937 tests pass. The three load-bearing guards were mutation-checked — removing the `stale` arm reds 2, letting the interceptor overwrite explicit headers reds 1, restoring `api.js`'s own logout reds 2. Related to #2791 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…eletes (#2791) Review finding on my own diff. The docblock's "why not `axios.create()`" argued from `stores/auth.js` mutating `axios.defaults.headers.common.Authorization` at login and deleting it at logout — the exact copy #2791 removes. The conclusion survives the mechanism (the global is still the only thing carrying a live credential, now because the request interceptor resolves it per request and `create()` gives an instance its own chain the global never reaches), which is precisely why the comment would have gone on reading as true. A comment that describes a mechanism the code no longer has is the class this repo's learnings ledger already records; the old reason is kept in parentheses because it explains why the answer did not change. Related to #2791 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
| mutation | reds |
|---|---|
remove the stale arm |
2 |
| interceptor overwrites explicit headers | 1 |
restore api.js's own removeItem + location.href logout |
2 |
Summary
- Critical: 0 · Fixed during review: 1 (F1,
ceb2e9384) · Informational: 3 - Security: no new exposure; one would-be regression (server-side revocation) caught and pinned
- Honest scope: AC Fix: Add missing Docker labels to system agent container #1 and AC Feature/vector log retention #3 are narrower than the PR body states — I1 and I2 say how. Both describe pre-existing surfaces this PR does not widen.
Still outstanding from the PR body: the finding-1 repro has not been exercised in a browser. Node-env specs cannot drive a real storage event or a real interceptor, so the end-to-end claim rests on unit coverage of the parts.
|
merge-train: ejected from the 2026-09-15 train — rides the next one once fixed. Nothing was pushed to this branch. This is a comment, not a review, so it is not a hold. The design is right and the pure-function half is genuinely well tested. C1 — the second credential source survives, so the new mechanism is inert
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`It runs on every boot with a token, on every route including And in axios 1.19.0 ( Measured against real axios with a stub adapter, not reasoned about:
Three consequences:
The guard that should have caught this walks one file. C2 — the architecture area file now asserts something false
The wiring that delivers AC #2/#3/#4 is pinned by regex onlyBaseline is 134 files / 3029 tests. Each of these mutations leaves it fully green:
What would get it on the next train
Also worth a look while you are in here:
Cleared and not re-raising: the other three 401 sites are not divergent logout handlers; there is no boot-order hole ( Your body says "Not verified in a browser … worth one manual pass of the finding-1 repro before merge." That manual pass is exactly what surfaces C1. |
|
merge-train: not on this trainThis branch already carries The design is right and the pure-function half is genuinely well tested — ❌ C1 — the second credential source survives, so the central mechanism is inert
In axios 1.19.0 ( Measured against real axios with a stub adapter: defaults 119 of 367 bare-axios call sites pass no explicit header and depend solely on the new interceptor ( ❌ C2 — the guard that should catch C1 walks one file
❌ C3 — the docs assert a false invariant
❌ C4 — new; contradicts a "pinned" claim in the PR body
Why CI can't see any of this
Mutation results on your head (baseline 131 files / 2937 tests green):
The live-consumer audit is clean, for what it's worth — every value you define has a real consumer. The defect is the inverse shape: live consumers with no executing test. Warnings
Security check, since the change touches session handling: Invariant #8 is not implicated — zero backend files, no 🤖 Generated with Claude Code |
…ute under test, and the veto reads the per-tab store (#2791) Merge-train review of #2811, all six ❌/W items: C1 App.vue wrote `axios.defaults.headers.common['Authorization']` on every boot with a token. Axios merges that default into the request BEFORE the interceptor chain runs, so it arrived at the per-request rebuild looking explicit and won over storage for the life of the tab — the whole mechanism was inert, and 119 bare-axios sites kept the boot-time token after a sibling re-login. The write is removed (the store seed + WS connect stay); `adoptStoredSession` / `applySessionEndedElsewhere` delete any such copy as a belt, so a tab can never ride a credential storage no longer holds (W2). C2 The "nobody writes the default" guard walked auth.js only. It now walks src/frontend/src/** and asserts zero writers; App.vue would have failed it. C3 architecture/workspace.md + workspace-session-signout.md said the copy is written nowhere while App.vue wrote it. Both now describe what is true and why the guard is tree-wide. C4 handlePlatformUnauthorized ended `router.push('/login')` without `return`, so notifyPlatformUnauthorized's absorber never engaged and a redundant navigation escaped as an unhandled rejection. It returns the navigation. CI The reaction, the storage listener and the request rebuild were inline in main.js and pinned by regex — restoring the reported bug on the `stale` branch and inverting the listener both stayed green. They are now `reactToPlatformUnauthorized`, `reactToStorageEvent` and `applyRequestCredential` in utils/platformSession.js, taking their collaborators as arguments; the spec EXECUTES them with fakes, and a five-mutation battery (stale→logout, inverted listener, header never applied, navigation dropped, App.vue writer back) is red on every one. main.js is wiring only, and the source guards assert exactly that. W1 The Workspace veto read `portalTokenPresent` from shared localStorage while portalHttp gates on the per-tab store; a client signing in in another tab stranded an operator's Workspace tab on an expired JWT with `ignore`. It reads `useClientPortalStore().portalToken` now. W3 The 22 direct `localStorage.getItem('token')` reads outside the reader (WS/EventSource included) go through `readStoredToken()`; a tree-wide guard keeps "one reader" true. W4 The stale `installCrossTabSync()` reference is gone with the rewrite. W6 The storage listener also hears `auth0_user`, and adopting an identical token refreshes the user from storage, so a sibling login's profile landing a tick after its token is not missed. 3066 frontend unit tests green; vite build green. Fixes #2791 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
All of it addressed in
3066 frontend unit tests green, Still true, and still worth the manual pass you asked for: none of this was verified in a browser. The finding-1 repro (Workspace tab open across a logout + re-login in another tab) is the thing to click through before merge. |
…ords its mutation (#2829) Three of five ejections on the 2026-09-15 merge train — the third train running — were tests that prove the code was written rather than that it runs: source-text regexes over the module under test (#2811), a bound check at the one value where both bounds coincide (#2817), a docstring claim about CI never negative-controlled (#2805). All green. - docs/testing/STRATEGY.md: a new "Evidence bar for a test" section beside the harness bar — the three spellings, the two greps (the live-consumer grep is the one that decides), guard-vs-source-only with the train's own pair (#2819 kept, #2811 ejected, same shape), mutation as the fix standard, bound tests away from the coincidence — each with what enforces it. - .github/pull_request_template.md: a Testing checkbox for "every new test executes the changed path" and a `Mutation:` line naming the test(s) that go red with the fix reverted ("n/a — not a fix" otherwise). The trailing space after the colon matches the existing `Journey Impact:` line — a fill-in prompt. - docs/memory/learnings.md: the class, with the prior occurrences. The skill half — /review Step 2.5 and /validate-pr §5.4 answered first and in writing, /implement's two done-criteria — is trinity-dev#29. Fixes #2829 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
| Item | Closed | Evidence |
|---|---|---|
C1 App.vue axios.defaults writer |
✅ | App.vue:41-72 — import axios gone; store seed + connect() only. Tree grep axios.defaults.headers[^=]*= → 0; the 3 remaining hits are deletes (auth.js:226,263,569) |
| C2 one-file guard | ✅ | platformSessionSync.spec.js:65-78,324-337 walks src/frontend/src/**, asserts writers == []; App.vue line re-inserted → 1 red |
| C3 false docs claim | ✅ | architecture/workspace.md + flow now describe the head and name the App.vue writer as the guard's reason |
C4 return router.push |
✅ | main.js:57-67 returns navigation; spec :220-239 drives it with a rejecting goToLogin, nothing reaches unhandledRejection |
| CI gap (regex-only wiring) | ✅ reactions / |
reactToPlatformUnauthorized, reactToStorageEvent, applyRequestCredential in platformSession.js:223-313, executed with fakes at spec :168-306; stale→logout() restored → 1 red. main.js wiring stays regex-pinned (:347-368) — acceptable now that it holds three one-line delegations and no decidable logic |
| W1 shared-storage veto | ✅ | main.js:51-56 reads useClientPortalStore().portalToken (per-tab) |
| W2 stale-loop early return | ✅ | auth.js:226 deletes the default before the compare; identical-token branch merges readStoredUser() (:236-239) |
| W3 22 direct readers | ✅ | tree grep localStorage.getItem('token') outside platformSession.js → 0; guard at spec :339-345 |
W4 phantom installCrossTabSync |
✅ | header rewritten platformSession.js:13-31 |
W5 Fixes #2791 |
✅ | body, bare form |
W6 auth0_user key |
✅ | reactToStorageEvent hears USER_KEY (:306); spec :271-275 |
Critical: none. Logout revoke carries the token explicitly (auth.js:557-559); applyRequestCredential never overrides an explicit header (:225), so #187 keeps revoking; the storage listener filters storageArea (:304).
Informational
- I1 (7/10) AC Setup improvements #5's veto ignores
platformFallbackSuppressed.main.js:53isportalTokenPresent = !!store.portalTokenonly. A client browser holding a dead operator JWT:App.vue:69seedsisAuthenticated+connect()→websocket.js:57-61mints a ticket, 401s, retries every 5 s. While the portal token lives →ignore(right). Once the client session expires,endSession(clientPortal.js:515-527) nullsportalTokenand setsplatformFallbackSuppressed=true; the next retry →sessionLostVerdictseesstoredToken+ no portal token →logout→/login: the client at the OTP form is bounced onto the operator login (the fix(workspace): Sign out doesn't sign out — the platform JWT silently re-authenticates on refresh #2258/fix(workspace): a client session that EXPIRES on a browser holding a platform login falls back to the platform identity #2261 class). Not a regression —devbounced on every such 401 — but the tab's own "I am a client tab" declaration is unread. Mechanical:portalTokenPresent = !!s.portalToken || s.platformFallbackSuppressed(+ one spec case). Per-tab sessionStorage, so it cannot reopen W1. - I2 (7/10)
workspaceSession.spec.js:451-461"verdict does not depend on the portal token racing away" passesportalTokenPresent:falseto bothbeforeandafter— it cannot fail. Should assert the I1 property instead. - I3 (5/10)
auth.js:121readsauth0_userraw instead ofreadStoredUser(); the one-reader guard coverstokenonly. Cosmetic. - Pre-existing, not this PR: the 5 s ticket-mint retry on a permanently-401ing JWT never gives up (
websocket.js:59).
Coverage: executed, not source-text — two mutations each produced a real red.
Verdict: READY. I1 + I2 are mechanical and will be applied on this branch before the train (announced here when pushed). Still worth the manual click-through you asked for: none of this was verified in a browser.
…ter expiry (#2811) — mechanical, per the merge-train note on the PR `handlePlatformUnauthorized` read `portalToken` alone. `endSession({expired})` nulls that token and sets `platformFallbackSuppressed` in the same breath, so for a client tab holding a dead operator JWT the 5 s ticket retry's next 401 fell through to `logout` and threw the client from the OTP form onto the operator login — the #2258/#2261 bounce, reopened for exactly that instant. `portalTokenPresent` now folds in the suppression flag (per-tab sessionStorage, so W1 cannot return). The wiring pin follows, and the tautological "racing away" spec case (identical inputs both sides) is replaced by one that can fail: client tab → ignore, live or expired; operator → logout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
merge-train: pushed to this branch — one commit, mechanical, per the review above.
|
vybe
left a comment
There was a problem hiding this comment.
/validate-pr (Lane B) — APPROVE. Independently confirmed at head 75b1a6f: zero axios.defaults.headers writers in src/frontend/src, zero direct localStorage token readers outside platformSession.js, I1 veto reads platformFallbackSuppressed. All checks green incl. build (test:unit) and e2e. Residual: finding-1 repro not yet verified in a browser — one manual pass on dev after merge.
…ords its mutation (#2829) (#2833) Three of five ejections on the 2026-09-15 merge train — the third train running — were tests that prove the code was written rather than that it runs: source-text regexes over the module under test (#2811), a bound check at the one value where both bounds coincide (#2817), a docstring claim about CI never negative-controlled (#2805). All green. - docs/testing/STRATEGY.md: a new "Evidence bar for a test" section beside the harness bar — the three spellings, the two greps (the live-consumer grep is the one that decides), guard-vs-source-only with the train's own pair (#2819 kept, #2811 ejected, same shape), mutation as the fix standard, bound tests away from the coincidence — each with what enforces it. - .github/pull_request_template.md: a Testing checkbox for "every new test executes the changed path" and a `Mutation:` line naming the test(s) that go red with the fix reverted ("n/a — not a fix" otherwise). The trailing space after the colon matches the existing `Journey Impact:` line — a fill-in prompt. - docs/memory/learnings.md: the class, with the prior occurrences. The skill half — /review Step 2.5 and /validate-pr §5.4 answered first and in writing, /implement's two done-criteria — is trinity-dev#29. Fixes #2829 Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
… a real transport, an agent key, and a counted backend (Abilityai/trinity-enterprise#628) Review C1 on #2826: `server.ts::addToolWithAudit` composes `withAgentAccess` around an `enforce` row's `execute` and hands the result to `withAudit`. Both existing files proved the wrapper — `access.test.ts` calls it directly, `tools/loops.test.ts` builds the composition by hand — and neither executed the composition site. Build the wrapper there and discard it (`withAudit(tool.name, tool.execute, …)`) and the suite stayed at 406 green with `run_agent_loop` ungated: the #2811 class, the reaction tested and the wiring that calls it not. `access-wiring.test.ts` drives the tool the way an agent does: a real `createServer` in key mode, a real MCP client presenting an agent-scoped key over the streamable-HTTP transport, and a stub backend that answers `/api/mcp/validate` and the permission-edge read and COUNTS every `POST /api/agents/<target>/loops`. Without an edge the count stays at zero and the caller reads the denial; with an edge the loop starts; a self loop starts without a permission read. Under the reviewer's mutation the first case is the one red (`loopPosts` = ["bravo"], `success: true`); restored byte-identical, 409/409. Pattern: `inline-auth-transport.test.ts` (#2035). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the gate a mechanism (Abilityai/trinity-enterprise#628) (#2826) * fix(mcp): gate the loop tools on the agent permission edge, and make the gate a mechanism (Abilityai/trinity-enterprise#628) `run_agent_loop` resolved its target from the caller's parameter and called no gate, and the backend behind it resolves an agent-scoped key to its owner (Invariant #8) — so an agent key could start a loop on any same-owner sibling with no `agent_permissions` edge. Verified live: the loop ran on the sibling. The per-tool gate had ten spellings across nine modules and the tool added last called none of them, so this closes the class, not the tool. - src/mcp-server/src/access.ts (new): ONE implementation of the agent-scope edge (`checkAgentEdge` — system bypasses; an agent key reaches itself and its permitted targets, the permitted list read fail-closed; a user key passes through because the backend already scopes it by role and per-user grant; any other scope is denied — an allowlist, #2323); `TOOL_ACCESS_POLICY`, one row per registered tool (enforce / in-tool / baselined:<owner> / none:<why>); `policyFor` (no row, an enforce on an undeclared parameter, or a none on a tool whose parameters name an agent throws at registration); and `withAgentAccess`, the enforce wrapper. - server.ts: every tool passes `policyFor`; enforce rows are wrapped before `withAudit`; dynamic tools declare their policy as an argument. - tools/loops.ts: `run_agent_loop` is an enforce row; `get_loop_status` / `stop_loop` resolve the loop's agent, gate, then act — a denial withholds the payload behind a compound uniform reason (the id was the caller's only input), and a failed resolve sends no stop and names the escape hatch. - tools/chat.ts: the agent/system branch delegates to the shared gate; unknown scopes are denied before the user branch (closes a fall-through that promoted an unnamed agent key to the same-owner rule); `resolveClient` moves to access.ts. types.ts / client.ts: `LoopStatus`, `getLoopStatus` typed. - .github/workflows/mcp-server-test.yml: an offline boot smoke of dist/server.js — bundler module resolution hides a missing `.js` until the container dies. - tests: access.test.ts (createServer boots against the real table, every row names a tool and every tool has a row; policyFor refusals; the wrapper never reaches execute on a denial; the read fails closed), tools/loops.test.ts (all three tools through the real row and wrapper with a fake client; a denial means the side-effecting call never happened; the reason is byte-identical to chat_with_agent's), J10: the strict xfail comes off, and a loop-id read or stop after the edge is removed is refused without naming the loop's agent while the owner can still stop it. - docs: the flow, the requirement (§38.1 said cross-agent loops were out of scope, the flow said "backend enforces", the code did neither), the architecture area file and the P-02 catalog entry now say one thing: a tool-surface gate at the MCP layer; the REST routes stay owner-equivalent, which is Abilityai/trinity-enterprise#629's ruling. The 53 `baselined` rows are that issue's work list. Learnings ledger +1; diff-scoped CSO report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(mcp): execute the line that wires the gate — run_agent_loop over a real transport, an agent key, and a counted backend (Abilityai/trinity-enterprise#628) Review C1 on #2826: `server.ts::addToolWithAudit` composes `withAgentAccess` around an `enforce` row's `execute` and hands the result to `withAudit`. Both existing files proved the wrapper — `access.test.ts` calls it directly, `tools/loops.test.ts` builds the composition by hand — and neither executed the composition site. Build the wrapper there and discard it (`withAudit(tool.name, tool.execute, …)`) and the suite stayed at 406 green with `run_agent_loop` ungated: the #2811 class, the reaction tested and the wiring that calls it not. `access-wiring.test.ts` drives the tool the way an agent does: a real `createServer` in key mode, a real MCP client presenting an agent-scoped key over the streamable-HTTP transport, and a stub backend that answers `/api/mcp/validate` and the permission-edge read and COUNTS every `POST /api/agents/<target>/loops`. Without an edge the count stays at zero and the caller reads the denial; with an edge the loop starts; a self loop starts without a permission read. Under the reviewer's mutation the first case is the one red (`loopPosts` = ["bravo"], `success: true`); restored byte-identical, 409/409. Pattern: `inline-auth-transport.test.ts` (#2035). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
What was wrong
Log out and log back in on the main app with a Workspace tab open from the previous session, and the new session dies within seconds.
That tab still holds the old JWT. Its 20 s asks poll 401s. The handler calls
authStore.logout(), which doeslocalStorage.removeItem('token')— removing the token the re-login had just written. The main tab's next request finds nothing and hard-redirects to/login.The handler never asked whether the credential that failed was still the current one.
Underneath that, one browser held the platform JWT in two places that could disagree — the in-memory
axios.defaults.headers.common['Authorization']copy written once at login, andlocalStorage['token']re-read per request byapi.js— with nostoragelistener anywhere undersrc/frontend/src, and three separate 401 implementations that had each drifted. The Workspace bites hardest because it opens in its own tab (ent#456) and polls.One source, one verdict, one handler
New
src/frontend/src/utils/platformSession.js.One source
readStoredToken()is the only reader. Theaxios.defaultscopy is no longer written —setupAxiosAuthis a documented no-op — andmain.jsinstalls a global axios request interceptor that rebuilds the header from storage on every request.That covers ~368 bare-
axioscall sites without rewriting them, which is the AC's second half ("or is provably never read in preference to the store"), and it is the stronger of the two options: a call site added tomorrow cannot forget to opt in.An explicit header on the config still wins, and exactly one caller depends on it — the logout revoke. #2258 clears local state before the revoke, so with the defaults copy gone the revoke would have gone out unauthenticated and #187 would have silently stopped revoking anything. The token is captured before the clear and passed after it.
One verdict
sessionLostVerdict()→ignore | stale | logout. Pure, so a node-env spec can reach it — which is the point, since the predicate it replaces had been hand-copied intoapi.js,main.jsandportalHttpand drifted three ways./login,/setup,/mignorestale— adopt the current session, never destroy itignore(an ordinary external client)logoutignore— AC #5logoutThe
stalearm is the reported bug. The Workspace veto is AC #5: a client whose browser holds a dead operator JWT is no longer thrown onto the operator login byinitializeAuth'sfetchUserProfile, which runs on every page load. It stays scoped by path as well as by portal token deliberately — off the Workspace the surface is an operator one, so an expired operator JWT still bounces there even with a stray portal token. This does not widen that.One handler
setPlatformUnauthorizedHandler/notifyPlatformUnauthorized.main.jsregisters the reaction (the only module that already has both the router and the store);api.js, the global interceptor andportalHttpall report to it.api.jsno longer hard-reloads, no longer leavesauth0_userbehind (AC #6), and carries no predicate of its own.clientPortal.jskeepsisPlatformSessionas its local gate — not redundant with the shared verdict: it is the only thing that knows this tab's client session was suppressed (#2261'splatformFallbackSuppressed), which no amount of reading localStorage reconstructs.Cross-tab sync
A
storagelistener on the platform token key. A sibling logging in →adoptStoredSession()(converge, re-fetch the profile, resetprofileVerifiedso role-gated UI stays closed until this token's profile lands — #2198's rule). A sibling logging out →applySessionEndedElsewhere(), which drops the in-memory mirror only: no second server revoke for an already-revoked token, and no write to storage, because N background tabs reacting to one event would each clear it again.Neither branch navigates. A background tab pushing
/loginis the noise this issue reports; the visible tab converges through the router guard and its next request, both of which read the state these set.A guard that was pinning nothing
workspaceSession.spec.js's "who gets bounced to /login" block defined its ownshouldBouncehelper — a hand-copied duplicate of the interceptors' expression. Nothing under test imported it. That is why it stayed green while the three real predicates drifted, and it would have stayed green through this change too. It now asserts the real function.Two things found while reviewing my own diff
handlePlatformUnauthorizedwasasyncandawaitedlogout().logout()clears local state synchronously before its first await, so the router guard is already satisfied — awaiting only held the user on a dead page for the length of the revoke, indefinitely if it hung. Now un-awaited, matching the previous timing.try/catchcannot see that, so a second 401 arriving while/loginwas loading would have surfaced as an unhandled rejection in every user's console. The registry absorbs it.Both are pinned.
Verification
Mutation-checked — each guard was made to fail by restoring the defect:
stalearmapi.js's ownremoveItem+location.hreflogoutFrontend only. No backend change, no schema change, no migration, no new endpoint, no config.
Acceptance criteria
axios.defaultscopy is never written (only cleared on logout, for tabs still running a pre-fix build).storagelistener, reaching the auth store and the Workspace's platform fallback.workspaceSession.spec.jsupdated to the new rule rather than left pinning the old one.api.js401 path no longer leavesauth0_userbehind and no longer diverges fromauthStore.logout().workspace-session-signout.mdandarchitecture/workspace.mdupdated for the single-source + cross-tab model.Not verified in a browser. The issue's findings were code-read and so is this fix; the specs are node-env and cannot drive a real
storageevent or a real interceptor. Worth one manual pass of the finding-1 repro before merge.Fixes #2791
🤖 Generated with Claude Code
https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf