Uh oh!
There was an error while loading. Please reload this page.
fix(auth): scope the active-organization key per user, and drop the previous user's client state on a session-user change - #5744
Merged
Conversation
…e previous user's state on a session-user change Fixes#5664 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
… and add the changeset Reading the previous owner from `current()` (memory-first) made the purge fire on a browser with nothing persisted to purge — deleting state written FOR the arriving user rather than by the previous one. The persisted pointer is the only witness to residue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-zhuang
marked this pull request as ready for review
August 23, 2026 03:56
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 23, 2026
yinlianghui pushed a commit
that referenced
this pull request
Aug 24, 2026
Recovers objectui#5746's measurement harness and lands it as a regression pin. Test-only: no provider behaviour moves. The harness mounts the REAL console boot path — real `AuthProvider`, real `ConnectedShell` #4042 session gate, real `MetadataProvider` — with only the auth server and the metadata adapter doubled, and nothing writing `objectui:metadata:*` by hand, so it cannot agree with a key format the provider does not actually produce. Re-measured on current `main` before anything was changed; every reading #5746 recorded still reproduces: S1 window (a) writes=1 anon-writes=0 -> SHUT S2 window (b) objectui:metadata:app:org_a:@anon S3 degenerate B rendered "setup,crm,hr-secret" -> HIT on A's key S4 guest objectui:metadata:app:@none:@anon S5 preview objectui:metadata:app:@none:@anon S1-S5 now ASSERT those readings instead of only reporting them, including the `@anon` seed the guest and preview boots write. That write is the observation #5828 carries and is deliberately NOT "fixed" here: whether it matters turns on whether `/meta/*` READS are permission-filtered on a stub-auth or marketplace-preview deployment, which nobody has measured. objectstack#11373 measured that anonymous /meta WRITES are refused (401) on a platform-default boot — a different door on a different boot, so it does not settle it. S6 is new and is the pin that can fail. #5746's load-bearing correction is that #5744's `purgePreviousUserClientState` does NOT cover the seed read — React runs child effects before parent effects, so `MetadataProvider`'s seed read precedes `AuthProvider`'s purge — which leaves #5198's principal-scoped key as the sole protection on that boot. S6 boots two DISTINCT bearers in one tab and asserts B misses A's entry. Ablating `principalScope()` to ignore the token turns S6 red alone (`expected '08dtegw0taozhy' not to be '08dtegw0taozhy'`, B renders `setup,crm,hr-secret`) while S0-S5 stay green, which is the predicted direction. Both instrument defects #5746 caught are preserved and re-measured. Restoring the `this === sessionStorage` guard drives S0's counter-probe red with zero recorded writes, confirming jsdom still hands out `sessionStorage` as a Proxy. Part of #5828 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
This was referenced Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#5664
Verified on
a92b9902c(the final commit; every result below was measured on that tree).What was wrong
auth-active-organization-idwas one un-namespacedlocalStoragekey while its siblingswere already user-scoped (
objectui-recent-items:u:,objectui-favorites:u:,flow-palette-recents:u:). On a browser handed from one account to another the arrivinguser's console read the previous user's organization id — and the consequence past the
cosmetics is the one that matters: the polluted org context suppressed
RequireOrganization's routing into the guided "Create your workspace" first-run flow, soa brand-new user on a shared browser silently never got the new-user flow.
Server side is unchanged and was already clean (403
USER_IS_NOT_A_MEMBERon the staleorg id). This is purely client state.
What changed
auth-active-organization-id:u:$userId, the convention@object-ui/app-shell'sscopedKeyalready uses.yet the value lives in memory for that page-load only.
scopedKey's un-namespacedfallback is right for recents and favourites; here the bare key is the defect, so
this file deliberately inverts that branch and says so at the call site.
(
SessionUserScope.adopt). This is an allowlist sweep of bothlocalStorageandsessionStorage— not a list of known keys — because that is the only shape thatcovers the next key someone adds without a
:u:scope. Only device-scoped entriessurvive: the arriving session's bearer token, the pointer recording whose state the
browser holds, and the UI theme.
ActiveOrganizationStoragemoved into its own file,packages/auth/src/ActiveOrganizationStorage.ts.Its export identity is unchanged —
createAuthenticatedFetch.tsre-exports it — so thebarrel, the existing tests and every consumer import the same symbol from the same place.
Resolving the user scope with no
awaitThe user id is not known from React state when this storage is first read:
createAuthenticatedFetchreads it on every request including the firstget-session,and
MetadataProviderreads it synchronously at mount to scope its seed cache. The scopeis therefore resolved from a plain
localStoragepointer (auth-session-user-id) thatthe previous page-load wrote — one synchronous read, no await.
The #5730 properties are preserved
Both are still pinned and still green:
get()prefers a non-nulllocalStorageread and falls back to_memoryValue.clear()as before, andnow by the user-change purge too, so the outgoing user's org id cannot outlive their
persisted key on the sign-out-then-sign-in path that never reloads.
packages/auth/src/__tests__/activeOrgStorageFallback-5703.test.tsxneeded two edits — thekey literal, and adopting a session user in
beforeEach. That is a spelling change:every property the file exists to pin is asserted unchanged. Without the adopt the cases
would still be green and would mean nothing, because with no session user
set()writesto memory only and every "reached the persisted layer" assertion would be vacuous.
Migration decision (stated, not silent)
No migration. The bare key is deleted. A value under the retired key is
unattributable — nothing recorded whose org id it is — so migrating it is precisely the
defect it would be migrating away from: on a handed-over browser it hands A's org to B. A
signed-in user loses nothing durable; the active organization is a server-owned fact that
refreshOrganizationsre-asks for whenever the list is non-empty and no active org isheld (including the ADR-0081 single-membership repair). One boot re-supplies it. Users
with no organization land on the guided first-run flow, which is the outcome this card is
about.
Why the purge decision reads storage, not memory
SessionUserScope.current()is memory-first (it answers "which key do I use in thistab").
adopt()deliberately reads the persisted pointer instead, because it answersa different question — "is another user's state sitting in this store" — and the persisted
pointer is the only witness to that. Deciding from memory made the purge fire on a browser
with nothing persisted to purge, deleting state written for the arriving user. That was
measured, not reasoned: it turned
packages/app-shell/src/providers/__tests__/MetadataProvider.crossPrincipalSeed.test.tsxred. Pinned in both directions (case
does not purge when the previous owner was never PERSISTED, plus ablation A5 below).Tests
New:
packages/auth/src/__tests__/sessionUserChangePurge-5664.test.tsx, 12 cases.The headline case is shaped as zero A-scoped reads, not "B reads the right thing" —
the latter is green on the buggy code too. Both stores'
getItemare instrumented, theassertion is over what the reads answered while B's session booted, and the
instrument's own liveness is asserted alongside so the zero hit is a measurement rather
than a silent no-op. The controls that must survive are asserted too:
auth-session-tokenis the arriving user's credential, so a purge that took it wouldsign B out on arrival.
Ablations — each mutation confirmed on disk in both directions, restored under
trap ... EXIT INT TERMEvery leg printed
orig=1 injected=0before andorig=0 injected=1after, and the run wasvoided if either check failed. No rebuild is involved: these tests import the module by
relative source path, so
dist/is not on the resolution path.scopedActiveOrgKey()returns the bare key (the naive port)adopt()no longer purgesauth-session-tokendropped from the device allowlistAuthProvidercurrent()(memory-first)The headline case dies under A1–A4. Three readings worth stating rather than smoothing over:
read log — with the key still namespaced nothing in this package reads A's residue.
Both halves of that case are load-bearing; neither alone covers A2.
never resurrects a value sitting under the retired bare keycorrectly stays green: it pins the deletion of the legacy value, which A1 does not
touch. A control that survives an ablation it has no business failing is a feature.
storage, because nothing adopts a user). That is a real signal about the wiring, and it
is narrower than the others — worth knowing when reading the table.
Gates run locally
pnpm lint(repo-wide,eslint . --no-inline-config)Tasks: 47 successful, 47 total— exit 0, no narrowingcheck-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errorstype-check— auth, app-shell, consoleDone, exit 0 (against a rebuilt dependency closure)check-type-check-coverage45/46 via type-check, 0 known-brokencheck-control-bytesOK (scanned 4790 tracked text file(s); skipped 85 binary)check-package-self-importNo package names itself inside its own src/check-node-esm-load --specifiers-onlyno un-ledgered package emits an extensionless relative specifiercheck-phantom-dependenciesEvery in-scope import is declared by the package that publishes itcheck-changeset-presence6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-eager-closure-budget,check-doc-snippet-typesandcheck-published-dist-toolingare the known worktree-broken gauges and were left to CI. On the budget specifically: this
change adds no static import anywhere —
auth-preflight.tsdeliberately spells the keyprefix out rather than importing
@object-ui/auth, precisely so the pre-render entry chunkdoes not gain that closure.
Notes for the batch
createAuthenticatedFetch.ts(read-only for Metadatatype: 'api'actions send the Bearer token and X-Tenant-ID to absolute third-party URLs — the #2725sameOriginOnlymitigation was never applied to this lane #5702): its runtime behaviour isunchanged — same
ActiveOrganizationStorage.get()call, same header logic, sameexports. The only change is that the storage object is now defined in a sibling module
and re-exported from here. No signature moved.
packages/auth/README.md, one table row that named the storage key. The change renamesthat key, so leaving the row would leave the package documenting a spelling that no
longer exists — to a cloud-repo audience that reads this table for the
X-Tenant-IDcontract. One line, same defect class, no other claimant. Drop it if the seat would
rather it went in a separate PR.
Generated by Claude Code