Skip to content

Commit 0d34066

Browse files
refactor(rest): computeExecCtx calls the one request-to-ExecutionContext composition, resolveRequestExecutionContext (#24260)
Part of #23217 Clause-②: no Ruling-ref: 6227928430 Slice 2 of #23217 under ruling record 6227928430 (letter A): REST's door, `RestServer#computeExecCtx` in `packages/rest/src/rest-server.ts`, calls the one `resolveRequestExecutionContext` that slice 1 landed in `@objectstack/core` (`f5ef054a7`). #23217 remains open for three doors (`service-settings`, `service-storage`, `plugin-hono-server`), each a later slice. No export moves and there is no app-author surface. ## What changed - **`computeExecCtx`** keeps what is this door's own and calls the function once. 192 lines out, 90 in. What stays at the door, in its old order: the environment and auth-service resolution with the `if (!authService) return undefined` early return, the header normalisation (append per array element), the engine through `wiredEngineOrLoud` (the kernel branch still absorbs), the tenancy wiring (now a thunk: the kernel's `getServiceAsync('tenancy')`, else `tenancyServiceProvider`, else none), then `__kernel`, `__authEnvironmentId`, the `Server-Timing` disclosure, the memo in `resolveExecCtx`, and the fail-closed `catch` with its `apiKeyLifecycleRefusals` side channel. - **REST's named parameters**, each measured against the hand-written code on `f5ef054a7` before it was replaced (slice 1's handover, confirmed row by row): | parameter | REST passes | what the hand-written code did | |:--|:--|:--| | `anonymous` | `'fail-closed'` | `if (!authz.userId) return undefined` and `assembleExecutionContext` | | `authGate` | `'reread-when-active'` | a key: `authz.authGate`. A session: re-read only while `isAuthGateActive()`; a throwing probe reads as inactive; a failed re-read is `AuthzStoreUnavailableError('auth_gate')` | | `credentials` | `'api-key-and-session'` | `headers` and `getSession` both handed to `resolveAuthzContext` | | `sessionApi` | `'required'` | `if (!api?.getSession) return undefined`, the API-key door included | | `session` | `'request'` | `api.getSession(inProcessSessionReadInput(headers))`, a throw read as no session | | `tenancy` | the thunk above | `classifyAdmissionTenancyPosture` on the same two wirings | | `localization` | `{ settings: () => provider ? seamOrUndefined(provider(env)) : undefined }` | the same lookup, then `resolveLocalizationContext` | | `requestLocale` | `this.extractLocale(req)` | the same parse, at assembly | | `accessToken` | `'withhold'` | `accessToken: undefined` | | `oauth` / `onAdmission` | `undefined` / `undefined` | none | - **The readers move with the call.** - `scripts/check-single-authz-resolver.mjs`: the rest row of `DELEGATORS` now names `resolveRequestExecutionContext`. Left on `resolveAuthzContext`, check (2) would pass on the five prose mentions of it that remain in the file. - `packages/core/src/security/authz-store-unavailable.test.ts`: `rest-server.ts` no longer calls `resolveAuthzContext({`, so its ledger row goes (the set-equality audit requires it). The scanner's positive control now names the composition's file. - `packages/rest/src/execctx-authz-input-seam-reachability.test.ts` §0 and `auth-gate-allowlist-fault-window.measurement.test.ts`'s structural pin read source text inside `computeExecCtx`. They are re-aimed in place, with the superseded pins quoted, as those files ask. Each still holds its subject: both tenancy wirings reach the classification, the tenancy region holds no `catch`, the door declares `'reread-when-active'` and keeps no second copy of the gate decision, and the call hands over no path. The decision's own window is pinned in core's `request-execution-context.test.ts` (choice 2). The behavioural drives in both files are unchanged and green. - `packages/runtime/src/security/request-execution-context.conformance.test.ts` (ruling item 4) gains REST as a row. It drives `RestServer.resolvePackageRouteExecutionContext` on the single-kernel wiring and sets aside `__kernel` and `__authEnvironmentId` by name (both asserted absent on this wiring). It has a new request that tells `'resolved'` from `'reread-when-active'` (the gated session while no gate is active) and a REST CONTROL case. Who a request resolves to is now derived from the row's declared `sessionApi`: REST authenticates nobody on a host with no auth service, while the runtime door admits the key there. - **The ruling's one ADR-0096 / ADR-0069 pin** is in `packages/rest/src/rest-exec-ctx-principal-kind.test.ts`. It is a six-row table driven through the real `GET /api/v1/data/:object` route and read at the protocol: - no credential gets `401` with exactly `ANONYMOUS_DENY_BODY`, and `findData` is never called; - a held session while a gate is active gets `403` with its code and message, and the engine is never asked; - the same session while no gate is active is admitted as `member1`, with no `authGate` on the envelope; - an API key beside the held cookie answers for its own owner; - an active gate whose re-read fails gets `503 SERVICE_UNAVAILABLE`, and the engine is never asked; - CONTROL: an ungated session is admitted. - **Changeset:** `patch` on `@objectstack/rest` (`.changeset/23217-s2-rest-door.md`). The published `rest-server` code changes, and the one ordering note below is a fact a reader of the package can meet. ## Evidence (head `5acdd53a9`) - **Merge of `main` at `fb6fb24e2`** (merge commit `5acdd53a9`; no rebase, amend or force-push). One conflicted hunk: the import block of `rest-server.ts` (about :220). Main (#23050 slice 13c) dropped the `metaSaveRequestOptions` import, and this branch dropped `AuthSessionApi` from the contracts type import. It was resolved to main's two comment lines and `import type { ISecurityService } from '@objectstack/spec/contracts';`. Neither symbol is used as code in the merged file; the only mention left is that comment. The merged file differs from main by exactly this slice's 284 changed lines, and the PR still touches the same 8 files. Census counts in `rest-server.ts`, main / merge: `resolveExecCtx` 55 / 55, `enforceAuth` 40 / 40, `seamOrUndefined` 11 / 11, `wiredEngineOrLoud` 11 / 11. Re-measured on the merge, after rebuilding the closure: - `@objectstack/rest` typecheck exit 0 (test layer 0 / 0); - ESLint `--no-inline-config` on `rest-server.ts` exit 0; - the REST full local project: `Test Files 295 passed (295)`, `Tests 6042 passed | 326 skipped (6368)`, which includes this slice's pins, the censuses and the `meta-disabled-package-doors.test.ts` file main brought; - conformance `Tests 9 passed (9)` and the core ledger `Tests 39 passed (39)`; - gates: 87 derived, 86 run with exit 0, and `check:dual-build-cjs-loads` NOT MEASURED (exit 3), reconciled `0 UNRUN`. The readings below were taken on the pre-merge head `96f05115f`. - **Before / after.** The pin was committed first, on the hand-written door (`865e7d135`): `Tests 20 passed (20)`. On the converted door it is green again, inside the full suite. - **REST, full local project, under the verify lock:** `Test Files 294 passed (294)`, `Tests 6036 passed | 326 skipped (6362)`, `VERDICT command-exit 0`. - **Targeted REST families** (execctx, tenancy posture, API-key lifecycle and account posture, auth gate, direct mounts, in-process session read, memo, censuses), at `e6e916a00` (same code): `Test Files 21 passed (21)`, `Tests 330 passed (330)`. - **Conformance:** `Tests 9 passed (9)` (two rows, six requests, two CONTROLs). Core repo-project ledger: `Tests 39 passed (39)` (40 before, minus the rest row). - **Typecheck:** `@objectstack/rest` exit 0 (test layer 0 / 0), `@objectstack/core` exit 0 (4 files / 4 errors held), `@objectstack/runtime` exit 0 (27 files / 190 errors held). All three debt ledgers are unchanged. - **Ablation, one-off.** Direction declared before running: RED. `authGate: 'reread-when-active'` became `'withhold'` via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `4aec26e3a6c4` to `8f9ccb644aee`, marker count 1). REST gate pins: `Tests 21 failed | 61 passed (82)` over 5 of 6 files. The red pins include the new pin's 403 and 503 rows, `#7280`'s end-to-end 403, both direct-mount HELD cases and the OUTAGE case, seam §4's POSITIVE CONTROL and its 503, and the key-posture pins. `rest-auth-gate.test.ts` stays green; it hand-builds its contexts. Conformance: `Tests 2 failed | 7 passed (9)` (the session row and the REST CONTROL). Restore: `blob == HEAD (4aec26e) and git diff HEAD is empty`, marker 0. Observed direction = declared direction. - **Census terms held:** `resolveExecCtx` stays at 55 and `enforceAuth` at 40 in `rest-server.ts`. These are the bare-term counts `execctx-consumer-census.test.ts` and the dogfood `authz-probe-blind-spot.census.ts` pin; the dogfood one does not run on PRs. `seamOrUndefined` stays at 11 and `wiredEngineOrLoud` at 10. - **The composition's distinctive code lines** in `rest-server.ts`, base to head: `resolveAuthzContext({` 1 to 0, `classifyAdmissionTenancyPosture(` 2 to 0, `assembleExecutionContext(` 1 to 0, `resolveLocalizationContext(` 1 to 0, `inProcessSessionReadInput(` 2 to 0, `normalizeAuthGate(` 1 to 0. `isAuthGateActive(` goes 3 to 1, and the one left is a line of the gate table in a comment. - ESLint `--no-inline-config` over the 7 changed `.ts` / `.mjs` files: exit 0, no output. ## Acceptance notes - **One ordering moved, measured.** The door resolves the engine before the composition asks the auth service for its session read; before, it asked first. A scratch probe (not committed) drove `resolvePackageRouteExecutionContext` on both trees. Five of seven readings are identical. The two that differ are double faults on the single-kernel wiring: the configured engine provider fails AND the auth service exposes no session read (or its `getApi()` rejects). Those answered `401` (no context) and now answer `503 SERVICE_UNAVAILABLE`, the engine outage. With a working auth service the engine outage was already `503`, so the new answer no longer depends on the auth service's state. The kernel branch absorbs an engine failure, so it is unaffected. Production compositions build the auth service from plugin-auth, whose `getApi()` always yields a session read. Keeping the old order byte for byte needs either a second copy of the session-API check at the door, which letter A item 3 forbids, or a lazy `ql` input on the function, which is a change to slice 1's export and outside this slice. The changeset states the window. - **`extractLocale(req)`** is now evaluated at the call, before the reads, instead of at assembly. It is a pure parse of the headers and `?locale` that cannot throw for a request that passed the header check, so no answer moves. - **Files outside the claim's named surface**, for the seat to declare: `packages/rest/src/execctx-authz-input-seam-reachability.test.ts` and `packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts`. Both are REST source-text pins on `computeExecCtx`'s body, re-aimed in place. - Local gates: 88 derived, 87 run, all exit 0; `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs a whole-tree build), so it is NOT MEASURED locally and CI runs it. `test:repo` is CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a422f04 commit 0d34066

8 files changed

Lines changed: 420 additions & 241 deletions

‎.changeset/23217-s2-rest-door.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
REST's request identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition
6+
7+
Clause-②: no
8+
9+
- `RestServer` resolves the caller of every data, metadata and package route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`, `credentials: 'api-key-and-session'`, `sessionApi: 'required'`, `session: 'request'`, its settings lookup for the localization, its own request-locale parse, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The environment and auth-service resolution, the header normalisation, the data engine, the tenancy wiring, the per-request memo, the `Server-Timing` disclosure and the fail-closed `catch` stay at the door.
10+
- The answers are the ones the door gave before: an anonymous caller gets `401` with the shared body; a session the authentication policy holds gets `403` with its code and message while a gate feature is active; an API key is judged by its own owner's posture; an unreadable permission store, tenancy service or active gate re-read gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code.
11+
- One ordering moved, and it shows only when two faults coincide on the single-kernel wiring. The data engine is now resolved before the auth service's session read is asked for. If the configured engine provider fails AND the auth service exposes no session read (or its `getApi()` rejects), the answer is now `503 SERVICE_UNAVAILABLE` (the engine outage) instead of `401`. With a working auth service, the engine outage was already `503`.
12+
- Nothing to do for a consumer: no export, route, configuration key or response shape changed.

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url));
179179
* throw reaches the transport's error mapping unaided.
180180
*/
181181
const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
182-
'packages/rest/src/rest-server.ts': 'guarded',
183182
// The one request-to-ExecutionContext composition (ruling record 6227928430,
184183
// letter A). It catches nothing around the resolver: every door that calls
185-
// it — the runtime dispatcher's `resolve-execution-context.ts` first —
184+
// it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's
185+
// `rest-server.ts` second, whose fail-closed `catch` re-raises the outage —
186186
// receives the outage and answers it at its own net.
187187
'packages/core/src/security/request-execution-context.ts': 'propagates',
188188
'packages/mcp/src/plugin.ts': 'propagates',
@@ -299,7 +299,10 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext',
299299
// below would be comparing two empty sets and passing.
300300
const found = discoverTransports();
301301
expect(found.length).toBeGreaterThanOrEqual(8);
302-
expect(found).toContain('packages/rest/src/rest-server.ts');
302+
// A transport this repo is known to hold. [#23217 slice 2] It named
303+
// `rest-server.ts` until that door moved onto the one composition, which
304+
// is the call site REST's answers now come from.
305+
expect(found).toContain('packages/core/src/security/request-execution-context.ts');
303306
}, SCAN_BUDGET_MS);
304307

305308
it('⭐ SET EQUALITY: the ledger names exactly the transports source contains', () => {

‎packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,13 +286,27 @@ describe('[#15021] §2 the subject — an allow-listed path inside the fail-clos
286286
it('MEASURED: the throw is upstream of the path — `computeExecCtx` never reads `req.path` for the gate decision', () => {
287287
// Structural corroboration of the drive above, derived from today's source
288288
// rather than quoted from the card. `isAuthGateAllowlisted` is imported by
289-
// this module, but the gate block that raises does not consult it.
290-
const start = SOURCE.indexOf('if (gateActive) {');
289+
// this module, but the gate decision that raises does not consult it.
290+
//
291+
// SUPERSEDED READING, quoted. [#23217 slice 2, ruling record 6227928430
292+
// letter A] the gate block this sliced left `rest-server.ts` for the ONE
293+
// request-to-context composition (`resolveRequestExecutionContext`,
294+
// @objectstack/core), so its anchors are gone from this file:
295+
// const start = SOURCE.indexOf('if (gateActive) {');
296+
// const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start));
297+
// expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);");
298+
// ⭐ RE-AIMED to the door's half of the same fact: the decision is made
299+
// from what the door hands the composition, and that call names the gate
300+
// choice and hands over no path. The raise itself is pinned where it lives
301+
// (`packages/core/src/security/request-execution-context.test.ts`, choice
302+
// 2), and the drives above still read it on this door's wire.
303+
const start = SOURCE.indexOf('const base = await resolveRequestExecutionContext({');
291304
expect(start).toBeGreaterThan(-1);
292-
const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start));
293-
expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);");
294-
expect(block).not.toContain('isAuthGateAllowlisted');
295-
expect(block).not.toContain('req.path');
305+
const call = SOURCE.slice(start, SOURCE.indexOf('\n });\n', start));
306+
expect(call).toContain("authGate: 'reread-when-active',");
307+
expect(call).not.toContain('isAuthGateAllowlisted');
308+
expect(call).not.toContain('req.path');
309+
expect(call).not.toMatch(/\bpath\b/);
296310
});
297311
});
298312

‎packages/rest/src/execctx-authz-input-seam-reachability.test.ts‎

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,30 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
194194
// REACHES the shared classification, and that neither grew a `catch` of its
195195
// own again. The discriminator is still the REGISTRY's brand, never message
196196
// text (#13905); it is asserted at its new home.
197-
expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/);
198-
expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/);
197+
//
198+
// SUPERSEDED PINS, quoted for the same reason. [#23217 slice 2, ruling
199+
// record 6227928430 letter A] the door calls the ONE request-to-context
200+
// composition (`resolveRequestExecutionContext`, @objectstack/core), which
201+
// runs the classification itself on the thunk the door hands it, so the
202+
// two `await` sites these matched are gone from this file:
203+
// expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/);
204+
// expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/);
205+
// ⭐ RE-AIMED again, and the subject still has not moved: BOTH wirings
206+
// reach the shared classification. Each branch now builds the thunk, and
207+
// the thunk is what the composition is handed; that the composition
208+
// classifies whatever it is handed is pinned in
209+
// `packages/core/src/security/request-execution-context.test.ts` (its
210+
// tenancy cases), and §2/§3 below still drive the wire answer end to end.
211+
expect(body).toMatch(/tenancy = \(\) => kernel\.getServiceAsync\('tenancy'\)/);
212+
expect(body).toMatch(/tenancy = \(\) => this\.tenancyServiceProvider!\(environmentId\)/);
213+
expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*tenancy,\n/);
199214
// ⛔ NARROWNESS CONTROL for the fold: the seam region itself holds NO
200215
// `catch`. A local `catch` reappearing here is exactly the silent-`catch`
201216
// degradation #13906 decision 1 option A forbids, and it would be invisible
202-
// to the two delegation pins above.
217+
// to the delegation pins above.
203218
const tenancySeam = body.slice(
204-
body.indexOf('let tenancyPosture;'),
205-
body.indexOf('const authz = await resolveAuthzContext('),
219+
body.indexOf('let tenancy: TenancyServiceResolver | undefined;'),
220+
body.indexOf('const base = await resolveRequestExecutionContext('),
206221
);
207222
expect(tenancySeam.length).toBeGreaterThan(0);
208223
expect(tenancySeam).not.toMatch(/catch/);
@@ -212,7 +227,7 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
212227
// a `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`, and
213228
// without this guard the dereference would raise an unbranded `TypeError`
214229
// and turn that host shape into a 503.
215-
expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/);
230+
expect(body).toMatch(/let tenancy: TenancyServiceResolver \| undefined;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/);
216231
// [#15256 / 1A] …and the OTHER wiring now has a leg of its own, with the
217232
// same classification. Before this card the `if` above was the whole
218233
// block, so on the single-kernel wiring `tenancyPosture` stayed the
@@ -255,18 +270,29 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
255270
// the comment-only swallow that made a FAILED re-read of an ACTIVE gate
256271
// indistinguishable from gate-off. That swallow is gone.
257272
expect(body).not.toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/);
258-
expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/);
259-
// The re-read is loud, and it is the RAW api call — ⛔ not the swallowing
260-
// `getSession` closure, which would re-collapse the very same two facts.
261-
// [#22258] Its argument is the in-process session-read rule's input (a
262-
// cookie request reads without renewal); the call itself is still the raw,
263-
// throwing one this pin exists for.
264-
expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/);
265-
expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/);
266-
// ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host
267-
// whose probe faults never declared a gate. If this ever flips, the repair
268-
// has widened past the window the ruling named.
269-
expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/);
273+
// SUPERSEDED PINS, quoted. [#23217 slice 2, ruling record 6227928430
274+
// letter A] the gate decision left this file for the ONE request-to-context
275+
// composition, so the door's own copy of it these matched is gone:
276+
// expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/);
277+
// expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/);
278+
// expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/);
279+
// expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/);
280+
// ⭐ RE-AIMED to what stays this door's: it declares the decision the
281+
// ruling made for it, `'reread-when-active'` (an active gate's re-read is
282+
// the raw, throwing read and fails as `AuthzStoreUnavailableError('auth_gate')`;
283+
// a probe that throws reads as inactive; a key keeps its own posture), and
284+
// it holds no second copy of that decision. The decision's own window, both
285+
// legs, is pinned where it lives (`packages/core/src/security/request-execution-context.test.ts`,
286+
// choice 2), and §4 below still drives every leg through this door's wire.
287+
expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*authGate: 'reread-when-active',\n/);
288+
// ⛔ NARROWNESS CONTROL for the move: no local gate probe or re-read came
289+
// back beside the call. A copy here would drift from the composition's
290+
// window exactly as the copies this card retired did. Read on the CODE
291+
// lines only: the door's comments still name the probe to explain the
292+
// parameter.
293+
const code = body.split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
294+
expect(code).toMatch(/authGate: 'reread-when-active'/);
295+
expect(code).not.toMatch(/isAuthGateActive|gatedSession|getSession\(/);
270296
});
271297

272298
it('KNOWN NEGATIVE: the engine seam #13476 repaired is NOT flagged as absorb', () => {

‎packages/rest/src/rest-exec-ctx-principal-kind.test.ts‎

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
// the protocol actually receives.
2222

2323
import { describe, it, expect, vi } from 'vitest';
24-
import { hashApiKey } from '@objectstack/core';
24+
import { hashApiKey, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
2525
import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability';
2626
import { RestServer } from './rest-server';
2727
import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js';
@@ -127,7 +127,7 @@ function makeRes() {
127127
* ExecutionContext the transport handed it — the instrumentation point of the
128128
* issue, moved into a test.
129129
*/
130-
function boot() {
130+
function boot(auth: unknown = makeAuth()) {
131131
const seen: any[] = [];
132132
const protocol: any = {
133133
getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }),
@@ -140,7 +140,7 @@ function boot() {
140140
undefined, // kernelManager
141141
undefined, // envRegistry
142142
undefined, // defaultEnvironmentIdProvider
143-
async () => makeAuth(), // authServiceProvider
143+
async () => auth, // authServiceProvider
144144
async () => makeQl(), // objectQLProvider
145145
);
146146
rest.registerRoutes();
@@ -150,8 +150,8 @@ function boot() {
150150
}
151151

152152
/** Drive `GET /api/v1/data/task` with the given request headers. */
153-
async function request(headers: Record<string, string>) {
154-
const { route, seen, protocol } = boot();
153+
async function request(headers: Record<string, string>, auth?: unknown) {
154+
const { route, seen, protocol } = boot(auth);
155155
const out = makeRes();
156156
await route!.handler({
157157
method: 'GET',
@@ -391,3 +391,100 @@ describe('#7280 — the ADR-0069 gate posture is an ASSEMBLED field on this face
391391
expect(protocol.findData).toHaveBeenCalled();
392392
});
393393
});
394+
395+
/**
396+
* [ruling record 6227928430, letter A item 2] REST's door, proven on the one
397+
* request-to-`ExecutionContext` composition by this pin plus its existing ones.
398+
*
399+
* `computeExecCtx` resolves through `resolveRequestExecutionContext`
400+
* (`@objectstack/core`), with REST's differences stated as that function's
401+
* named parameters. The two answers a door owes before any other are pinned
402+
* here on the real data route and read at the protocol, the engine's door:
403+
*
404+
* - ADR-0096: a request that resolves no principal never reaches the engine.
405+
* REST answers it at its anonymous floor, with the shared body.
406+
* - ADR-0069: a session the authentication policy holds is refused with its
407+
* posture. REST reads the session's posture again only while the auth
408+
* service says a gate is active, a key is judged by its own owner's posture
409+
* whatever cookie rides beside it, and a re-read that fails is the outage,
410+
* never an admission.
411+
*
412+
* Every row was green on the door's hand-written composition and is green on
413+
* the shared one.
414+
*/
415+
describe('[ruling 6227928430 item 2] REST\'s door: the ADR-0096 principal-less refusal and the ADR-0069 gate on the data route', () => {
416+
const HELD = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' };
417+
418+
/** `makeAuth`'s sessions, with the gate feature on or off, and a re-read that may fail. */
419+
const authWith = (opts: { gateActive: boolean; rereadFails?: boolean }) => {
420+
const sessions = makeAuth();
421+
let reads = 0;
422+
return {
423+
isAuthGateActive: () => opts.gateActive,
424+
api: {
425+
getSession: async (input: { headers: any }) => {
426+
// The identity read is the request's first; the gate's re-read is its second.
427+
if (opts.rereadFails && ++reads > 1) throw new Error('session backend unavailable');
428+
return sessions.api.getSession(input);
429+
},
430+
},
431+
};
432+
};
433+
434+
const ROWS: Array<{
435+
name: string;
436+
headers: Record<string, string>;
437+
gate: { gateActive: boolean; rereadFails?: boolean };
438+
status: number;
439+
body?: unknown;
440+
code?: string;
441+
principal?: string;
442+
}> = [
443+
{
444+
name: 'ADR-0096: no credential is refused at the anonymous floor with the shared body, and the engine is never asked',
445+
headers: {}, gate: { gateActive: true },
446+
status: ANONYMOUS_DENY_STATUS, body: ANONYMOUS_DENY_BODY,
447+
},
448+
{
449+
name: 'ADR-0069: a held session while a gate is active is refused 403 with its posture, and the engine is never asked',
450+
headers: { cookie: 'member-gated' }, gate: { gateActive: true },
451+
status: 403, body: { error: HELD },
452+
},
453+
{
454+
name: 'ADR-0069: the same held session while no gate is active is admitted, and the engine is asked as that principal',
455+
headers: { cookie: 'member-gated' }, gate: { gateActive: false },
456+
status: 200, principal: 'member1',
457+
},
458+
{
459+
name: 'ADR-0069: an API key beside the held cookie answers for its own owner, never for the cookie',
460+
headers: { cookie: 'member-gated', 'x-api-key': RAW_API_KEY }, gate: { gateActive: true },
461+
status: 200, principal: 'member1',
462+
},
463+
{
464+
name: 'ADR-0069: an active gate whose re-read fails is the 503 outage, and the engine is never asked',
465+
headers: { cookie: 'member' }, gate: { gateActive: true, rereadFails: true },
466+
status: 503, code: 'SERVICE_UNAVAILABLE',
467+
},
468+
{
469+
name: 'CONTROL: an ungated session while a gate is active is admitted, and the engine is asked as that principal',
470+
headers: { cookie: 'member' }, gate: { gateActive: true },
471+
status: 200, principal: 'member1',
472+
},
473+
];
474+
475+
it.each(ROWS)('$name', async (row) => {
476+
const { ctx, out, protocol } = await request(row.headers, authWith(row.gate));
477+
478+
expect(out.getStatus()).toBe(row.status);
479+
if (row.body !== undefined) expect(out.getJson()).toEqual(row.body);
480+
// The data route answers a thrown outage in its flat dialect: `code` beside `error`.
481+
if (row.code !== undefined) expect(out.getJson()?.code).toBe(row.code);
482+
if (row.principal === undefined) {
483+
expect(protocol.findData).not.toHaveBeenCalled();
484+
return;
485+
}
486+
expect(protocol.findData).toHaveBeenCalledTimes(1);
487+
expect(ctx?.userId).toBe(row.principal);
488+
expect('authGate' in ctx).toBe(false);
489+
});
490+
});

0 commit comments

Comments
 (0)