Skip to content

fix(auth): make the ActiveOrganizationStorage memory fallback reachable when localStorage rejects writes - #5730

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5703-active-org-storage-fallback
Aug 23, 2026
Merged

fix(auth): make the ActiveOrganizationStorage memory fallback reachable when localStorage rejects writes#5730
os-zhuang merged 1 commit into
mainfrom
claude/issue-5703-active-org-storage-fallback

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5703

ActiveOrganizationStorage.get() returned the localStorage read unconditionally, so the module-level _memoryValue fallback was reachable only when the read itself threw. There is a real browser state where the read does not throw and the fallback is nonetheless the only copy of the value: localStorage present and readable but rejecting writes — Safari private browsing, and any quota-exhausted origin, where setItem throws QuotaExceededError.

In that state set() correctly swallowed the write failure into _memoryValue, and get() then never consulted it. The active org was stored and could not be read back.

The repair

get() now prefers a non-nulllocalStorage read and falls back to _memoryValue otherwise:

constpersisted=localStorage.getItem(ACTIVE_ORG_STORAGE_KEY);if(persisted!==null)returnpersisted;

A working localStorage is untouched, and a non-null persisted read still wins over the memory value — so another tab (or a page that outlived a memory value) remains the authority.

The half that had to be pinned, not assumed

The new fallback fires exactly when the localStorage read is null — which is also the state clear() leaves behind. Sign-out calls clear(), so "fall back whenever the read is null" is, on its own, the precise shape that would re-stamp a cleared org.

It answers null here because clear() nulls _memoryValuebefore it touches localStorage. That is a property of clear()'s body, not a guarantee of get(), so this PR pins it directly rather than reading it off the current source and trusting it to stay:

  • _memoryValue itself is asserted null after clear(), not merely get(). A future clear() that only removed the persisted key fails a test instead of silently resurrecting the org.
  • The same statement is pinned one level out, on the wire: after clear(), createAuthenticatedFetch sends noX-Tenant-ID at all — absent, not present-and-empty.
  • clear() carries a comment naming the line as security-relevant, since that is where a future editor would break it.

Tests

packages/auth/src/__tests__/activeOrgStorageFallback-5703.test.tsx — 9 cases covering triage's matrix: (a) read-succeeds/write-fails, (b) clear-then-get stays null, (c) plain working localStorage unchanged, plus (d) the read-throws path the fallback was originally written for, so the repair does not cost the case it already handled.

The card's probe is reproduced as a committed test against source (the repo's vitest aliases @object-ui/auth to packages/auth/src), not the built dist/ the one-off probe used.

Measured red before the fix, on the same tree:

× reads back a value it could not persist, when localStorage rejects writes
× stamps X-Tenant-ID for the whole session when localStorage rejects writes
× does NOT resurrect a cleared org through the fallback
AssertionError: expected null to be 'org-42' // Object.is equality
Tests 3 failed | 6 passed (9)

Worth noting which three went red: the clear-case failed at its own precondition (get() must return the org before clear() can be tested), because the fallback was not live. That case was vacuous before this fix — which is exactly why it needed pinning rather than relying on the current behaviour.

Green after, on 4cae3d6a8:

Test Files 21 passed (21)
Tests 218 passed (218) # whole packages/auth
Test Files 3 passed (3)
Tests 13 passed (13) # the MetadataProvider consumers

Blast radius — noted, deliberately not fixed here

Two consequences disappear on their own once get() is repaired; neither is touched by this PR:

  • X-Tenant-ID was never stamped for the whole session, not just the documented first-boot window. Per the edge contract documented on Confirm whether X-Tenant-ID has a reader: the framework derives the tenant from the session, not the header #5279 the header is a routing hint a reader falls through on — the framework scopes from the session — so this is not a data-scoping bug; what was missing is the tenant-routing input, on every request.
  • activeOrgScope() in packages/app-shell/src/providers/MetadataProvider.tsx reads the same storage, so its org-scoped session cache stayed permanently keyed @none and the first-boot relabel never fired. packages/app-shell/** is out of scope for this PR; its three MetadataProvider consumer tests are run above and stay green.

Cross-references #5279, which documented this window's observable consequence without repairing it.

Verification

GateVerdict
pnpm exec vitest run packages/authTest Files 21 passed (21) / Tests 218 passed (218)
pnpm exec vitest run (3 MetadataProvider consumers)Test Files 3 passed (3) / Tests 13 passed (13)
pnpm --filter @object-ui/auth type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
eslint . in packages/auth42 files, 0 errors, 29 warnings (all in files this PR does not touch)
check-control-bytes.mjsOK (scanned 4782 tracked text file(s); skipped 85 binary)
check-changeset-presence.mjs2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major.mjsNo changeset declares a major bump.
check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.
check-phantom-dependencies.mjsEvery in-scope import is declared by the package that publishes it.
check-package-self-import.mjsNo package names itself inside its own src/.

The repo-wide pnpm test and pnpm lint runs are left to CI, which runs the farm exactly once regardless. The local lint above is a declared narrowing: the population is ESLint's own selection (42 files), the count is read from --format json, and no type-aware linting is configured (eslint.config.js sets no projectService, project:, or parserOptions), so this diff cannot move the verdict on any file it does not itself contain.


Generated by Claude Code

…le when localStorage rejects writes (#5703)
`ActiveOrganizationStorage.get()` returned the `localStorage` read
unconditionally, so `_memoryValue` was reachable only when the read itself
threw. In a browser where `localStorage` is present and readable but rejects
writes -- Safari private browsing, any quota-exhausted origin, where `setItem`
throws `QuotaExceededError` -- `set()` correctly swallowed the write failure
into `_memoryValue` and `get()` then never consulted it: the active org was
stored and could not be read back, so `X-Tenant-ID` went unstamped for the
whole session rather than only the documented first-boot window.
`get()` now prefers a non-null `localStorage` read and falls back to
`_memoryValue` otherwise. A working `localStorage` is unchanged, and a non-null
persisted read still wins over the memory value.
The fallback fires exactly when the `localStorage` read is null, which is also
the state `clear()` leaves behind, so sign-out is the case that had to be
pinned rather than assumed: it answers null because `clear()` nulls
`_memoryValue` too. `activeOrgStorageFallback-5703.test.tsx` asserts that
property directly, so a future `clear()` that only removed the persisted key
fails a test instead of silently re-stamping a cleared org.
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)3915.7 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-DaGSPqBD.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 (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)32.45KB8.06KB
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)11.67KB4.55KB
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)508.30KB114.17KB
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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-zhuang@claude