Skip to content

Commit f5ef054

Browse files
feat(core): resolveRequestExecutionContext, the one request-to-ExecutionContext composition; the runtime dispatcher's door calls it (#24248)
Part of #23217 Clause-②: yes (widening) Ruling-ref: 6227928430 Slice 1 of #23217 under ruling record 6227928430 (letter A): the one request-to-ExecutionContext function in `@objectstack/core`, plus the runtime dispatcher's own door. #23217 remains open for the other four doors (`rest-server.ts`, `service-settings`, `service-storage`, `plugin-hono-server`), each a later slice with `Clause-②: no`. No door's outward behaviour changes, and there is no app-author surface. ## What changed - **`@objectstack/core`**: new `packages/core/src/security/request-execution-context.ts` with `resolveRequestExecutionContext`, exported from core's `.` entry through `security/index.ts`, beside `classifyAdmissionTenancyPosture`. Its docblock states the two choices (the anonymous entry, the ADR-0069 gate) and every named parameter with the door that needs it. - **`@objectstack/runtime`**: `resolveExecutionContext` (`packages/runtime/src/security/resolve-execution-context.ts`) keeps what is its own (the service lookups, the header normalisation, the `/mcp` OAuth verification and scope interpretation) and hands the rest to the function. 209 lines out, 87 in. - **Ruling item 4**: `packages/runtime/src/security/request-execution-context.conformance.test.ts`, one row per converted door. - **Unit tests**: `packages/core/src/security/request-execution-context.test.ts`, 20 cases, one battery per choice and per parameter, each reading paired with its control. - **Two files outside the claim's surface, both forced by the move** (named for the seat to declare): - `packages/core/src/security/authz-store-unavailable.test.ts`: its transport ledger is set-equal to the files that call `resolveAuthzContext({`. That call moved from the runtime door into the new core module, which is ledgered `propagates`. - `scripts/check-single-authz-resolver.mjs`: check (2) required the runtime door to contain `resolveAuthzContext`, and after the move only its docblock prose would have satisfied that. `DELEGATORS` now names each delegator's call: rest and the new core module call `resolveAuthzContext`, and the runtime door calls `resolveRequestExecutionContext`. The self-test drives the non-delegating case for every row. - **Changeset**: `minor` on `@objectstack/core`. Runtime has none: no byte a reader sees moves. ## The exported signature (for the seat's review of the new export) This is verbatim except for one change: `‹` and `›` stand for the angle brackets, because the body sanitizer strips tag-shaped fragments. The byte-exact text is `packages/core/src/security/request-execution-context.ts` lines 84-166 and 214-221 at `f5a955e12`. ```ts export type RequestAuthService = Pick‹ IAuthService, 'api' | 'getApi' | 'isAuthGateActive' | 'resolveUserAuthGates' | 'resolveApiKeyLifecyclePolicy' ›; export interface EstablishedRequestSession { user: { id: string }; session: { userId: string; activeOrganizationId?: string }; } export interface RequestAdmission { credential: 'api_key' | 'session'; authGate: AuthGate | undefined; } export interface RequestExecutionContextInput { headers: Headers; ql: unknown; authService: RequestAuthService | undefined; tenancy: TenancyServiceResolver | undefined; anonymous: 'fail-closed' | 'guest'; authGate: 'withhold' | 'resolved' | 'reread-when-active'; credentials: 'api-key-and-session' | 'session-only'; sessionApi: 'optional' | 'required'; session: 'request' | 'none' | { readonly established: EstablishedRequestSession }; localization: 'withhold' | { readonly settings: () => unknown }; requestLocale: string | undefined; accessToken: 'carry' | 'withhold'; oauth: OAuthTokenProvenance | undefined; onAdmission: ((admission: RequestAdmission) => void) | undefined; } export function resolveRequestExecutionContext( input: RequestExecutionContextInput & { anonymous: 'guest' }, ): Promise‹ExecutionContext›; export function resolveRequestExecutionContext( input: RequestExecutionContextInput, ): Promise‹ExecutionContext | undefined›; ``` Every key is required, as on `ExecutionContextAssemblyInput`: a door with no value passes `undefined` or the withholding literal, so no door can drop an input by leaving it out. The function catches nothing around the resolver. An unreadable permission store, tenancy service or active-gate re-read (`AuthzStoreUnavailableError`, 503), and an API key its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403), reach the door's own net. Two notes on the name, for the seat. A runtime-internal helper of the same name was deleted in the GraphQL retirement (`packages/spec/CHANGELOG.md`, "the now-dead `resolveRequestExecutionContext` helper removed"). It was never exported, and this export is unrelated. The ruling's other name candidates are open if the seat prefers one. ## H1: the door differences, counted before slice 1 was written (`origin/main` `7be3cc728`) Every door was read one step at a time: the session read, the ADR-0069 gate, `resolveAuthzContext`, the posture classification, the anonymous entry, and any extra fields. | # | step | rest `rest-server.ts` `computeExecCtx` | runtime `resolve-execution-context.ts` | settings `settings-service-plugin.ts` `verifiedContextFromRequest` | storage `storage-service-plugin.ts` `buildFileReadAuthorizer` + `buildAuthGateSessionReader` | hono `current-user-endpoints.ts` `makeExecutionContextResolver` | named parameter | |:--|:--|:--|:--|:--|:--|:--|:--| | 1 | anonymous entry | fail-closed (no context, 401) | guest envelope + guest anchor grants | fail-closed (`{ enforced: true }`) | fail-closed (`'unauthenticated'`) | fail-closed (no context) | `anonymous` | | 2 | ADR-0069 gate | key: the admission's posture; session: re-read only while `isAuthGateActive()`, a failed re-read is 503 | withheld (the dispatcher's `enforceAuthGate` reads the session; a key's answer leaves through `onAdmission`) | `authz.authGate` from the admitting read, no re-read | its own gate reader: active check, key arm, session re-read, a failed re-read is 503 | withheld (`/me` routes are allow-listed for a gated user) | `authGate` | | 3 | credential doors | API key + session | API key + session | API key + session | API key + session | session only (`headers: undefined` to the resolver) | `credentials` | | 4 | no session API | no context (the key is not honoured) | resolves (the key is honoured) | resolves | no auth service means no authorizer (door-level); otherwise resolves | no context | `sessionApi` | | 5 | session source | the auth service's in-process read | the same; on `/mcp`, a verified token's session, or none for an unverifiable JWT | the same | the same | the same (it drops a session with no `user.id`) | `session` | | 6 | tenancy posture | kernel `getServiceAsync`, or the provider | the lookup thunk | kernel `getServiceAsync` when present | registry `getServiceAsync` when present | none (the session's claim stands) | `tenancy` (the resolution stays at the door, as #16013 left it) | | 7 | admission channel | none | `onAdmission` | none | none | none | `onAdmission` | | 8 | localization | settings provider | settings lookup | none (no envelope) | none (no envelope) | withheld (the endpoint reads the cascade) | `localization` | | 9 | request locale | header or `?locale` (`metaRequestLocale`) | `Accept-Language` | none | none | none | `requestLocale` | | 10 | session bearer | withheld | carried | none | carried (the raw envelope) | withheld | `accessToken` | | 11 | OAuth provenance | none | `/mcp` only | none | none | none | `oauth` | **Count: 11 differences, and every one is a named parameter. Zero doors need the item-5 fallback, so the premise holds.** The remaining differences are door-side. They are not the composition, they stay at each door, and none of them is a copy: - How a door turns its transport's headers into a Web `Headers`. The function takes a `Headers`. - How a door reaches its services. - How a door answers a fault. The function propagates every fault. - What a door adds after assembly: rest adds `__kernel`, `__authEnvironmentId`, the per-request memo and the perf disclosure; hono adds the perf disclosure. - What a door does with the envelope: settings projects it to `SettingsContext`, and storage hands it to `engine.find` as `context`. ## H2: the OAuth arm The arm is split, measured as follows: - **Stays at the door, ahead of the call.** Verification, and reading the scopes in the AI vocabulary (`scopesToAgentPermissionSets`, `MCP_OAUTH_SCOPE_ACTIONS`). Both need `@objectstack/spec/ai`, which core deliberately does not depend on (see the `OAuthTokenProvenance` docblock in `assemble-execution-context.ts`). - **Enters the function as two named parameters.** `session` (`{ established }` for a verified token, `'none'` for an unverifiable JWT bearer) and `oauth`. #24137's pins, `resolve-execution-context.oauth-organization.test.ts`, are green and unchanged (15 of 15). ## H3: the published surface - Core's `.` entry reaches the new export through `export * from './security/index.js'` (`packages/core/src/index.ts:68`). - No export census lists core's names. `classifyAdmissionTenancyPosture` appears nowhere outside `src/`: 0 hits in `scripts/` and in package JSON files. - The published-surface gates the derivation named are in the gates section below. ## H4: dependency direction The new module imports: - core-internal modules; - `@objectstack/spec/kernel` and `@objectstack/spec/contracts`, types only; - `@objectstack/types` (`inProcessSessionReadInput`). Both packages are already core dependencies, so no dependency was added. Nothing in the module imports runtime, rest or a plugin. ## Ruling item 4: the conformance test Each converted door is a row. A row gives the door's own entry and the named parameters it declares. Every request is driven through both the door and the function, and the two contexts must be equal. A principal must also be the same principal at every door. The five requests were chosen so that each parameter changes at least one answer: no credential; a gated session user with a bearer and `Accept-Language`; an API key; an ex-member's key under a walled posture; and an API key on a host with no auth service. A CONTROL case shows that the fixture tells the runtime door's parameters apart. Slice 2 adds its door as one more row. ## Tests (HEAD `f5a955e12`, after merging `origin/main` `af284b05e`) - `pnpm --filter @objectstack/core exec vitest run --project local`: 104 files, 2551 tests passed. - The edited repo-project ledger, `vitest run --project repo src/security/authz-store-unavailable.test.ts`: 40 passed. - `pnpm --filter @objectstack/runtime exec vitest run --project local`: 413 files, 6318 passed and 19 skipped (VERDICT command-exit 0). - The door pins and the dispatcher pins that drive the door, with the verbose reporter, at `7667d9b4d` before the merge (the post-merge full suite above includes them): 7 files, 111 tests passed. These are `resolve-execution-context.test.ts` (39), `resolve-execution-context.oauth-organization.test.ts` (15), the conformance test (7), `http-dispatcher.mcp-oauth`, `http-dispatcher.tenancy-posture-outage`, `http-dispatcher.keys` and `in-process-session-renewal.pin`. - `pnpm --filter @objectstack/core typecheck` and `pnpm --filter @objectstack/runtime typecheck` both exit 0. The test layers are held at their ledgers: core 4 files and 4 errors, runtime 27 files and 190 errors, unchanged. **Ablation of choice 1** (at `7667d9b4d`). Declared direction: red. The mutation makes the function always take the fail-closed entry: `return assembleExecutionContext(assembly); // ABLATION-23217-anonymous-entry`. - **Landing.** Through `scripts/ablation-replace.mjs`: anchor 1 to 0, marker 0 to 1, blob `e3a17add` to `86ec4e1f`. - **No dist step.** Runtime's vitest aliases `@objectstack/core` to `../core/src/index.ts`, so no `dist/` step applies. - **Result.** Core: 1 failed and 19 passed. The failing case is the guest-envelope case of choice 1. Runtime: 10 failed and 36 passed. Nine are door pins, among them "a sessionless request still yields the WHOLE guest envelope" and the tenancy "POSITIVE CONTROL". The tenth is the conformance CONTROL. - **Restore.** The blob is `e3a17add` again, equal to HEAD, and `git diff HEAD` is empty. - **Reading.** The conformance equalities themselves stay green under this ablation, by construction: both sides go through the one function. The CONTROL case and the unit battery are what catch a defect in the function. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f5a955e12` derived 84 commands. Results follow in the os-dev-report. The lane blind spot, `pnpm exec eslint --no-inline-config` over the 7 changed `.ts`/`.mjs` files, exits 0 with no output. `node scripts/check-single-authz-resolver.mjs --self-test` and the gate itself both exit 0 ("all 3 entry points delegate"). ## Acceptance notes - **Runtime door, one ordering difference no production lookup reaches.** The door now looks up `auth` once, at the top. It used to look it up three times: two lookups absorbed their faults, and the gate-source lookup, which ran after the tenancy read, did not absorb a synchronous throw. Every answer is the same for a lookup that resolves or rejects. The answers differ only when the `auth` lookup throws synchronously and a tenancy service that failed to build is also present: the synchronous error now wins over the 503. The dispatcher's lookup (`resolveService`) never throws. - **The card's text.** #23217's title ("not a published export") and its scope item 3 ("No new published export") still read as ruling C′ wrote them. Letter A amended item 1 to an export from core's `.` entry. That is a text fix for the seat, not a code question. - **Storage (slice 4).** Storage hands the raw `ResolvedAuthzContext` to `engine.find` as `context`. The function's envelope adds `isSystem: false` and `principalKind: 'human'` and drops `credential`. Measured: no objectql, plugin-security or plugin-sharing source reads `context.credential`, and `principalKind` is read for `'guest'` only (`explain-engine.ts:149`). Slice 4's pins decide whether the envelope may be passed instead. - **Hono (slice 5).** Hono's session read drops a session that has no `user.id`. The resolver reads `user.id ?? session.userId`, and no better-auth read answers without `user.id`. Slice 5 measures this. ## Handover for slice 2 (rest) - **The call.** Convert `computeExecCtx` to call the function with `anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`, `credentials: 'api-key-and-session'`, `sessionApi: 'required'`, `session: 'request'`, `localization: { settings: () => provider ? seamOrUndefined(() => provider(env)) : undefined }`, `requestLocale: this.extractLocale(req)`, `accessToken: 'withhold'`, `oauth: undefined` and `onAdmission: undefined`. - **The tenancy thunk.** It stays rest's own: the kernel-or-provider branch. - **Kept at the door.** The `if (!authService) return undefined` lookup, the header normalisation, `__kernel`, `__authEnvironmentId`, the memo, the perf disclosure, and the `catch` with its lifecycle-refusal side channel. - **Then.** Move `DELEGATORS`' rest row to `resolveRequestExecutionContext`, add rest as a conformance row (runtime already depends on `@objectstack/rest`), and pin ADR-0096 and ADR-0069 per the ruling. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbfa5e4 commit f5ef054

