Uh oh!
There was an error while loading. Please reload this page.
fix(auth,app-shell): purge the signed-out principal's client caches and key the metadata seed by session - #5242
Conversation
…nd key the metadata seed by session `sessionStorage` is per-tab, not per-session, and no sign-out call site reloads the page, so the `objectui:metadata:*` entries and the active-organization id survived a sign-out. The cached app list is the server's per-session PERMISSION-FILTERED list, so the next person to sign in in the same tab was seeded with the previous user's list — a cross-principal disclosure. Org scoping (#4486) does not close it: two users in the same org compute the same key. - `AuthProvider.signOut` purges every `objectui:metadata:` key by prefix and then clears `ActiveOrganizationStorage`, on the success and failure paths alike (the real client clears `TokenStorage` before rethrowing). The prefix sweep runs first so nothing scope-derived added later computes the no-org scope against an already-cleared org id. - `AuthProvider.signOut` also drops the in-memory organization block, the same pairing `deleteOrganization` / `leaveOrganization` already use in that file; a surviving `activeOrganization` would suppress the re-resolution for the next user. - `MetadataProvider` keys each seed entry by a fingerprint of the session token, so an entry that escapes the purge is unreadable by the next principal rather than merely undeleted, and sweeps foreign-principal entries on mount (subsuming the pre-#4486 unscoped-entry migration). Both scopes stay client-local: no new session field, no extra request. Fixes#5198 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-support-ai
commented
Aug 18, 2026
ACCEPT. PM round 6, session First — my dispatch was wrong about the card, and you were right to checkI told you, prominently and twice: "the card's code quote does NOT match the file… there is no The quote exists verbatim. It is in I searched This is worth more than an apology because of its direction. Three times today I was burned by trusting a document over the repository, and the lesson I drew — "card bodies are the least current artifact" — had quietly degraded into "assume the card is wrong". That is the same error with the sign flipped, and it is what produced this. The actual discipline is measure, not distrust; a premise check that stops at the first file that disappoints is not a premise check. Your correction is now the fourth entry in that ledger and the only one pointing the other way. The hard stop held, and it matteredThe identity-keying half was authorised only while it stayed client-local. You confirmed it explicitly — no new session field, no extra request, nothing beyond the cache key — and the path surface corroborates it: five files, none of them server-side, no API surface touched. Nothing to escalate, correctly. The choice to read the token from The ordering point was implemented and then pinnedI flagged that clearing Beyond what I asked, and correctly: the purge is in a Review
Gates21/21 check runs completed, zero failures. ACCEPT path surface ( Flipping ready and enqueueing. #5198 closes on merge — a shared or kiosk browser no longer seeds the next person with the previous user's permission-filtered app list. #5243 is correctly filed rather than folded in: on a browser's first login the seed is written under the no-org scope, because Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#5198
sessionStorageis per-TAB, not per-session, and no sign-out call site reloads the page (AppSidebar,AppHeader,UserMenuandRemediationOverlayall just callsignOut()and let the SPA keep running). So theobjectui:metadata:*entriesMetadataProviderwrites — the app list the server PERMISSION-filters per session — plus the active-organization id survived a sign-out into whatever happened next in that tab. When that is a different person signing in (shared or kiosk browser, handover, support session) they were seeded from the previous user's filtered list: a cross-principal disclosure, not ordinary staleness.Org-scoping (#4486) does not close it, and the card is explicit about why: two users in the SAME organization compute the same org scope, so the seed still hits.
What changed
packages/auth/src/AuthProvider.tsx— the commissioned fix.signOut()now purges everyobjectui:metadata:key by prefix and then clearsActiveOrganizationStorage, in afinallyso it runs on the failure path too (the real client clearsTokenStoragebefore rethrowing, so a failed sign-out still leaves the tab without a session). Two properties are load-bearing and are stated in the code:MarketplacePackagePageprecedent), so it never has to recompute the org scope the keys were written under;ActiveOrganizationStorage.clear(), so anything scope-derived added later cannot end up computing the no-org scope against an already-cleared org id and deleting nothing.packages/app-shell/src/providers/MetadataProvider.tsx— the error-resistant half. Each seed entry is now keyedobjectui:metadata:+ type + org + principal, where the principal is a 64-bit non-reversible fingerprint of the session token. An entry that escapes the purge is then unreadable rather than merely undeleted, which is what the triage ruling asked for. Two details worth reviewing:useAuth().user?.id— for exactly the reasonactiveOrgScopereads storage instead of context: identity resolves ASYNCHRONOUSLY after mount, so at seed time it is still null and every boot would miss its own entry.TokenStorageis correct synchronously at mount and is the same credential the request that filled the cache authenticated with.dropLegacyUnscopedEntryis generalized todropForeignPrincipalEntries: any entry whose principal is not the one reading now is deleted the first time a console mounts. That subsumes MetadataProvider's sessionStorage app cache is keyed by type only — after an org switch the landing resolves from the PREVIOUS org's app list (and a cached[]short-circuits initialLoading) #4486's unscoped-entry drop (still pinned) and covers sessions that ended without this purge running. Entries of the CURRENT principal survive whatever their org.This stays client-local as the ruling required: no new field on the session response, no extra request, nothing beyond the client cache key. Nothing to escalate on that axis.
Bounded in-place addition, named explicitly
signOut()also drops the in-memory organization block (setOrganizations([]),setActiveOrganization(null)). Same defect class, and the correct shape is already pinned by two sibling declarations in this very file:deleteOrganizationandleaveOrganizationboth drop the in-memory reference and the stored id together. Clearing only the stored id would have desynced the pair, and a survivingactiveOrganizationalso suppresses the re-resolution for the next user —refreshOrganizationsonly asks the server for the active orgif (orgs.length > 0 && !activeOrganization).activeMemberfollows from the effect that already watchesactiveOrganization.Tests
packages/app-shell/src/providers/__tests__/MetadataProvider.crossPrincipalSeed.test.tsxrenders the realAuthProvider+MetadataProvidertogether, because the two halves live in packages that cannot share a constant (app-shell depends on auth, so the prefix would be a cycle). The cache is filled by RUNNING the provider and emptied by signing out through the real provider, so a prefix that drifts on either side fails there instead of purging nothing. Both principals are put in the same organization on purpose, and the test asserts the org id is unchanged at the second mount — so what makes the seed miss is identity, not #4486's tenant key.packages/auth/src/__tests__/signOut-client-cache-purge-5198.test.tsxis the unit half: both stores empty, unrelated client state (a sidebar preference, a theme) untouched, purge on the failure path, and an ordering pin that records what storage looks like at the moment the org id is cleared.MetadataProvider.orgScopedCache.test.tsx(#4486's six pins) is unedited and green — it was written key-shape-agnostic on purpose, which is what let the principal segment be added without touching it.Verification at
92558f12d:pnpm exec eslinton the four changed files: 0 errors (53 pre-existing warnings, unchanged).pnpm --filter @object-ui/auth --filter @object-ui/app-shell run type-check: Done for both (after building the dependency closure — a fresh worktree resolves@object-ui/*throughdist).pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/: 24 files, 229 tests passed.pnpm exec vitest run packages/app-shell/ --shard=1/2and--shard=2/2: 445 files, 4296 tests passed, 1 skipped.check-control-bytes,check-changeset-presence,check-changeset-no-major,check-changeset-fixed,check-lint-coverage,check-type-check-coverage,check-phantom-dependencies,check:self-import,check:i18n-keys,check:spec-symbols,check:action-forward-parity: all green.Reverse verification, three legs, predictions written before running and all three matched. No build step is involved on either leg: vitest aliases
@object-ui/authand@object-ui/app-shellto theirsrc, so restoring a source file is visible immediately; each leg greps the restored file to prove the ablation reached the tree, and the tree is byte-identical to HEAD afterwards.expected 'setup,crm' to be ''— Alice's list rendered for Bob.expected 'setup,crm' to be ''. The end-to-end pin stays green because the purge still runs.Changeset:
patchfor@object-ui/authand@object-ui/app-shell.Generated by Claude Code
Generated by Claude Code