Skip to content

ActiveOrganizationStorage's in-memory fallback is unreachable when localStorage exists but rejects writes — the tenant header is then never stamped for the whole session #5703

Description

@os-sales

Found while documenting the X-Tenant-ID edge contract for #5279 (that card documents the observable consequence; it does not fix this). Measured, not reasoned — the probe is below.

The shape

packages/auth/src/createAuthenticatedFetch.tsActiveOrganizationStorage keeps a module-level _memoryValue as the fallback for environments without localStorage. set() always writes it. get() reads it only when the localStorage read itself throws:

get(): string | null {
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem(ACTIVE_ORG_STORAGE_KEY); // returns null, no throw
}
} catch { /* SSR / test */ }
return this._memoryValue; // unreachable in that case
},

There is a real browser state where localStorageexists, reads fine, and rejects writes: Safari private browsing and any quota-exhausted origin, where setItem throws QuotaExceededError. In that state set()'s try swallows the write failure (correctly — the fallback is right there), but get() never consults the fallback, because the read path succeeded and returned null.

Probe

Run against the built dist/, with a localStorage that reads but refuses writes:

const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: () => { throw new Error('QuotaExceededError'); },
removeItem: (k) => store.delete(k),
};
ActiveOrganizationStorage.set('org-42');

Result:

after set(), _memoryValue = org-42
after set(), get() = null

The value was stored and cannot be read back.

What it costs

For the whole session, in such a browser:

  1. X-Tenant-ID is never stamped.createAuthenticatedFetch reads get(), so the request looks like a permanent first boot. Per the 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 that the edge falls through on, so this is not a data-scoping bug — the framework scopes from the session — but it does mean the tenant-routing input is missing on every request, not just early ones.
  2. MetadataProvider's org-scoped session cache is permanently keyed @none.activeOrgScope() (packages/app-shell/src/providers/MetadataProvider.tsx) reads the same storage, and the whole point of that key is that it equals the tenant the entries were fetched under. It stays @none forever, and the first-boot relabel path never fires because the org id it waits for never becomes readable.
  3. switchOrganization appears to succeed — the server-side active org does change — while the client-side stamp and cache scope never follow.

None of this is loud. There is no error, no warning, and no test covering the read-succeeds/write-fails combination; the existing tests exercise localStorage present and working, or absent entirely.

Repair sketch, for triage rather than as a decision

Make get() prefer a non-null localStorage read and fall back to _memoryValue otherwise, rather than returning the localStorage result unconditionally. That keeps the persisted value authoritative when it exists, and makes the memory fallback reachable in the state it was written for. The one thing to be careful about: after clear() the memory value is nulled too, so a fallback on null does not resurrect a cleared org — worth a test either way, since sign-out's clear is a security-relevant path.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions