Skip to content

fix(auth): scope the active-organization key per user, and drop the previous user's client state on a session-user change - #5744

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5664-per-user-client-state
Aug 23, 2026
Merged

fix(auth): scope the active-organization key per user, and drop the previous user's client state on a session-user change#5744
os-zhuang merged 2 commits into
mainfrom
claude/issue-5664-per-user-client-state

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5664

Verified on a92b9902c (the final commit; every result below was measured on that tree).

What was wrong

auth-active-organization-id was one un-namespaced localStorage key while its siblings
were already user-scoped (objectui-recent-items:u:, objectui-favorites:u:,
flow-palette-recents:u:). On a browser handed from one account to another the arriving
user'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, so
a 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_MEMBER on the stale
org id). This is purely client state.

What changed

  1. The key is per-userauth-active-organization-id:u:$userId, the convention
    @object-ui/app-shell's scopedKey already uses.
  2. It can no longer be written un-namespaced at all. Where no session user is known
    yet the value lives in memory for that page-load only. scopedKey's un-namespaced
    fallback 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.
  3. A change of session user drops the previous user's client state wholesale
    (SessionUserScope.adopt). This is an allowlist sweep of both localStorage and
    sessionStorage — not a list of known keys — because that is the only shape that
    covers the next key someone adds without a :u: scope. Only device-scoped entries
    survive: the arriving session's bearer token, the pointer recording whose state the
    browser holds, and the UI theme.

ActiveOrganizationStorage moved into its own file, packages/auth/src/ActiveOrganizationStorage.ts.
Its export identity is unchanged — createAuthenticatedFetch.ts re-exports it — so the
barrel, the existing tests and every consumer import the same symbol from the same place.

Resolving the user scope with no await

The user id is not known from React state when this storage is first read:
createAuthenticatedFetch reads it on every request including the first get-session,
and MetadataProvider reads it synchronously at mount to scope its seed cache. The scope
is therefore resolved from a plain localStorage pointer (auth-session-user-id) that
the 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-null localStorage read and falls back to _memoryValue.
  • the memory value is nulled before storage is touched — by clear() as before, and
    now 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.tsx needed two edits — the
key 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() writes
to 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
refreshOrganizations re-asks for whenever the list is non-empty and no active org is
held (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 this
tab"). adopt() deliberately reads the persisted pointer instead, because it answers
a 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.tsx
red. 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' getItem are instrumented, the
assertion 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-token is the arriving user's credential, so a purge that took it would
sign B out on arrival.

pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/__tests__/ --maxWorkers=2
Test Files 26 passed (26) Tests 246 passed (246)
pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/ packages/app-shell/src/layout/ \
packages/app-shell/src/console/ apps/console/src --maxWorkers=2
Test Files 201 passed (201) Tests 1747 passed (1747)

Ablations — each mutation confirmed on disk in both directions, restored under trap ... EXIT INT TERM

Every leg printed orig=1 injected=0 before and orig=0 injected=1 after, and the run was
voided 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.

#MutationRed
A1scopedActiveOrgKey() returns the bare key (the naive port)5 of 12
A2adopt() no longer purges3 of 12
A3auth-session-token dropped from the device allowlist2 of 12
A4the invariant unwired from AuthProvider1 of 12
A5purge decided from current() (memory-first)1 of 12

The headline case dies under A1–A4. Three readings worth stating rather than smoothing over:

  • Under A2 the headline case is caught by the surviving-keys assertion, not by the
    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.
  • Under A1 the case never resurrects a value sitting under the retired bare key
    correctly 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.
  • Under A4 the case fails at its own setup assertion (A's org id never reaches
    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

GateVerdict line
pnpm lint (repo-wide, eslint . --no-inline-config)Tasks: 47 successful, 47 total — exit 0, no narrowing
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors
type-check — auth, app-shell, consoleall Done, exit 0 (against a rebuilt dependency closure)
check-type-check-coverage45/46 via type-check, 0 known-broken
check-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 specifier
check-phantom-dependenciesEvery in-scope import is declared by the package that publishes it
check-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-types and check-published-dist-tooling
are the known worktree-broken gauges and were left to CI. On the budget specifically: this
change adds no static import anywhere — auth-preflight.ts deliberately spells the key
prefix out rather than importing @object-ui/auth, precisely so the pre-render entry chunk
does not gain that closure.

Notes for the batch

  • createAuthenticatedFetch.ts (read-only for Metadata type: 'api' actions send the Bearer token and X-Tenant-ID to absolute third-party URLs — the #2725 sameOriginOnly mitigation was never applied to this lane #5702): its runtime behaviour is
    unchanged — same ActiveOrganizationStorage.get() call, same header logic, same
    exports. The only change is that the storage object is now defined in a sibling module
    and re-exported from here. No signature moved.
  • One file outside the dispatched fence was touched, deliberately and loudly:
    packages/auth/README.md, one table row that named the storage key. The change renames
    that 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-ID
    contract. 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

os-project-managerand others added 2 commits August 23, 2026 03:14
…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3917.2 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-2pAaG2Ty.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)16.66KB6.35KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)33.99KB8.57KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)510.39KB114.67KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 03:56
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 343c598Aug 23, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5664-per-user-client-state branch August 23, 2026 03:56
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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appsdocumentationImprovements or additions to documentationtests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-user client-state pollution: previous user's workspace shown to a new user, and the guided first-run flow suppressed

2 participants

@os-zhuang@os-project-manager