docs(auth): write down the X-Tenant-ID edge contract and its unstamped-first-request gap - #5706
Conversation
…d-first-request gap (#5279) The header `createAuthenticatedFetch` stamps had no written contract, and the shape of the missing information was actively misleading: its only non-CORS consumer lives in the cloud repository, so a search confined to this repo and the framework returns zero readers and reads as "nothing consumes this stamp". #5279 was filed on exactly that reading and held until a cloud-side reading came back non-empty. Without the contract written down, the next person to grep reaches the same false conclusion and deletes a live routing input. packages/auth/README.md gains "The X-Tenant-ID edge contract": what the header means (a routing hint carrying the better-auth activeOrganizationId, not an identity claim, not an authorization input, not what scopes rows), who stamps it and under exactly which condition, who reads it, and what a reader may and may not assume. The framework half is stated as a negative with its pin — resolveAuthzContext takes tenantId from the API-key principal or session.activeOrganizationId and from no header — next to plugin-sharing's record that trusting x-tenant-id as identity was a vulnerability. The configuration half is quoted from the contract this package can actually resolve, TenantRoutingConfigSchema in @objectstack/spec/cloud, where X-Tenant-ID is the default of a configurable tenantHeaderName and `header` ranks second of six identification sources behind `subdomain`. The unstamped-first-request gap gets its own section: ActiveOrganizationStorage is filled only after AuthProvider's async organization chain resolves, so early-boot requests carry no tenant header at all. What a reader observes is documented as absent, never present-and-empty, with the five situations that open the window and the instruction to fall through to the next identification source rather than fail closed. The gap is recorded, deliberately not closed: the cloud readers observe today's behaviour. The stamping site carries the decisive facts inline, because the source is what a grep-then-delete reader reads first. Three cases in createAuthenticatedFetch.test.tsx pin the statements the prose makes about the wire, so the documentation cannot drift away from the behaviour unnoticed. Documentation only. Proven: with comments stripped, the emitted JS for createAuthenticatedFetch.ts is byte-identical before and after (2750 bytes both), and the package's other 20 dist JS files are hash-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
✅ 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
|
|
Seat review — accepted on its merits. HELD only on #5705, which is a red base, not this diff. Fence held exactly: Three things I want on the record:
The inline comment at the stamping site is the part that will still be earning its keep in a year — it names the exact failure this card was filed on:
The Held pending #5705 ( Generated by Claude Code |
✅ 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
|
Fixes #5279
Documentation only. The header
createAuthenticatedFetchstamps had no written contract, and the shape of the missing information was actively misleading.Why this card existed at all
X-Tenant-ID's only non-CORS consumer lives in the cloud repository. A search confined to this repo and the framework (objectstack) finds zero readers and reads as "nothing consumes this stamp". #5279 was filed on exactly that reading, held on it, and was only discharged when a cloud-side reading came back non-empty (packages/service-tenant/src/tenant-context.ts,packages/tenant-router/src/spec/turso-multi-tenant.zod.ts).That makes the missing documentation an active hazard rather than a gap: the next person to grep reaches the same false conclusion and deletes a live routing input. So the decisive facts go where a grep-then-delete reader looks first — the stamping site itself — and the full contract goes in the package README, which ships to npm.
What is documented
packages/auth/README.mdgains "TheX-Tenant-IDedge contract":activeOrganizationId. Not an identity claim, not an authorization input, not what scopes rowscreateAuthenticatedFetch, fromActiveOrganizationStorage, whenever that holds a value — not gated on the URL being an/api/call, suppressed bysameOriginOnlyfor cross-origin URLs, overwrites a caller-supplied valueAuthProvideronly, at four moments (organization load, org switch, delete/leave, sign-out)resolveAuthzContexttakestenantIdfrom the API-key principal orsession.activeOrganizationIdand from no header, pinned bypackages/verify/src/harness.org-context.test.tsThe configuration half is not paraphrased from the cloud repo — it is measured from the contract this package actually resolves.
TenantRoutingConfigSchemafrom@objectstack/spec/cloud(17.1.0, the version@object-ui/authresolves), parsed on an empty config:Two things a client author needs from that: the header name is configurable (
X-Tenant-IDis a default, not a constant), and the header is one of six identification sources ranked second, behindsubdomain— on a subdomain-routed deployment it is not what picks the tenant.The negative half is stated with its own evidence, because "the framework ignores it" is the half that reads as "nobody uses it": the CORS allow-list comment (
X-Tenant-ID/X-Environment-Idroute "a request to its environment") andplugin-sharing's record that trustingx-tenant-idas identity was a vulnerability.The unstamped-first-request gap
Its own section.
ActiveOrganizationStorageis filled only afterAuthProvider's asyncgetSession->listOrganizations->getActiveOrganizationchain resolves, so early-boot requests carry no tenant header at all. Documented:""instead of falling through to its next identification source.localStoragerejects writes — filed separately asActiveOrganizationStorage's in-memory fallback is unreachable whenlocalStorageexists but rejects writes — the tenant header is then never stamped for the whole session #5703).MetadataProvider's eagerappfetch lands inside it, which is what MetadataProvider's first-boot seed entry is written under the no-org scope, so the entry the next boot looks for is never there #5243 had to reason about.Three pins, so the prose cannot drift
packages/auth/src/__tests__/createAuthenticatedFetch.test.tsxgains one case per wire-level statement the README makes: no active organization means no header at all (.has()false, not empty-string); the stamp is not gated on/api/the wayAuthorizationis; the active organization overwrites a caller-suppliedX-Tenant-ID.The middle one is labelled in the test body as recorded, not endorsed — it makes today's asymmetry visible so that gating the stamp becomes a deliberate, red-test change rather than a silent one, and the question is filed for triage rather than answered here.
Scope
Deliberately not in this PR, per the card:
content/docs/**andapps/site/**. Untouched — the contract lives with the code.skills/objectui/guides/auth-permissions.md, whose multi-tenancy paragraph implies the header is what enforces row isolation. Outside the fence; filed as Published skill teaches thatX-Tenant-IDis what makes tenant scoping work — the framework scopes from the session, and trusting the header as identity is the recorded vulnerability #5704.Verification
Run at
f3e0d6313, the head of this branch.Scope is narrowed to
@object-ui/auth, and the narrowing is proven rather than asserted: every.tsedit is comment-only, so the change can have no runtime effect. Demonstrated by compiling both revisions ofcreateAuthenticatedFetch.tswithremoveCommentsand diffing — byte-identical, 2750 bytes each — and by the package's other 20 emitteddist/*.jsfiles being hash-identical across the rebuild. The instrument was self-checked: re-spelling one header literal in a scratchpad copy makes the same comparison go red, so a green reading is a measurement, not a no-op.pnpm --filter @object-ui/auth buildpnpm --filter @object-ui/auth type-check(tsc --noEmit && tsc -p tsconfig.test.json)pnpm --filter @object-ui/auth lint29 problems (0 errors, 29 warnings), every warning pre-existing inAuthProvider.tsxpnpm exec vitest run packages/auth/Test Files 18 passed (18)/Tests 190 passed (190). The three new cases were confirmed to actually execute by a--reporter=verboserun of the edited file (Tests 13 passed (13), each new title printed), not inferred from the file-level countnode scripts/check-control-bytes.mjscheck-control-bytes: OK (scanned 4754 tracked text file(s); skipped 85 binary)node scripts/check-changeset-presence.mjs2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)node scripts/check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.node scripts/check-doc-snippet-types.mjscheck-doc-snippet-typesis the gate this diff most obviously reaches: its declared scan surface is every page undercontent/docsplus everypackages/<name>/README.md. It refuses to run against an unbuilt tree (The snippet program was NOT run: the packages it resolves against are not built) and wants 14 packages built first, so it was not run in full here — CI runs it on a built tree.What replaces it is a measurement rather than a hope, because the narrowing is provable. The gate compiles only the fence languages in its own exported
TS_FENCE_LANGUAGES—ts,tsx,typescript— so the population was read from the gate itself and applied to both revisions of the file:The two blocks this PR adds are
httpandtext— the header example and the parsed-config dump are not compilable programs, and marking them as fragments would have been a worse answer than writing them as TypeScript. The set of blocks the gate compiles is therefore unchanged by this diff, so its verdict on this file is unchanged. The extractor was self-checked: planting onetsblock in a scratchpad copy takes the population from 10 to 11, so the zero-delta reading is a measurement and not a broken probe.The rest of the farm is CI's run, as always.
Generated by Claude Code