8 files changed

Lines changed: 1055 additions & 231 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): export `resolveRequestExecutionContext`, the one composition that turns an inbound request into the caller's `ExecutionContext`
6+
7+
Clause-②: yes (widening)
8+
9+
- **New export from `@objectstack/core`'s `.` entry.** `resolveRequestExecutionContext(input)` reads the session through the auth service (the in-process rule: a request carrying a session cookie reads without renewal), classifies the tenancy posture with `classifyAdmissionTenancyPosture`, resolves the principal and its grants with `resolveAuthzContext`, resolves the reference localization, decides the ADR-0069 authentication-policy gate and assembles the envelope with `assembleExecutionContext` or `assembleExecutionContextOrGuest`. Its input type `RequestExecutionContextInput` and the types `RequestAuthService`, `EstablishedRequestSession` and `RequestAdmission` are exported with it.
10+
- **Two choices, made once.** `anonymous: 'fail-closed'` yields no context for a request that resolves no principal (the door answers 401); `anonymous: 'guest'` serves it as the guest principal with the guest anchor's grants. `authGate: 'withhold' | 'resolved' | 'reread-when-active'` says whether the gate's posture rides the envelope, and how it is read.
11+
- **Every other difference between doors is a named, required parameter:** `credentials`, `sessionApi`, `session`, `tenancy`, `localization`, `requestLocale`, `accessToken`, `oauth` and `onAdmission`. A door with no value passes `undefined` or the withholding literal, so no door can drop an input by omitting it.
12+
- **First caller.** The runtime dispatcher's identity door (`@objectstack/runtime`'s `resolveExecutionContext`) now calls it. What that door answers is unchanged: the same guest envelope, the same 503 for a tenancy service that failed to build, the same OAuth agent principal on `/mcp`.
13+
- **Faults propagate.** It catches nothing around the resolver: an unreadable permission store or tenancy service (`AuthzStoreUnavailableError`, 503) and an API key its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403) reach the caller, which answers them at its own net.

