Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/tenant-header-edge-contract-5279.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@object-ui/auth': patch
---

Document the `X-Tenant-ID` edge contract that `createAuthenticatedFetch` stamps, and the
unstamped-first-request window in which it is not sent (objectui#5279). Documentation
only — no behaviour changes.

The header had no written contract anywhere, 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 (`objectstack`) returns zero readers and
reads as "nothing consumes this stamp". #5279 was filed on exactly that reading, and was
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 — alongside
`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 `getSession` -> `listOrganizations` ->
`getActiveOrganization` 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, so changing when the header first
appears is its own decision.

Three cases in `createAuthenticatedFetch.test.tsx` pin the statements the prose makes
about the wire — no active organization means no header at all, the stamp is not gated on
`/api/` the way `Authorization` is, and the active organization overwrites a caller-set
`X-Tenant-ID` — so the documentation cannot drift away from the behaviour unnoticed.
148 changes: 148 additions & 0 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,154 @@ const authedFetch = createAuthenticatedFetch();
const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
```

## The `X-Tenant-ID` edge contract

`createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is
that header's contract: what it means, who stamps it, who reads it, what a reader may
assume, and the one window in which it is not sent at all.

It is written down because **a grep cannot answer any of those questions from this
repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a
search confined to this repo and the framework (`objectstack`) finds zero readers and
reads as *"nothing consumes this stamp"*. That reading is what
[objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and
it is false. Do not act on it.

### What it means

A **routing hint** for the hosting edge — *this request belongs to tenant `<id>`*:

```http
X-Tenant-ID: <better-auth activeOrganizationId>
```

The value is the active **organization** id, the same id the session carries as
`session.activeOrganizationId`. The header is **not** an identity claim, **not** an
authorization input, and **not** what scopes rows.

### Who stamps it

| | |
| --- | --- |
| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
| Value read from | `ActiveOrganizationStorage` — `localStorage`, key `auth-active-organization-id` |
| Condition | that storage holds a non-empty value |
| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
| Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` |

`ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx):

| Event | Effect |
| --- | --- |
| `refreshOrganizations` — after `getSession` &rarr; `listOrganizations` &rarr; `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set |
| `switchOrganization` — the org switcher | set, or clear when the server returns no org |
| `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear |
| sign-out | clear |

### Who reads it

**The cloud edge — yes.** The non-test readers are recorded on objectui#5279:
`packages/service-tenant/src/tenant-context.ts` (header resolution) and
`packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud`
repository. That repository is not readable from this one, so those paths are cited as the
recorded reading rather than re-derived here.

**Its configuration contract, though, IS readable from here**, because this package
depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is
what configures a tenant resolver; parsing an empty config on the version this package
resolves today (17.1.0) yields:

```text
enabled: false
identificationSources: ["subdomain", "header", "jwt_claim"]
tenantHeaderName: "X-Tenant-ID"
jwtOrganizationClaim: "organizationId"
```

Two consequences matter to a client author:

- **The header name is configurable.** `X-Tenant-ID` is its default, not a constant.
- **The header is one of six identification sources** (`subdomain`, `custom_domain`,
`header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks
**second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing
that picks the tenant, and a deployment may leave it out of `identificationSources`
entirely.

**The framework (`objectstack`) — no.** `resolveAuthzContext`
(`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the
API-key principal or from `session.activeOrganizationId`, and from no header;
`packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId`
is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel
routing read the hostname and `X-Environment-Id`. The framework's only other mentions are
the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes
`X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and
`plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a
vulnerability**: its secure default stopped reading identity from headers because doing so
let a client forge attribution and enumerate or revoke other users' links.

### What a reader may assume

A reader **may**:

- use it to select the tenant database or environment to route to, subject to its own
`identificationSources` precedence;
- treat it as a hint that may be absent, stale, or contradicted by the session.

A reader **may not**:

- treat it as authenticated identity, or as an authorization decision. It is
client-controlled — any caller can send any value, and `plugin-sharing` is the recorded
precedent for what happens to a server that trusts it;
- assume the row scoping it sees downstream came from this header. It did not: the
framework scopes from the session;
- assume the header is present. See below.

### The unstamped-first-request gap

The stamp reads storage that `AuthProvider` fills only **after** `getSession` &rarr;
`listOrganizations` &rarr; `getActiveOrganization` resolves. Every request that leaves
before that chain completes carries **no** `X-Tenant-ID` at all.

The window opens in five situations:

1. a browser that has never signed in, or whose site data was cleared, including a private
window;
2. the page load in which the user signs in — from the sign-in response until the
organization chain resolves;
3. after sign-out, which clears the storage, until a new organization resolves;
4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the
storage falls back to a per-process in-memory value that starts empty on every cold
start;
5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing,
quota exhaustion). There the window never closes — the in-memory fallback is written
but never read back, because `get()` only falls back when reading itself *throws*, and
a rejected write leaves reads working and returning `null`. Reported on objectui#5279
as a separate defect; it is not fixed here.

Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its
eager `app` metadata fetch inside exactly this window, which is why its first-boot cache
scope needed its own reasoning
([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx),
pinned by `MetadataProvider.firstBootOrgScope.test.tsx`).

**What a reader observes in the window:** the header is **absent**, never
present-and-empty. A resolver must fall through to its next configured identification
source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and
`defaultTenantId` behind it — rather than fail closed on the absence. It must also not
cache a routing decision taken inside the window as *the* tenant for the session: the very
next request will normally carry the header.

**Why the gap does not corrupt data scoping.** A response computed inside the window is
still computed for the right tenant, because the framework takes `tenantId` from the
session rather than from the header. The gap is a *routing-input* gap, not a scoping gap.
This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
entry that had been written in the window.

**Closing the gap is a separate decision, not an omission.** The cloud readers observe the
current behaviour, so changing when the header first appears changes what they see. If it
should be closed, it needs its own card.

## Server Feature Flags (`GET /auth/config`)

`createAuthClient().getConfig()` fetches the server's public auth configuration. The
Expand Down
47 changes: 47 additions & 0 deletions packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,51 @@ describe('createAuthenticatedFetch', () => {
await createAuthenticatedFetch()('https://third-party.example.com/api/x');
expect(calls[0].headers.get('Authorization')).toBe('Bearer tok123');
});

// ── X-Tenant-ID: the edge contract, pinned (#5279) ────────────────────
//
// The header IS consumed — by the cloud edge, in a repository this one
// cannot see. These cases pin the three statements the README's "The
// `X-Tenant-ID` edge contract" section makes about what leaves the browser,
// so the prose cannot drift away from the wire without a red test.

it('sends NO tenant header at all when no organization is active (the unstamped-first-request gap)', async () => {
// The gap itself, on the wire. `AuthProvider` fills
// `ActiveOrganizationStorage` only after its async organization chain
// resolves, so every request before that looks like this one. The
// distinction a cloud-side resolver depends on is ABSENT vs
// present-and-empty: an empty-string header would make the resolver see a
// tenant id of "" instead of falling through to its next identification
// source.
expect(ActiveOrganizationStorage.get()).toBeNull();
const calls = stubFetch();
await createAuthenticatedFetch()(API_URL);
expect(calls[0].headers.get('X-Tenant-ID')).toBeNull();
expect(calls[0].headers.has('X-Tenant-ID')).toBe(false);
});

it('stamps the tenant header on non-API URLs too — recorded, not endorsed (#5279)', async () => {
// Unlike `Authorization` and `Accept-Language`, the tenant stamp is not
// gated on `isApiCall`. This case exists so that asymmetry is VISIBLE:
// it records what ships today, it does not bless it, and the question of
// whether the stamp should be gated is reported on #5279 for triage. If
// that is answered by gating the stamp, this expectation changes with the
// fix — deliberately, rather than silently.
ActiveOrganizationStorage.set('org-42');
const calls = stubFetch();
await createAuthenticatedFetch()('http://localhost/static/logo.png');
expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-42');
// The contrast that makes the asymmetry the point of this case:
expect(calls[0].headers.get('Authorization')).toBeNull();
});

it('the active organization wins over an X-Tenant-ID the caller supplied', async () => {
// `headers.set` overwrites. Stated in the README as the header's
// precedence rule, because a caller reading its own value back off the
// request would otherwise be surprised.
ActiveOrganizationStorage.set('org-42');
const calls = stubFetch();
await createAuthenticatedFetch()(API_URL, { headers: { 'X-Tenant-ID': 'org-caller' } });
expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-42');
});
});
59 changes: 55 additions & 4 deletions packages/auth/src/createAuthenticatedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@ export interface AuthenticatedAdapterOptions {
const ACTIVE_ORG_STORAGE_KEY = 'auth-active-organization-id';

/**
* Get/set the active organization ID for tenant-scoped API requests.
* Used by createAuthenticatedFetch to inject X-Tenant-ID header.
* Get/set the active organization id that {@link createAuthenticatedFetch}
* stamps as `X-Tenant-ID`.
*
* `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
* sets it once the `getSession` -> `listOrganizations` ->
* `getActiveOrganization` chain resolves (including the ADR-0081
* single-membership repair), `switchOrganization` sets or clears it,
* `deleteOrganization` / `leaveOrganization` clear it when the active org is
* the one going away, and sign-out clears it.
*
* Because the first of those is asynchronous, this reads EMPTY for the first
* stretch of a boot, and every request that leaves in that window carries no
* tenant header at all. That window is a documented part of the header's
* contract, not an accident to paper over — see the "unstamped-first-request
* gap" section of this package's README (objectui#5279).
*/
export const ActiveOrganizationStorage = {
_memoryValue: null as string | null,
Expand Down Expand Up @@ -82,7 +95,9 @@ function isCrossOrigin(url: string): boolean {
/**
* Creates an authenticated fetch wrapper that injects the Bearer token
* from localStorage into every request to the ObjectStack API.
* Also injects X-Tenant-ID header when an active organization is set.
* Also injects X-Tenant-ID header when an active organization is set — see the
* "The `X-Tenant-ID` edge contract" section of this package's README for what
* that header means, who reads it, and the window in which it is not sent.
*
* @example
* ```ts
Expand Down Expand Up @@ -111,7 +126,43 @@ export function createAuthenticatedFetch(
if (token && isApiCall) {
headers.set('Authorization', `Bearer ${token}`);
}
// Inject tenant header for multi-tenant routing
// ── `X-Tenant-ID` — the edge routing contract (objectui#5279) ────────
//
// WHAT IT MEANS. A routing hint for the hosting edge: "this request belongs
// to tenant <id>". The value is the better-auth `activeOrganizationId` the
// session already carries. It is NOT an identity claim, NOT an
// authorization input, and NOT what scopes rows.
//
// DO NOT DELETE THIS ON THE STRENGTH OF A GREP. The framework
// (`objectstack`) does not read it: `resolveAuthzContext` takes `tenantId`
// from the API-key principal or `session.activeOrganizationId` and never
// from a header, and environment routing reads the hostname and
// `X-Environment-Id`. So a search confined to this repo plus the framework
// finds zero consumers and reads as "dead stamp" — which is the false
// premise objectui#5279 was filed on. The consumer is in the CLOUD repo,
// which neither checkout contains: `service-tenant`'s `tenant-context.ts`
// resolves the header, and `tenant-router`'s `turso-multi-tenant.zod.ts`
// configures that resolution. The configuration contract IS readable from
// here — `TenantRoutingConfigSchema` in `@objectstack/spec/cloud` defaults
// `tenantHeaderName` to `X-Tenant-ID` and ranks `header` SECOND of six
// identification sources, behind `subdomain`.
//
// THE UNSTAMPED-FIRST-REQUEST GAP. `ActiveOrganizationStorage` is filled
// only after AuthProvider's async organization chain resolves, so early
// requests go out with the header ABSENT — never present-and-empty. A
// reader must fall through to its next identification source rather than
// fail closed. Nothing about row visibility rides on this: the framework
// scopes from the session, so a response computed inside the window is
// still computed for the right tenant.
//
// Not gated on `isApiCall`, unlike `Authorization` and `Accept-Language`
// above — recorded as the behaviour that ships, not endorsed; the
// asymmetry is reported on objectui#5279 for triage to route. A wrapper
// built with `sameOriginOnly` short-circuits every cross-origin request
// before this line.
//
// Full contract, including what a reader may and may not assume: this
// package's README, "The `X-Tenant-ID` edge contract".
const activeOrgId = ActiveOrganizationStorage.get();
if (activeOrgId) {
headers.set('X-Tenant-ID', activeOrgId);
Expand Down
Loading