-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathendpoint-executor.ts
More file actions
604 lines (565 loc) · 28.2 KB
/
Copy pathendpoint-executor.ts
File metadata and controls
604 lines (565 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
// 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.
*
* ## What calls this, and since when
*
* The dispatch step (`api-endpoint-step.ts`, #5090) calls it on every match:
* E5b replaced that step's provisional 501 with `policy → execute`, so this
* module runs whenever a declared endpoint is matched. The E7 publish flip
* (`packages/spec/src/api/endpoint-publish-gate.ts`) then made declarations
* possible at all — a non-empty `apis:` is no longer refused wholesale, only
* shape by shape — so this is LIVE code on a real deployment, not a unit
* waiting for its seam. The showcase exercises it end to end over a socket
* (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`);
* the unit tests below still drive it directly with stubs, which is how the
* exact delegated call shape stays assertable.
*
* ## 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, resolveThrownHttpError, demotedDeclaredCode } from '@objectstack/types';
import { apiErrorResponse } from './error-envelope.js';
import { isServiceServeable } from './service-serveable.js';
import { validationFailure } from './validation-failure.js';
import { buildAutomationContext } from './domains/automation.js';
import {
classifyFlowRefusal,
flowIsUnknown,
flowNotFoundMessage,
FLOW_NOT_FOUND_STATUS,
} from './flow-dispatch-status.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<string, string | string[]>;
headers?: Record<string, string | string[]>;
/** 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<string, string>;
/** Upper-cased, as every method comparison in the dispatcher stack is. */
method: string;
path: string;
query: Record<string, string | string[]>;
headers: Record<string, string | string[]>;
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<string, string>;
}
/**
* 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.
*
* [#9106] No longer a restatement of `HttpDispatcher.errorFromThrown` — a
* DELEGATION to the same shared resolver that exit and the REST door call
* (`resolveThrownHttpError`, `@objectstack/types`): same status precedence
* (`.status` → `.statusCode` → validation ⇒ 400 → fallback), same `details`
* assembly (non-string `code` as context, `issues`, `fields[]`), and — the
* #9106 ruling — the same closed `error.code`: the resolver's narrowed `code`
* is what reaches the declared field, and a producer's unregistered spelling
* rides the wire's `declaredCode` sibling instead (presence means demotion).
* This matters HERE because a delegated pipeline runs tenant-authored hooks —
* the same author-thrown limb `domains/actions.ts` serves. The 5xx
* sanitisation stays this module's (a boundary property, not the throw's).
*/
export function endpointErrorAnswer(e: any, fallbackStatus = 500): EndpointExecutionAnswer {
const thrown = resolveThrownHttpError(e, fallbackStatus);
const declaredCode = demotedDeclaredCode(thrown);
return apiErrorResponse({
message: sanitizeMessage(thrown.message, thrown.status),
httpStatus: thrown.status,
code: thrown.code,
details: thrown.details,
...(declaredCode !== undefined ? { extra: { declaredCode } } : {}),
});
}
/** 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<any>;
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=<record 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<EndpointExecutionAnswer> {
const { object, operation } = plan;
const call = (action: string, params: Record<string, unknown>) =>
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<string, unknown> = {};
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`.
*
* ## [#9462] The outcome is the #9378 status table, from the ONE definition
*
* | engine exit | reality | this door answers |
* |------------------------|------------------|----------------------------|
* | flow not found | never dispatched | `404` |
* | flow disabled | never dispatched | `409` `FLOW_DISABLED` |
* | flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` |
* | ran and failed | ran, rejected | `400` `FLOW_FAILED` |
*
* Read from `../flow-dispatch-status.js` — the third and last door to converge
* on it (maintainer ruling, 2026-08-18, verbatim 「同意」: the table is a
* property of the flow-dispatch CONTRACT, not of the trigger route). ⛔ A
* fourth copy of the table, here or anywhere, is the defect by construction:
* three private readings of one engine result is exactly how a DISABLED flow
* came to be `409` at the trigger door, `400 FLOW_FAILED` at `/actions`, and
* `200` here, all at once.
*
* **This is a BREAKING change to what this seam answers.** Until now every
* outcome was `200` with the raw result in `data`, so a failing flow reached
* the caller as `{"success":true,"data":{"success":false,…}}` — the double
* envelope #3962 ruled out for `/actions`, on a surface an app publishes as
* its own public API. A consumer that branched on `data.success` now gets a
* 4xx whose `error.code` carries the same fact; one that branched on the HTTP
* status alone was reading failures as successes and now reads them correctly.
*
* `outputMapping` needs nothing here and deliberately gets nothing: it is
* applied by `api-endpoint-step.ts` on `answer.status < 400`, so the refusal
* rows fall outside it by the rule that was already written — which is also
* the fix for a real hole, since an `outputMapping` projection used to be
* applied to the `200`-wrapped FAILURE body and could present it as data. The
* policy chain is upstream of this function and is untouched: a refusal here
* is reached only by a request that already passed `rateLimit` /
* `authRequired`, and `Cache-Control` from `cacheTtl` rides success only,
* again on the same `status < 400` test.
*/
async function executeFlow(
ctx: EndpointExecutionContext,
deps: EndpointExecutorDeps,
plan: { flow: string },
): Promise<EndpointExecutionAnswer> {
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<IAutomationService, 'execute'>;
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'),
});
}
// [#9462] Row 1 of the table, answered by the SAME optional `getFlow`
// registry probe the trigger door and `/actions` use — the engine's
// not-found exit carries neither a `code` nor a `status`, so this is the
// only reading of it that is not a regex over its message (PD #12). A
// service that omits `getFlow` cannot be asked and dispatches as before,
// exactly as at the other two doors.
if (await flowIsUnknown(service, plan.flow)) {
return apiErrorResponse({
message: sanitizeMessage(flowNotFoundMessage(plan.flow), FLOW_NOT_FOUND_STATUS),
httpStatus: FLOW_NOT_FOUND_STATUS,
});
}
const automationContext = buildAutomationContext(ctx.body, ctx.protocolContext) as AutomationContext;
const result = await automation.execute(plan.flow, automationContext);
// [#9462] Rows 2-4, read off the PRODUCER's classification through the one
// shared table. What stood here was an unconditional `successAnswer`, so
// EVERY refusal — a flow that never dispatched included — reached the
// caller as `200 {success:true,data:{success:false,…}}`: the double
// envelope #3962 removed from `/actions`, on a surface an app publishes as
// its own public API.
const refusal = classifyFlowRefusal(plan.flow, result);
if (refusal) {
// The run's own artefacts ride the 400 arm ONLY — they describe a run
// that happened, and a never-dispatched refusal has neither an author
// failure text nor a node log to point at. Byte-identical to the
// trigger door's details (`domains/automation.ts`), because #5040 §4
// makes that route's answer this seam's contract.
const runDetails = refusal.code === 'FLOW_FAILED'
? {
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}),
}
: {};
return apiErrorResponse({
message: sanitizeMessage(refusal.message, refusal.status),
httpStatus: refusal.status,
code: refusal.code,
...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}),
});
}
// An UNCLASSIFIED `success: false` keeps today's 200 — this door reads it
// the TRIGGER door's way, not `/actions`'s, and the difference is decided
// by what a declared endpoint IS. #5040 §4 (this module's opening rule)
// makes a `type: 'flow'` endpoint a stable URL plus a policy layer over
// `POST /automation/:name/trigger`: same context builder, same `execute`
// call, so the same answer, or the alias has become the second execution
// dialect the whole module exists to prevent. `/actions` refuses the
// residual under its own #3962 ruling about ITS route; adopting that here
// would PROMOTE an exit the producer never classified — the one thing the
// shared table's note says a door must not do — and would do it by
// borrowing a ruling about a different door. If the residual should speak
// HTTP everywhere, that is a change to the shared table for all three
// doors, not a fourth reading invented at this one.
return successAnswer(result);
}
/**
* 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<EndpointExecutionAnswer> {
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);
}
}