diff --git a/.changeset/25976-run-door-input-shape.md b/.changeset/25976-run-door-input-shape.md new file mode 100644 index 00000000000..1e27305193c --- /dev/null +++ b/.changeset/25976-run-door-input-shape.md @@ -0,0 +1,25 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the action door and the flow trigger door refuse a body or a `params` that is not a JSON object, instead of running with an empty bag + +`POST /api/v1/actions/:object/:action` read its body, and then `params`, only when each was a truthy object. A string such as `{"params":"oops"}` became an empty params bag and the action ran anyway, answering `200` and starting a run the same as `{"params":{}}`; an array `params` was handed on as the bag itself; and a JSON string or array body ran the action with an empty bag too. + +`POST /api/v1/automation/:name/trigger`, its legacy spelling `POST /api/v1/automation/trigger/:name`, and every declared `type: 'flow'` API endpoint read the request body only when it was a truthy object. A JSON string body dispatched the flow with an empty bag, so it failed later with a misleading `400 FLOW_FAILED` "Unknown variable" or ran doing the wrong thing, and an array body forwarded its indices as params. + +Both now answer `400 VALIDATION_FAILED` with one `fields[]` entry, `{ field: '(body)', code: 'invalid_type' }` for a body and `{ field: 'params', code: 'invalid_type' }` for a `params`, whose message names the type received. The refusal comes before anything is dispatched, so no run starts and no run row is written. It is the refusal the resume door already gives a non-object body, and the three doors now ask the question through one check, so their answers have the same shape; the resume door's `fields[]` message now also names the type received. + +Unchanged: an object `params` (an empty one included) reaches the action as before, and an action call with no body, a JSON `null` body or no `params` key still runs with an empty bag. A trigger with no body, or a JSON `null` body, still runs with an empty bag. An unknown action or flow still answers `404`, and a caller the action or flow refuses still answers `403`, before the body is judged. + +Clause-②: no (narrowing) — the action and trigger doors refuse a body that is a string, number, boolean or array, and the action door refuses a `params` that is a string, number, boolean, array or `null`. Nothing is widened. + +Metadata-protocol: none (an HTTP request body's handling at the action and trigger doors; nothing a metadata author writes changes) + + + +**BREAKING** for a caller that sent the action door's body or `params`, or the trigger door's body, as something other than an object and relied on the call running anyway, shipped as `patch` because the metadata protocol does not move. + +- **Before.** A non-object body or `params` on the action door, or a non-object trigger body, was dropped (an array trigger body was forwarded as index-keyed params), and the action or flow was dispatched. +- **Now.** The request is refused with `400 VALIDATION_FAILED` naming `params` or `(body)`, and nothing runs. +- **Fix.** Send `params` as an object of input names to values, for example `{"params":{"note":"x"}}`, send the action body as an object (`{"recordId":"…","params":{…}}`), and send a trigger body as an object (`{"params":{"ownerId":"u1"}}` or a flat `{"ownerId":"u1"}`), or leave any of them out. diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index f2f7a90154f..bbfb76f7d4c 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -71,7 +71,10 @@ import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage } fro import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; import * as actionExec from '../action-execution.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; -import { validationFailureDetails } from '../validation-failure.js'; +import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; +// [#25976] The one value-shape check the run doors share — the trigger and +// resume doors ask it of their input too. +import { objectValueFailure } from '../object-value-shape.js'; import { buildApiError } from '../error-envelope.js'; // [ADR-0126 §5] The shared activation write-authority gates — the same two // tiers `/automation`'s toggle door passes, one implementation. @@ -685,9 +688,29 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string return { handled: true, response: deps.error(actionExec.flowActionUnavailableError(actionDef), 503) }; } - const reqBody = body && typeof body === 'object' ? body : {}; + // [#25976] The body, and then its params bag, must be objects before the + // param contract or any handler reads them. These reads used to be + // `body && typeof body === 'object' ? body : {}` and + // `params && typeof params === 'object' ? params : {}`, so a JSON string + // body, an array body and `{"params":"oops"}` all ran the action with an + // EMPTY bag, and an array `params` was handed on as the bag itself — each + // answered `200` with a started run, exactly like `{"params":{}}`. Asked + // through `objectValueFailure`, the check the trigger and resume doors ask + // too, and answered the way they answer it. `undefined` / `null` stay the + // legal bodyless call and an absent `params` key keeps its empty bag. + // Before the param contract, the record load and dispatch: a refused call + // reads no row and runs nothing. + const invalidBody = (message: string, failure: { field: string; code: 'invalid_type'; message: string }) => ({ + handled: true, + response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, [failure]))), + }); + const bodyFailure = objectValueFailure('(body)', body ?? {}); + if (bodyFailure) return invalidBody(`Invalid action body — ${bodyFailure.message}`, bodyFailure); + const reqBody = (body ?? {}) as Record; + const paramsFailure = objectValueFailure('params', reqBody.params); + if (paramsFailure) return invalidBody(`Invalid action body — \`params\` ${paramsFailure.message}`, paramsFailure); const recordId = recordIdFromPath ?? reqBody.recordId; - const reqParams = (reqBody.params && typeof reqBody.params === 'object') ? reqBody.params : {}; + const reqParams: Record = reqBody.params ?? {}; // [ADR-0104 D2] Enforce the declared param contract before the handler // runs — required/option/multiple/reference-id shape + unknown keys. diff --git a/packages/runtime/src/domains/automation-trigger-params-shape.test.ts b/packages/runtime/src/domains/automation-trigger-params-shape.test.ts index 3fb19ef0f95..db3713635b0 100644 --- a/packages/runtime/src/domains/automation-trigger-params-shape.test.ts +++ b/packages/runtime/src/domains/automation-trigger-params-shape.test.ts @@ -19,7 +19,7 @@ * The resume door already refuses the same value-shape class on its own * object-valued keys (#9416), so the two doors of one engine disagreed. The * trigger door now asks the resume door's question through the same function - * (`objectValueFailure`, `domains/automation.ts`) and answers the same + * (`objectValueFailure`, `object-value-shape.ts`) and answers the same * refusal: `400 VALIDATION_FAILED`, one `fields[]` entry naming `params` with * `invalid_type`, before dispatch — `execute()` is never called, so no run row * exists. `null` is refused too, as the resume door refuses `{"inputs":null}`. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index bba3d067ca8..3824ff12724 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -71,6 +71,9 @@ import { import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; import { readDispatchedBody } from '../deferred-request-body.js'; +// [#9416, #25821, #25976] The one value-shape check every door that starts or +// resumes a run asks — shared with the action door, so it lives beside both. +import { objectValueFailure } from '../object-value-shape.js'; // [#20552] The per-type credential redaction every metadata read exit applies, // and its write-path inverse — reused, never restated, for the definitions this // domain serves and overwrites. @@ -79,38 +82,6 @@ import { carryForwardRedactedValues, redactMetadataItem } from '@objectstack/met // the `protocol` service at request time, never re-derived here. import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -/** - * [#9416, #25821] The value-shape check for a body key whose value must be a - * JSON object (a map of names to values): the resume door's `inputs`, - * `variables` and `output`, and the trigger door's `params`. ONE function, so - * the two doors of one engine ask the same question and answer it in the same - * words — a second spelling of it is how they disagreed. - * - * Returns the located `fields[]` entry (ADR-0114 `invalid_type`) for a value - * that is not an object, `undefined` otherwise. `undefined` counts as ABSENT, - * never mis-shaped: `JSON.stringify` drops such a key, so no HTTP caller can - * send one, and the in-process spelling `{ key: maybeUndefined }` means "not - * sent". An explicit `null` IS refused — JSON can express it, and it is the - * value that used to be dropped most quietly of all. - * - * The received-type wording is spelled here rather than borrowed from the - * resume arm's `jsonTypeOf`, whose note keeps that namer local to the arm: what - * is shared is this one refusal, not a formatter. - */ -function objectValueFailure( - field: string, - value: unknown, -): { field: string; code: 'invalid_type'; message: string } | undefined { - if (value === undefined) return undefined; - if (value !== null && typeof value === 'object' && !Array.isArray(value)) return undefined; - const received = value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`; - return { - field, - code: 'invalid_type', - message: `expected an object (a map of names to values), received ${received}`, - }; -} - /** * Translate a trigger request body into the canonical `AutomationContext` the * engine expects, and forward the caller's resolved identity. @@ -147,14 +118,25 @@ function objectValueFailure( * caller supplied, as opposed to the row id this builder seeds — so every route * sharing it carries the same caller-provenance signal. * - * [#25821] A `params` that is not a JSON object is refused here, thrown as the - * validation failure both dispatcher exits and the endpoint executor answer as - * `400 VALIDATION_FAILED` + `fields[]`. Every door that builds its context here - * calls this before `execute()`, so a refused trigger dispatches nothing and - * leaves no run row. See {@link objectValueFailure}. + * [#25821, #25976] A body, or a `params`, that is not a JSON object is refused + * here, thrown as the validation failure both dispatcher exits and the endpoint + * executor answer as `400 VALIDATION_FAILED` + `fields[]`. Every door that + * builds its context here calls this before `execute()`, so a refused trigger + * dispatches nothing and leaves no run row. See {@link objectValueFailure}. */ export function buildAutomationContext(body: any, context: HttpProtocolContext): Record { - const ctxBody = body && typeof body === 'object' ? body : {}; + // [#25976] The BODY must be an object before its keys mean anything. This + // read used to be `body && typeof body === 'object' ? body : {}`, so a JSON + // string body dispatched the flow with an EMPTY bag, and an array passed the + // `typeof` guard and reached the flat-body branch below, which forwarded its + // indices as params. The resume door refuses the same body (#9416); this + // asks the same function. `undefined` / `null` stay the legal bodyless + // trigger, so `?? {}` survives for exactly those two. + const bodyFailure = objectValueFailure('(body)', body ?? {}); + if (bodyFailure) { + throw validationFailure(`Invalid trigger body — ${bodyFailure.message}`, [bodyFailure]); + } + const ctxBody = (body ?? {}) as Record; // [#25821] The params bag must be an object before anything is read from // it. The read below used to be `params && typeof params === 'object'`, so // a string became an EMPTY bag and the flow ran with no inputs, an array @@ -3319,11 +3301,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str */ const jsonTypeOf = (v: unknown): string => v === null ? 'null' : Array.isArray(v) ? 'an array' : `a ${typeof v}`; - if (typeof rawBody !== 'object' || Array.isArray(rawBody)) { - throw validationFailure( - `Invalid resume body — expected an object with ${accepted}, received ${jsonTypeOf(rawBody)}`, - [{ field: '(body)', code: 'invalid_type', message: `expected an object with ${accepted}` }], - ); + // [#25976] Asked through `objectValueFailure`, the check the + // trigger and action doors ask of their input too, so the three + // doors refuse one shape in one way; only the wording names this + // body's closed key set. + const bodyFailure = objectValueFailure('(body)', rawBody, `an object with ${accepted}`); + if (bodyFailure) { + throw validationFailure(`Invalid resume body — ${bodyFailure.message}`, [bodyFailure]); } const b = rawBody as Record; // [commit a4331227b] The outer envelope is a CLOSED SET (maintainer ruling diff --git a/packages/runtime/src/domains/field-error-code-catalog.test.ts b/packages/runtime/src/domains/field-error-code-catalog.test.ts index 0e7d6e88a3b..ab15e6e6733 100644 --- a/packages/runtime/src/domains/field-error-code-catalog.test.ts +++ b/packages/runtime/src/domains/field-error-code-catalog.test.ts @@ -156,6 +156,30 @@ function securityHarness() { return { dispatcher, saveMetaItem }; } +/** The action door: one declared `type: 'flow'` action on one object, and an automation service to run it. */ +function actionsHarness() { + const action = { name: 'reassign', label: 'Reassign', objectName: 'task', type: 'flow', target: 'welcome_flow' }; + const object = { name: 'task', actions: [action] }; + const ql: any = { + executeAction: vi.fn(async () => ({ ok: true })), + getSchema: (n: string) => (n === 'task' ? object : undefined), + registry: { getObject: (n: string) => (n === 'task' ? object : undefined), getItem: () => undefined }, + find: async () => [], + }; + const metadata = { + load: async () => null, + loadDiagnosed: async () => ({ data: null, degraded: false, errors: [] }), + loadMany: async () => [], + listObjects: async () => [object], + getObject: async (n: string) => (n === 'task' ? object : undefined), + }; + const automation = { + execute: vi.fn(async () => ({ success: true, runId: 'run_9' })), + getFlow: async (name: string) => (name === 'welcome_flow' ? { name } : null), + }; + return { dispatcher: new HttpDispatcher(kernelOf({ objectql: ql, data: ql, metadata, automation }) as never), automation }; +} + function analyticsHarness() { const analytics = { query: vi.fn(async () => ({ rows: [] })) }; return { dispatcher: new HttpDispatcher(kernelOf({ analytics }) as never), analytics }; @@ -257,6 +281,18 @@ const automationDoor = (path: string, method: string, body: unknown, ctx: () => () => automationHarness(opts).dispatcher.handleAutomation(path, method, body, ctx(), undefined); const CENSUS: CensusCase[] = [ + // actions.ts `POST /actions/:object/:action` — the body and `params` through `objectValueFailure` + { + site: 'actions.ts action door — body not an object', + run: () => (actionsHarness().dispatcher as any).handleActions('/task/reassign', 'POST', [1, 2], MEMBER()), + rows: [{ field: '(body)', code: 'invalid_type' }], + }, + { + site: 'actions.ts action door — `params` not an object', + run: () => (actionsHarness().dispatcher as any).handleActions('/task/reassign', 'POST', { params: 'oops' }, MEMBER()), + rows: [{ field: 'params', code: 'invalid_type' }], + }, + // activation-gate.ts `readActivationBody` (the catalog activation door; the action activation door reads the same helper) ...([ ['body not an object', 'garbage', [{ field: '(body)', code: 'invalid_type' }]], @@ -306,6 +342,10 @@ const CENSUS: CensusCase[] = [ { site: 'automation.ts flow clone — `label` missing', run: automationDoor('/welcome_flow/clone', 'POST', { name: 'welcome_copy' }, AUTHOR), rows: [{ field: 'label', code: 'required' }] }, { site: 'automation.ts flow clone — `label` not a string', run: automationDoor('/welcome_flow/clone', 'POST', { name: 'welcome_copy', label: 42 }, AUTHOR), rows: [{ field: 'label', code: 'invalid_type' }] }, + // automation.ts `buildAutomationContext` — the trigger door's body and `params`, through `objectValueFailure` + { site: 'automation.ts trigger — body not an object', run: automationDoor('/welcome_flow/trigger', 'POST', 'garbage', MEMBER), rows: [{ field: '(body)', code: 'invalid_type' }] }, + { site: 'automation.ts trigger — `params` not an object', run: automationDoor('/welcome_flow/trigger', 'POST', { params: [1, 2] }, MEMBER), rows: [{ field: 'params', code: 'invalid_type' }] }, + // automation.ts `POST /:name/runs/:runId/resume` { site: 'automation.ts resume — body not an object', run: automationDoor('/approval_flow/runs/run_7/resume', 'POST', 'garbage', MEMBER), rows: [{ field: '(body)', code: 'invalid_type' }] }, { site: 'automation.ts resume — unknown key', run: automationDoor('/approval_flow/runs/run_7/resume', 'POST', { inptus: {} }, MEMBER), rows: [{ field: 'inptus', code: 'unknown_field' }] }, diff --git a/packages/runtime/src/domains/run-door-input-shape.test.ts b/packages/runtime/src/domains/run-door-input-shape.test.ts new file mode 100644 index 00000000000..d86b4a2c96f --- /dev/null +++ b/packages/runtime/src/domains/run-door-input-shape.test.ts @@ -0,0 +1,338 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25976 — every door that starts or resumes a run refuses non-object input + * the same way, through the one shared shape check (`objectValueFailure`, + * `object-value-shape.ts`). + * + * Two neighbouring doors still coerced a mis-shaped value silently after the + * trigger door's `params` was closed (#25821): + * + * - the action door (`POST /actions/:object/:action`) read + * `reqBody.params && typeof reqBody.params === 'object' ? reqBody.params : {}`, + * so `{"params":"oops"}` ran the action with an EMPTY bag, and an array was + * handed on as the bag itself — measured on a showcase boot as `200` and a + * started run, the same answer `{"params":{}}` gets. Its body was read with + * the same truthy-object guard, so a JSON string or array BODY ran the + * action with an empty bag too; + * - the trigger door read its body with `body && typeof body === 'object' ? body : {}`, + * so a JSON string body dispatched the flow with an empty bag and an array + * body forwarded its indices as params (the flat-body branch). + * + * The resume door already refused both shapes on its own body (#9416). All + * three now answer `400 VALIDATION_FAILED` with one `fields[]` entry naming + * `params` or `(body)` with `invalid_type` and the received type, before + * anything is dispatched — the engine is never asked, so no run row exists. + * + * BOTH directions are pinned, because a door that refuses everything ships + * just as green as a correct one: every refused shape answers the envelope + * with nothing dispatched, AND every accepted body — an absent `params`, an + * absent body — still reaches the engine with the bag it always did. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { FieldErrorCode } from '@objectstack/spec/api'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; +import { buildEndpointExecutionContext, executeEndpointTarget } from '../endpoint-executor.js'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +const OBJECT = 'showcase_task'; +const FLOW = 'showcase_notify_owner'; + +/** The value shapes the card measured, plus the falsy ones a truthiness guard read as absent. */ +const NON_OBJECTS: Array<[string, unknown, string]> = [ + ['a string', 'oops', 'a string'], + ['an empty string', '', 'a string'], + ['an array', [1, 2], 'an array'], + ['an empty array', [], 'an array'], + ['a number', 42, 'a number'], + ['zero', 0, 'a number'], + ['a boolean', true, 'a boolean'], + ['false', false, 'a boolean'], +]; + +/** `params` is a key, so an explicit `null` is a sent value and refused; a `null` BODY is the bodyless request. */ +const NON_OBJECT_PARAMS: Array<[string, unknown, string]> = [...NON_OBJECTS, ['null', null, 'null']]; + +// ───────────────────────────────────────────────────────────────────────────── +// The action door +// ───────────────────────────────────────────────────────────────────────────── + +const FLOW_ACTION = { + name: 'showcase_bulk_reassign', + label: 'Reassign', + objectName: OBJECT, + type: 'flow', + target: FLOW, + params: [{ name: 'note', type: 'text' }], +}; +const SCRIPT_ACTION = { + name: 'showcase_close', + label: 'Close', + objectName: OBJECT, + type: 'script', + target: 'showcase_close', + params: [{ name: 'note', type: 'text' }], +}; +/** A flow action with no declared params — the param contract never runs, so only the shape check stands. */ +const UNDECLARED_FLOW_ACTION = { ...FLOW_ACTION, name: 'showcase_ping', params: undefined }; +const OBJECT_DEF = { name: OBJECT, actions: [FLOW_ACTION, SCRIPT_ACTION, UNDECLARED_FLOW_ACTION] }; +const ACTIONS = [FLOW_ACTION, SCRIPT_ACTION, UNDECLARED_FLOW_ACTION]; + +function actionDoor() { + const ql: any = { + executeAction: vi.fn(async (_object: string, _action: string, _ctx: any) => ({ ok: true })), + getSchema: (n: string) => (n === OBJECT ? OBJECT_DEF : undefined), + registry: { getObject: (n: string) => (n === OBJECT ? OBJECT_DEF : undefined), getItem: () => undefined }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const automation = { + execute: vi.fn(async (_flow: string, _context?: any) => ({ + success: true, status: 'paused', runId: 'run_test', durationMs: 1, + })), + getFlow: vi.fn(async (name: string) => (name === FLOW ? { name } : null)), + }; + const metadata = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [OBJECT_DEF]), + getObject: vi.fn(async (n: string) => (n === OBJECT ? OBJECT_DEF : undefined)), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? automation + : null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const context: any = { + request: {}, + environmentId: 'platform', + executionContext: { userId: 'usr_1', tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] }, + }; + const call = async (action: string, body: unknown) => + (await (new HttpDispatcher(kernel) as any).handleActions(`/${OBJECT}/${action}`, 'POST', body, context)).response; + return { call, ql, automation }; +} + +describe('#25976 — the action door refuses a non-object `params` before anything runs', () => { + for (const action of ACTIONS) { + describe(`${action.type} action \`${action.name}\`${action.params ? '' : ' (no declared params)'}`, () => { + it.each(NON_OBJECT_PARAMS)('refuses %s — 400 VALIDATION_FAILED naming `params`, nothing dispatched', async (_label, value, received) => { + const door = actionDoor(); + const res = await door.call(action.name, { params: value }); + expect(res.status, `{"params":${JSON.stringify(value)}} answered ${JSON.stringify(res.body)}`).toBe(VALIDATION_FAILED_STATUS); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(res.body.error.details.fields).toMatchObject([{ field: 'params', code: 'invalid_type' }]); + expect(FieldErrorCode.safeParse(res.body.error.details.fields[0].code).success).toBe(true); + expect(res.body.error.details.fields[0].message).toBe(`expected an object (a map of names to values), received ${received}`); + expect(res.body.error.message).toMatch(/`params`/); + // Before dispatch: neither the flow engine nor the script runner was asked. + expect(door.automation.execute).not.toHaveBeenCalled(); + expect(door.ql.executeAction).not.toHaveBeenCalled(); + }); + }); + } + + describe('…and a body that is not a JSON object, the same way', () => { + for (const action of [FLOW_ACTION, SCRIPT_ACTION]) { + it.each(NON_OBJECTS)(`${action.type} action: refuses a body that is %s — 400 at \`(body)\`, nothing dispatched`, async (_label, body, received) => { + const door = actionDoor(); + const res = await door.call(action.name, body); + expect(res.status, `body ${JSON.stringify(body)} answered ${JSON.stringify(res.body)}`).toBe(VALIDATION_FAILED_STATUS); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(res.body.error.details.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]); + expect(res.body.error.details.fields[0].message).toBe(`expected an object (a map of names to values), received ${received}`); + expect(res.body.error.message).toMatch(/^Invalid action body/); + expect(door.automation.execute).not.toHaveBeenCalled(); + expect(door.ql.executeAction).not.toHaveBeenCalled(); + }); + } + }); + + describe('the accepted shapes still run, with the bag they always did', () => { + const ACCEPTED: Array<[string, unknown, Record]> = [ + ['an object `params`', { params: { note: 'x' } }, { note: 'x' }], + ['an empty object `params`', { params: {} }, {}], + ['no `params` key', {}, {}], + ['no body at all', undefined, {}], + ['a JSON `null` body', null, {}], + ]; + + it.each(ACCEPTED)('flow action: %s', async (_label, body, params) => { + const door = actionDoor(); + const res = await door.call(FLOW_ACTION.name, body); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(door.automation.execute).toHaveBeenCalledTimes(1); + expect((door.automation.execute.mock.calls[0] as any)[1].params).toMatchObject(params); + }); + + it.each(ACCEPTED)('script action: %s', async (_label, body, params) => { + const door = actionDoor(); + const res = await door.call(SCRIPT_ACTION.name, body); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(door.ql.executeAction).toHaveBeenCalledTimes(1); + expect((door.ql.executeAction.mock.calls[0] as any)[2].params).toMatchObject(params); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The trigger door's body +// ───────────────────────────────────────────────────────────────────────────── + +function triggerDoor() { + const execute = vi.fn(async () => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => (name === FLOW ? { name } : null)); + const services: Record = { automation: { execute, getFlow } }; + const resolve = (name: string) => services[name]; + const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; + return { dispatcher: new HttpDispatcher(kernel), execute }; +} + +const CALLER = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any); + +/** Both spellings of the REST trigger door. */ +const TRIGGER_ROUTES: Array<{ label: string; path: string }> = [ + { label: 'POST /:name/trigger', path: `/${FLOW}/trigger` }, + { label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW}` }, +]; + +/** + * Drive a REST trigger door and report the refusal as the wire would: the + * status both dispatcher error exits derive for a thrown validation failure + * carrying no `.status` of its own (#3918). + */ +async function triggerRefusal(path: string, body: unknown) { + const { dispatcher, execute } = triggerDoor(); + let thrown: unknown; + let response: unknown; + try { + response = (await dispatcher.handleAutomation(path, 'POST', body, CALLER())).response; + } catch (e) { + thrown = e; + } + expect(thrown, `trigger body ${JSON.stringify(body)} was dispatched (answered ${JSON.stringify(response)}) instead of refused`).toBeDefined(); + const details = validationFailureDetails(thrown); + const status = typeof (thrown as any)?.status === 'number' ? (thrown as any).status : details ? VALIDATION_FAILED_STATUS : 500; + return { details, status, message: (thrown as Error).message, execute }; +} + +describe('#25976 — the trigger door refuses a body that is not a JSON object before dispatch', () => { + for (const route of TRIGGER_ROUTES) { + describe(route.label, () => { + it.each(NON_OBJECTS)('refuses a body that is %s — 400 VALIDATION_FAILED at `(body)`, no run', async (_label, body, received) => { + const r = await triggerRefusal(route.path, body); + expect(r.status).toBe(VALIDATION_FAILED_STATUS); + expect(r.details?.code).toBe('VALIDATION_FAILED'); + // `(body)` is the root-level locator, as on the resume door. + expect(r.details?.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]); + expect((r.details?.fields[0] as any).message).toBe(`expected an object (a map of names to values), received ${received}`); + expect(r.message).toMatch(/^Invalid trigger body/); + expect(r.execute).not.toHaveBeenCalled(); + }); + }); + } + + describe('a bodyless trigger keeps its empty bag', () => { + for (const route of TRIGGER_ROUTES) { + it.each([ + ['no body at all', undefined], + ['a JSON `null` body', null], + ['an empty object', {}], + ])(`${route.label}: %s`, async (_label, body) => { + const { dispatcher, execute } = triggerDoor(); + const result = await dispatcher.handleAutomation(route.path, 'POST', body, CALLER()); + expect(result.response?.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + expect((execute.mock.calls[0] as any)[1].params).toEqual({}); + }); + } + }); + + describe('a declared `type: flow` endpoint, which shares the builder, refuses the same way', () => { + const EC = { userId: 'user-1' } as ExecutionContext; + const endpoint = ApiEndpointSchema.parse({ + name: 'notify_owner', + path: '/api/v1/apps/showcase/notify-owner', + method: 'POST', + type: 'flow', + target: FLOW, + }); + const run = async (body: unknown) => { + const match: ApiEndpointMatch = { endpoint, params: {} }; + const execute = vi.fn().mockResolvedValue({ success: true }); + const answer = await executeEndpointTarget(buildEndpointExecutionContext({ + request: { method: 'POST', path: endpoint.path, query: {}, headers: {}, body }, + match, + executionContext: EC, + environmentId: 'env-7', + dataDriver: {}, + }), { callData: vi.fn(), automationService: { execute } }); + return { answer, execute }; + }; + + it.each(NON_OBJECTS)('refuses a body that is %s', async (_label, body) => { + const { answer, execute } = await run(body); + const res: any = answer.body; + expect(answer.status).toBe(400); + expect(res.error.code).toBe('VALIDATION_FAILED'); + expect(res.error.details.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]); + expect(execute).not.toHaveBeenCalled(); + }); + + it('still dispatches a bodyless request with an empty bag', async () => { + const { answer, execute } = await run(undefined); + expect(answer.status).toBe(200); + expect(execute.mock.calls[0]![1].params).toEqual({}); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// One check, one answer — the three doors side by side +// ───────────────────────────────────────────────────────────────────────────── + +describe('#25976 — the action, trigger and resume doors answer one mis-shaped value in the same words', () => { + async function resumeRefusal(body: unknown) { + const resume = vi.fn(async () => ({ success: true, runId: 'run_7' })); + const services: Record = { + automation: { resume, getFlow: async () => ({ name: FLOW }), getRun: async () => null }, + }; + const resolve = (name: string) => services[name]; + const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; + try { + await new HttpDispatcher(kernel).handleAutomation(`/${FLOW}/runs/run_7/resume`, 'POST', body, CALLER()); + } catch (e) { + return { details: validationFailureDetails(e), message: (e as Error).message, resume }; + } + throw new Error(`resume body ${JSON.stringify(body)} was not refused`); + } + + it.each(NON_OBJECTS)('%s', async (_label, value, received) => { + const action = await actionDoor().call(FLOW_ACTION.name, { params: value }); + const trigger = await triggerRefusal(`/${FLOW}/trigger`, value); + const resume = await resumeRefusal(value); + + const rows = [ + action.body.error.details.fields[0], + trigger.details?.fields[0] as any, + resume.details?.fields[0] as any, + ]; + for (const row of rows) { + expect(row.code).toBe('invalid_type'); + expect(row.message).toMatch(new RegExp(`^expected an object .*, received ${received}$`)); + } + // The resume body names its closed key set as what it expects, and its + // refusal sentence is the one it has always answered. + expect(resume.message).toBe( + `Invalid resume body — expected an object with \`inputs\`, \`variables\`, \`output\`, \`branchLabel\`, received ${received}`, + ); + expect(resume.resume).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/object-value-shape.ts b/packages/runtime/src/object-value-shape.ts new file mode 100644 index 00000000000..5e1d5c5495e --- /dev/null +++ b/packages/runtime/src/object-value-shape.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9416, #25821, #25976] THE value-shape check for request input that must be + * a JSON object (a map of names to values). Every door that starts or resumes a + * run asks it, so the doors of one engine ask the same question and answer it + * in the same words — a second spelling of it is how they disagreed: + * + * - the resume door (`domains/automation.ts`): the body itself, and its + * `inputs`, `variables` and `output` bags; + * - the trigger door (`buildAutomationContext`, shared by both trigger routes + * and every declared `type: 'flow'` endpoint): the body itself, and its + * `params` bag; + * - the action door (`domains/actions.ts`): its `params` bag. + * + * It lived inside `domains/automation.ts` while only the two flow doors asked + * it; the action door asking too is why it lives here, in a module both domains + * import, rather than a copy over there — beside `validation-failure.ts`, whose + * constructor carries the entry it returns. It answers nothing itself, so it is + * not a dispatcher domain. + * + * Returns the located `fields[]` entry (ADR-0114 `invalid_type`) for a value + * that is not an object, `undefined` otherwise. `undefined` counts as ABSENT, + * never mis-shaped: `JSON.stringify` drops such a key, so no HTTP caller can + * send one, and the in-process spelling `{ key: maybeUndefined }` means "not + * sent". An explicit `null` IS refused — JSON can express it, and it is the + * value that used to be dropped most quietly of all. A door whose bodyless + * request is legal (a JSON `null` body included) reads the body as + * `body ?? {}` before asking, so absence keeps its empty bag there too. + * + * `expected` names what the door accepts in place of the generic "an object (a + * map of names to values)": the resume body's keys are a closed set, so it + * names them. Only the wording varies; the judgement is this one. + */ +export function objectValueFailure( + field: string, + value: unknown, + expected = 'an object (a map of names to values)', +): { field: string; code: 'invalid_type'; message: string } | undefined { + if (value === undefined) return undefined; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) return undefined; + const received = value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`; + return { + field, + code: 'invalid_type', + message: `expected ${expected}, received ${received}`, + }; +}