Skip to content
25 changes: 25 additions & 0 deletions .changeset/25976-run-door-input-shape.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the action and flow trigger doors handle a request whose body or `params` is not a JSON object: it is refused instead of dropped. The remedy is a request edit (send `params` or the body as an object, or leave it out), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
29 changes: 26 additions & 3 deletions packages/runtime/src/domains/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, any>;
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<string, unknown> = reqBody.params ?? {};

// [ADR-0104 D2] Enforce the declared param contract before the handler
// runs — required/option/multiple/reference-id shape + unknown keys.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`.
Expand Down
70 changes: 27 additions & 43 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<string, unknown> {
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<string, any>;
// [#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
Expand Down Expand Up @@ -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<string, unknown>;
// [commit a4331227b] The outer envelope is a CLOSED SET (maintainer ruling
Expand Down
40 changes: 40 additions & 0 deletions packages/runtime/src/domains/field-error-code-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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' }]],
Expand Down Expand Up @@ -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' }] },
Expand Down
Loading
Loading