From d72cfa371de8f04966afcd7e555cb10fbbd62eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:43:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20=E7=AB=AF=E7=82=B9=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20=E2=80=94=E2=80=94=20=E7=AD=96=E7=95=A5=20?= =?UTF-8?q?=E2=86=92=20=E6=89=A7=E8=A1=8C,=E5=85=9C=E5=BA=95=E5=99=A8?= =?UTF-8?q?=E5=85=A8=E4=B8=8A=E4=B8=8B=E6=96=87=20(#5129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 派发步命中分支的 501 换成完整链:匹配 → 策略(E4)→ executeEndpointTarget(E5) → 响应映射。cacheTtl 的 Cache-Control 只并进成功答复,错误答复一律不带。 兜底器补三根线: - 每进程构建一次端点限流注册表(与 server 级限流器同一个 resolveCache); - 每请求喂完整 EndpointPolicyContext(headers / remoteAddress / resolveSessionPrincipalId / limiters / trustProxy / logger),并把 answer.headers 写到线上(429 的 Retry-After 此前被丢弃); - 匹配前用 HttpDispatcher.resolveRequestScope(自 dispatch() 原地抽出)解析本 请求的环境 / 身份 / driver,委派调用带调用方 ExecutionContext 运行。 多租户 host 解析不到环境时该步弃权(不写任何东西),不拿默认 kernel 作答。 现网行为零变更:非空 apis: 在 publish 仍被硬拒(E7 前不撤),整条链结构性不可达。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/endpoint-chain-wiring.md | 11 + .../runtime/src/api-endpoint-step.test.ts | 132 +++++++- packages/runtime/src/api-endpoint-step.ts | 178 ++++++++--- ...ugin.endpoint-fallback.integration.test.ts | 292 +++++++++++++++++- packages/runtime/src/dispatcher-plugin.ts | 164 ++++++++-- packages/runtime/src/http-dispatcher.ts | 162 ++++++---- packages/runtime/src/route-ledger.ts | 19 +- 7 files changed, 809 insertions(+), 149 deletions(-) create mode 100644 .changeset/endpoint-chain-wiring.md diff --git a/.changeset/endpoint-chain-wiring.md b/.changeset/endpoint-chain-wiring.md new file mode 100644 index 0000000000..56c92a1e4d --- /dev/null +++ b/.changeset/endpoint-chain-wiring.md @@ -0,0 +1,11 @@ +--- +"@objectstack/runtime": minor +--- + +端点链接线:声明式 `apis:` 端点的派发步现在跑完整条链 —— 匹配 → 策略(`authRequired` / `rateLimit` / `cacheTtl`)→ 目标执行(`object_operation` 走 `/data` 同一个 `callData`,`flow` 走 automation 服务)。 + +兜底器(`dispatcher-plugin`)补齐三根一直缺的线:把完整的 `EndpointPolicyContext`(请求头、`remoteAddress`、与 server 级限流器同一个会话查询、每端点限流注册表、`trustProxy`)喂给派发步;把 `answer.headers` **写到线上**(此前 429 的 `Retry-After` 会被丢掉,客户端拿到一个不知道何时重试的 429);并在匹配前解析本请求的环境 / 身份(与 `dispatch()` 同一个 `HttpDispatcher.resolveRequestScope`),使委派调用带着调用方的 `ExecutionContext` 运行,而不是以 system 身份绕过 RLS。 + +`cacheTtl` 的 `Cache-Control` 只挂在**成功**答复上,任何错误答复都不带它。多租户 host 若无法把请求解析到某个环境,该步**弃权**(不写任何东西,保留传输层原本的 404),而不是拿默认 kernel 的数据来回答。 + +现网行为零变更:publish / validate 对非空 `apis:` 仍然硬拒(#5040 E7 前不撤),因此本链结构性不可达。 diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index 73c99d3a5a..f49eaf1067 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -25,6 +25,7 @@ import { appEndpointMountPrefix, isAppEndpointPath, runAppEndpointStep, + type AppEndpointExecutionInput, } from './api-endpoint-step.js'; import { createEndpointRateLimiterRegistry, @@ -123,7 +124,7 @@ describe('the step writes nothing unless a declaration owns the request', () => }); }); -describe('a match answers 501 until the executor lands (#5040 E5)', () => { +describe('a match with no wiring answers an honest 501', () => { it('reports NOT_IMPLEMENTED in the declared error envelope', async () => { const { service } = matcherFor([TASKS]); const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service); @@ -136,7 +137,7 @@ describe('a match answers 501 until the executor lands (#5040 E5)', () => { // Names the endpoint it matched and says plainly that nothing ran — // "matched but not executed" must never read as "executed and empty". expect(body.error.message).toContain('showcase_tasks'); - expect(body.error.message).toContain('not enabled'); + expect(body.error.message).toContain('no wiring'); expect(String(body.error.hint)).toContain('#5040'); }); @@ -266,3 +267,130 @@ describe('the policy chain runs between the match and the answer', () => { expect([...entries.keys()]).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]); }); }); + +/** + * Execution, wired to the far side of the policy chain (#5040 E5b / #5129). + * + * The delegation itself is `endpoint-executor.test.ts`'s subject; what is + * asserted here is the JOIN — that a passing request reaches the executor with + * the request's own coordinates and identity, that a denial never does, and + * that `cacheTtl`'s header lands on a success and on nothing else. + */ +describe('execution runs on the far side of the policy chain', () => { + const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ ...TASKS, name: 'showcase_open', authRequired: false }); + + const limiters = () => createEndpointRateLimiterRegistry({ resolveCache: async () => undefined }); + + /** Records every delegated `callData` call and answers with a stub result. */ + function callDataSpy(result: unknown = { object: 'showcase_task', records: [], total: 0 }) { + const calls: unknown[][] = []; + return { + calls, + fn: async (...args: unknown[]) => { calls.push(args); return result; }, + }; + } + + const wiredStep = ( + endpoints: ApiEndpoint[], + execution: Partial & { deps: AppEndpointExecutionInput['deps'] }, + policy: Partial = {}, + method = 'GET', + ) => runAppEndpointStep({ + method, + path: endpoints[0]!.path, + prefix: '/api/v1', + metadataService: matcherFor(endpoints).service as never, + policy: { limiters: limiters(), ...policy }, + execution: { + request: { + method, + path: endpoints[0]!.path, + query: { limit: '5' }, + headers: { 'x-caller': 'integration' }, + body: undefined, + }, + ...execution, + }, + }); + + it('delegates a passing request with the request\'s own identity envelope', async () => { + const spy = callDataSpy(); + const executionContext = { userId: 'usr_7', isSystem: false } as never; + const answer = await wiredStep([OPEN], { + deps: { callData: spy.fn as never }, + executionContext, + environmentId: 'env_1', + dataDriver: { driver: true }, + }); + + expect(answer?.status).toBe(200); + expect(answer?.body).toEqual({ + success: true, + data: { object: 'showcase_task', records: [], total: 0 }, + meta: undefined, + }); + // The identity envelope, the driver and the scope ride on the delegated + // call — #5040 §4's red line, and the exact thing #4936's dead branch + // dropped (it would have read as `system`, RLS bypassed). + expect(spy.calls).toEqual([[ + 'query', + { object: 'showcase_task', query: { limit: '5' } }, + { driver: true }, + 'env_1', + executionContext, + ]]); + }); + + it('puts the cacheTtl Cache-Control on a SUCCESS answer', async () => { + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const answer = await wiredStep([cached], { deps: { callData: callDataSpy().fn as never } }); + + expect(answer?.status).toBe(200); + expect(answer?.headers).toEqual({ 'Cache-Control': 'private, max-age=30' }); + }); + + it('never puts it on an ERROR answer, however the failure arose', async () => { + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + // A delegated pipeline that throws — the executor maps it to a 4xx/5xx + // answer, and a client must not be told to reuse a failure for 30s. + const answer = await wiredStep([cached], { + deps: { callData: async () => { throw { statusCode: 404, message: 'no such object' }; } }, + }); + + expect(answer?.status).toBe(404); + expect(answer?.headers).toBeUndefined(); + + // Same for a declaration this runtime does not execute (501 from the + // executor's own `unsupported` arm, not from the no-wiring branch). + const proxied = ApiEndpointSchema.parse({ + ...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtl: 30, + }); + const unsupported = await wiredStep([proxied], { deps: { callData: async () => ({}) } }); + expect(unsupported?.status).toBe(501); + expect(unsupported?.headers).toBeUndefined(); + expect(String((unsupported!.body as { error: { message: string } }).error.message)).toContain('proxy'); + }); + + it('never reaches the executor when a policy denied the request', async () => { + const spy = callDataSpy(); + // `TASKS` keeps the default `authRequired: true`; the caller is anonymous. + const answer = await wiredStep([TASKS], { deps: { callData: spy.fn as never } }); + + expect(answer?.status).toBe(401); + expect(spy.calls, 'the executor ran for a request the policy chain denied').toEqual([]); + }); + + it('answers an honest 501 when a caller wired policies but no executor', async () => { + const answer = await runAppEndpointStep({ + method: 'GET', + path: OPEN.path, + prefix: '/api/v1', + metadataService: matcherFor([OPEN]).service as never, + policy: { limiters: limiters() }, + }); + expect(answer?.status).toBe(501); + const hint = String((answer!.body as { error: { hint: unknown } }).error.hint); + expect(hint).toContain('enforced'); + expect(hint).toContain('no execution wiring'); + }); +}); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index c98a2cecf3..3b5d73cd26 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -19,36 +19,46 @@ * * ## What it does today, and what it does not * - * Today it answers **501 NOT_IMPLEMENTED** on a match. That is the honest - * report of the state of the executor: the endpoint IS declared and IS matched, - * and the thing that would run it lands in 17.x (#5040 E5). It is also - * structurally unreachable — a non-empty `apis:` is rejected at publish / - * validate until the E7 flip — so no deployment can observe it; the tests below - * drive `matchEndpoint` through a stub, exactly as #5040 §5 prescribes for - * every E-series unit that lands before the flip. + * On a match it runs the WHOLE chain: policies (#5040 E4) and then target + * execution (#5040 E5), wired together here by E5b. It is still structurally + * unreachable — a non-empty `apis:` is rejected at publish / validate until the + * E7 flip — so no deployment can observe it; the tests drive `matchEndpoint` + * through a stub, exactly as #5040 §5 prescribes for every E-series unit that + * lands before the flip. * - * ## The policy chain (#5040 E4) now runs between the match and the answer + * ## The chain, in the one order it can run in * * `authRequired` / `rateLimit` / `cacheTtl` are enforced by * {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the * caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the - * answer; a pass still ends in the 501 below, because the thing that would run - * the endpoint is E5. + * answer. A pass reaches {@link executeEndpointTarget} — and NOTHING else can: + * the execution call sits INSIDE the post-policy branch, which is unreachable + * without a policy context and short-circuited by a denial. "Wired the executor, + * forgot the policies" is therefore not a mistake a future change can make by + * omission; there is nowhere else to put the call. * - * That ordering is structural, not stylistic: execution lands INSIDE the - * post-policy branch, which is unreachable without a policy context. Wiring an - * executor without wiring policies is therefore not something a future change - * can do by forgetting — it would have nowhere to put the call. + * `verdict.responseHeaders` (the `Cache-Control` computed from `cacheTtl`) is + * merged into SUCCESS answers only. An error answer never carries it: the + * header describes a body the caller should be willing to reuse, and telling a + * client to cache a 401 / 429 / 500 for a minute is worse than saying nothing. * - * What it does NOT do yet, so nobody reads more into it than is here: - * `inputMapping` / `outputMapping` and target execution — `object_operation` - * via `callData`, `flow` via the automation service (E5). + * What it does NOT do, so nobody reads more into it than is here: + * `inputMapping` / `outputMapping` (declared, still unread — #5040's E7 gate + * must not flip before they are, or the two keys sit in the "declared, legal, + * ignored" state this program exists to end). */ import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; import { apiErrorResponse } from './error-envelope.js'; import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js'; +import { + buildEndpointExecutionContext, + executeEndpointTarget, + type EndpointExecutionRequest, + type EndpointExecutorDeps, +} from './endpoint-executor.js'; /** * The platform's single reserved carve-out segment for app-declared endpoints @@ -94,18 +104,44 @@ export interface AppEndpointStepAnswer { status: number; body: unknown; /** - * Headers that are part of THIS answer and must be written with it — today - * only `Retry-After` on a rate-limit denial, where the header carries the - * one piece of information the client needs to behave. + * Headers that are part of THIS answer and must be written with it: + * `Retry-After` on a rate-limit denial (the one piece of information a + * throttled client needs to behave), and `Cache-Control` on a SUCCESSFUL + * execution result (from `cacheTtl`). * - * Note what is NOT here: the `Cache-Control` computed from `cacheTtl`. It - * describes a successful response body that does not exist yet, and telling - * a client to cache a 501 would be worse than saying nothing. It stays on - * the policy verdict until execution lands (#5040 E5). + * The asymmetry is deliberate and enforced below — `Cache-Control` rides + * only on a success, never on an error answer. */ headers?: Record; } +/** + * The execution wiring: everything the step needs to RUN a matched endpoint, + * resolved by the caller on the request's own kernel. + * + * Nothing here is looked up by this module. That is what keeps the "which + * kernel / which environment" question in the one place that already answers it + * (`HttpDispatcher.resolveRequestScope`, called by the dispatch seam) instead of + * growing a second, weaker copy in a consumer. + */ +export interface AppEndpointExecutionInput { + /** The request as the fallback seam sees it — body and `remoteAddress` included. */ + request: EndpointExecutionRequest; + /** The `callData` binding and the `automation` slot occupant. */ + deps: EndpointExecutorDeps; + /** + * The identity envelope the dispatcher resolved for this request, or + * `undefined` for anonymous. Threaded into every delegated call so RLS/FLS + * and the ADR-0049 exposure gate apply exactly as on the built-in route + * (#5040 §4's red line; #4936 is what its absence looks like). + */ + executionContext?: ExecutionContext; + /** Environment scoping for service resolution. */ + environmentId?: string; + /** Environment-scoped data driver, when the host resolved one. */ + dataDriver?: unknown; +} + export interface AppEndpointStepInput { /** Request method, as the transport reports it. */ method: string; @@ -126,14 +162,22 @@ export interface AppEndpointStepInput { * headers and peer address, the principal lookup, the endpoint limiter * registry, `trustProxy`. * - * Optional ONLY because the dispatch seam that calls this step does not - * thread it yet — that plumbing lands with the executor wiring (#5040 E5), - * which is the same change that needs the request body and the environment - * anyway. Omitting it does not open anything: the terminal answer without a - * policy context is the 501 below, so no request can be SERVED unpoliced, - * and execution can only be added on the far side of the chain. + * The real dispatch seam ALWAYS threads it (#5040 E5b), so the branch that + * answers without one is unreachable in the composed runtime — pinned by + * the integration test rather than deleted, because the honest report is + * cheap and a direct caller of this module (a test, a future host) can still + * omit it. Omitting it does not open anything: the terminal answer without a + * policy context is a 501, so no request can be SERVED unpoliced, and + * execution lives strictly on the far side of the chain. */ policy?: EndpointPolicyContext; + /** + * Execution wiring (#5040 E5b). Threaded by the same seam that threads + * {@link policy}; without it a policed request that PASSED still ends in a + * 501 that says so, which is the honest answer for a host that mounted the + * step but wired no executor. + */ + execution?: AppEndpointExecutionInput; } /** @@ -167,15 +211,14 @@ export async function runAppEndpointStep( if (!match) return undefined; if (!input.policy) { - // No policy context threaded yet (see `AppEndpointStepInput.policy`). - // The answer is the same 501 this seam has always given, and the hint - // says which keys were NOT evaluated — a report that is wrong about - // what ran is worse than no report. + // No policy context threaded (see `AppEndpointStepInput.policy`). The + // answer is the 501 this seam gave before anything was wired, and the + // hint says which keys were NOT evaluated — a report that is wrong + // about what ran is worse than no report. return notImplemented(match, method, path, - 'The mounting seam is in place; execution (target dispatch, mappings) lands with #5040 E5. This ' - + 'request reached the step without a policy context, so authRequired / rateLimit / cacheTtl were ' - + 'not evaluated — nothing was served either. Until the E7 flip a non-empty `apis:` is rejected at ' - + 'publish, so no reachable deployment can produce this answer.'); + 'This request reached the step without a policy context, so authRequired / rateLimit / cacheTtl ' + + 'were not evaluated — and nothing was executed either. The composed runtime always threads one ' + + '(#5040 E5b), so reaching this answer means a host mounted the step by hand and omitted it.'); } const verdict = await applyEndpointPolicies({ ...input.policy, endpoint: match.endpoint, method }); @@ -188,18 +231,53 @@ export async function runAppEndpointStep( } // ── Everything past this line has been through the policy chain ────── - // This is where target execution lands (#5040 E5), and it is the ONLY place - // it can land: the branch is unreachable without a policy context, and the - // deny above short-circuits before it. `verdict.responseHeaders` carries the - // `Cache-Control` that the executor's success answer should apply — it is - // deliberately not applied to the 501 (see `AppEndpointStepAnswer.headers`). - return notImplemented(match, method, path, - 'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them; target ' - + 'execution lands with #5040 E5. Until the E7 flip a non-empty `apis:` is rejected at publish, so no ' - + 'reachable deployment can produce this answer.'); + // The ONLY place execution can be called from: this branch is unreachable + // without a policy context, and a denial short-circuits before it. + if (!input.execution) { + return notImplemented(match, method, path, + 'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them, but no ' + + 'execution wiring was supplied, so the target was not run. The composed runtime always supplies ' + + 'it (#5040 E5b).'); + } + + const { request, deps, executionContext, environmentId, dataDriver } = input.execution; + const answer = await executeEndpointTarget( + buildEndpointExecutionContext({ + request, + match, + ...(executionContext !== undefined ? { executionContext } : {}), + ...(environmentId !== undefined ? { environmentId } : {}), + ...(dataDriver !== undefined ? { dataDriver } : {}), + }), + deps, + ); + + // `Cache-Control` (from `cacheTtl`) applies to a SUCCESS and nothing else. + // `executeEndpointTarget` never throws — a delegated failure is already an + // error answer here — so the status is the whole test, and an endpoint whose + // execution failed cannot hand the client a cache directive for the failure. + const isSuccess = answer.status < 400; + const headers = { + ...(answer.headers ?? {}), + ...(isSuccess ? verdict.responseHeaders : {}), + }; + return { + status: answer.status, + body: answer.body, + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; } -/** The one 501 body this step answers with, whichever branch produced it. */ +/** + * The 501 for a match this step was not given enough to serve. + * + * Both callers are INCOMPLETE-WIRING branches, not feature gaps: the composed + * runtime threads both a policy context and execution wiring, so neither is + * reachable through `dispatcher-plugin` (pinned in + * `dispatcher-plugin.endpoint-fallback.integration.test.ts`). They stay because + * this module is callable directly, and answering an honest "nothing ran" + * beats pretending — or crashing on a missing collaborator. + */ function notImplemented( match: ApiEndpointMatch, method: string, @@ -210,8 +288,8 @@ function notImplemented( code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, httpStatus: 501, message: - `Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the endpoint ` - + 'executor is not enabled in this build. It lands in 17.x (#5040).', + `Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the caller of the ` + + 'endpoint step supplied no wiring to serve it with (#5040).', extra: { hint }, }); } diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index 868e561765..a2a47eea0c 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The declarative-endpoint mount seam, through a REAL boot (#5040 E3 / #5090). + * The declarative-endpoint chain, through a REAL boot (#5040 E3 / E5b). * * `api-endpoint-step.test.ts` covers the decision; this file covers the * WIRING — LiteKernel + the real Hono transport + the real dispatcher plugin, @@ -11,6 +11,12 @@ * fact that nothing ever mounted the paths it claimed to serve. "Who serves * this path" is a question about the composed runtime; ask it there. * + * Since #5129 the seam serves the whole chain — policies (E4) then target + * delegation (E5) — so the cases below drive REAL `callData` and a REAL + * automation slot, and pin the two things only a socket can prove: that a 429 + * carries its `Retry-After` ON THE WIRE, and that a `cacheTtl` `Cache-Control` + * rides a success and never an error. + * * The load-bearing assertion in most of these is a NEGATIVE one: that adding * this seam changed nothing for anybody. Today's unmatched answers — the bare * 404 and the 405 + `Allow` — must come back byte for byte, since a stack @@ -163,14 +169,17 @@ describe('matcher present — the endpoint dispatch step (#5090)', () => { }, 30_000); afterAll(() => shutdown(kernel), 30_000); - it('answers a MATCH with 501 NOT_IMPLEMENTED in the declared envelope', async () => { + it('answers a MATCH by running the chain — anonymous meets `authRequired` first', async () => { + // Both declarations keep the schema default `authRequired: true`, and + // this boot has no auth service, so every caller is anonymous. The 401 + // is the WHOLE answer: the request never reaches execution, which is + // what "policies, then the target" means (#5040 §3). const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`); - expect(res.status).toBe(501); + expect(res.status).toBe(401); const body = await res.json() as { success: boolean; error: Record }; expect(body.success).toBe(false); - expect(body.error.code).toBe('NOT_IMPLEMENTED'); - expect(body.error.httpStatus).toBe(501); - expect(body.error.message).toContain('showcase_tasks'); + expect(body.error.code).toBe('UNAUTHENTICATED'); + expect(queries).toContainEqual({ path: '/api/v1/apps/showcase/tasks', method: 'GET' }); }); it('reaches the step for a POST carrying a JSON body', async () => { @@ -179,9 +188,7 @@ describe('matcher present — the endpoint dispatch step (#5090)', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ olderThanDays: 30 }), }); - expect(res.status).toBe(501); - const body = await res.json() as { error: Record }; - expect(body.error.message).toContain('showcase_purge_inquiries'); + expect(res.status).toBe(401); expect(queries).toContainEqual({ path: '/api/v1/apps/showcase/inquiries/purge', method: 'POST' }); }); @@ -226,6 +233,22 @@ describe('matcher present — the endpoint dispatch step (#5090)', () => { expect(body.allowed).toEqual(['PUT']); }); + it('answers an execution result, never the "nothing was wired" 501', async () => { + // The two 501 branches inside the step (no policy context / no execution + // wiring) are honest reports for a host that mounted the step by hand. + // The COMPOSED runtime threads both, so neither may ever appear on this + // wire — asserted rather than assumed, since the shape of the bug they + // describe is "the seam silently stopped doing half its job". + for (const path of ['/api/v1/apps/showcase/tasks', '/api/v1/apps/showcase/inquiries/purge']) { + const res = await fetch(`${baseUrl}${path}`, { method: path.endsWith('purge') ? 'POST' : 'GET' }); + const text = await res.text(); + expect(res.status).not.toBe(501); + expect(text).not.toContain('no wiring'); + expect(text).not.toContain('without a policy context'); + expect(text).not.toContain('no execution wiring'); + } + }); + it('answers 5xx — not 404 — when the matcher itself fails', async () => { // `matchEndpoint`'s contract: an implementation that cannot read its // store MUST throw, because a miss becomes a 404 and an outage must not @@ -250,3 +273,254 @@ describe('matcher present — the endpoint dispatch step (#5090)', () => { } }, 30_000); }); + +// ============================================================================ +// The wired chain (#5040 E5b / #5129) +// ============================================================================ + +/** + * The declarations this boot serves. Each one exists to pin ONE key of the + * chain end to end; `authRequired: false` where the case is about something + * else, so a 401 can never be mistaken for the property under test. + */ +const EXECUTABLE: ApiEndpoint[] = [ + ApiEndpointSchema.parse({ + name: 'showcase_open_tasks', + path: '/api/v1/apps/showcase/open-tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: false, + }), + ApiEndpointSchema.parse({ + name: 'showcase_my_tasks', + path: '/api/v1/apps/showcase/my-tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + // The schema default, spelled out: this endpoint is the auth case. + authRequired: true, + }), + ApiEndpointSchema.parse({ + name: 'showcase_purge', + path: '/api/v1/apps/showcase/purge', + method: 'POST', + type: 'flow', + target: 'showcase_inquiry_janitor', + authRequired: false, + }), + ApiEndpointSchema.parse({ + name: 'showcase_limited', + path: '/api/v1/apps/showcase/limited', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: false, + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 1 }, + }), + ApiEndpointSchema.parse({ + name: 'showcase_cached', + path: '/api/v1/apps/showcase/cached', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: false, + cacheTtl: 30, + }), + ApiEndpointSchema.parse({ + // Same `cacheTtl`, but a shape whose execution FAILS: `get` with no + // `?id=` is a 400 from the executor. The pair is the whole point — + // one key, two outcomes, only one of them cacheable. + name: 'showcase_cached_get', + path: '/api/v1/apps/showcase/cached-get', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'get' }, + authRequired: false, + cacheTtl: 30, + }), +]; + +/** Rows the fake engine serves, so a 200 body can be checked against real data. */ +const TASK_ROWS = [ + { id: 'tsk_1', name: 'Draft the brief', status: 'open' }, + { id: 'tsk_2', name: 'Ship the thing', status: 'done' }, +]; + +const ADMIN_SET = { + id: 'ps-admin', + name: 'admin_full_access', + object_permissions: { '*': { viewAllRecords: true, modifyAllRecords: true } }, +}; + +/** Every `execute` the automation slot received, with the context it was given. */ +const flowRuns: Array<{ name: string; context: Record }> = []; +/** Every `find` the data engine served, with the ExecutionContext it was handed. */ +const engineFinds: Array<{ object: string; options: any }> = []; + +/** + * The services a real `os serve` provisions, stubbed at the KERNEL boundary — + * not at the plugin boundary. Everything between the socket and these stubs is + * the production path: the fallback seam, the scope resolution, the policy + * chain, `action-execution.callData`, `buildAutomationContext`. + */ +function executionServicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.endpoint-execution-services', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + async getSession({ headers }: { headers: Headers }) { + const uid = headers.get('x-test-user'); + return uid ? { user: { id: uid } } : null; + }, + }, + }); + ctx.registerService('objectql', { + async find(object: string, options: any) { + engineFinds.push({ object, options }); + if (object === 'sys_user_permission_set') { + return options?.where?.user_id === 'admin1' + ? [{ user_id: 'admin1', permission_set_id: 'ps-admin', organization_id: null }] + : []; + } + if (object === 'sys_permission_set') { + const ids: string[] = options?.where?.id?.$in ?? []; + return ids.includes('ps-admin') ? [ADMIN_SET] : []; + } + if (object === 'showcase_task') return TASK_ROWS; + return []; + }, + }); + ctx.registerService('automation', { + async execute(name: string, context: Record) { + flowRuns.push({ name, context }); + return { runId: 'run_1', status: 'completed' }; + }, + }); + }, + }; +} + +describe('the wired chain — policies, then the real pipeline (#5129)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + queries.length = 0; + flowRuns.length = 0; + engineFinds.length = 0; + ({ kernel, baseUrl } = await boot([ + fakeMetadataPlugin({ withMatcher: true, endpoints: EXECUTABLE }), + executionServicesPlugin(), + ])); + }, 30_000); + afterAll(() => shutdown(kernel), 30_000); + + it('serves an object_operation through the REAL callData pipeline', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/open-tasks`); + expect(res.status).toBe(200); + // The `/data` list body, not a reshaped one: `success(result)` where + // `result` is what `callData('query', …)` returned. #5040 §4 requires + // the declared endpoint and the built-in route to answer the same thing, + // and a second success shape here would be the first divergence. + expect(await res.json()).toEqual({ + success: true, + data: { object: 'showcase_task', records: TASK_ROWS, total: 2 }, + }); + expect(engineFinds.some((f) => f.object === 'showcase_task')).toBe(true); + }); + + it('runs a flow through the automation slot with the trigger route\'s context', async () => { + flowRuns.length = 0; + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/purge`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-test-user': 'admin1' }, + body: JSON.stringify({ recordId: 'inq_1', objectName: 'showcase_inquiry', params: { olderThanDays: 30 } }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { runId: 'run_1', status: 'completed' } }); + expect(flowRuns).toHaveLength(1); + expect(flowRuns[0]!.name).toBe('showcase_inquiry_janitor'); + // `buildAutomationContext`'s shape, reused rather than reinvented: the + // `{recordId, objectName, params}` translation INCLUDING the + // `Id` alias a flow author writes, plus the identity the + // envelope carries — a `runAs:'user'` flow that loses it is refused + // fail-closed (#3760) or runs as somebody else (#1888). + expect(flowRuns[0]!.context).toMatchObject({ + object: 'showcase_inquiry', + event: 'manual', + userId: 'admin1', + params: { olderThanDays: 30, recordId: 'inq_1', showcaseInquiryId: 'inq_1' }, + }); + }); + + it('denies an anonymous caller on the default authRequired, and serves a session', async () => { + const anon = await fetch(`${baseUrl}/api/v1/apps/showcase/my-tasks`); + expect(anon.status).toBe(401); + expect((await anon.json() as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED'); + + const signedIn = await fetch(`${baseUrl}/api/v1/apps/showcase/my-tasks`, { + headers: { 'x-test-user': 'admin1' }, + }); + expect(signedIn.status).toBe(200); + expect((await signedIn.json() as { success: boolean }).success).toBe(true); + }); + + it('answers 429 WITH the Retry-After header on the wire once the budget is spent', async () => { + // The regression this pins: the fallback used to write `status` + body + // and drop `answer.headers`, so a 429 arrived with nothing telling the + // client when to come back. Header-on-the-socket is the only assertion + // that can catch that — the step-level one passed the whole time. + const first = await fetch(`${baseUrl}/api/v1/apps/showcase/limited`); + expect(first.status).toBe(200); + + const second = await fetch(`${baseUrl}/api/v1/apps/showcase/limited`); + expect(second.status).toBe(429); + const retryAfter = second.headers.get('Retry-After'); + expect(retryAfter).toBeTruthy(); + expect(Number(retryAfter)).toBeGreaterThan(0); + expect(Number(retryAfter)).toBeLessThanOrEqual(60); + const body = await second.json() as { error: { code: string; details?: Record } }; + expect(body.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(body.error.details?.retryAfterSeconds).toBe(Number(retryAfter)); + }); + + it('sends cacheTtl\'s Cache-Control on a success and on nothing else', async () => { + const ok = await fetch(`${baseUrl}/api/v1/apps/showcase/cached`); + expect(ok.status).toBe(200); + expect(ok.headers.get('Cache-Control')).toBe('private, max-age=30'); + + // Same endpoint family, same `cacheTtl: 30`, but the execution fails + // (a `get` with no `?id=`). Telling the client to reuse a 400 for 30 + // seconds would make an author's typo sticky. + const failed = await fetch(`${baseUrl}/api/v1/apps/showcase/cached-get`); + expect(failed.status).toBe(400); + expect(failed.headers.get('Cache-Control')).toBeNull(); + const body = await failed.json() as { error: { code: string; details?: { fields?: Array<{ field: string }> } } }; + expect(body.error.code).toBe('VALIDATION_FAILED'); + expect(body.error.details?.fields?.[0]?.field).toBe('id'); + }); + + it('still leaves every non-endpoint answer exactly as it was', async () => { + // The negative guarantee, re-asserted on the boot that CAN execute: the + // seam costs a request nothing unless a declaration owns it. + queries.length = 0; + const outside = await fetch(`${baseUrl}/api/v1/nope`); + expect(outside.status).toBe(404); + expect(await outside.json()).toEqual(BARE_NOT_FOUND); + + const missUnderMount = await fetch(`${baseUrl}/api/v1/apps/showcase/not-declared`); + expect(missUnderMount.status).toBe(404); + expect(await missUnderMount.json()).toEqual(BARE_NOT_FOUND); + + expect(queries).toEqual([{ path: '/api/v1/apps/showcase/not-declared', method: 'GET' }]); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 1df03d6fbe..33f7151fd3 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -5,11 +5,13 @@ import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService, IMetadataService } from '@objectstack/spec/contracts'; import type { CounterStore } from '@objectstack/plugin-auth'; -import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js'; +import { HttpDispatcher, HttpDispatcherResult, type HttpProtocolContext } from './http-dispatcher.js'; import { isServiceServeable } from './service-serveable.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; import { buildApiError } from './error-envelope.js'; -import { appEndpointMountPrefix, runAppEndpointStep } from './api-endpoint-step.js'; +import { appEndpointMountPrefix, isAppEndpointPath, runAppEndpointStep } from './api-endpoint-step.js'; +import { callData } from './action-execution.js'; +import { createEndpointRateLimiterRegistry } from './endpoint-policy.js'; import { buildSecurityHeaders, createInboundRateLimitMiddleware, @@ -1265,37 +1267,148 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // semantic `ROUTE_NOT_FOUND` envelope is a separate decision and // deliberately NOT taken here (#5090). if (typeof rawServer.setFallbackHandler === 'function') { + // ── The endpoint rate-limit registry: built ONCE ───────────── + // Per-endpoint token buckets over one shared counter store. + // Building it per request would rebuild the lazy store handle + // every time, which re-emits the "no shared cache, so the + // effective limit is budget × nodes" warning on every request AND + // throws away the bucket cache the limiter keys its budget on — + // an endpoint budget that resets on every call is not a budget. + // Same `resolveCache` the server-level limiter uses, so one + // deployment has one counter backend, not two. + const endpointLimiters = createEndpointRateLimiterRegistry({ + resolveCache: async () => safeGetService(ctx, 'cache'), + logger: ctx.logger, + }); + rawServer.setFallbackHandler(async (req: any, res: any) => { try { + const path: string = req.path ?? ''; + // ── Scoping test FIRST, before any resolution ──────── + // Everything below costs something (a kernel resolve, a + // session lookup), and an unmatched request that is not + // under the endpoint carve-out must keep costing exactly + // what it costs today: nothing. The same predicate the + // step itself applies — spelled once, in the step. + if (!isAppEndpointPath(path, prefix)) return; + + // ── Per-request environment + identity ─────────────── + // The SAME resolution `dispatch()` performs, through the + // same method (#5040 E5b). It must run BEFORE the match: + // on a multi-tenant host `matchEndpoint` has to be asked + // on the request's own kernel, or one tenant's + // declarations decide another tenant's URLs. It also + // yields the `executionContext` / `dataDriver` / + // `environmentId` the delegated call needs to run as the + // caller instead of as the system principal (#4936). + const protocolContext: HttpProtocolContext = { request: req }; + await dispatcher.resolveRequestScope(protocolContext, path.replace(/\/$/, '')); + + // A multi-tenant host that could not place this request + // in an environment DECLINES — writes nothing, so the + // transport's own 404 stands. Serving it from the default + // kernel would answer one tenant's URL out of another + // tenant's data, which is worse than not answering. + if (dispatcher.isMultiTenantHost() && !protocolContext.environmentId) { + ctx.logger.warn( + '[dispatcher] a declarative-endpoint path reached the fallback on a multi-tenant ' + + 'host but resolved to no environment; declining rather than serving it from the ' + + 'default kernel.', + { path }, + ); + return; + } + + // Both slots are resolved PER REQUEST and never cached: + // during `start()` a slot may still be filling, and + // recording "absent" as a verdict that outlives the + // moment is the #4771 defect class. + // + // `metadata` is resolved WITH the environment id — the + // same lookup `callData` performs for this very request's + // ADR-0049 exposure gate. `automation` is resolved + // WITHOUT one, which is what `POST /automation/:name/ + // trigger` does (`domains/automation.ts` reads it off the + // request kernel): a declared endpoint must reach the same + // occupant the built-in route reaches, or "same operation, + // same answer" (#5040 §4) stops being true. + const execDeps = dispatcher.actionExecutionDeps; + const metadataService = await execDeps + .resolveService('metadata', protocolContext.environmentId) + .catch(() => undefined) as IMetadataService | undefined; + const automationService = await execDeps + .resolveService('automation') + .catch(() => undefined); + const answer = await runAppEndpointStep({ method: req.method, - path: req.path, + path, prefix, - // Resolved PER REQUEST and never cached: during - // `start()` the `metadata` slot may still be filling, - // and recording "absent" as a verdict that outlives - // the moment is the #4771 defect class. A read-only - // probe per request is the sanctioned shape. - // - // It resolves this kernel's metadata service. A - // multi-tenant host serves each request from a - // per-environment kernel, and reaching THAT one means - // running the resolver + kernel swap `dispatch()` - // performs — which the executor needs anyway for its - // `executionContext`, and which therefore lands with - // it (#5040 E5). Nothing here reads data through the - // service: the step only probes and reports 501, so - // there is no wrong-environment answer to give. - metadataService: safeGetService(ctx, 'metadata'), + metadataService, + policy: { + headers: req.headers ?? {}, + ...(req.remoteAddress ? { remoteAddress: req.remoteAddress } : {}), + // The SAME session query the server-level limiter + // and the dispatcher's route mounts make (#4910). + // Two answers to "who is calling" eventually + // disagree about what counts as authenticated. + resolvePrincipalId: (headers) => + resolveSessionPrincipalId( + safeGetService(ctx, 'auth'), + headers as Record, + ), + limiters: endpointLimiters, + // The authored `server.trustProxy`, read from the + // same declaration the server-level limiter reads. + // A second trust switch is a second answer to + // "may I believe X-Forwarded-For". + trustProxy: config.rateLimit?.trustProxy === true, + logger: ctx.logger, + }, + execution: { + request: { + method: req.method, + path, + query: req.query ?? {}, + headers: req.headers ?? {}, + body: req.body, + ...(req.remoteAddress ? { remoteAddress: req.remoteAddress } : {}), + }, + deps: { + // `callData` with its `deps` bound — the same + // object, and therefore the same pipeline, + // `/data` calls (`domains/data.ts`). + callData: (action, params, driver, scope, ec) => + callData(execDeps, action, params, driver, scope, ec), + ...(automationService !== undefined ? { automationService } : {}), + }, + ...(protocolContext.executionContext !== undefined + ? { executionContext: protocolContext.executionContext } + : {}), + ...(protocolContext.environmentId !== undefined + ? { environmentId: protocolContext.environmentId } + : {}), + ...(protocolContext.dataDriver !== undefined + ? { dataDriver: protocolContext.dataDriver } + : {}), + }, }); - // `undefined` = not an app-endpoint path, no matcher, or - // no declaration owns it. Writing nothing is how this - // handler says "not mine" (contract on setFallbackHandler). + // `undefined` = no matcher, or no declaration owns it. + // Writing nothing is how this handler says "not mine" + // (contract on setFallbackHandler). if (!answer) return; res.status(answer.status); if (securityHeaders) { for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v); } + // The answer's OWN headers — `Retry-After` on a 429, + // `Cache-Control` on a success. Written after the security + // headers so an answer-specific value wins, and written at + // all because a 429 that loses its `Retry-After` has told + // the client nothing it can act on. + if (answer.headers) { + for (const [k, v] of Object.entries(answer.headers)) res.header(k, v); + } res.json(answer.body); } catch (err: any) { // `matchEndpoint` throws when it cannot read its store — @@ -1307,9 +1420,10 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); ctx.logger.info('Declarative endpoint dispatch step armed', { mount: appEndpointMountPrefix(prefix), - // Said plainly so this line is never read as "endpoints work - // now": the seam is mounted, execution is not built yet. - executes: false, + // True as of #5040 E5b: policy chain + target execution are + // wired. Nothing can be DECLARED until the E7 publish flip, so + // this still describes a surface no deployment can reach. + executes: true, }); } else { // `debug`, not `warn`: no stack can declare an endpoint yet (a diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index ded84cb6de..41c9ee9676 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -14,7 +14,10 @@ import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from // `import * as actionExec from './action-execution.js'` was dropped in #4936: // the dispatcher's own `callData` delegate was its last consumer here, and the // delegate died with the `handleApiEndpoint` branch. The domain modules import -// `action-execution` for themselves. +// `action-execution` for themselves. What came back in #5040 E5b is the TYPE +// only — `actionExecutionDeps` hands the endpoint step the same `deps` object +// every domain already passes to `callData`, without this file calling it. +import type { ActionExecutionDeps } from './action-execution.js'; import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analytics.js'; import { isServiceServeable } from './service-serveable.js'; import { createI18nDomain, handleI18nRequest } from './domains/i18n.js'; @@ -288,6 +291,108 @@ export class HttpDispatcher { return !!this.kernelResolver; } + /** + * Resolve the per-request ENVIRONMENT, kernel and identity envelope — the + * step {@link dispatch} performs before any handler runs, extracted so a + * caller that serves from OUTSIDE the route pipeline gets the SAME answers + * rather than a lookalike. + * + * It mutates `context` in place, exactly as it always did inside + * `dispatch()`: the host's resolver writes `environmentId` (+ `dataDriver`), + * the identity step writes `executionContext`, and `this.kernel` is swapped + * to the kernel this request is served from. + * + * ## Its one out-of-band caller (#5040 E5b) + * + * The declarative-endpoint step runs in the transport's + * `setFallbackHandler` seam, which `dispatch()` deliberately never sees + * (#5090 keeps unmatched requests out of the dispatch pipeline). But that + * step DELEGATES into `callData` and the automation service, so it needs + * precisely what a `/data` request has: the request's kernel, its + * environment, its driver, and its `ExecutionContext`. Restating this block + * there would be a second, weaker identity resolution — and #4936 is the + * record of what a data call with no `ExecutionContext` does: it reads as + * the system principal with RLS bypassed. + * + * `cleanPath` is the dispatcher's cleaned route path (trailing slash + * trimmed). It feeds URL parsing hints and the MCP-only OAuth decision + * below, nothing else. + */ + async resolveRequestScope(context: HttpProtocolContext, cleanPath: string): Promise { + // ── Environment Resolution + Multi-Kernel Routing (ADR-0006 Phase 5) ── + // The host's KernelResolver owns the whole step: it resolves the + // request to an environment (hostname / header / session / defaults — + // strategy lives in the cloud distribution), SETS + // `context.environmentId` (+ `dataDriver`), and returns the kernel to + // serve from. The dispatcher only contributes parsing hints. No + // resolver registered → single-environment: every request serves from + // `defaultKernel` with no environment context. + this.prepareResolverHints(context, cleanPath); + if (this.kernelResolver) { + this.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel; + } else { + this.kernel = this.defaultKernel; + } + + // Touch scope for TTL/LRU tracking in shared-kernel mode + if (this.scopeManager && context.environmentId && context.environmentId !== 'platform') { + this.scopeManager.touch(context.environmentId); + } + + // ── Identity Resolution (RBAC/RLS/FLS context) ── + // Resolve once per request; SecurityPlugin middleware reads + // ctx.userId/roles/permissions/tenantId via opCtx.context. + try { + context.executionContext = await this.timedResolveExecutionContext({ + getService: (n: string) => this.resolveService(n, context.environmentId), + // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped + // `resolveService('objectql', envId)` factory can return a different + // instance that doesn't see THIS env's rows (the gotcha + // `handleActions` works around) — which made the api-key lookup miss + // `sys_api_key` on the MCP path and reject valid keys with 401, while + // REST accepted them (rest-server resolves identity via + // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel` + // keeps REST + MCP identity resolution aligned; falls back to the + // scoped path when the kernel can't hand back an objectql directly. + getQl: async () => { + const k: any = this.kernel; + if (k && typeof k.getServiceAsync === 'function') { + const ql = await k.getServiceAsync('objectql').catch(() => undefined); + if (ql && (ql.registry || typeof ql.find === 'function')) return ql; + } + return this.getObjectQLService(context.environmentId); + }, + request: context.request, + // OAuth 2.1 access tokens are honoured ONLY on the MCP + // surface (#2698): their coarse tool-family scopes are + // enforced at MCP tool dispatch, which other routes don't do. + // Matches the plain and `/projects/:id`-scoped route forms + // (the scoped prefix is stripped only by the caller, later). + acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath), + }); + } catch { + // anonymous request — leave executionContext undefined + } + } + + /** + * The action-execution facilities — the `deps` argument + * `action-execution.callData(deps, …)` takes — bound to this dispatcher's + * per-request kernel. + * + * Every live data path already calls `callData` with exactly this object; + * it reaches them as part of {@link DomainHandlerDeps} because they are + * domain modules. The declarative-endpoint step is not a domain module (it + * serves from the fallback seam, outside `dispatch()`), so it needs the same + * object by a name it can ask for. Exposing the NARROW `ActionExecutionDeps` + * view rather than the whole dispatcher-facility contract is the point: the + * endpoint executor may look services up and run data calls, and nothing + * else. + */ + get actionExecutionDeps(): ActionExecutionDeps { + return this.domainDeps; + } + /** * ADR-0076 D11 step ③ — seed the domain registry with the domains lifted * out of the `dispatch()` if-chain. Bodies of the four service-backed @@ -1471,60 +1576,7 @@ export class HttpDispatcher { async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { let cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths - // ── Environment Resolution + Multi-Kernel Routing (ADR-0006 Phase 5) ── - // The host's KernelResolver owns the whole step: it resolves the - // request to an environment (hostname / header / session / defaults — - // strategy lives in the cloud distribution), SETS - // `context.environmentId` (+ `dataDriver`), and returns the kernel to - // serve from. The dispatcher only contributes parsing hints. No - // resolver registered → single-environment: every request serves from - // `defaultKernel` with no environment context. - this.prepareResolverHints(context, cleanPath); - if (this.kernelResolver) { - this.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel; - } else { - this.kernel = this.defaultKernel; - } - - // Touch scope for TTL/LRU tracking in shared-kernel mode - if (this.scopeManager && context.environmentId && context.environmentId !== 'platform') { - this.scopeManager.touch(context.environmentId); - } - - // ── Identity Resolution (RBAC/RLS/FLS context) ── - // Resolve once per request; SecurityPlugin middleware reads - // ctx.userId/roles/permissions/tenantId via opCtx.context. - try { - context.executionContext = await this.timedResolveExecutionContext({ - getService: (n: string) => this.resolveService(n, context.environmentId), - // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped - // `resolveService('objectql', envId)` factory can return a different - // instance that doesn't see THIS env's rows (the gotcha - // `handleActions` works around) — which made the api-key lookup miss - // `sys_api_key` on the MCP path and reject valid keys with 401, while - // REST accepted them (rest-server resolves identity via - // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel` - // keeps REST + MCP identity resolution aligned; falls back to the - // scoped path when the kernel can't hand back an objectql directly. - getQl: async () => { - const k: any = this.kernel; - if (k && typeof k.getServiceAsync === 'function') { - const ql = await k.getServiceAsync('objectql').catch(() => undefined); - if (ql && (ql.registry || typeof ql.find === 'function')) return ql; - } - return this.getObjectQLService(context.environmentId); - }, - request: context.request, - // OAuth 2.1 access tokens are honoured ONLY on the MCP - // surface (#2698): their coarse tool-family scopes are - // enforced at MCP tool dispatch, which other routes don't do. - // Matches the plain and `/projects/:id`-scoped route forms - // (the scoped prefix is stripped only later, below). - acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath), - }); - } catch { - // anonymous request — leave executionContext undefined - } + await this.resolveRequestScope(context, cleanPath); // ── ADR-0069 Authentication-policy gate ── // Block a gated session (expired password / required MFA) from diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index b891513fd6..7ef4ee187e 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -278,14 +278,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + '`/apps//` and therefore not enumerable here. Served by neither ' + 'the domain registry nor the dispatch() if-chain: dispatcher-plugin installs an ' + '`IHttpServer.setFallbackHandler` (Hono `app.notFound`) that runs only after every registered ' - + 'route has missed, probes `metadata.matchEndpoint` for paths under this prefix, and — as of ' - + '#5090 — answers 501 NOT_IMPLEMENTED on a match. EXECUTION IS NOT WIRED: target dispatch and ' - + 'the authRequired / rateLimit / cacheTtl / mapping keys land with #5040 E4–E5. A miss (or an ' - + 'occupant of the metadata slot with no matchEndpoint) writes nothing, leaving the transport\'s ' - + '404/405 answer untouched. Structurally unreachable today: a non-empty `apis:` is rejected at ' - + 'publish until the #5040 E7 flip, so nothing can be declared for this seam to match. No SDK ' - + 'surface — app-declared endpoints are an external-integration channel (ADR-0121 D3), called by ' - + 'the integrator\'s own client, not by `@objectstack/client`', + + 'route has missed, and for paths under this prefix resolves the request\'s environment + ' + + 'identity, probes `metadata.matchEndpoint`, and on a match runs the full chain (#5040 E5b): ' + + 'the policy keys authRequired / rateLimit / cacheTtl (E4), then target delegation (E5) — ' + + '`object_operation` through the same `callData` as /data, `flow` through the automation ' + + 'service. `script` / `proxy` targets and the inputMapping / outputMapping keys are NOT ' + + 'executed and answer 501. A miss (or an occupant of the metadata slot with no matchEndpoint, ' + + 'or a multi-tenant request that resolves to no environment) writes nothing, leaving the ' + + 'transport\'s 404/405 answer untouched. Structurally unreachable today: a non-empty `apis:` ' + + 'is rejected at publish until the #5040 E7 flip, so nothing can be declared for this seam to ' + + 'match. No SDK surface — app-declared endpoints are an external-integration channel ' + + '(ADR-0121 D3), called by the integrator\'s own client, not by `@objectstack/client`', }, // ── misc legacy ───────────────────────────────────────────────────────────