‎packages/core/src/security/authz-store-unavailable.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,11 @@ const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url));
180180
*/
181181
const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
182182
'packages/rest/src/rest-server.ts': 'guarded',
183-
'packages/runtime/src/security/resolve-execution-context.ts': 'propagates',
183+
// The one request-to-ExecutionContext composition (ruling record 6227928430,
184+
// letter A). It catches nothing around the resolver: every door that calls
185+
// it — the runtime dispatcher's `resolve-execution-context.ts` first —
186+
// receives the outage and answers it at its own net.
187+
'packages/core/src/security/request-execution-context.ts': 'propagates',
184188
'packages/mcp/src/plugin.ts': 'propagates',
185189
'packages/services/service-datasource/src/admin-routes.ts': 'guarded',
186190
'packages/services/service-settings/src/settings-service-plugin.ts': 'guarded',

‎packages/core/src/security/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ export {
160160
type TenancyServiceResolver,
161161
} from './admission-tenancy-posture.js';
162162

163+
// [ruling record 6227928430, letter A] The ONE request-to-ExecutionContext
164+
// composition the platform's doors call: the session read, the posture
165+
// classification above, `resolveAuthzContext`, the localization, the ADR-0069
166+
// gate and the assembly below, with the anonymous entry and the gate decided
167+
// once and every door's intentional difference a named parameter.
168+
export {
169+
resolveRequestExecutionContext,
170+
type RequestExecutionContextInput,
171+
type RequestAuthService,
172+
type EstablishedRequestSession,
173+
type RequestAdmission,
174+
} from './request-execution-context.js';
175+
163176
// [commit 6a180e42d] The LOUD failure an unreachable permission store raises, and the
164177
// brand predicate a fail-closed `catch` uses to re-raise it instead of
165178
// degrading an outage into a capability denial. Ruled 2026-08-30.

