You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.ts — ActiveOrganizationStorage 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:
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.
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.
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.
Found while documenting the
X-Tenant-IDedge 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.ts—ActiveOrganizationStoragekeeps a module-level_memoryValueas the fallback for environments withoutlocalStorage.set()always writes it.get()reads it only when thelocalStorageread itself throws:There is a real browser state where
localStorageexists, reads fine, and rejects writes: Safari private browsing and any quota-exhausted origin, wheresetItemthrowsQuotaExceededError. In that stateset()'stryswallows the write failure (correctly — the fallback is right there), butget()never consults the fallback, because the read path succeeded and returnednull.Probe
Run against the built
dist/, with alocalStoragethat reads but refuses writes:Result:
The value was stored and cannot be read back.
What it costs
For the whole session, in such a browser:
X-Tenant-IDis never stamped.createAuthenticatedFetchreadsget(), 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.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@noneforever, and the first-boot relabel path never fires because the org id it waits for never becomes readable.switchOrganizationappears 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
localStoragepresent and working, or absent entirely.Repair sketch, for triage rather than as a decision
Make
get()prefer a non-nulllocalStorageread and fall back to_memoryValueotherwise, rather than returning thelocalStorageresult 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: afterclear()the memory value is nulled too, so a fallback onnulldoes not resurrect a cleared org — worth a test either way, since sign-out's clear is a security-relevant path.