From 865e7d135972aa77347cc0c7fdb8477c7b87947b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:04:48 +0000 Subject: [PATCH 1/3] test(rest): pin REST's ADR-0096 principal-less refusal and ADR-0069 gate on the data route (WIP) The one ADR-0096 / ADR-0069 pin ruling record 6227928430 item 2 asks of each later door, written against the door's current composition first so the same table reads before and after the conversion. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../src/rest-exec-ctx-principal-kind.test.ts | 107 +++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index 79b1461df97..f73b191a80b 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -21,7 +21,7 @@ // the protocol actually receives. import { describe, it, expect, vi } from 'vitest'; -import { hashApiKey } from '@objectstack/core'; +import { hashApiKey, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; import { RestServer } from './rest-server'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; @@ -127,7 +127,7 @@ function makeRes() { * ExecutionContext the transport handed it — the instrumentation point of the * issue, moved into a test. */ -function boot() { +function boot(auth: unknown = makeAuth()) { const seen: any[] = []; const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), @@ -140,7 +140,7 @@ function boot() { undefined, // kernelManager undefined, // envRegistry undefined, // defaultEnvironmentIdProvider - async () => makeAuth(), // authServiceProvider + async () => auth, // authServiceProvider async () => makeQl(), // objectQLProvider ); rest.registerRoutes(); @@ -150,8 +150,8 @@ function boot() { } /** Drive `GET /api/v1/data/task` with the given request headers. */ -async function request(headers: Record) { - const { route, seen, protocol } = boot(); +async function request(headers: Record, auth?: unknown) { + const { route, seen, protocol } = boot(auth); const out = makeRes(); await route!.handler({ method: 'GET', @@ -391,3 +391,100 @@ describe('#7280 — the ADR-0069 gate posture is an ASSEMBLED field on this face expect(protocol.findData).toHaveBeenCalled(); }); }); + +/** + * [ruling record 6227928430, letter A item 2] REST's door, proven on the one + * request-to-`ExecutionContext` composition by this pin plus its existing ones. + * + * `computeExecCtx` resolves through `resolveRequestExecutionContext` + * (`@objectstack/core`), with REST's differences stated as that function's + * named parameters. The two answers a door owes before any other are pinned + * here on the real data route and read at the protocol, the engine's door: + * + * - ADR-0096: a request that resolves no principal never reaches the engine. + * REST answers it at its anonymous floor, with the shared body. + * - ADR-0069: a session the authentication policy holds is refused with its + * posture. REST reads the session's posture again only while the auth + * service says a gate is active, a key is judged by its own owner's posture + * whatever cookie rides beside it, and a re-read that fails is the outage, + * never an admission. + * + * Every row was green on the door's hand-written composition and is green on + * the shared one. + */ +describe('[ruling 6227928430 item 2] REST\'s door: the ADR-0096 principal-less refusal and the ADR-0069 gate on the data route', () => { + const HELD = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + + /** `makeAuth`'s sessions, with the gate feature on or off, and a re-read that may fail. */ + const authWith = (opts: { gateActive: boolean; rereadFails?: boolean }) => { + const sessions = makeAuth(); + let reads = 0; + return { + isAuthGateActive: () => opts.gateActive, + api: { + getSession: async (input: { headers: any }) => { + // The identity read is the request's first; the gate's re-read is its second. + if (opts.rereadFails && ++reads > 1) throw new Error('session backend unavailable'); + return sessions.api.getSession(input); + }, + }, + }; + }; + + const ROWS: Array<{ + name: string; + headers: Record; + gate: { gateActive: boolean; rereadFails?: boolean }; + status: number; + body?: unknown; + code?: string; + principal?: string; + }> = [ + { + name: 'ADR-0096: no credential is refused at the anonymous floor with the shared body, and the engine is never asked', + headers: {}, gate: { gateActive: true }, + status: ANONYMOUS_DENY_STATUS, body: ANONYMOUS_DENY_BODY, + }, + { + name: 'ADR-0069: a held session while a gate is active is refused 403 with its posture, and the engine is never asked', + headers: { cookie: 'member-gated' }, gate: { gateActive: true }, + status: 403, body: { error: HELD }, + }, + { + name: 'ADR-0069: the same held session while no gate is active is admitted, and the engine is asked as that principal', + headers: { cookie: 'member-gated' }, gate: { gateActive: false }, + status: 200, principal: 'member1', + }, + { + name: 'ADR-0069: an API key beside the held cookie answers for its own owner, never for the cookie', + headers: { cookie: 'member-gated', 'x-api-key': RAW_API_KEY }, gate: { gateActive: true }, + status: 200, principal: 'member1', + }, + { + name: 'ADR-0069: an active gate whose re-read fails is the 503 outage, and the engine is never asked', + headers: { cookie: 'member' }, gate: { gateActive: true, rereadFails: true }, + status: 503, code: 'SERVICE_UNAVAILABLE', + }, + { + name: 'CONTROL: an ungated session while a gate is active is admitted, and the engine is asked as that principal', + headers: { cookie: 'member' }, gate: { gateActive: true }, + status: 200, principal: 'member1', + }, + ]; + + it.each(ROWS)('$name', async (row) => { + const { ctx, out, protocol } = await request(row.headers, authWith(row.gate)); + + expect(out.getStatus()).toBe(row.status); + if (row.body !== undefined) expect(out.getJson()).toEqual(row.body); + // The data route answers a thrown outage in its flat dialect: `code` beside `error`. + if (row.code !== undefined) expect(out.getJson()?.code).toBe(row.code); + if (row.principal === undefined) { + expect(protocol.findData).not.toHaveBeenCalled(); + return; + } + expect(protocol.findData).toHaveBeenCalledTimes(1); + expect(ctx?.userId).toBe(row.principal); + expect('authGate' in ctx).toBe(false); + }); +}); From e6e916a00d2e4fc10ed1260b4f5c2b5e91490eef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:13:16 +0000 Subject: [PATCH 2/3] refactor(rest): computeExecCtx calls the one request-to-ExecutionContext composition (WIP) REST's door keeps what is its own (environment and auth-service resolution, header normalisation, the engine through wiredEngineOrLoud, the tenancy wiring as a thunk, __kernel, __authEnvironmentId, the memo, the perf disclosure and the fail-closed catch) and hands the rest to resolveRequestExecutionContext with its differences as named parameters. The readers move with the call: the rest DELEGATORS row, the transport ledger, the seam pins, and a REST conformance row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../security/authz-store-unavailable.test.ts | 9 +- ...allowlist-fault-window.measurement.test.ts | 26 +- ...cctx-authz-input-seam-reachability.test.ts | 62 ++-- packages/rest/src/rest-server.ts | 284 ++++++------------ ...uest-execution-context.conformance.test.ts | 159 +++++++++- scripts/check-single-authz-resolver.mjs | 2 +- 6 files changed, 306 insertions(+), 236 deletions(-) diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index b1264f47310..28dbb05b706 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -179,10 +179,10 @@ const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); * throw reaches the transport's error mapping unaided. */ const TRANSPORT_LEDGER: Record = { - 'packages/rest/src/rest-server.ts': 'guarded', // The one request-to-ExecutionContext composition (ruling record 6227928430, // letter A). It catches nothing around the resolver: every door that calls - // it — the runtime dispatcher's `resolve-execution-context.ts` first — + // it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's + // `rest-server.ts` second, whose fail-closed `catch` re-raises the outage — // receives the outage and answers it at its own net. 'packages/core/src/security/request-execution-context.ts': 'propagates', 'packages/mcp/src/plugin.ts': 'propagates', @@ -299,7 +299,10 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext', // below would be comparing two empty sets and passing. const found = discoverTransports(); expect(found.length).toBeGreaterThanOrEqual(8); - expect(found).toContain('packages/rest/src/rest-server.ts'); + // A transport this repo is known to hold. [#23217 slice 2] It named + // `rest-server.ts` until that door moved onto the one composition, which + // is the call site REST's answers now come from. + expect(found).toContain('packages/core/src/security/request-execution-context.ts'); }, SCAN_BUDGET_MS); it('⭐ SET EQUALITY: the ledger names exactly the transports source contains', () => { diff --git a/packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts b/packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts index bdffc8e42df..824b98b6373 100644 --- a/packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts +++ b/packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts @@ -286,13 +286,27 @@ describe('[#15021] §2 the subject — an allow-listed path inside the fail-clos it('MEASURED: the throw is upstream of the path — `computeExecCtx` never reads `req.path` for the gate decision', () => { // Structural corroboration of the drive above, derived from today's source // rather than quoted from the card. `isAuthGateAllowlisted` is imported by - // this module, but the gate block that raises does not consult it. - const start = SOURCE.indexOf('if (gateActive) {'); + // this module, but the gate decision that raises does not consult it. + // + // SUPERSEDED READING, quoted. [#23217 slice 2, ruling record 6227928430 + // letter A] the gate block this sliced left `rest-server.ts` for the ONE + // request-to-context composition (`resolveRequestExecutionContext`, + // @objectstack/core), so its anchors are gone from this file: + // const start = SOURCE.indexOf('if (gateActive) {'); + // const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start)); + // expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);"); + // ⭐ RE-AIMED to the door's half of the same fact: the decision is made + // from what the door hands the composition, and that call names the gate + // choice and hands over no path. The raise itself is pinned where it lives + // (`packages/core/src/security/request-execution-context.test.ts`, choice + // 2), and the drives above still read it on this door's wire. + const start = SOURCE.indexOf('const base = await resolveRequestExecutionContext({'); expect(start).toBeGreaterThan(-1); - const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start)); - expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);"); - expect(block).not.toContain('isAuthGateAllowlisted'); - expect(block).not.toContain('req.path'); + const call = SOURCE.slice(start, SOURCE.indexOf('\n });\n', start)); + expect(call).toContain("authGate: 'reread-when-active',"); + expect(call).not.toContain('isAuthGateAllowlisted'); + expect(call).not.toContain('req.path'); + expect(call).not.toMatch(/\bpath\b/); }); }); diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 8a886dc8cdf..c01f5b4d1fa 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -194,15 +194,30 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // REACHES the shared classification, and that neither grew a `catch` of its // own again. The discriminator is still the REGISTRY's brand, never message // text (#13905); it is asserted at its new home. - expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/); - expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/); + // + // SUPERSEDED PINS, quoted for the same reason. [#23217 slice 2, ruling + // record 6227928430 letter A] the door calls the ONE request-to-context + // composition (`resolveRequestExecutionContext`, @objectstack/core), which + // runs the classification itself on the thunk the door hands it, so the + // two `await` sites these matched are gone from this file: + // expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/); + // expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/); + // ⭐ RE-AIMED again, and the subject still has not moved: BOTH wirings + // reach the shared classification. Each branch now builds the thunk, and + // the thunk is what the composition is handed; that the composition + // classifies whatever it is handed is pinned in + // `packages/core/src/security/request-execution-context.test.ts` (its + // tenancy cases), and §2/§3 below still drive the wire answer end to end. + expect(body).toMatch(/tenancy = \(\) => kernel\.getServiceAsync\('tenancy'\)/); + expect(body).toMatch(/tenancy = \(\) => this\.tenancyServiceProvider!\(environmentId\)/); + expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*tenancy,\n/); // ⛔ NARROWNESS CONTROL for the fold: the seam region itself holds NO // `catch`. A local `catch` reappearing here is exactly the silent-`catch` // degradation #13906 decision 1 option A forbids, and it would be invisible - // to the two delegation pins above. + // to the delegation pins above. const tenancySeam = body.slice( - body.indexOf('let tenancyPosture;'), - body.indexOf('const authz = await resolveAuthzContext('), + body.indexOf('let tenancy: TenancyServiceResolver | undefined;'), + body.indexOf('const base = await resolveRequestExecutionContext('), ); expect(tenancySeam.length).toBeGreaterThan(0); expect(tenancySeam).not.toMatch(/catch/); @@ -212,7 +227,7 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // a `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`, and // without this guard the dereference would raise an unbranded `TypeError` // and turn that host shape into a 503. - expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/); + expect(body).toMatch(/let tenancy: TenancyServiceResolver \| undefined;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/); // [#15256 / 1A] …and the OTHER wiring now has a leg of its own, with the // same classification. Before this card the `if` above was the whole // 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', // the comment-only swallow that made a FAILED re-read of an ACTIVE gate // indistinguishable from gate-off. That swallow is gone. expect(body).not.toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); - expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/); - // The re-read is loud, and it is the RAW api call — ⛔ not the swallowing - // `getSession` closure, which would re-collapse the very same two facts. - // [#22258] Its argument is the in-process session-read rule's input (a - // cookie request reads without renewal); the call itself is still the raw, - // throwing one this pin exists for. - expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/); - expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/); - // ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host - // whose probe faults never declared a gate. If this ever flips, the repair - // has widened past the window the ruling named. - expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/); + // SUPERSEDED PINS, quoted. [#23217 slice 2, ruling record 6227928430 + // letter A] the gate decision left this file for the ONE request-to-context + // composition, so the door's own copy of it these matched is gone: + // expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/); + // expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/); + // expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/); + // expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/); + // ⭐ RE-AIMED to what stays this door's: it declares the decision the + // ruling made for it, `'reread-when-active'` (an active gate's re-read is + // the raw, throwing read and fails as `AuthzStoreUnavailableError('auth_gate')`; + // a probe that throws reads as inactive; a key keeps its own posture), and + // it holds no second copy of that decision. The decision's own window, both + // legs, is pinned where it lives (`packages/core/src/security/request-execution-context.test.ts`, + // choice 2), and §4 below still drives every leg through this door's wire. + expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*authGate: 'reread-when-active',\n/); + // ⛔ NARROWNESS CONTROL for the move: no local gate probe or re-read came + // back beside the call. A copy here would drift from the composition's + // window exactly as the copies this card retired did. Read on the CODE + // lines only: the door's comments still name the probe to explain the + // parameter. + const code = body.split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n'); + expect(code).toMatch(/authGate: 'reread-when-active'/); + expect(code).not.toMatch(/isAuthGateActive|gatedSession|getSession\(/); }); it('KNOWN NEGATIVE: the engine seam #13476 repaired is NOT flagged as absorb', () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8876294348c..4aec26e3a6c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { - IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted, + IHttpServer, isAuthGateAllowlisted, // [commit 6a180e42d] Re-raise a permission-store OUTAGE through the fail-closed nets // below instead of degrading it into an anonymous/denied answer. rethrowAuthzStoreUnavailable, @@ -9,19 +9,21 @@ import { // be RESOLVED leaves the caller's permissions equally undetermined, so it // takes the same loud answer rather than the quiet 403 it used to wear. AuthzStoreUnavailableError, - // [#13906 / #16013] The ONE classification the tenancy seam applies on - // BOTH of its wirings: the REGISTRY's own "never registered" brand absorbs - // the supported no-tenancy composition (never message text, #13905) while - // every other rejection stays loud. ⛔ The two wirings themselves are NOT - // the helper's — see `computeExecCtx`. - classifyAdmissionTenancyPosture, + // [ruling record 6227928430, letter A] The ONE request-to-ExecutionContext + // composition `computeExecCtx` calls, and the shape of the tenancy wiring + // it hands over: [#13906 / #16013] the composition classifies the + // `tenancy` read on BOTH wirings (the REGISTRY's own "never registered" + // brand absorbs the supported no-tenancy composition, never message text, + // #13905; every other rejection stays loud). ⛔ The two wirings themselves + // are NOT the composition's — see `computeExecCtx`. + resolveRequestExecutionContext, + type TenancyServiceResolver, effectiveTenancyPosture, // The registry's "never registered" brand, asked directly by the public // form doors' tenancy read (see `registerFormEndpoints`). isServiceNotRegisteredError, - assembleExecutionContext, normalizeAuthGate, userAuthGateSourceOf, type AuthGate, - // [#23367] The API-key lifecycle policy source, and its branded refusal. - apiKeyLifecyclePolicySourceOf, isApiKeyLifecycleRefusalError, type ApiKeyLifecycleRefusalError, + // [#23367] The API-key lifecycle's branded refusal. + isApiKeyLifecycleRefusalError, type ApiKeyLifecycleRefusalError, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // `isAudienceBindingSuggestionStatus` and `unknownAudienceBindingSuggestionStatusMessage`, // the suggested-binding `?status=` vocabulary (#7678), moved with `registerSecurityEndpoints`' @@ -40,8 +42,6 @@ import { // [#20061] The thrown `VALIDATION_FAILED` + `fields[]` shape every catch in // this file already maps to `400` — see `readDeclaredQueryNumber` below. validationFailure, - // [#22258] The in-process session-read rule — see `computeExecCtx`. - inProcessSessionReadInput, } from '@objectstack/types'; import { allowPerfDisclosure, @@ -218,7 +218,7 @@ import { // [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read // through the ONE mapping the runtime dispatcher's `/meta` domain asks too. import { metaSaveRequestOptions } from './meta-save-request.js'; -import type { AuthSessionApi, ISecurityService } from '@objectstack/spec/contracts'; +import type { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, effectiveOperationsArray, @@ -246,9 +246,6 @@ import { // still emits — which this deliberately is not. import { sendError as sendEnvelopeError } from '@objectstack/types'; -/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ -type SessionRead = Awaited>>; - /** * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462 * A1.5): wire-normalized data CRUD plus the metadata control plane — not the @@ -3046,14 +3043,16 @@ export class RestServer { if (authService) authEnvironmentId = environmentId; } if (!authService) return undefined; - // The auth service may be the AuthManager wrapper (which exposes - // `getApi()`) or the raw better-auth instance (which exposes - // `.api` directly). Normalize to the raw API object. - let api: any = authService.api; - if (!api && typeof authService.getApi === 'function') { - api = await authService.getApi(); - } - if (!api?.getSession) return undefined; + // [ruling record 6227928430, letter A] From here to the context is + // the ONE request-to-ExecutionContext composition + // (`resolveRequestExecutionContext`, @objectstack/core), called once + // below with this door's differences as its named parameters. What + // stays in this method is this door's own: the environment and the + // auth service above, the headers, the engine and the tenancy wiring + // below, and what the context carries out of here. + // + // The auth service may be the AuthManager wrapper (`getApi()`) or the + // raw better-auth instance (`.api`); the composition reads either. // better-auth's `getSession` requires a Web `Headers` instance // (it calls `headers.get('cookie')`). Adapter req.headers may @@ -3072,7 +3071,7 @@ export class RestServer { return undefined; } - // Resolve the data engine for this scope (shared by the resolver below). + // Resolve the data engine for this scope (handed to the composition below). // // [#13476] The PROVIDER branch reaches the seam through // `wiredEngineOrLoud`, so "no engine is wired" and "the engine could @@ -3093,24 +3092,6 @@ export class RestServer { () => this.objectQLProvider!(environmentId), ); - // Delegate ALL identity + role/permission/RLS aggregation to the SINGLE - // shared resolver (`resolveAuthzContext`, @objectstack/core) — the same one - // the runtime dispatcher uses, so the REST and dispatcher entry points can - // never drift on authorization. (This path previously kept its own copy that - // silently omitted sys_user_position / sys_position_permission_set / platform_admin / - // ai_seat — see the resolver's module doc.) - // - // [#22258] Read through the in-process session rule: this request - // answers with its OWN response, so a renewal here would move - // `sys_session.expires_at` while the renewed cookie is discarded — - // the browser's cookie then dies before its session. A request - // carrying a session cookie therefore reads without renewal (only - // `/get-session`, which re-issues the cookie, renews it); a - // bearer-only request renews as before. - // [#24222] Answers as `AuthSessionApi` declares the read, not `any`. - const getSession = async (h: any): Promise => { - try { return await api.getSession(inProcessSessionReadInput(h)); } catch { return undefined; } - }; // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` // service — the same source plugin-security reconciles for the Layer 0 // wall, so API-key admission and the wall agree. @@ -3190,8 +3171,9 @@ export class RestServer { // absence is the only fault it could report anyway. It keeps the // previous quiet answer, unchanged. // - // [#16013] The CLASSIFICATION below is one shared function, not two - // hand-written copies: `classifyAdmissionTenancyPosture` answers + // [#16013] The CLASSIFICATION is one shared function, not two + // hand-written copies: the composition hands the `tenancy` thunk + // built below to `classifyAdmissionTenancyPosture`, which answers // quiet `undefined` for the branded "never registered" (the // supported no-tenancy composition, no posture-conditional refusal) // and raises `AuthzStoreUnavailableError('tenancy', err)` for every @@ -3200,12 +3182,11 @@ export class RestServer { // the posture is an authorization INPUT and admission was never // decided. ⛔ The WIRING branch is NOT shared and must not become // so: which of the two wirings may be asked is this file's fact - // alone, for the reason spelled out in the `else if` below. - let tenancyPosture; + // alone, for the reason spelled out in the `else if` below. No + // wiring here, no thunk: the composition then reads no posture. + let tenancy: TenancyServiceResolver | undefined; if (kernel && typeof kernel.getServiceAsync === 'function') { - tenancyPosture = await classifyAdmissionTenancyPosture( - () => kernel.getServiceAsync('tenancy') as any, - ); + tenancy = () => kernel.getServiceAsync('tenancy') as any; } else if (this.tenancyServiceProvider) { // [#15256 / 1A] The SINGLE-KERNEL branch — the wiring every // deployment the open core builds actually runs, and the one @@ -3223,158 +3204,75 @@ export class RestServer { // rejection is the outage it is. The provider re-raises // unbranded rejections for precisely that reason — see // `rest-api-plugin.ts`. - tenancyPosture = await classifyAdmissionTenancyPosture( - () => this.tenancyServiceProvider!(environmentId) as any, - ); + tenancy = () => this.tenancyServiceProvider!(environmentId) as any; } - // [#23287, ruling C′ item 3] The auth service's per-user posture - // computation rides into the shared resolver, where the API-key - // admission seam asks it for a key's owner: ONE judgment for every - // face, this one included. - const authz = await resolveAuthzContext({ - ql, + // [#23287, ruling C′ item 3 · #23367] The auth service's per-user + // posture computation and its API-key lifecycle policy reach the + // shared resolver from `authService` itself, inside the composition: + // ONE judgment for every face, this one included. Identity, the + // role/permission/RLS aggregation, the localization and the envelope + // are the composition's; each parameter below is this door's answer + // from before the composition existed (its docblock tables them). + const base = await resolveRequestExecutionContext({ headers, - getSession, - tenancyPosture, - userAuthGates: userAuthGateSourceOf(authService), - // [#23367] The API-key lifecycle policy, from the same service. - apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), - }); - // [commit f586f1a89] The anonymous contract IS the shared assembler's default - // entry: no resolved principal → no context → 401. Taken early here - // only so an anonymous request does not pay for the localization and - // auth-gate reads it would never use; `assembleExecutionContext` - // below re-affirms the same rule. - if (!authz.userId) return undefined; - - const settings = this.settingsServiceProvider - ? await seamOrUndefined(() => this.settingsServiceProvider!(environmentId)) - : undefined; - const localization = await resolveLocalizationContext({ ql, - settings, - tenantId: authz.tenantId, - userId: authz.userId, - }); - - // ADR-0069 — authentication-policy gate posture. Only when a gate - // feature is active (cheap sync check) do we re-read the session for - // its `user.authGate` (computed in customSession). enforceAuth() then - // blocks protected resources for a gated user. Zero cost when off. - // - // [#7280] Normalized through the shared `normalizeAuthGate` instead - // of copied verbatim: the session user crosses an external boundary - // as `any`, and `ExecutionContext.authGate` now DECLARES the shape - // (`{ code, message }`), so this is where the declaration is met — - // a gate with a blank message no longer rides into a 403 body as - // `undefined`. - // - // [#13906 — maintainer ruling 2026-09-02, decision 2 option B] The - // gate is best-effort NO LONGER in one precisely measured window: - // `isAuthGateActive()` answered `true` AND the gate's session - // re-read then FAILED. Measured before the repair, same fixture, - // the wiring differing only in how the gate faulted: - // - // | gate wiring | before | after | - // |:--|:--|:--| - // | INACTIVE (the common, correct case)| admitted | admitted — unchanged | - // | ACTIVE, healthy re-read, gated user| 403 code+message | 403 — unchanged | - // | `isAuthGateActive()` THROWS | admitted | admitted — unchanged | - // | ACTIVE, re-read FAILS | **admitted** | **503** | - // - // ⇒ an enforcement the deployment DECLARED active used to vanish - // with no wire trace, deep-equal to gate-off. A declared promise - // that disappears silently is the fail-OPEN this card measured. - // - // ⛔ The probe-throws row stays absorbed DELIBERATELY, and it is the - // narrowness the ruling asked for: a host whose probe faults never - // answered `true`, so it never declared a gate, and refusing on it - // would block deployments that never asked for one. - // - // [#23287 — ruling C′ item 3] The cookie re-read below is the - // SESSION's gate, and it runs only for a principal a session - // admitted. A principal an API KEY admitted is judged by the key's - // own answer, decided once at the shared key-admission seam - // (`authz.authGate`: the key owner's account-scope posture). It - // used to re-read the cookie here whatever had admitted the - // caller, so a key sent beside a held cookie was refused with the - // COOKIE's posture — another credential's, possibly another user's - // — while the same key sent alone was admitted with no question - // asked of its owner at all. - let authGate: AuthGate | undefined; - let gateActive = false; - if (authz.credential === 'api_key') { - authGate = authz.authGate; - } else { - try { - gateActive = typeof authService.isAuthGateActive === 'function' - && authService.isAuthGateActive() === true; - } catch { - gateActive = false; - } - } - if (gateActive) { - // [#24222] Typed as `AuthSessionApi` declares the answer, not `any`. - let gatedSession: SessionRead | null; - try { - // ⛔ NOT the `getSession` closure above, and not its - // `.catch(() => undefined)`: both convert a THROW into the - // same `undefined` a gate-less user produces, which is - // precisely the collapse being repaired. A session that - // RESOLVES carrying no gate is not a failure — that user is - // simply not gated, and still admits. - // [#22258] Same in-process read rule as the closure above. - gatedSession = await api.getSession(inProcessSessionReadInput(headers)); - } catch (err) { - throw new AuthzStoreUnavailableError('auth_gate', err); - } - authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; - } - - // [commit f586f1a89 — maintainer ruling 2026-08-08, Option A] The assembly of - // the ExecutionContext itself is now the SINGLE shared one - // (`assembleExecutionContext`, @objectstack/core), the same module - // the runtime / MCP dispatcher assembles through. Before this, the - // step AFTER `resolveAuthzContext` was two hand-written copies and - // the copies drifted: #6071 (this face never set `principalKind`, - // so every enforcement judgment reading it was silently never-true - // here) and commit 8e13ca876 / #6551 (a dropped `accessible_org_ids` produced - // real 403s on the share-link faces). The field set is closed by - // type there, so a new `ExecutionContext` field cannot land on one - // face and miss another. - // - // This face takes the FAIL-CLOSED DEFAULT entry — no resolved - // principal → no context → `enforceAuth` answers 401. The runtime - // face takes the explicit guest entry instead. Both behaviours are - // unchanged; the divergence is now named API rather than drift. - const base = assembleExecutionContext({ - authz, + authService, + tenancy, + // [commit f586f1a89] The FAIL-CLOSED default entry: no resolved + // principal → no context → `enforceAuth` answers 401. The runtime + // face takes the guest entry; the divergence is named API, not + // drift. An anonymous request reads no localization and no gate. + anonymous: 'fail-closed', + // ADR-0069 — this face CARRIES the gate, and its consumer is + // `enforceAuth` (403 `{ code, message }`). [#23287] A key is judged + // by its owner's posture, decided at the shared key-admission + // seam, whatever cookie rides beside it; the request's own session + // is read again only while a gate feature is active (a cheap sync + // probe, zero cost when off). [#13906 — maintainer ruling + // 2026-09-02, decision 2 option B] fails closed in exactly one + // window: + // + // | gate wiring | answer | + // |:--|:--| + // | INACTIVE (the common, correct case) | admitted | + // | ACTIVE, healthy re-read, gated user | 403 code + message | + // | `isAuthGateActive()` THROWS | admitted: a probe that faults never declared a gate | + // | ACTIVE, re-read FAILS | 503: a declared enforcement never vanishes silently | + authGate: 'reread-when-active', + credentials: 'api-key-and-session', + // Without a session read this door authenticates nobody, the + // API-key door included. + sessionApi: 'required', + // [#22258] The request's own session, under the in-process read + // rule: this request answers with its OWN response, so a request + // carrying a session cookie reads without renewal (only + // `/get-session`, which re-issues the cookie, renews it). + session: 'request', + // The reference localization from this door's settings provider; + // a provider that throws or rejects reads as no service. + localization: { + settings: () => (this.settingsServiceProvider + ? seamOrUndefined(() => this.settingsServiceProvider!(environmentId)) + : undefined), + }, + // [#3957] The request's OWN locale wins over the workspace + // default; the precedence lives in the shared assembler. + requestLocale: this.extractLocale(req), + // A NAMED divergence (commit f586f1a89): this transport has never + // carried the better-auth session bearer, and + // `ExecutionContext.accessToken` is a PUBLISHED hook surface + // (`session.accessToken`, hook.zod.ts). Widening it to a second + // transport is a product decision, not a refactor. + accessToken: 'withhold', // OAuth access tokens are honoured on the `/mcp` door alone // (`acceptOAuthAccessToken`), precisely so coarse tool-family // scopes cannot ride onto REST — so `principalKind: 'agent'`, // `onBehalfOf` and `oauthScopes` are not representable here. oauth: undefined, - localization, - // [#3957] The request's OWN locale wins over the workspace - // default; the precedence itself lives in the shared assembler. - requestLocale: this.extractLocale(req), - // A NAMED divergence, deliberately preserved (commit f586f1a89): this - // transport has never carried the better-auth session bearer on - // the envelope, and `ExecutionContext.accessToken` is a - // PUBLISHED hook surface (`session.accessToken`, hook.zod.ts). - // Widening it to a second transport is a product decision, not - // a refactor — so REST withholds it on the record. - accessToken: undefined, - // [ADR-0069 / #7280] This face DOES carry the gate: its consumer - // is ten lines up (`enforceAuth` → 403 `{ code, message }`). It - // used to be spread on AFTER assembly behind an `as any`, which - // is precisely how it stayed outside the closed field set; it is - // a declared `ExecutionContext` field and an assembler input now. - authGate, + // The dispatcher's admission channel. This door enforces the gate + // off the envelope, at `enforceAuth`. + onAdmission: undefined, }); - // Unreachable: the anonymous early-return above already took this - // branch. Kept because the shared entry — not this method — is the - // authority on what an anonymous request yields. if (!base) return undefined; const execCtx = { diff --git a/packages/runtime/src/security/request-execution-context.conformance.test.ts b/packages/runtime/src/security/request-execution-context.conformance.test.ts index a1edecab199..d3ada10276d 100644 --- a/packages/runtime/src/security/request-execution-context.conformance.test.ts +++ b/packages/runtime/src/security/request-execution-context.conformance.test.ts @@ -11,11 +11,19 @@ * through the door's OWN entry, and the named parameters the door declares to * the function. Every request in {@link REQUESTS} goes through both, and the * two contexts must be equal. A later slice converts its door and adds it as - * one more row; nothing else here changes. + * one more row. * - * Across rows, a request that resolves a principal must resolve the SAME - * principal at every door ({@link IDENTITY}): the named parameters decide what - * a door withholds or adds around the identity, never who the caller is. + * Who a request resolves to is the request's {@link Request.principal} at every + * door, except where a door's declared parameters authenticate nobody for it + * ({@link expectedPrincipal}: a door that requires a session API, on a host + * with no auth service). Across rows, every door that resolves a principal + * resolves the SAME one ({@link IDENTITY}): the named parameters decide what a + * door withholds or adds around the identity, and whether it admits at all, + * never who the caller is. + * + * A door's own additions after the function returns (REST's `__kernel` and + * `__authEnvironmentId`, see its `drive`) are not the composition and are + * set aside before the comparison, by name. * * ## Why the requests are these * @@ -27,6 +35,7 @@ * |:--|:--| * | no credential | `anonymous` | * | a session cookie, a gated user, a session bearer, `Accept-Language` | `session`, `authGate`, `accessToken`, `requestLocale`, `localization` | + * | the same session while no gate is active | `authGate` (`'resolved'` against `'reread-when-active'`) | * | an API key | `credentials` | * | an ex-member's organization-stamped key under a walled posture | `tenancy` | * | an API key on a host with no auth service | `sessionApi` | @@ -43,6 +52,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { resolveRequestExecutionContext, type RequestExecutionContextInput } from '@objectstack/core'; +import { RestServer } from '@objectstack/rest'; + import { hashApiKey } from './api-key.js'; import { resolveExecutionContext } from './resolve-execution-context.js'; import { bindCatalogFromTables } from './security-catalog.testkit.js'; @@ -59,7 +70,7 @@ interface World { services: Record; } -function world(opts: { auth: boolean }): World { +function world(opts: { auth: boolean; gateActive?: boolean }): World { const tables: Record>> = { sys_api_key: [ { id: 'k_live', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }, @@ -111,7 +122,7 @@ function world(opts: { auth: boolean }): World { }; }, }, - isAuthGateActive: () => true, + isAuthGateActive: () => opts.gateActive ?? true, resolveUserAuthGates: async () => [], }; } @@ -122,7 +133,9 @@ interface Request { name: string; headers: Record; auth: boolean; - /** Who the request resolves to at every door, or `undefined` for none. */ + /** Whether the auth service says a gate feature is active (default: active). */ + gateActive?: boolean; + /** Who the request resolves to, or `undefined` for none; see {@link expectedPrincipal}. */ principal: string | undefined; } @@ -134,6 +147,13 @@ const REQUESTS: Request[] = [ auth: true, principal: 'u_ses', }, + { + name: 'the same session while no gate is active', + headers: { cookie: COOKIE, 'accept-language': 'zh-CN,zh;q=0.9' }, + auth: true, + gateActive: false, + principal: 'u_ses', + }, { name: 'an API key', headers: { 'x-api-key': KEY }, auth: true, principal: 'u_key' }, { name: "an ex-member's organization-stamped key under the walled posture", headers: { 'x-api-key': EX_MEMBER_KEY }, auth: true, principal: undefined }, { name: 'an API key on a host with no auth service', headers: { 'x-api-key': KEY }, auth: false, principal: 'u_key' }, @@ -146,6 +166,56 @@ interface DoorRow { parameters(w: World, headers: Headers): Omit; } +/** + * Who `request` resolves to at a door declaring `parameters`: its principal, + * unless the door requires a session API and the host has no auth service, in + * which case the door authenticates nobody, the API-key door included. + */ +function expectedPrincipal(request: Request, parameters: Pick): string | undefined { + return !request.auth && parameters.sessionApi === 'required' ? undefined : request.principal; +} + +/** + * REST's door, `RestServer#computeExecCtx`, through its public entry, on the + * single-kernel wiring `rest-api-plugin` builds: the auth service, the engine, + * the settings service and the tenancy service each through its provider. + */ +async function driveRest(w: World, headers: Record): Promise { + const provide = (service: unknown) => async () => service; + const rest = new RestServer( + { get() {}, post() {}, put() {}, delete() {}, patch() {}, use() {}, async listen() {}, async close() {} } as never, + {} as never, + {}, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + provide(w.services.auth), // authServiceProvider + provide(w.ql), // objectQLProvider + undefined, // emailServiceProvider + undefined, // sharingServiceProvider + undefined, // the retired slot + undefined, // approvalsServiceProvider + undefined, // sharingRulesServiceProvider + undefined, // i18nServiceProvider + undefined, // analyticsServiceProvider + provide(w.services.settings), // settingsServiceProvider + undefined, // serviceExistsProvider + undefined, // securityServiceProvider + undefined, // requestEnvResolver + undefined, // metadataServiceProvider + provide(w.services.tenancy), // tenancyServiceProvider + ); + const ctx = await rest.resolvePackageRouteExecutionContext({ headers, params: {}, query: {}, method: 'GET', path: '/api/v1/data/task' }); + if (!ctx) return undefined; + // The door's own additions after the function returns, set aside by name: + // the resolved kernel (none on this wiring) and the environment whose auth + // service validated the caller (none named on this wiring). + const { __kernel, __authEnvironmentId, ...context } = ctx; + expect(__kernel).toBeUndefined(); + expect(__authEnvironmentId).toBeUndefined(); + return context as ExecutionContext; +} + const DOORS: DoorRow[] = [ { door: 'packages/runtime/src/security/resolve-execution-context.ts', @@ -168,6 +238,24 @@ const DOORS: DoorRow[] = [ onAdmission: undefined, }), }, + { + door: 'packages/rest/src/rest-server.ts', + drive: driveRest, + parameters: (w, headers) => ({ + tenancy: () => w.services.tenancy as never, + anonymous: 'fail-closed', + authGate: 'reread-when-active', + credentials: 'api-key-and-session', + sessionApi: 'required', + session: 'request', + localization: { settings: () => w.services.settings }, + // REST reads the header first, then `?locale`; no request here carries `?locale`. + requestLocale: preferredLocaleFromHeader(headers.get('accept-language')), + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }), + }, ]; /** The facts that say WHO the caller is; equal at every door that resolves one. */ @@ -177,27 +265,31 @@ describe('[ruling 6227928430 item 4] one request, the function and every convert for (const request of REQUESTS) { it(`${request.name}: each door's context IS the function's under the door's declared parameters`, async () => { for (const row of DOORS) { - const w = world({ auth: request.auth }); + const w = world({ auth: request.auth, gateActive: request.gateActive }); const viaDoor = await row.drive(w, request.headers); const headers = new Headers(request.headers); + const parameters = row.parameters(w, headers); const viaFunction = await resolveRequestExecutionContext({ headers, ql: w.ql, authService: w.services.auth as RequestExecutionContextInput['authService'], - ...row.parameters(w, headers), + ...parameters, }); expect(viaDoor, row.door).toEqual(viaFunction); - expect(viaDoor?.userId, row.door).toBe(request.principal); + expect(viaDoor?.userId, row.door).toBe(expectedPrincipal(request, parameters)); } }); } - it('a principal is the same principal at every door', async () => { + it('a principal is the same principal at every door that resolves one', async () => { for (const request of REQUESTS.filter((r) => r.principal)) { - const identities = await Promise.all(DOORS.map(async (row) => { - const ctx = await row.drive(world({ auth: request.auth }), request.headers); - return Object.fromEntries(IDENTITY.map((k) => [k, ctx?.[k]])); - })); + const contexts = await Promise.all(DOORS.map((row) => row.drive(world({ auth: request.auth, gateActive: request.gateActive }), request.headers))); + const identities = contexts + .filter((ctx): ctx is ExecutionContext => ctx !== undefined) + .map((ctx) => Object.fromEntries(IDENTITY.map((k) => [k, ctx[k]]))); + // Which doors resolve one is the first case's to assert; this one needs + // at least one, or it would compare nothing. + expect(identities.length, request.name).toBeGreaterThan(0); for (const identity of identities) expect(identity, request.name).toEqual(identities[0]); } }); @@ -226,4 +318,41 @@ describe('[ruling 6227928430 item 4] one request, the function and every convert expect(flipped?.authGate).toEqual({ code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }); expect(flipped?.accessToken).toBeUndefined(); }); + + it('CONTROL: the requests tell the REST door\'s named parameters apart', async () => { + const rest = DOORS[1]!; + const w = world({ auth: true }); + // `authGate: 'reread-when-active'` under an active gate, `accessToken: 'withhold'`, + // the request locale and the settings lookup. + const session = await rest.drive(w, REQUESTS[1]!.headers); + expect(session).toMatchObject({ userId: 'u_ses', principalKind: 'human', locale: 'zh-CN', timezone: 'Europe/Paris', tenantId: ORG }); + expect(session?.authGate).toEqual({ code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }); + expect(session?.accessToken).toBeUndefined(); + // `anonymous: 'fail-closed'`: no context at all. + expect(await rest.drive(w, REQUESTS[0]!.headers)).toBeUndefined(); + // `reread-when-active` and not `'resolved'`: no active gate, no posture. + const inactive = world({ auth: true, gateActive: false }); + expect((await rest.drive(inactive, REQUESTS[2]!.headers))?.authGate).toBeUndefined(); + const headers = new Headers(REQUESTS[2]!.headers); + const resolved = await resolveRequestExecutionContext({ + headers, + ql: inactive.ql, + authService: inactive.services.auth as RequestExecutionContextInput['authService'], + ...rest.parameters(inactive, headers), + authGate: 'resolved', + }); + expect(resolved?.authGate).toEqual({ code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }); + // `sessionApi: 'required'`: the key the runtime door admits on a host with + // no auth service is not admitted here, and `'optional'` would admit it. + const noAuth = world({ auth: false }); + expect(await rest.drive(noAuth, REQUESTS[REQUESTS.length - 1]!.headers)).toBeUndefined(); + const optional = await resolveRequestExecutionContext({ + headers: new Headers(REQUESTS[REQUESTS.length - 1]!.headers), + ql: noAuth.ql, + authService: undefined, + ...rest.parameters(noAuth, new Headers()), + sessionApi: 'optional', + }); + expect(optional?.userId).toBe('u_key'); + }); }); diff --git a/scripts/check-single-authz-resolver.mjs b/scripts/check-single-authz-resolver.mjs index efce2ce82be..5cd595b2eac 100644 --- a/scripts/check-single-authz-resolver.mjs +++ b/scripts/check-single-authz-resolver.mjs @@ -218,7 +218,7 @@ const ALLOW = new Map([ // 6227928430, letter A). A door converted onto that composition moves its row's // call, and the composition is itself a delegator of the resolver. const DELEGATORS = new Map([ - ['packages/rest/src/rest-server.ts', 'resolveAuthzContext'], + ['packages/rest/src/rest-server.ts', 'resolveRequestExecutionContext'], ['packages/runtime/src/security/resolve-execution-context.ts', 'resolveRequestExecutionContext'], ['packages/core/src/security/request-execution-context.ts', 'resolveAuthzContext'], ]); From 96f05115f979b0b24a8babf013e484581c31edd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:15:51 +0000 Subject: [PATCH 3/3] chore(changeset): patch on @objectstack/rest for the door's move onto the one composition (WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/23217-s2-rest-door.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/23217-s2-rest-door.md diff --git a/.changeset/23217-s2-rest-door.md b/.changeset/23217-s2-rest-door.md new file mode 100644 index 00000000000..5a559173391 --- /dev/null +++ b/.changeset/23217-s2-rest-door.md @@ -0,0 +1,12 @@ +--- +'@objectstack/rest': patch +--- + +REST's request identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + +Clause-②: no + +- `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. +- 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. +- 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`. +- Nothing to do for a consumer: no export, route, configuration key or response shape changed.