‎packages/core/src/security/request-execution-context.test.ts‎

Lines changed: 393 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* resolveRequestExecutionContext — the ONE composition that turns an inbound
5+
* request into the caller's {@link ExecutionContext}.
6+
*
7+
* ## Why it exists (ruling record 6227928430, letter A)
8+
*
9+
* Five platform doors each hand-rolled the same steps around the shared
10+
* pieces: read the session through the auth service, classify the tenancy
11+
* posture, call `resolveAuthzContext`, pick the anonymous entry, resolve the
12+
* localization, decide the ADR-0069 gate, and assemble the envelope. Each copy
13+
* was repaired separately when it omitted one input another copy had (the
14+
* tenancy posture, the in-process session read, six envelope fields). This
15+
* function is those steps, once. A door supplies the pieces it holds and
16+
* states its intentional differences as the named parameters below; ⛔ a door
17+
* never re-composes the steps beside it.
18+
*
19+
* ## The two choices, made here
20+
*
21+
* 1. **The anonymous entry** ({@link RequestExecutionContextInput.anonymous}).
22+
* `'fail-closed'` is the default entry: a request that resolves no
23+
* principal yields NO context, and the door answers 401
24+
* (`assembleExecutionContext`). `'guest'` serves the request as the
25+
* first-class guest principal and resolves the guest anchor's grants
26+
* (`assembleExecutionContextOrGuest`, `resolveAuthzContext`'s
27+
* `guestEntry`). A door takes `'guest'` only when serving anonymous
28+
* principals is its product semantics (ADR-0138 D2 lists those doors).
29+
* 2. **The ADR-0069 authentication-policy gate**
30+
* ({@link RequestExecutionContextInput.authGate}). The posture is decided
31+
* for whichever credential admitted the caller, by the shared key-admission
32+
* seam for an API key and from the session read for a session. A door either
33+
* carries it on the envelope for a consumer that reads it there, or
34+
* withholds it because it enforces the gate at its own seam.
35+
*
36+
* ## The named parameters, and the door each one exists for
37+
*
38+
* Every key is REQUIRED, the way `ExecutionContextAssemblyInput`'s are: a door
39+
* that has no value passes `undefined` or the withholding literal on the
40+
* record, so a door cannot drop an input by omission.
41+
*
42+
* | parameter | values, and the door that takes each |
43+
* |:--|:--|
44+
* | `anonymous` | `'guest'`: the runtime / MCP dispatcher. `'fail-closed'`: REST, settings, storage, the `/auth/me` endpoints |
45+
* | `authGate` | `'withhold'`: the dispatcher (its `enforceAuthGate` re-reads the session; a key's answer leaves through `onAdmission`) and the `/auth/me` endpoints (allow-listed for a gated user). `'resolved'`: settings. `'reread-when-active'`: REST and the storage family door |
46+
* | `credentials` | `'session-only'`: the `/auth/me` endpoints, which never opened an API-key door. `'api-key-and-session'`: every other door |
47+
* | `sessionApi` | `'required'`: REST and the `/auth/me` endpoints, which authenticate nobody without a session API. `'optional'`: the dispatcher, settings and storage, which still admit an API key |
48+
* | `session` | `'request'`: every door. `{ established }` / `'none'`: the dispatcher's `/mcp` face, for a verified / an unverifiable OAuth access token |
49+
* | `tenancy` | the door's own way to reach the `tenancy` service (#16013: the resolution stays at the door), or `undefined` where the door reads no posture (the `/auth/me` endpoints) |
50+
* | `localization` | the door's settings-service lookup (the dispatcher, REST), or `'withhold'` (the `/auth/me` endpoints read the cascade per endpoint) |
51+
* | `requestLocale` | the door's own parse: `Accept-Language` (the dispatcher), the header or `?locale` (REST), none (the `/auth/me` endpoints) |
52+
* | `accessToken` | `'carry'`: the dispatcher. `'withhold'`: REST and the `/auth/me` endpoints |
53+
* | `oauth` | the dispatcher's `/mcp` face, which alone speaks the OAuth scope vocabulary; `undefined` elsewhere |
54+
* | `onAdmission` | the dispatcher, whose gate needs the credential the envelope does not carry; `undefined` elsewhere |
55+
*
56+
* ## What stays at the door
57+
*
58+
* How the door reaches its services (a kernel per environment, a plugin
59+
* context, a registry slice), how it turns its transport's headers into a Web
60+
* `Headers`, how it answers a fault (this function PROPAGATES every
61+
* rejection: an unreadable permission store, an unreadable posture or gate, an
62+
* API key its own lifecycle refuses), and what it does with the context
63+
* afterwards. Those are transport facts, not the composition.
64+
*/
65+
66+
import type { ExecutionContext } from '@objectstack/spec/kernel';
67+
import type { AuthSessionApi, IAuthService } from '@objectstack/spec/contracts';
68+
import { inProcessSessionReadInput } from '@objectstack/types';
69+
70+
import { classifyAdmissionTenancyPosture, type TenancyServiceResolver } from './admission-tenancy-posture.js';
71+
import { apiKeyLifecyclePolicySourceOf } from './api-key.js';
72+
import {
73+
assembleExecutionContext,
74+
assembleExecutionContextOrGuest,
75+
type EntryLocalization,
76+
type ExecutionContextAssemblyInput,
77+
type OAuthTokenProvenance,
78+
} from './assemble-execution-context.js';
79+
import { normalizeAuthGate, userAuthGateSourceOf, type AuthGate } from './auth-gate.js';
80+
import { AuthzStoreUnavailableError } from './authz-store-unavailable.js';
81+
import { resolveAuthzContext, resolveLocalizationContext, type ResolvedAuthzContext } from './resolve-authz-context.js';
82+
83+
/** The slice of the kernel's `auth` service this composition reads. */
84+
export type RequestAuthService = Pick<
85+
IAuthService,
86+
'api' | 'getApi' | 'isAuthGateActive' | 'resolveUserAuthGates' | 'resolveApiKeyLifecyclePolicy'
87+
>;
88+
89+
/**
90+
* A session a door has already established from a credential other than the
91+
* request's own session: the dispatcher's verified OAuth access token. The
92+
* organization is the one the token's consent bound, and the session arm vets
93+
* it as it vets a browser session's active organization (ADR-0090 D10).
94+
*/
95+
export interface EstablishedRequestSession {
96+
user: { id: string };
97+
session: { userId: string; activeOrganizationId?: string };
98+
}
99+
100+
/** Which credential admitted the principal, and the ADR-0069 posture decided for it. */
101+
export interface RequestAdmission {
102+
credential: 'api_key' | 'session';
103+
authGate: AuthGate | undefined;
104+
}
105+
106+
/** Everything the composition reads. Every key is required; see the module docblock. */
107+
export interface RequestExecutionContextInput {
108+
/** The request's headers, as a Web `Headers`. */
109+
headers: Headers;
110+
/** The data engine (ObjectQL), or `undefined` when the door has none. */
111+
ql: unknown;
112+
/** The kernel's `auth` service, or `undefined` when none is reachable. */
113+
authService: RequestAuthService | undefined;
114+
/**
115+
* How this door reaches the `tenancy` service, handed to
116+
* `classifyAdmissionTenancyPosture` (never registered: no posture; failed:
117+
* the 503 outage). `undefined`: this door reads no posture.
118+
*/
119+
tenancy: TenancyServiceResolver | undefined;
120+
/** Choice 1: the anonymous entry. */
121+
anonymous: 'fail-closed' | 'guest';
122+
/**
123+
* Choice 2: the ADR-0069 gate on the envelope.
124+
*
125+
* - `'withhold'`: never on the envelope; the door enforces the gate itself.
126+
* - `'resolved'`: the posture `resolveAuthzContext` decided for whichever
127+
* credential admitted the caller, from the read that admitted it.
128+
* - `'reread-when-active'`: an API key's posture as `'resolved'`; for the
129+
* request's own session, read again only while the auth service says a
130+
* gate is active (`isAuthGateActive()`, a probe that throws reads as
131+
* inactive). A re-read that fails is `AuthzStoreUnavailableError('auth_gate')`:
132+
* a gate the deployment declared active is never dropped silently.
133+
*/
134+
authGate: 'withhold' | 'resolved' | 'reread-when-active';
135+
/** Which credential doors this door opens. `'session-only'` reads no API key. */
136+
credentials: 'api-key-and-session' | 'session-only';
137+
/**
138+
* `'required'`: with no auth service, or one exposing no session read, this
139+
* door authenticates nobody, the API-key door included. `'optional'`: the
140+
* API-key door still admits.
141+
*/
142+
sessionApi: 'optional' | 'required';
143+
/**
144+
* Where the session arm reads its session. `'request'`: the auth service's
145+
* read of this request, under the in-process rule (a request carrying a
146+
* session cookie reads without renewal). `{ established }`: a session the
147+
* door established from another credential. `'none'`: the door's own
148+
* credential failed, and no session answers for it, ⛔ the cookie beside it
149+
* included.
150+
*/
151+
session: 'request' | 'none' | { readonly established: EstablishedRequestSession };
152+
/**
153+
* For an authenticated principal, the door's settings-service lookup for the
154+
* reference localization (a lookup that rejects reads as no service), or
155+
* `'withhold'` to resolve none.
156+
*/
157+
localization: 'withhold' | { readonly settings: () => unknown };
158+
/** The request's own locale preference, parsed by the door; wins over the workspace default. */
159+
requestLocale: string | undefined;
160+
/** Whether the session bearer rides the envelope to hooks (`session.accessToken`). */
161+
accessToken: 'carry' | 'withhold';
162+
/** OAuth access-token provenance, derived by the one door that accepts one. */
163+
oauth: OAuthTokenProvenance | undefined;
164+
/** Told which credential admitted the principal; not called for an anonymous request. */
165+
onAdmission: ((admission: RequestAdmission) => void) | undefined;
166+
}
167+
168+
/** `.api` (the legacy direct mount) or `getApi()` (the lazy plugin). */
169+
async function sessionApiOf(authService: RequestAuthService | undefined): Promise<AuthSessionApi | undefined> {
170+
let api = authService?.api;
171+
if (!api && typeof authService?.getApi === 'function') api = await authService.getApi();
172+
return api;
173+
}
174+
175+
/** Choice 2, for a resolved principal. */
176+
async function decideAuthGate(
177+
input: RequestExecutionContextInput,
178+
authz: ResolvedAuthzContext,
179+
sessionApi: () => Promise<AuthSessionApi | undefined>,
180+
): Promise<AuthGate | undefined> {
181+
if (input.authGate === 'withhold') return undefined;
182+
if (input.authGate === 'resolved') return authz.authGate;
183+
// 'reread-when-active'. A key, or a session the door established from another
184+
// credential, is judged by the answer the admission already decided.
185+
if (authz.credential !== 'session' || input.session !== 'request') return authz.authGate;
186+
let active = false;
187+
try {
188+
active = input.authService?.isAuthGateActive?.() === true;
189+
} catch {
190+
// A probe that faults never declared a gate.
191+
active = false;
192+
}
193+
if (!active) return undefined;
194+
let gatedSession: Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;
195+
try {
196+
const api = await sessionApi();
197+
gatedSession = await api!.getSession!(inProcessSessionReadInput(input.headers));
198+
} catch (err) {
199+
throw new AuthzStoreUnavailableError('auth_gate', err);
200+
}
201+
return normalizeAuthGate(gatedSession?.user) ?? undefined;
202+
}
203+
204+
/**
205+
* Resolve the request's {@link ExecutionContext}: the guest entry always
206+
* yields one; the fail-closed entry yields `undefined` for a request that
207+
* resolves no principal.
208+
*
209+
* @throws {AuthzStoreUnavailableError} an authorization input that exists and
210+
* could not be read (the permission store, the `tenancy` service, an active
211+
* gate's re-read).
212+
* @throws {ApiKeyLifecycleRefusalError} an API key its own lifecycle refuses.
213+
*/
214+
export function resolveRequestExecutionContext(
215+
input: RequestExecutionContextInput & { anonymous: 'guest' },
216+
): Promise<ExecutionContext>;
217+
export function resolveRequestExecutionContext(
218+
input: RequestExecutionContextInput,
219+
): Promise<ExecutionContext | undefined>;
220+
export async function resolveRequestExecutionContext(
221+
input: RequestExecutionContextInput,
222+
): Promise<ExecutionContext | undefined> {
223+
const { headers, ql, authService } = input;
224+
225+
// A door that requires a session API asks for it before any read, and
226+
// without one authenticates nobody. A door that does not asks lazily, from
227+
// the session read itself, so an API-key request never builds the auth
228+
// instance.
229+
let requiredApi: AuthSessionApi | undefined;
230+
let credentialsOpen = true;
231+
if (input.sessionApi === 'required') {
232+
requiredApi = await sessionApiOf(authService);
233+
if (typeof requiredApi?.getSession !== 'function') {
234+
if (input.anonymous === 'fail-closed') return undefined;
235+
credentialsOpen = false;
236+
}
237+
}
238+
const sessionApi = () => (input.sessionApi === 'required' ? Promise.resolve(requiredApi) : sessionApiOf(authService));
239+
240+
const tenancyPosture = input.tenancy ? await classifyAdmissionTenancyPosture(input.tenancy) : undefined;
241+
242+
const session = input.session;
243+
const readSession = async () => {
244+
if (session === 'none') return undefined;
245+
if (typeof session === 'object') return session.established;
246+
try {
247+
const api = await sessionApi();
248+
return await api?.getSession?.(inProcessSessionReadInput(headers));
249+
} catch {
250+
return undefined;
251+
}
252+
};
253+
254+
const authz = await resolveAuthzContext({
255+
ql,
256+
headers: credentialsOpen && input.credentials === 'api-key-and-session' ? headers : undefined,
257+
getSession: credentialsOpen ? readSession : undefined,
258+
tenancyPosture,
259+
guestEntry: input.anonymous === 'guest' ? true : undefined,
260+
userAuthGates: userAuthGateSourceOf(authService),
261+
apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService),
262+
});
263+
if (authz.userId && authz.credential) {
264+
input.onAdmission?.({ credential: authz.credential, authGate: authz.authGate });
265+
}
266+
267+
// An anonymous request has no scope to localize against and no session for a
268+
// gate to attach to: both are read only for a resolved principal.
269+
let localization: EntryLocalization | undefined;
270+
let authGate: AuthGate | undefined;
271+
if (authz.userId) {
272+
if (input.localization !== 'withhold') {
273+
const settings = await Promise.resolve(input.localization.settings()).catch(() => undefined);
274+
localization = await resolveLocalizationContext({ ql, settings, tenantId: authz.tenantId, userId: authz.userId });
275+
}
276+
authGate = await decideAuthGate(input, authz, sessionApi);
277+
}
278+
279+
const assembly: ExecutionContextAssemblyInput = {
280+
authz,
281+
oauth: input.oauth,
282+
localization,
283+
requestLocale: input.requestLocale,
284+
accessToken: input.accessToken === 'carry' ? authz.accessToken : undefined,
285+
authGate,
286+
};
287+
return input.anonymous === 'guest' ? assembleExecutionContextOrGuest(assembly) : assembleExecutionContext(assembly);
288+
}

0 commit comments

Comments
 (0)