From 5c18b6d24ccd741fb1a9a813e8289871d31b3401 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:12:54 +0000 Subject: [PATCH 1/6] test(runtime): pin the action, trigger and resume doors refusing non-object input (red) Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- .../runtime/src/domains/object-value-shape.ts | 46 +++ .../src/domains/run-door-input-shape.test.ts | 319 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/runtime/src/domains/object-value-shape.ts create mode 100644 packages/runtime/src/domains/run-door-input-shape.test.ts diff --git a/packages/runtime/src/domains/object-value-shape.ts b/packages/runtime/src/domains/object-value-shape.ts new file mode 100644 index 00000000000..e25dfb64335 --- /dev/null +++ b/packages/runtime/src/domains/object-value-shape.ts @@ -0,0 +1,46 @@ +// 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. + * + * 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}`, + }; +} 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..438fa68209d --- /dev/null +++ b/packages/runtime/src/domains/run-door-input-shape.test.ts @@ -0,0 +1,319 @@ +// 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`, + * `domains/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; + * - 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('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, {}], + ]; + + 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(); + }); +}); From 3903da4b5feef1c5b318154c43785f2138f0af49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:14:33 +0000 Subject: [PATCH 2/6] fix(runtime): the action door's params, the trigger door's body and the resume body refuse non-object input through one shape check Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- packages/runtime/src/domains/actions.ts | 25 ++++++- .../automation-trigger-params-shape.test.ts | 2 +- packages/runtime/src/domains/automation.ts | 70 +++++++------------ .../domains/field-error-code-catalog.test.ts | 34 +++++++++ 4 files changed, 85 insertions(+), 46 deletions(-) diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index f2f7a90154f..5732ef85bd2 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. @@ -687,7 +690,25 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string const reqBody = body && typeof body === 'object' ? body : {}; const recordId = recordIdFromPath ?? reqBody.recordId; - const reqParams = (reqBody.params && typeof reqBody.params === 'object') ? reqBody.params : {}; + // [#25976] The params bag must be an object before the param contract or + // any handler reads it. This read used to be + // `params && typeof params === 'object' ? params : {}`, so `{"params":"oops"}` + // ran the action with an EMPTY bag and an array was handed on as the bag + // itself — 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` is + // absent, so a body with no `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 paramsFailure = objectValueFailure('params', reqBody.params); + if (paramsFailure) { + const message = `Invalid action body — \`params\` ${paramsFailure.message}`; + return { + handled: true, + response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, [paramsFailure]))), + }; + } + 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..a10dcd1d3b6 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`, `domains/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..3baee866133 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..de358f8ac56 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,29 @@ 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 = { + 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 +280,13 @@ 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` — `params` through `objectValueFailure` + { + 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 +336,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' }] }, From 793ee79f8ccc2f0a62220dc59fd0676111e0fa1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:15:05 +0000 Subject: [PATCH 3/6] chore(changeset): the run doors refuse non-object input Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- .changeset/25976-run-door-input-shape.md | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/25976-run-door-input-shape.md diff --git a/.changeset/25976-run-door-input-shape.md b/.changeset/25976-run-door-input-shape.md new file mode 100644 index 00000000000..1c7194dc66d --- /dev/null +++ b/.changeset/25976-run-door-input-shape.md @@ -0,0 +1,25 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the action door refuses a `params` that is not a JSON object, and the flow trigger door refuses a body that is not one, instead of running with an empty bag + +`POST /api/v1/actions/:object/:action` read `params` only when it 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 was handed on as the bag itself. + +`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: 'params', code: 'invalid_type' }` on the action door and `{ field: '(body)', code: 'invalid_type' }` on the trigger door, 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 body with 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 door refuses a `params` that is a string, number, boolean, array or `null`, and the trigger doors refuse a body that is a string, number, boolean or array. 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 `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 `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"}}`, and send a trigger body as an object (`{"params":{"ownerId":"u1"}}` or a flat `{"ownerId":"u1"}`), or leave either out. From ddec0dabaa61a2e94b1d5f9d270a5bb5c610815f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:19:25 +0000 Subject: [PATCH 4/6] test(runtime): the census action double carries executeAction Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- packages/runtime/src/domains/field-error-code-catalog.test.ts | 1 + 1 file changed, 1 insertion(+) 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 de358f8ac56..76aa421a16b 100644 --- a/packages/runtime/src/domains/field-error-code-catalog.test.ts +++ b/packages/runtime/src/domains/field-error-code-catalog.test.ts @@ -161,6 +161,7 @@ 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 () => [], From c6d50b16fc51913ea21b148f3a5c62a33b92353b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:41:53 +0000 Subject: [PATCH 5/6] refactor(runtime): the shared value-shape check lives beside validation-failure.ts, not among the dispatcher domains Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- packages/runtime/src/domains/actions.ts | 2 +- .../src/domains/automation-trigger-params-shape.test.ts | 2 +- packages/runtime/src/domains/automation.ts | 2 +- packages/runtime/src/domains/run-door-input-shape.test.ts | 2 +- packages/runtime/src/{domains => }/object-value-shape.ts | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) rename packages/runtime/src/{domains => }/object-value-shape.ts (92%) diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 5732ef85bd2..bf77d713979 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -74,7 +74,7 @@ import { actorUserFromExecutionContext, resolveActorDisplayName } from '../secur 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 { 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. 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 a10dcd1d3b6..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/object-value-shape.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 3baee866133..3824ff12724 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -73,7 +73,7 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. 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'; +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. diff --git a/packages/runtime/src/domains/run-door-input-shape.test.ts b/packages/runtime/src/domains/run-door-input-shape.test.ts index 438fa68209d..33aa2293297 100644 --- a/packages/runtime/src/domains/run-door-input-shape.test.ts +++ b/packages/runtime/src/domains/run-door-input-shape.test.ts @@ -3,7 +3,7 @@ /** * #25976 — every door that starts or resumes a run refuses non-object input * the same way, through the one shared shape check (`objectValueFailure`, - * `domains/object-value-shape.ts`). + * `object-value-shape.ts`). * * Two neighbouring doors still coerced a mis-shaped value silently after the * trigger door's `params` was closed (#25821): diff --git a/packages/runtime/src/domains/object-value-shape.ts b/packages/runtime/src/object-value-shape.ts similarity index 92% rename from packages/runtime/src/domains/object-value-shape.ts rename to packages/runtime/src/object-value-shape.ts index e25dfb64335..5e1d5c5495e 100644 --- a/packages/runtime/src/domains/object-value-shape.ts +++ b/packages/runtime/src/object-value-shape.ts @@ -15,7 +15,9 @@ * * 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. + * 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, From e321fc54f209cbba4d1ea74fcb8639ea8e027a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:15:32 +0000 Subject: [PATCH 6/6] fix(runtime): the action door refuses a non-object body through the same shape check Claude-Session: https://claude.ai/code/session_01V8GCLpAeKZ1wyQV8dx54An Co-authored-by: Claude --- .changeset/25976-run-door-input-shape.md | 18 ++++----- packages/runtime/src/domains/actions.ts | 40 ++++++++++--------- .../domains/field-error-code-catalog.test.ts | 7 +++- .../src/domains/run-door-input-shape.test.ts | 21 +++++++++- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/.changeset/25976-run-door-input-shape.md b/.changeset/25976-run-door-input-shape.md index 1c7194dc66d..1e27305193c 100644 --- a/.changeset/25976-run-door-input-shape.md +++ b/.changeset/25976-run-door-input-shape.md @@ -2,24 +2,24 @@ '@objectstack/runtime': patch --- -fix(runtime)!: the action door refuses a `params` that is not a JSON object, and the flow trigger door refuses a body that is not one, instead of running with an empty bag +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 `params` only when it 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 was handed on as the bag itself. +`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: 'params', code: 'invalid_type' }` on the action door and `{ field: '(body)', code: 'invalid_type' }` on the trigger door, 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. +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 body with 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. +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 door refuses a `params` that is a string, number, boolean, array or `null`, and the trigger doors refuse a body that is a string, number, boolean or array. Nothing is widened. +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 `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. +**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 `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. +- **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"}}`, and send a trigger body as an object (`{"params":{"ownerId":"u1"}}` or a flat `{"ownerId":"u1"}`), or leave either out. +- **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 bf77d713979..bbfb76f7d4c 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -688,26 +688,28 @@ 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 : {}; - const recordId = recordIdFromPath ?? reqBody.recordId; - // [#25976] The params bag must be an object before the param contract or - // any handler reads it. This read used to be - // `params && typeof params === 'object' ? params : {}`, so `{"params":"oops"}` - // ran the action with an EMPTY bag and an array was handed on as the bag - // itself — 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` is - // absent, so a body with no `params` key keeps its empty bag. Before the - // param contract, the record load and dispatch: a refused call reads no - // row and runs nothing. + // [#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) { - const message = `Invalid action body — \`params\` ${paramsFailure.message}`; - return { - handled: true, - response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, [paramsFailure]))), - }; - } + if (paramsFailure) return invalidBody(`Invalid action body — \`params\` ${paramsFailure.message}`, paramsFailure); + const recordId = recordIdFromPath ?? reqBody.recordId; const reqParams: Record = reqBody.params ?? {}; // [ADR-0104 D2] Enforce the declared param contract before the handler 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 76aa421a16b..ab15e6e6733 100644 --- a/packages/runtime/src/domains/field-error-code-catalog.test.ts +++ b/packages/runtime/src/domains/field-error-code-catalog.test.ts @@ -281,7 +281,12 @@ 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` — `params` through `objectValueFailure` + // 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()), diff --git a/packages/runtime/src/domains/run-door-input-shape.test.ts b/packages/runtime/src/domains/run-door-input-shape.test.ts index 33aa2293297..d86b4a2c96f 100644 --- a/packages/runtime/src/domains/run-door-input-shape.test.ts +++ b/packages/runtime/src/domains/run-door-input-shape.test.ts @@ -12,7 +12,9 @@ * `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; + * 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). @@ -137,12 +139,29 @@ describe('#25976 — the action door refuses a non-object `params` before anythi }); } + 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) => {