From 1bcb38e7bee45cab177593d9269aef5cdf26baa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:57:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20=E7=AB=AF=E7=82=B9=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=9B=AE=E6=A0=87=E5=A7=94=E6=B4=BE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20endpoint-executor=20=E7=BA=AF=E6=A8=A1=E5=9D=97=20(?= =?UTF-8?q?#5092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 命中的声明式 `apis:` 端点按 `type` 委派到既有执行管线,零新执行语义 (#5040 §4 裁决:声明式端点是既有管线的稳定 URL 别名 + 策略层,不是第二 套执行方言)。 - `object_operation` → `action-execution.callData`,五个操作的参数形状逐条 对齐 `/data`(注释里逐行标注对照的 `domains/data.ts` 行号);记录 id 取 `query.id`(词表未定义路径模板,不发明);`object` 只来自声明;身份信封 在五个操作上一律透传(#4936 摘除的死代码正是丢了这个参数)。 - `flow` → `IAutomationService.execute(target, buildAutomationContext(...))`, 复用 `/automation` 触发路由同一个上下文构造函数(该函数因此导出), 身份信封与 `{recordId, objectName, params}` 翻译一并继承(#3760/#1888); 槽空或自称非 handler → 501 + discovery 同款处方句(ADR-0076 D12)。 - `script` / `proxy` 与缺 `objectParams` 的 `object_operation` → 结构化 501 NOT_IMPLEMENTED(带处方),不支持子集只列一处供 E7 publish 门照读。 - 失败走既有错误包络:状态优先级与 details 组装照抄 `errorFromThrown`, 5xx 过 `looksLikeInternalErrorLeak` 消毒;新模块入 error-envelope 源码 扫描名单。 模块为 (request, match, deps) 的纯函数,内部零查找;调度步接线是随后的小型 单(与 E4 #5091 一并落地后),叠加 publish 硬拒 → 结构性不可达,现网零变更。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/endpoint-execution-targets.md | 14 + packages/runtime/src/domains/automation.ts | 9 +- .../runtime/src/endpoint-executor.test.ts | 621 ++++++++++++++++++ packages/runtime/src/endpoint-executor.ts | 512 +++++++++++++++ .../src/error-envelope.conformance.test.ts | 5 + 5 files changed, 1160 insertions(+), 1 deletion(-) create mode 100644 .changeset/endpoint-execution-targets.md create mode 100644 packages/runtime/src/endpoint-executor.test.ts create mode 100644 packages/runtime/src/endpoint-executor.ts diff --git a/.changeset/endpoint-execution-targets.md b/.changeset/endpoint-execution-targets.md new file mode 100644 index 0000000000..c5beb2efb6 --- /dev/null +++ b/.changeset/endpoint-execution-targets.md @@ -0,0 +1,14 @@ +--- +'@objectstack/runtime': minor +--- + +**声明式端点的执行目标委派:`endpoint-executor` 纯模块(#5040 E5)** + +命中的 `apis:` 端点按 `type` 委派到**既有**执行管线,零新执行语义 —— 这是 #5040 §4 的裁决:声明式端点是既有管线的「稳定 URL 别名 + 策略层」,不是第二套执行方言;同一操作经声明端点与经内建路由必须得到一致的答案。 + +- `type: object_operation` → `action-execution.callData`,参数形状逐条对齐 `/data`(`find`→`('query', {object, query})`、`get`→`('get', {object, id, select?, expand?})`、`create`→`('create', {object, data})`、`update`→`('update', {object, id, data})`、`delete`→`('delete', {object, id})`)。记录 id 取 `query.id`(词表未定义路径模板,执行器不发明一套);`object` **只**来自声明,请求改不动它。身份信封(`executionContext`)在五个操作上一律透传 —— #4936 摘除的死代码正是丢了这个参数,真跑起来会以 system 身份绕过 RLS。 +- `type: flow` → `IAutomationService.execute(target, buildAutomationContext(body, ctx))`,复用 `/automation` 触发路由**同一个**上下文构造函数(该函数因此从 `domains/automation.ts` 导出):`{recordId, objectName, params}` 翻译与完整身份信封转发一并继承,`runAs:'user'` 的流程不会 fail-closed 被拒(#3760)或以他人身份运行(#1888)。automation 槽为空或自称非 handler 时答 501,携带 discovery 同款处方句(ADR-0076 D12)。 +- `type: script` / `proxy`,以及缺 `objectParams` 的 `object_operation`:结构化 **501 NOT_IMPLEMENTED**(带处方),不猜语义。这个「不支持子集」在模块里只列一处,供 E7 的 publish 门直接照读。 +- 失败一律走既有错误包络:状态优先级与 `details` 组装照抄 `HttpDispatcher.errorFromThrown`(`.status` → `.statusCode` → 校验失败 400 → 兜底 500),5xx 消息过 `looksLikeInternalErrorLeak` 消毒(#3867/#3918)。新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。 + +**本次落地不接线**:调度步(`api-endpoint-step.ts`)命中后仍答 501,把它换成「策略 → 执行」链是随后的小型接线单(等 E4 #5091 一并落地);叠加 publish 对非空 `apis:` 的硬拒(E7 前不撤),该模块结构性不可达,现网行为零变更。 diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index a659020b3e..a323e51709 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -41,8 +41,15 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. * a `runAs:'user'` flow enforces RLS exactly as the triggering user — their * positions/permissions/tenant, not a member fallback (#1888). The engine * elevates to a system principal only when the flow declares `runAs:'system'`. + * + * [#5040 E5] Exported — the declarative endpoint executor + * (`../endpoint-executor.ts`) triggers flows too, and must send the SAME + * context this route sends or a `type: 'flow'` endpoint becomes a second + * trigger dialect with its own identity-forwarding bugs. Exporting it is the + * whole point: the alternative (a second builder over there) is the shape + * #4127 above was written to remove. */ -function buildAutomationContext(body: any, context: HttpProtocolContext): Record { +export function buildAutomationContext(body: any, context: HttpProtocolContext): Record { const ctxBody = body && typeof body === 'object' ? body : {}; // `{recordId, objectName, params}` (the UI/SDK request shape) → the // canonical AutomationContext shape: diff --git a/packages/runtime/src/endpoint-executor.test.ts b/packages/runtime/src/endpoint-executor.test.ts new file mode 100644 index 0000000000..e14bf55db2 --- /dev/null +++ b/packages/runtime/src/endpoint-executor.test.ts @@ -0,0 +1,621 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Execution-target delegation in isolation (#5040 E5 / #5092). + * + * The suite is organised around the ONE claim the module makes: a declared + * endpoint delegates to an EXISTING pipeline, with the arguments that pipeline + * already receives on its built-in route. So most cases here assert the exact + * delegated call — `callData('query', { object, query }, driver, env, ec)` and + * friends — rather than the response, because a drifting argument is how the + * "same pipeline" claim would quietly stop being true while every response + * assertion stayed green. + * + * Nothing is resolved inside the module, so everything is a stub: the `callData` + * binding and the `automation` slot occupant arrive as arguments. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ApiEndpointSchema, ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode, envelopeViolations, SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { serviceUnavailableMessage } from '@objectstack/spec/system'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; + +import { + buildEndpointExecutionContext, + endpointErrorAnswer, + executeEndpointTarget, + planEndpointTarget, + type EndpointExecutionAnswer, + type EndpointExecutionRequest, + type EndpointExecutorDeps, +} from './endpoint-executor.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */ +function endpoint(overrides: Record): ApiEndpoint { + return ApiEndpointSchema.parse({ + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + ...overrides, + }); +} + +const EC: ExecutionContext = { + userId: 'user-1', + positions: ['sales_rep'], + permissions: ['task_edit'], + tenantId: 'tenant-9', +} as ExecutionContext; + +const DRIVER = { __driver: true }; + +function request(overrides: Partial = {}): EndpointExecutionRequest { + return { + method: 'GET', + path: '/api/v1/apps/showcase/tasks', + query: {}, + headers: {}, + ...overrides, + }; +} + +function contextFor(ep: ApiEndpoint, req: Partial = {}) { + const match: ApiEndpointMatch = { endpoint: ep, params: {} }; + return buildEndpointExecutionContext({ + request: request(req), + match, + executionContext: EC, + environmentId: 'env-7', + dataDriver: DRIVER, + }); +} + +/** The same, for a request the dispatcher resolved no identity for. */ +function anonymousContextFor(ep: ApiEndpoint, req: Partial = {}) { + return buildEndpointExecutionContext({ + request: request(req), + match: { endpoint: ep, params: {} }, + environmentId: 'env-7', + dataDriver: DRIVER, + }); +} + +function depsWith(overrides: Partial = {}): EndpointExecutorDeps & { callData: ReturnType } { + const callData = vi.fn().mockResolvedValue({ ok: true }); + return { callData, ...overrides } as EndpointExecutorDeps & { callData: ReturnType }; +} + +/** Every error answer must be the declared envelope, whatever produced it. */ +function expectConformantError(answer: EndpointExecutionAnswer) { + const body: any = answer.body; + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + expect(body.success).toBe(false); + expect(ApiErrorSchema.safeParse(body.error).success).toBe(true); + expect(typeof body.error.code).toBe('string'); + expect(body.error.code).not.toBe(String(answer.status)); + expect(body.error.httpStatus).toBe(answer.status); + return body.error; +} + +// --------------------------------------------------------------------------- +// Context construction +// --------------------------------------------------------------------------- + +describe('buildEndpointExecutionContext — a projection, not an interpretation', () => { + it('carries method/path/query/headers/body/remoteAddress through verbatim', () => { + const ctx = contextFor(endpoint({}), { + method: 'post', + path: '/api/v1/apps/showcase/tasks', + query: { status: 'open', tag: ['a', 'b'] }, + headers: { 'x-request-id': 'req-1', accept: 'application/json' }, + body: { title: 'write the executor' }, + remoteAddress: '203.0.113.9', + }); + + // Upper-cased, as every method comparison in this stack is. + expect(ctx.method).toBe('POST'); + expect(ctx.path).toBe('/api/v1/apps/showcase/tasks'); + // Verbatim: normalising here would put a second, weaker copy of each + // pipeline's own input handling in front of it. + expect(ctx.query).toEqual({ status: 'open', tag: ['a', 'b'] }); + expect(ctx.headers).toEqual({ 'x-request-id': 'req-1', accept: 'application/json' }); + expect(ctx.body).toEqual({ title: 'write the executor' }); + expect(ctx.remoteAddress).toBe('203.0.113.9'); + }); + + it('carries the match: the parsed endpoint, and `params` — always {} in 17.x', () => { + const ep = endpoint({}); + const ctx = contextFor(ep); + expect(ctx.endpoint).toBe(ep); + // The vocabulary defines no path template syntax, so the matcher never + // extracts anything. The member exists so adding templates later is + // additive; the executor reads record ids from `query.id` instead. + expect(ctx.params).toEqual({}); + }); + + it('builds the HttpProtocolContext the existing domain handlers receive', () => { + const req = request({ body: { a: 1 } }); + const ctx = buildEndpointExecutionContext({ + request: req, + match: { endpoint: endpoint({}), params: {} }, + executionContext: EC, + environmentId: 'env-7', + dataDriver: DRIVER, + }); + + // `{ request: req }` — the raw request object, exactly as every + // dispatcher-plugin route builds it. + expect(ctx.protocolContext.request).toBe(req); + expect(ctx.protocolContext.environmentId).toBe('env-7'); + expect(ctx.protocolContext.dataDriver).toBe(DRIVER); + expect(ctx.protocolContext.executionContext).toBe(EC); + }); + + it('omits the identity envelope for an anonymous request rather than faking one', () => { + const ctx = anonymousContextFor(endpoint({})); + expect('executionContext' in ctx).toBe(false); + expect('executionContext' in ctx.protocolContext).toBe(false); + }); + + it('defaults absent query/headers to empty objects, never undefined', () => { + const ctx = buildEndpointExecutionContext({ + request: { method: 'GET', path: '/api/v1/apps/showcase/tasks' }, + match: { endpoint: endpoint({}), params: {} }, + }); + expect(ctx.query).toEqual({}); + expect(ctx.headers).toEqual({}); + expect(ctx.body).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Target classification +// --------------------------------------------------------------------------- + +describe('planEndpointTarget — the unsupported subset is enumerated once', () => { + it('reads an object_operation declaration off objectParams', () => { + expect(planEndpointTarget(endpoint({ objectParams: { object: 'task', operation: 'update' } }))) + .toEqual({ kind: 'object_operation', object: 'task', operation: 'update' }); + }); + + it('reads a flow declaration off `target`', () => { + expect(planEndpointTarget(endpoint({ type: 'flow', target: 'purge_inquiries', objectParams: undefined }))) + .toEqual({ kind: 'flow', flow: 'purge_inquiries' }); + }); + + it.each<[string, Record, string]>([ + ['object missing', { object: undefined, operation: 'find' }, 'objectParams.object'], + ['operation missing', { object: 'task', operation: undefined }, 'objectParams.operation'], + ])('refuses an incomplete object_operation (%s)', (_label, objectParams, expected) => { + const plan = planEndpointTarget(endpoint({ objectParams })); + expect(plan.kind).toBe('unsupported'); + expect((plan as any).reason).toContain(expected); + }); + + it.each(['script', 'proxy'])('refuses `%s` — declared by the vocabulary, not executed in 17.x', (type) => { + const plan = planEndpointTarget(endpoint({ type, objectParams: undefined })); + expect(plan.kind).toBe('unsupported'); + // The reason names the type so an author is not left guessing which of + // their endpoints the runtime declined. + expect((plan as any).reason).toContain(`'${type}'`); + expect((plan as any).hint).toContain('#5040 §7-3'); + }); +}); + +// --------------------------------------------------------------------------- +// object_operation delegation +// --------------------------------------------------------------------------- + +describe('object_operation delegates to callData with the argument shape /data uses', () => { + it('find → callData("query", { object, query }) with the request query verbatim', async () => { + const deps = depsWith(); + deps.callData.mockResolvedValue({ object: 'showcase_task', records: [{ id: '1' }], total: 1 }); + const ctx = contextFor(endpoint({}), { query: { status: 'open', $top: '10' } }); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledTimes(1); + expect(deps.callData).toHaveBeenCalledWith( + 'query', + { object: 'showcase_task', query: { status: 'open', $top: '10' } }, + DRIVER, + 'env-7', + EC, + ); + // `domains/data.ts:119-120` answers `success(result)` — the FindDataResponse + // as the protocol built it, not a reshaped copy of it. + expect(answer).toEqual({ status: 200, body: { success: true, data: { object: 'showcase_task', records: [{ id: '1' }], total: 1 }, meta: undefined } }); + }); + + it('get → callData("get", { object, id }) with id from `query.id`', async () => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: { object: 'showcase_task', operation: 'get' } }), { + query: { id: 'rec-1' }, + }); + + await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledWith('get', { object: 'showcase_task', id: 'rec-1' }, DRIVER, 'env-7', EC); + }); + + it('get allowlists exactly select/expand and drops every other query param', async () => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: { object: 'showcase_task', operation: 'get' } }), { + // `filters` is the parameter-pollution case `domains/data.ts:80-85` + // allowlists against — it must not reach the read. + query: { id: 'rec-1', select: 'name', expand: 'owner', filters: '[["id","!=","rec-1"]]' }, + }); + + await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledWith( + 'get', + { object: 'showcase_task', id: 'rec-1', select: 'name', expand: 'owner' }, + DRIVER, + 'env-7', + EC, + ); + }); + + it('create → callData("create", { object, data: body }) and answers 201 like POST /data/:object', async () => { + const deps = depsWith(); + const ctx = contextFor( + endpoint({ method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }), + { method: 'POST', body: { title: 'new' } }, + ); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledWith('create', { object: 'showcase_task', data: { title: 'new' } }, DRIVER, 'env-7', EC); + expect(answer.status).toBe(201); + }); + + it('update → callData("update", { object, id, data: body })', async () => { + const deps = depsWith(); + const ctx = contextFor( + endpoint({ method: 'PATCH', objectParams: { object: 'showcase_task', operation: 'update' } }), + { method: 'PATCH', query: { id: 'rec-1' }, body: { title: 'renamed' } }, + ); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledWith( + 'update', + { object: 'showcase_task', id: 'rec-1', data: { title: 'renamed' } }, + DRIVER, + 'env-7', + EC, + ); + expect(answer.status).toBe(200); + }); + + it('delete → callData("delete", { object, id })', async () => { + const deps = depsWith(); + const ctx = contextFor( + endpoint({ method: 'DELETE', objectParams: { object: 'showcase_task', operation: 'delete' } }), + { method: 'DELETE', query: { id: 'rec-1' } }, + ); + + await executeEndpointTarget(ctx, deps); + + expect(deps.callData).toHaveBeenCalledWith('delete', { object: 'showcase_task', id: 'rec-1' }, DRIVER, 'env-7', EC); + }); + + it('takes `object` from the DECLARATION — a request cannot redirect the operation', async () => { + const deps = depsWith(); + const ctx = contextFor(endpoint({}), { + // The #3946 shape: a caller naming a different object. On `/data` the + // body could once move the read; here the declaration is the only + // source, so the request cannot reach the object at all. + query: { object: 'sys_user' }, + body: { object: 'sys_user' }, + }); + + await executeEndpointTarget(ctx, deps); + + const [, params] = deps.callData.mock.calls[0]!; + expect(params.object).toBe('showcase_task'); + }); + + it.each(['get', 'update', 'delete'] as const)( + 'refuses a %s endpoint with no `?id=` — 400 VALIDATION_FAILED with fields[], not a 500', + async (operation) => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: { object: 'showcase_task', operation } })); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(deps.callData).not.toHaveBeenCalled(); + expect(answer.status).toBe(400); + const error = expectConformantError(answer); + expect(error.code).toBe('VALIDATION_FAILED'); + // `code` was PROMOTED into the declared field by the shared builder, + // so `details` carries genuine context only — the per-field envelope + // a UI maps back onto the form. + expect(error.details).toEqual({ + fields: [{ field: 'id', code: 'required', message: expect.any(String) }], + }); + }, + ); + + it('rejects a repeated `?id=` rather than silently taking one of them', async () => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: { object: 'showcase_task', operation: 'get' } }), { + query: { id: ['rec-1', 'rec-2'] }, + }); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(deps.callData).not.toHaveBeenCalled(); + expect(answer.status).toBe(400); + }); + + it.each(['find', 'get', 'create', 'update', 'delete'] as const)( + 'forwards the identity envelope on %s — the #5040 §4 red line', + async (operation) => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: { object: 'showcase_task', operation } }), { + query: { id: 'rec-1' }, + body: {}, + }); + + await executeEndpointTarget(ctx, deps); + + // The dead `handleApiEndpoint` code removed in #4936 dropped this + // argument, which would have read as a system principal with RLS + // bypassed had it ever run. + const [, , driver, scope, ec] = deps.callData.mock.calls[0]!; + expect(driver).toBe(DRIVER); + expect(scope).toBe('env-7'); + expect(ec).toBe(EC); + }, + ); + + it('forwards `undefined` identity for an anonymous request — never a substitute', async () => { + const deps = depsWith(); + const ctx = anonymousContextFor(endpoint({})); + + await executeEndpointTarget(ctx, deps); + + expect(deps.callData.mock.calls[0]![4]).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// flow delegation +// --------------------------------------------------------------------------- + +describe('flow delegates to IAutomationService.execute with the trigger route’s own context', () => { + const FLOW = () => endpoint({ type: 'flow', target: 'purge_inquiries', method: 'POST', objectParams: undefined }); + + it('calls execute(target, buildAutomationContext(body, ctx)) and answers like the trigger route', async () => { + const execute = vi.fn().mockResolvedValue({ success: true, runId: 'run-1' }); + const deps = depsWith({ automationService: { execute } }); + const ctx = contextFor(FLOW(), { + method: 'POST', + body: { recordId: 'rec-1', objectName: 'showcase_inquiry', params: { reason: 'spam' } }, + }); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(execute).toHaveBeenCalledTimes(1); + const [flowName, automationContext] = execute.mock.calls[0]!; + expect(flowName).toBe('purge_inquiries'); + // The `{recordId, objectName, params}` translation flow variables resolve + // from, plus the `Id` alias — none of it re-implemented here. + expect(automationContext).toEqual({ + params: { reason: 'spam', recordId: 'rec-1', showcaseInquiryId: 'rec-1' }, + object: 'showcase_inquiry', + event: 'manual', + // The FULLY-RESOLVED identity envelope: without it a `runAs:'user'` + // flow's data operations are refused fail-closed (#3760) or run as + // somebody else (#1888). + userId: 'user-1', + positions: ['sales_rep'], + permissions: ['task_edit'], + tenantId: 'tenant-9', + }); + expect(answer).toEqual({ status: 200, body: { success: true, data: { success: true, runId: 'run-1' }, meta: undefined } }); + }); + + it('passes a flat body through as flow params, exactly as POST /automation/:name/trigger does', async () => { + const execute = vi.fn().mockResolvedValue({}); + const deps = depsWith({ automationService: { execute } }); + const ctx = contextFor(FLOW(), { method: 'POST', body: { olderThanDays: 30 } }); + + await executeEndpointTarget(ctx, deps); + + expect(execute.mock.calls[0]![1].params).toEqual({ olderThanDays: 30 }); + }); + + it('answers 501 with the slot’s own remedy sentence when the automation slot is empty', async () => { + const deps = depsWith({ automationService: undefined }); + + const answer = await executeEndpointTarget(contextFor(FLOW(), { method: 'POST' }), deps); + + expect(answer.status).toBe(501); + const error = expectConformantError(answer); + expect(error.code).toBe(DispatcherErrorCode.enum.NOT_IMPLEMENTED); + // The same sentence discovery reports for the slot — one remedy, so the + // 501 body and the discovery entry cannot name different fixes. + expect(error.message).toBe(serviceUnavailableMessage('automation')); + }); + + it('answers 501 for a self-declared non-handler — a stub is as much capability as an empty slot', async () => { + const execute = vi.fn().mockResolvedValue({ success: true }); + const stub = { execute, [SERVICE_SELF_INFO_KEY]: { status: 'stub', handlerReady: false } }; + const deps = depsWith({ automationService: stub }); + + const answer = await executeEndpointTarget(contextFor(FLOW(), { method: 'POST' }), deps); + + expect(answer.status).toBe(501); + // The point of the gate: a stub whose `execute` returns `{success:true}` + // must not answer 200 for a flow that never ran (ADR-0076 D12). + expect(execute).not.toHaveBeenCalled(); + }); + + it('keeps serving a `degraded` occupant — degraded is not a stub', async () => { + const execute = vi.fn().mockResolvedValue({ success: true }); + const degraded = { execute, [SERVICE_SELF_INFO_KEY]: { status: 'degraded' } }; + const deps = depsWith({ automationService: degraded }); + + const answer = await executeEndpointTarget(contextFor(FLOW(), { method: 'POST' }), deps); + + expect(answer.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('answers 501 for an occupant that carries no `execute` rather than throwing a TypeError', async () => { + const deps = depsWith({ automationService: { listFlows: async () => [] } }); + + const answer = await executeEndpointTarget(contextFor(FLOW(), { method: 'POST' }), deps); + + expect(answer.status).toBe(501); + expect(expectConformantError(answer).code).toBe(DispatcherErrorCode.enum.NOT_IMPLEMENTED); + }); + + it('never reaches the data pipeline', async () => { + const deps = depsWith({ automationService: { execute: vi.fn().mockResolvedValue({}) } }); + await executeEndpointTarget(contextFor(FLOW(), { method: 'POST' }), deps); + expect(deps.callData).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// The unsupported subset +// --------------------------------------------------------------------------- + +describe('an unsupported declaration gets a structured 501, never invented semantics', () => { + it.each(['script', 'proxy'])('%s answers 501 NOT_IMPLEMENTED with a hint', async (type) => { + const deps = depsWith({ automationService: { execute: vi.fn() } }); + const ctx = contextFor(endpoint({ type, target: 'whatever', objectParams: undefined })); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(answer.status).toBe(501); + const error = expectConformantError(answer); + expect(error.code).toBe(DispatcherErrorCode.enum.NOT_IMPLEMENTED); + expect(error.hint).toContain('script'); + // Nothing was delegated: refusing is the whole behavior. + expect(deps.callData).not.toHaveBeenCalled(); + }); + + it('an object_operation with no objectParams answers 501 and touches no pipeline', async () => { + const deps = depsWith(); + const ctx = contextFor(endpoint({ objectParams: undefined })); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(answer.status).toBe(501); + expect(deps.callData).not.toHaveBeenCalled(); + }); + + it('a flow with an empty target answers 501 and calls nothing', async () => { + const execute = vi.fn(); + const deps = depsWith({ automationService: { execute } }); + const ctx = contextFor(endpoint({ type: 'flow', target: '', objectParams: undefined })); + + expect((await executeEndpointTarget(ctx, deps)).status).toBe(501); + expect(execute).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Failure mapping +// --------------------------------------------------------------------------- + +describe('a delegated failure maps onto the EXISTING envelope, class by class', () => { + async function failWith(err: unknown): Promise { + const deps = depsWith(); + deps.callData.mockRejectedValue(err); + return executeEndpointTarget(contextFor(endpoint({})), deps); + } + + it('honours the error’s own status — a 404 stays a 404', async () => { + const answer = await failWith({ status: 404, message: 'Record not found' }); + expect(answer.status).toBe(404); + expect(expectConformantError(answer).message).toBe('Record not found'); + }); + + it('honours `statusCode` too — the shape callData’s exposure gate throws', async () => { + // `action-execution.callData` throws `{ statusCode, message }` for an + // object whose `apiEnabled`/`apiMethods` deny the operation (ADR-0049). + const answer = await failWith({ statusCode: 403, message: 'API access denied' }); + expect(answer.status).toBe(403); + expect(expectConformantError(answer).message).toBe('API access denied'); + }); + + it('maps a record ValidationError to 400 with fields[] (#3918)', async () => { + const err = Object.assign(new Error('title is required'), { + name: 'ValidationError', + code: 'VALIDATION_FAILED', + fields: [{ field: 'title', message: 'required' }], + }); + + const answer = await failWith(err); + + expect(answer.status).toBe(400); + const error = expectConformantError(answer); + expect(error.code).toBe('VALIDATION_FAILED'); + expect((error.details as any).fields).toEqual([{ field: 'title', message: 'required' }]); + }); + + it('falls back to 500 for an error carrying no status', async () => { + const answer = await failWith(new Error('something broke')); + expect(answer.status).toBe(500); + expect(expectConformantError(answer).message).toBe('something broke'); + }); + + it('sanitises a 5xx message that looks like an internal leak (#3867)', async () => { + const answer = await failWith(new Error('SQLITE_CONSTRAINT: UNIQUE constraint failed: showcase_task.name')); + expect(answer.status).toBe(500); + // The physical table/column name must not reach the caller. + expect(expectConformantError(answer).message).toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('leaves a 4xx message intact even when it would trip the leak heuristic', async () => { + // A 4xx is a deliberate business answer and must reach the caller whole — + // the guard is scoped to 5xx exactly as the dispatcher’s own exits scope it. + const answer = await failWith({ status: 400, message: 'select a valid owner' }); + expect(answer.status).toBe(400); + expect(expectConformantError(answer).message).toBe('select a valid owner'); + }); + + it('carries the producer’s own code into the declared field', async () => { + const answer = await failWith({ status: 409, message: 'conflict', code: 'RECORD_LOCKED' }); + expect(expectConformantError(answer).code).toBe('RECORD_LOCKED'); + }); + + it('maps a flow failure the same way — one error path for both pipelines', async () => { + const deps = depsWith({ + automationService: { execute: vi.fn().mockRejectedValue({ status: 422, message: 'flow refused input' }) }, + }); + const ctx = contextFor(endpoint({ type: 'flow', target: 'purge_inquiries', objectParams: undefined }), { method: 'POST' }); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(answer.status).toBe(422); + expect(expectConformantError(answer).message).toBe('flow refused input'); + }); + + it('executeEndpointTarget never throws — the caller has exactly one answer type', async () => { + const deps = depsWith(); + deps.callData.mockRejectedValue('a bare string, not an Error'); + await expect(executeEndpointTarget(contextFor(endpoint({})), deps)).resolves.toMatchObject({ status: 500 }); + }); + + it('endpointErrorAnswer is usable directly with a caller-chosen fallback status', async () => { + expect(endpointErrorAnswer(new Error('nope'), 502).status).toBe(502); + }); +}); diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts new file mode 100644 index 0000000000..3601dbadfb --- /dev/null +++ b/packages/runtime/src/endpoint-executor.ts @@ -0,0 +1,512 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * EXECUTION-TARGET DELEGATION for declarative `apis:` endpoints (#5040 E5). + * + * ## The one rule this module exists to keep + * + * A declarative endpoint is a **stable URL alias plus a policy layer over an + * EXISTING pipeline** — never a second execution dialect. #5040 §4 states it as + * a ruling: the same operation reached through a declared endpoint and through + * the built-in route must answer the same thing, because two pipelines for one + * operation drift, and an endpoint whose semantics cannot be read off the + * existing docs is a hidden dialect (Prime Directive #12). + * + * So there is **zero new execution semantics** here. `object_operation` + * delegates to `action-execution.callData` — the same call `/data` makes, with + * the same argument shape, cited line by line below — and `flow` delegates to + * `IAutomationService.execute` with the context `buildAutomationContext` + * builds, the same one `POST /automation/:name/trigger` sends. What this module + * contributes is only: which pipeline, with which arguments, and how its answer + * (or its throw) becomes an HTTP response. + * + * ## Nothing calls this yet + * + * The dispatch step (`api-endpoint-step.ts`, #5090) still answers 501 on a + * match. Replacing that branch with `policy → execute` is a deliberate + * follow-up single, landing after this and #5091 (E4, policy keys) are both on + * `main` — the two were developed in parallel on disjoint file surfaces (#4604), + * and wiring them together is one small reviewable change rather than a race + * between two PRs over the same lines. Until that lands this module is + * unreachable, and on top of that a non-empty `apis:` is still rejected at + * publish/validate until the E7 flip. Zero live behavior change; the tests drive + * it directly with stubs, exactly as #5040 §5 prescribes for every E-series unit + * that lands before the flip. + * + * ## Pure by construction + * + * Every collaborator arrives as an argument — the `callData` binding, the + * `automation` slot occupant, the resolved `executionContext`, the environment + * scoping. NOTHING is looked up in here. That is what makes the delegated call + * shape assertable in a unit test (the tests below check the exact arguments + * `/data` would have passed), and it keeps service resolution owned by the + * caller that already performs the per-request kernel swap. + */ + +import { DispatcherErrorCode } from '@objectstack/spec/api'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IAutomationService } from '@objectstack/spec/contracts'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { serviceUnavailableMessage } from '@objectstack/spec/system'; +import { INTERNAL_ERROR_MESSAGE, looksLikeInternalErrorLeak } from '@objectstack/types'; +import { apiErrorResponse } from './error-envelope.js'; +import { isServiceServeable } from './service-serveable.js'; +import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; +import { buildAutomationContext } from './domains/automation.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; + +// ============================================================================ +// Request → execution context +// ============================================================================ + +/** + * The request as the `setFallbackHandler` seam sees it — the `IHttpRequest` + * members an endpoint execution can legitimately read. + * + * `params` is deliberately NOT here: the transport's own route params are + * meaningless for a path no route matched, and the endpoint's path parameters + * arrive on {@link ApiEndpointMatch.params} instead (always `{}` in 17.x — the + * frozen vocabulary defines no template syntax). + */ +export interface EndpointExecutionRequest { + method: string; + path: string; + query?: Record; + headers?: Record; + /** Parsed body. The fallback seam populates it exactly as a route does. */ + body?: unknown; + /** The transport's own peer address — never a header (#4910). */ + remoteAddress?: string; +} + +export interface BuildEndpointExecutionContextInput { + request: EndpointExecutionRequest; + /** The matcher's verdict — endpoint with schema defaults MATERIALIZED. */ + match: ApiEndpointMatch; + /** + * 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 as they do on the built-in route. + * + * #5040 §4 makes this a red line, and names the defect it guards against: + * the dead `handleApiEndpoint` code removed in #4936 called + * `callData('query', …)` with NO context, so had it ever run it would have + * read as a system principal with RLS bypassed. + */ + executionContext?: ExecutionContext; + /** Environment scoping for service resolution (`HttpProtocolContext`). */ + environmentId?: string; + /** Environment-scoped data driver, when the host resolved one. */ + dataDriver?: unknown; +} + +/** + * Everything a target delegation needs about one request, derived once. + * + * The members mirror what the existing callers of these pipelines read off + * their own `HttpProtocolContext` (`domains/data.ts`, `domains/automation.ts`) — + * no context member is invented here. + */ +export interface EndpointExecutionContext { + /** Matched endpoint, schema defaults materialized. */ + endpoint: ApiEndpoint; + /** + * Path parameters from the match. **Always `{}` in 17.x** and deliberately + * NOT consulted when resolving a record id — the id comes from `query.id` + * (#5040 §2), because reading it from a template segment would require a + * template syntax the vocabulary does not define. + */ + params: Record; + /** Upper-cased, as every method comparison in the dispatcher stack is. */ + method: string; + path: string; + query: Record; + headers: Record; + body: unknown; + remoteAddress?: string; + environmentId?: string; + dataDriver?: unknown; + executionContext?: ExecutionContext; + /** + * The `HttpProtocolContext` shape the existing domain handlers receive, so + * a collaborator that already takes one (`buildAutomationContext`) is fed + * the same object it is fed on the built-in route rather than a lookalike. + */ + protocolContext: HttpProtocolContext; +} + +/** + * Derive the execution context for one matched endpoint request. + * + * A pure projection: it reads its inputs and writes nothing. `query`, `headers` + * and `body` ride through VERBATIM — normalising them here would put a second, + * weaker copy of each pipeline's own input handling in front of it (the + * `/data` list route carries the same note about transport query params: + * `domains/data.ts:109-118`). + */ +export function buildEndpointExecutionContext( + input: BuildEndpointExecutionContextInput, +): EndpointExecutionContext { + const { request, match, executionContext, environmentId, dataDriver } = input; + const query = request.query ?? {}; + const headers = request.headers ?? {}; + + const protocolContext: HttpProtocolContext = { + // `{ request: req }` is how every dispatcher-plugin route builds this + // (dispatcher-plugin.ts, ~40 call sites) — the raw request object, + // unwrapped. + request, + ...(environmentId !== undefined ? { environmentId } : {}), + ...(dataDriver !== undefined ? { dataDriver } : {}), + ...(executionContext !== undefined ? { executionContext } : {}), + }; + + return { + endpoint: match.endpoint, + params: match.params, + method: request.method.toUpperCase(), + path: request.path, + query, + headers, + body: request.body, + ...(request.remoteAddress !== undefined ? { remoteAddress: request.remoteAddress } : {}), + ...(environmentId !== undefined ? { environmentId } : {}), + ...(dataDriver !== undefined ? { dataDriver } : {}), + ...(executionContext !== undefined ? { executionContext } : {}), + protocolContext, + }; +} + +// ============================================================================ +// What a declaration asks for — and what this executor can honour +// ============================================================================ + +/** The record operations `callData` serves, as the vocabulary spells them. */ +export type ObjectOperation = 'find' | 'get' | 'create' | 'update' | 'delete'; + +/** + * The executor's reading of one declaration. + * + * The `unsupported` arm is the point of this type: the subset of the FROZEN + * vocabulary that 17.x does not execute is enumerated **once, here**, so the + * E7 publish gate (`packages/spec`) has a single place to read the list off + * rather than restating it. Every `unsupported` shape is one the gate rejects + * at publish — so this arm is a structural backstop for a declaration that + * reached the store some other way (a direct `metadata.register()`), never a + * silent degradation of a legal one. + */ +export type EndpointTargetPlan = + | { kind: 'object_operation'; object: string; operation: ObjectOperation } + | { kind: 'flow'; flow: string } + | { kind: 'unsupported'; reason: string; hint: string }; + +/** + * Classify a declaration into the pipeline that will run it. + * + * `script` and `proxy` are declared by the vocabulary and NOT executed in 17.x, + * on purpose (#5040 §7-3, restated in the E5 card): the automation contract's + * `execute` doc mentions "flow or script" but nothing in this repo verifies a + * script target is reachable that way, and `proxy` is an entirely new outbound + * surface (SSRF / egress policy) that needs its own security ruling. Answering + * "not implemented" is the declared=enforced posture for them — inventing + * semantics here is precisely what a frozen vocabulary forbids. + */ +export function planEndpointTarget(endpoint: ApiEndpoint): EndpointTargetPlan { + if (endpoint.type === 'object_operation') { + const object = endpoint.objectParams?.object; + const operation = endpoint.objectParams?.operation; + if (!object || !operation) { + return { + kind: 'unsupported', + reason: + `Endpoint '${endpoint.name}' declares type 'object_operation' but ` + + `objectParams.${!object ? 'object' : 'operation'} is missing.`, + hint: 'An object_operation endpoint must declare both `objectParams.object` and ' + + '`objectParams.operation`; publish rejects the incomplete form (#5040 E7).', + }; + } + return { kind: 'object_operation', object, operation }; + } + + if (endpoint.type === 'flow') { + const flow = endpoint.target; + if (!flow) { + return { + kind: 'unsupported', + reason: `Endpoint '${endpoint.name}' declares type 'flow' but names no target flow.`, + hint: 'Set `target` to the flow name (snake_case) this endpoint triggers.', + }; + } + return { kind: 'flow', flow }; + } + + return { + kind: 'unsupported', + reason: `Endpoint '${endpoint.name}' declares type '${endpoint.type}', which this runtime does not execute.`, + hint: "Only 'object_operation' and 'flow' endpoints execute in 17.x. 'script' and 'proxy' " + + 'are rejected at publish pending their own rulings — script reachability through the ' + + 'automation service is unverified, and proxy is an outbound (SSRF) surface (#5040 §7-3).', + }; +} + +// ============================================================================ +// Answers +// ============================================================================ + +/** What the executor decided: a response for the caller to write. */ +export interface EndpointExecutionAnswer { + status: number; + body: unknown; + headers?: Record; +} + +/** + * The success envelope, built exactly as `HttpDispatcher.success` builds it + * (`http-dispatcher.ts:460-465`) — `{ success, data, meta }`, `meta` present as + * a key even when undefined so it drops out of JSON the same way. + */ +function successAnswer(data: unknown, status = 200): EndpointExecutionAnswer { + return { status, body: { success: true, data, meta: undefined } }; +} + +/** + * A 5xx message is sanitised before it reaches the wire, exactly as both + * existing dispatcher exits do it (`HttpDispatcher.error`, + * `dispatcher-plugin.errorResponseBase`): a delegated pipeline can be carrying + * a driver message that names physical tables and columns. 4xx messages are + * deliberate business answers and pass through intact (#3867). + */ +function sanitizeMessage(message: string, httpStatus: number): string { + return httpStatus >= 500 && looksLikeInternalErrorLeak(message) ? INTERNAL_ERROR_MESSAGE : message; +} + +/** + * A THROWN failure from a delegated pipeline → the existing error envelope. + * + * A restatement of `HttpDispatcher.errorFromThrown` (`http-dispatcher.ts:530`) + * over this module's answer type rather than the dispatcher's private one: same + * status precedence (`.status` → `.statusCode` → validation ⇒ 400 → fallback), + * same `details` assembly (`code`, `issues`, then `fields[]` last so + * `VALIDATION_FAILED` wins for an error matched by `name` alone), same 5xx + * sanitisation. No new envelope shape and no new code vocabulary: `code` is the + * producer's own or is derived from the status by `buildApiError`. + */ +export function endpointErrorAnswer(e: any, fallbackStatus = 500): EndpointExecutionAnswer { + const validation = validationFailureDetails(e); + const status = + typeof e?.status === 'number' ? e.status + : typeof e?.statusCode === 'number' ? e.statusCode + : validation ? VALIDATION_FAILED_STATUS + : fallbackStatus; + const issues = Array.isArray(e?.issues) ? e.issues : undefined; + const details = + issues || e?.code || validation + ? { + ...(e?.code ? { code: e.code } : {}), + ...(issues ? { issues } : {}), + ...(validation ?? {}), + } + : undefined; + const message = sanitizeMessage(e?.message ?? String(e), status); + return apiErrorResponse({ message, httpStatus: status, details }); +} + +/** The 501 answer for a declaration this runtime cannot delegate. */ +function unsupportedAnswer(plan: { reason: string; hint: string }): EndpointExecutionAnswer { + return apiErrorResponse({ + code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, + httpStatus: 501, + message: sanitizeMessage(plan.reason, 501), + extra: { hint: plan.hint }, + }); +} + +// ============================================================================ +// Delegation +// ============================================================================ + +/** + * The `callData` bridge, as a value. + * + * Identical to `action-execution.callData` with its `deps` argument already + * bound — the caller supplies + * `(action, params, driver, scope, ec) => callData(deps, action, params, driver, scope, ec)`. + * Injected rather than imported so this module performs no lookup of its own + * and a test can assert the EXACT delegated call, which is the property that + * keeps "same pipeline, same arguments as `/data`" honest over time. + */ +export type CallDataFn = ( + action: string, + params: any, + dataDriver?: any, + scopeId?: string, + executionContext?: ExecutionContext, +) => Promise; + +export interface EndpointExecutorDeps { + callData: CallDataFn; + /** + * The `automation` slot occupant, or `undefined` when the slot is empty. + * Resolved by the caller (per request, on the request's own kernel) and + * checked here with `isServiceServeable`, the same predicate `/automation` + * applies — a self-declared non-handler is as much capability as an empty + * slot (ADR-0076 D12). + */ + automationService?: unknown; +} + +/** `query.id`, or a 400-class validation failure naming the missing parameter. */ +function requireRecordId(ctx: EndpointExecutionContext): string { + const raw = ctx.query.id; + if (typeof raw !== 'string' || raw === '') { + // Thrown, not returned, so it lands in the SAME mapping as a pipeline + // failure — one error path, one envelope. `validationFailure` is the + // repo's constructor for the shape both dispatcher exits map to 400 + + // `fields[]` (#3878/#3918). + throw validationFailure( + `Query parameter 'id' is required for a '${ctx.endpoint.objectParams?.operation}' endpoint.`, + [{ field: 'id', code: 'required', message: "Record id must be supplied as `?id=`." }], + ); + } + return raw; +} + +/** + * `object_operation` → `callData`, with the argument shape `/data` uses. + * + * Each mapping is the one the built-in route already performs, cited so a + * reader can check it rather than take it on trust: + * + * | operation | delegated call | mirrors | + * |---|---|---| + * | `find` | `('query', { object, query: {…query} })` | `domains/data.ts:119` (GET list — transport query params ride through verbatim; folding them into QueryAST names belongs to the protocol's own normalizer, #3795) | + * | `get` | `('get', { object, id, select?, expand? })` | `domains/data.ts:87` (GET by id — `select`/`expand` are the ONLY allowlisted query params, everything else is dropped to prevent parameter pollution) | + * | `create` | `('create', { object, data: body })` | `domains/data.ts:126` (POST) | + * | `update` | `('update', { object, id, data: body })` | `domains/data.ts:95` (PATCH) | + * | `delete` | `('delete', { object, id })` | `domains/data.ts:103` (DELETE) | + * + * The record id comes from `query.id` for `get`/`update`/`delete` (#5040 §2): + * `/data` takes it from a path segment, which a declared endpoint has no way to + * express — the vocabulary defines no path templates and this executor does not + * invent one. + * + * `object` comes from `objectParams.object`, NEVER from the request. `/data` + * had the mirror-image bug (#3946: a body key could move the read to a + * different object than the URL named); here the declaration is the only + * source, so a request cannot redirect the operation at all. + * + * The success status mirrors `/data` too: 201 for a create (`data.ts:127-128`), + * 200 otherwise. + */ +async function executeObjectOperation( + ctx: EndpointExecutionContext, + deps: EndpointExecutorDeps, + plan: { object: string; operation: ObjectOperation }, +): Promise { + const { object, operation } = plan; + const call = (action: string, params: Record) => + deps.callData(action, params, ctx.dataDriver, ctx.environmentId, ctx.executionContext); + + if (operation === 'find') { + return successAnswer(await call('query', { object, query: { ...ctx.query } })); + } + + if (operation === 'get') { + const id = requireRecordId(ctx); + const { select, expand } = ctx.query; + const allowedParams: Record = {}; + if (select != null) allowedParams.select = select; + if (expand != null) allowedParams.expand = expand; + return successAnswer(await call('get', { object, id, ...allowedParams })); + } + + if (operation === 'create') { + return successAnswer(await call('create', { object, data: ctx.body }), 201); + } + + if (operation === 'update') { + const id = requireRecordId(ctx); + return successAnswer(await call('update', { object, id, data: ctx.body })); + } + + const id = requireRecordId(ctx); + return successAnswer(await call('delete', { object, id })); +} + +/** + * `flow` → `IAutomationService.execute`, with the context the trigger route + * builds. + * + * `buildAutomationContext` (`domains/automation.ts`) is THE construction point + * for both existing trigger routes, and reusing it is what carries the parts + * that are easy to omit and expensive to omit: the `{recordId, objectName, + * params}` translation flow variables resolve from, and the FULLY-RESOLVED + * identity envelope (`userId` / `positions` / `permissions` / `tenantId`) a + * `runAs:'user'` flow needs — without which its data operations are REFUSED + * fail-closed (#3760) or, worse, run as somebody else (#1888). #5040 §4 is + * explicit that the executor uses this and not the `runFlow({flowId, inputs})` + * shape the removed dead code invented, which no contract ever declared. + * + * The request body is the flow input, exactly as on `POST + * /automation/:name/trigger`. + */ +async function executeFlow( + ctx: EndpointExecutionContext, + deps: EndpointExecutorDeps, + plan: { flow: string }, +): Promise { + const service = deps.automationService; + // Empty slot, or a slot filled by a self-declared non-handler — the same + // amount of automation capability, and the same answer `/automation` gives: + // 501 carrying the remedy sentence discovery reports for the slot + // (`domains/unavailable.ts`, `serviceUnavailableMessage`). NOT a 404: the + // endpoint IS declared and IS matched; what is missing is the implementation + // behind it. + if (!isServiceServeable(service)) { + return apiErrorResponse({ + code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, + httpStatus: 501, + message: serviceUnavailableMessage('automation'), + }); + } + + const automation = service as Pick; + if (typeof automation.execute !== 'function') { + // A serveable occupant that cannot execute is the same wall from the + // caller's side, and saying so is more use than letting a TypeError + // become an opaque 500. + return apiErrorResponse({ + code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, + httpStatus: 501, + message: serviceUnavailableMessage('automation'), + }); + } + + const automationContext = buildAutomationContext(ctx.body, ctx.protocolContext) as AutomationContext; + return successAnswer(await automation.execute(plan.flow, automationContext)); +} + +/** + * Execute one matched endpoint and map the outcome onto an HTTP answer. + * + * Never throws: a throw out of a delegated pipeline is mapped by + * {@link endpointErrorAnswer} into the existing envelope, so the caller has one + * answer type to write and cannot accidentally give a delegated failure a + * different shape than a policy failure. + */ +export async function executeEndpointTarget( + ctx: EndpointExecutionContext, + deps: EndpointExecutorDeps, +): Promise { + const plan = planEndpointTarget(ctx.endpoint); + if (plan.kind === 'unsupported') return unsupportedAnswer(plan); + + try { + return plan.kind === 'object_operation' + ? await executeObjectOperation(ctx, deps, plan) + : await executeFlow(ctx, deps, plan); + } catch (err) { + return endpointErrorAnswer(err); + } +} diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index 32bbd42419..e22ddfff38 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -241,6 +241,11 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => { // the first that answers a request no route matched. Listed the day it // was written, for the same reason the line above was. './api-endpoint-step.ts', + // [#5092] The endpoint executor maps a delegated pipeline's failure onto + // this wire surface — a restatement of `errorFromThrown` outside the + // dispatcher class, which is exactly the kind of second copy this scan + // exists to keep honest. Listed the day it was written. + './endpoint-executor.ts', ]; for (const file of MODULES) {