Skip to content

Commit e12d3f3

Browse files
fix(runtime)!: the flow trigger door refuses a params that is not a JSON object (#25974)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQd91J7V4CWRKjvyX2EBFVK)_ Fixes #25821 Clause-②: no (narrowing) Metadata-protocol: none Contract review owed: the `(narrowing)` arm. The trigger doors refuse request shapes they used to dispatch, a `params` that is a string, number, boolean, array or `null`. Nothing is widened, and no `packages/spec/**` path is touched. The seat's claim (6409382263) carries `Clause-②: no`; the line above is the measured value on this diff, declared as the dispatch order asked. ## What changed `buildAutomationContext` (`packages/runtime/src/domains/automation.ts`) read `params` with `params && typeof params === 'object' ? { ...params } : {}`. A string became an empty bag and the flow ran with no inputs. An array was spread into an index-keyed bag. A falsy value (`null`, `0`, `false`, `""`) took the flat-body branch, which forwarded the body's other top-level keys as params instead. The builder now refuses a `params` that is not a JSON object, before anything is dispatched: `400 VALIDATION_FAILED`, one `fields[]` entry `{ field: 'params', code: 'invalid_type' }`, message `Invalid trigger body — params expected an object (a map of names to values), received a string` (the received type varies). It is thrown as the validation failure both dispatcher exits and the endpoint executor already answer as 400. The check is one function, `objectValueFailure(field, value)`, hoisted out of the resume door's #9416 value-shape loop. The resume door now calls it for `inputs`, `variables` and `output` and the trigger door calls it for `params`, so the two doors of one engine ask the same question in the same words. The resume answers are byte-identical (its suites pass unchanged). The resume arm's local `jsonTypeOf` stays local, as its note asks; only this one refusal is shared. `undefined` counts as absent, as on the resume door, so a body with no `params` key still takes the flat-body branch. An explicit `null` is refused, as the resume door refuses `{"inputs":null}`. ## Doors measured | Door | Shares the builder? | Now | |---|---|---| | `POST /api/v1/automation/:name/trigger` | yes, through `respondToFlowTrigger` | refuses | | `POST /api/v1/automation/trigger/:name` (legacy, `client.automation.trigger()`) | yes, same function | refuses | | A declared `type: 'flow'` API endpoint (`endpoint-executor.ts` `executeFlow`) | yes | refuses | | `POST /api/v1/actions/:object/:action` on a `type: 'flow'` action | no, it reads `reqBody.params` itself (`domains/actions.ts`) | unchanged, see Acceptance notes | | Flow-to-flow (`subflow`, `map` nodes) | no, they call `engine.execute` with a built context | not a wire door | | MCP | no MCP tool starts a flow by name; `verify` calls `execute` in process | not applicable | Order on the REST doors and the endpoint: an unknown flow still answers `404` and a caller the flow refuses still answers `403`, before the body is judged. The refusal is before `execute()`, so no run starts and no run row is written. ## Evidence Live, fresh showcase boot (`pnpm dev -- --fresh`) on this branch at `7f0c45b4`, signed in as the seeded admin: - `POST /api/v1/automation/showcase_notify_owner/trigger` and `POST /api/v1/automation/trigger/showcase_notify_owner`, each with `{"params":"oops"}`, `{"params":[1,2]}`, `{"params":null,"ownerId":"u1"}` and `{"params":42}`: all eight answer `400 VALIDATION_FAILED` naming `params` with the received type. - Control `{"params":{}}` on both routes still dispatches (`400 FLOW_FAILED`, "Unknown variable: ownerId", the flow's own answer with no inputs). - `GET /api/v1/automation/showcase_notify_owner/runs` afterwards lists 2 runs, the two controls. The eight refused requests left no run row. - `POST /api/v1/apps/showcase/inquiries/purge` (a declared `type: 'flow'` endpoint) with `{"params":"oops"}`: `400 VALIDATION_FAILED` naming `params`. Tests (`packages/runtime/src/domains/automation-trigger-params-shape.test.ts`, new, 42 cases): - Red first: with `automation.ts` restored from base `9ca36b16`, 29 failed / 13 passed (every refusal case red, every preservation case green). - Green on the fix: 42/42, with the neighbouring suites (resume value-shape, resume envelope, trigger route status, endpoint executor, caller param keys, run locale, anonymous gate): 8 files, 215 tests passed. - Ablation (`scripts/ablation-replace.mjs`, `if (paramsFailure) {` to `if (paramsFailure && false) {`, anchor 1 to 0, blob moved, restored to the HEAD blob with `git diff HEAD` empty): 29 failed / 13 passed. - `pnpm --filter @objectstack/runtime typecheck && pnpm --filter @objectstack/runtime test` at `7f0c45b4`: lock VERDICT command-exit 0, 448 test files passed, 7224 tests passed, 19 skipped. Gates at `7f0c45b4`: `node scripts/pm/dispatch-gates.mjs --commands` derived 69 families; all 69 exit 0 (three needed the full build or an unshallow fetch first and were rerun after it). `--ran` reconciliation: 69 derived, 69 run, 0 NOT-MEASURED. `eslint --no-inline-config packages/runtime`: exit 0. Full `pnpm turbo run build`: 121/121 tasks. Dogfood battery (the `(narrowing)` arm), full, at `7f0c45b4`: `pnpm --filter @objectstack/dogfood test` under the lock, VERDICT command-exit 0, Test Files 296 passed / 3 skipped (299), Tests 2523 passed / 18 skipped (2541), no failures. `origin/main` moved after the merge (10 commits, none touching `domains/automation.ts`, `endpoint-executor.ts` or `http-dispatcher.ts`; `git merge-tree` clean), so this head is not re-merged; CI runs on the merge ref. ## Acceptance notes Two sibling doors drop a non-object value the same way. Neither shares the builder, so neither is touched here; both are reported to the seat with their live readings. - `POST /api/v1/actions/:object/:action` reads `params` with the same `params && typeof params === 'object'` guard (`domains/actions.ts`). On the fresh boot, `POST /api/v1/actions/showcase_task/showcase_bulk_reassign` with `{"params":"oops"}` and with `{"params":[1,2]}` each answered `200` and started a paused run, exactly as `{"params":{}}` did. - The trigger door's body itself is not judged: `ctxBody = body && typeof body === 'object' ? body : {}`. A JSON string body `"oops"` dispatched `showcase_notify_owner` with an empty bag (`400 FLOW_FAILED`, "Unknown variable: ownerId"), and an array body `[1,2]` forwarded its indices as params. The resume door refuses a non-object body; the trigger ruling covers `params` only. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ny9nEFwaZEjuu6xpu6Y4gC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1663ba commit e12d3f3

3 files changed

Lines changed: 284 additions & 13 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime)!: the flow trigger door refuses a `params` that is not a JSON object, instead of running the flow with an empty bag
6+
7+
`POST /api/v1/automation/:name/trigger`, its legacy spelling `POST /api/v1/automation/trigger/:name` (the route `client.automation.trigger()` calls), and every declared `type: 'flow'` API endpoint build the flow's context the same way. That build read `params` only when it was a truthy object. A string such as `{"params":"oops"}` became an empty params bag, and the flow ran with no inputs: it failed later with a misleading `400 FLOW_FAILED` "Unknown variable", or succeeded doing the wrong thing. An array was spread into an index-keyed bag (`{ "0": 1, "1": 2 }`). A falsy value (`null`, `0`, `false`, `""`) was read as an absent `params`, so the body's other top-level keys were forwarded as params instead.
8+
9+
These doors now answer `400 VALIDATION_FAILED` with one `fields[]` entry, `{ field: 'params', code: 'invalid_type' }`, whose message names the type received. The refusal comes before the flow is dispatched, so no run starts and no run row is written. It is the refusal the resume door already gives a non-object `inputs`, `variables` or `output`, and both doors now ask the question through one check, so their wording is the same.
10+
11+
Unchanged: an object `params` (an empty one included) builds the same bag as before, a body with no `params` key still forwards its other top-level keys as params, and an empty body still runs with an empty bag. An unknown flow still answers `404`, and a caller the flow refuses still answers `403`, before the body is judged.
12+
13+
Clause-②: no (narrowing) — the trigger doors refuse request shapes they used to dispatch: a `params` that is a string, number, boolean, array or `null`. Nothing is widened.
14+
15+
Metadata-protocol: none (an HTTP request body's handling at the trigger doors; nothing a metadata author writes changes)
16+
17+
<!-- 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 flow trigger doors handle a request body whose `params` is not a JSON object: it is refused instead of dropped. The remedy is a request edit (send `params` 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). -->
18+
19+
**BREAKING** for a caller that sent `params` as something other than an object and relied on the flow running anyway, shipped as `patch` because the metadata protocol does not move.
20+
21+
- **Before.** A non-object `params` was dropped (or, for `null` and the other falsy values, replaced by the body's other keys), and the flow was dispatched.
22+
- **Now.** The request is refused with `400 VALIDATION_FAILED` naming `params`, and nothing runs.
23+
- **Fix.** Send `params` as an object of flow input names to values, for example `{"params":{"ownerId":"u1"}}`, or leave the key out.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25821 — the trigger door refuses a `params` that is not a JSON object.
5+
*
6+
* `buildAutomationContext` read `params` with
7+
* `ctxBody.params && typeof ctxBody.params === 'object' ? { ...ctxBody.params } : {}`,
8+
* so a mis-shaped value never reached the flow and never reached the caller
9+
* either:
10+
*
11+
* - a string (`{"params":"oops"}`) became an EMPTY bag and the flow ran with
12+
* no inputs — measured on a stock showcase boot as `400 FLOW_FAILED`
13+
* "Unknown variable: ownerId", and as `200` for a flow that needs none;
14+
* - an array passed the `typeof` guard and was spread into an index-keyed
15+
* bag (`{ "0": 1, "1": 2 }`);
16+
* - a falsy value (`null`, `0`, `false`, `""`) fell into the flat-body
17+
* back-compat branch, which forwarded the body's OTHER keys as params.
18+
*
19+
* The resume door already refuses the same value-shape class on its own
20+
* object-valued keys (#9416), so the two doors of one engine disagreed. The
21+
* trigger door now asks the resume door's question through the same function
22+
* (`objectValueFailure`, `domains/automation.ts`) and answers the same
23+
* refusal: `400 VALIDATION_FAILED`, one `fields[]` entry naming `params` with
24+
* `invalid_type`, before dispatch — `execute()` is never called, so no run row
25+
* exists. `null` is refused too, as the resume door refuses `{"inputs":null}`.
26+
*
27+
* Three doors share the builder and all three are driven here: both spellings
28+
* of the REST trigger route, and a declared `type: 'flow'` endpoint
29+
* (`endpoint-executor.ts`, #5040 E5). BOTH directions are pinned, because a
30+
* door that refuses everything ships just as green as a correct one: every
31+
* refused shape answers the envelope with `execute` never called, AND every
32+
* accepted body still reaches `execute` with the params bag it always did.
33+
*/
34+
35+
import { describe, it, expect, vi } from 'vitest';
36+
37+
import { HttpDispatcher } from '../http-dispatcher.js';
38+
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
39+
import { buildEndpointExecutionContext, executeEndpointTarget } from '../endpoint-executor.js';
40+
import { ApiEndpointSchema } from '@objectstack/spec/api';
41+
import type { ApiEndpointMatch } from '@objectstack/spec/contracts';
42+
import type { ExecutionContext } from '@objectstack/spec/kernel';
43+
44+
const FLOW = 'showcase_notify_owner';
45+
46+
function makeDispatcher() {
47+
const execute = vi.fn(async () => ({ success: true, output: {} }));
48+
const getFlow = vi.fn(async (name: string) => (name === FLOW ? { name } : null));
49+
const services: Record<string, unknown> = { automation: { execute, getFlow } };
50+
const resolve = (name: string) => services[name];
51+
const kernel: any = {
52+
getService: resolve,
53+
getServiceAsync: async (name: string) => resolve(name),
54+
context: { getService: resolve },
55+
};
56+
return { dispatcher: new HttpDispatcher(kernel), execute };
57+
}
58+
59+
const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any);
60+
61+
/** Both spellings of the REST trigger door. */
62+
const ROUTES: Array<{ label: string; path: string }> = [
63+
{ label: 'POST /:name/trigger', path: `/${FLOW}/trigger` },
64+
{ label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW}` },
65+
];
66+
67+
/** The value shapes the card measured, plus the falsy ones that took the flat-body branch. */
68+
const NON_OBJECTS: Array<[string, unknown, string]> = [
69+
['a string', 'oops', 'a string'],
70+
['an empty string', '', 'a string'],
71+
['an array', [1, 2], 'an array'],
72+
['an empty array', [], 'an array'],
73+
['a number', 42, 'a number'],
74+
['zero', 0, 'a number'],
75+
['a boolean', true, 'a boolean'],
76+
['false', false, 'a boolean'],
77+
['null', null, 'null'],
78+
];
79+
80+
/**
81+
* Drive a REST trigger door and report the refusal as the wire would: the
82+
* status both dispatcher error exits derive for a thrown validation failure
83+
* carrying no `.status` of its own (#3918) — the derivation the resume door's
84+
* value-shape suite reads the same way.
85+
*/
86+
async function refusalFor(path: string, body: unknown) {
87+
const { dispatcher, execute } = makeDispatcher();
88+
let thrown: unknown;
89+
let response: unknown;
90+
try {
91+
response = (await dispatcher.handleAutomation(path, 'POST', body, CTX())).response;
92+
} catch (e) {
93+
thrown = e;
94+
}
95+
expect(
96+
thrown,
97+
`trigger body ${JSON.stringify(body)} was dispatched (answered ${JSON.stringify(response)}) instead of refused`,
98+
).toBeDefined();
99+
const details = validationFailureDetails(thrown);
100+
const status =
101+
typeof (thrown as any)?.status === 'number' ? (thrown as any).status
102+
: details ? VALIDATION_FAILED_STATUS
103+
: 500;
104+
return { details, status, message: (thrown as Error).message, execute };
105+
}
106+
107+
describe('#25821 — the REST trigger door refuses a non-object `params` before dispatch', () => {
108+
for (const route of ROUTES) {
109+
describe(route.label, () => {
110+
it.each(NON_OBJECTS)('refuses %s — 400 VALIDATION_FAILED naming `params`, no run', async (_label, value, received) => {
111+
const r = await refusalFor(route.path, { params: value });
112+
expect(r.status).toBe(VALIDATION_FAILED_STATUS);
113+
expect(r.details?.code).toBe('VALIDATION_FAILED');
114+
// Located: `params` is named with the ADR-0114 catalog code, and
115+
// the expected and received types are both stated.
116+
expect(r.details?.fields).toMatchObject([{ field: 'params', code: 'invalid_type' }]);
117+
expect((r.details?.fields[0] as any).message).toMatch(/expected an object/);
118+
expect((r.details?.fields[0] as any).message).toContain(`received ${received}`);
119+
expect(r.message).toMatch(/`params`/);
120+
// Before dispatch: the engine was never asked, so no run row exists.
121+
expect(r.execute).not.toHaveBeenCalled();
122+
});
123+
124+
it('refuses a non-object `params` even when the body carries flat keys the old branch forwarded', async () => {
125+
// `{"params":null,"ownerId":"u1"}` used to run with `{ ownerId: 'u1' }`
126+
// — the flat-body branch read the falsy `params` as absent.
127+
const r = await refusalFor(route.path, { params: null, ownerId: 'u1' });
128+
expect(r.details?.fields).toMatchObject([{ field: 'params', code: 'invalid_type' }]);
129+
expect(r.execute).not.toHaveBeenCalled();
130+
});
131+
});
132+
}
133+
});
134+
135+
describe('#25821 — the accepted shapes still reach the engine unchanged', () => {
136+
const ACCEPTED: Array<[string, unknown, Record<string, unknown>]> = [
137+
['an object `params`', { params: { ownerId: 'u1' } }, { ownerId: 'u1' }],
138+
['an empty object `params`', { params: {} }, {}],
139+
['no `params` key — the flat body is forwarded', { ownerId: 'u1' }, { ownerId: 'u1' }],
140+
['an empty body', {}, {}],
141+
['no body at all', undefined, {}],
142+
['a record launch', { recordId: 'r1', objectName: 'crm_lead', params: { note: 'x' } },
143+
{ note: 'x', recordId: 'r1', crmLeadId: 'r1' }],
144+
];
145+
146+
for (const route of ROUTES) {
147+
it.each(ACCEPTED)(`${route.label}: %s`, async (_label, body, params) => {
148+
const { dispatcher, execute } = makeDispatcher();
149+
const result = await dispatcher.handleAutomation(route.path, 'POST', body, CTX());
150+
expect(result.response?.status).toBe(200);
151+
expect(execute).toHaveBeenCalledTimes(1);
152+
expect((execute.mock.calls[0] as any)[1].params).toEqual(params);
153+
});
154+
}
155+
});
156+
157+
describe('#25821 — a declared `type: flow` endpoint, which shares the builder, refuses the same way', () => {
158+
const EC = { userId: 'user-1' } as ExecutionContext;
159+
const endpoint = ApiEndpointSchema.parse({
160+
name: 'notify_owner',
161+
path: '/api/v1/apps/showcase/notify-owner',
162+
method: 'POST',
163+
type: 'flow',
164+
target: FLOW,
165+
});
166+
167+
function contextFor(body: unknown) {
168+
const match: ApiEndpointMatch = { endpoint, params: {} };
169+
return buildEndpointExecutionContext({
170+
request: { method: 'POST', path: endpoint.path, query: {}, headers: {}, body },
171+
match,
172+
executionContext: EC,
173+
environmentId: 'env-7',
174+
dataDriver: {},
175+
});
176+
}
177+
178+
it.each(NON_OBJECTS)('refuses %s — 400 VALIDATION_FAILED naming `params`, no run', async (_label, value) => {
179+
const execute = vi.fn().mockResolvedValue({ success: true });
180+
const answer = await executeEndpointTarget(contextFor({ params: value }), {
181+
callData: vi.fn(),
182+
automationService: { execute },
183+
});
184+
const body: any = answer.body;
185+
expect(answer.status).toBe(400);
186+
expect(body.error.code).toBe('VALIDATION_FAILED');
187+
expect(body.error.details.fields).toMatchObject([{ field: 'params', code: 'invalid_type' }]);
188+
expect(execute).not.toHaveBeenCalled();
189+
});
190+
191+
it('still dispatches an object `params` with the bag it always built', async () => {
192+
const execute = vi.fn().mockResolvedValue({ success: true });
193+
const answer = await executeEndpointTarget(contextFor({ params: { ownerId: 'u1' } }), {
194+
callData: vi.fn(),
195+
automationService: { execute },
196+
});
197+
expect(answer.status).toBe(200);
198+
expect(execute.mock.calls[0]![1].params).toEqual({ ownerId: 'u1' });
199+
});
200+
});

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,38 @@ import { carryForwardRedactedValues, redactMetadataItem } from '@objectstack/met
7979
// the `protocol` service at request time, never re-derived here.
8080
import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
8181

82+
/**
83+
* [#9416, #25821] The value-shape check for a body key whose value must be a
84+
* JSON object (a map of names to values): the resume door's `inputs`,
85+
* `variables` and `output`, and the trigger door's `params`. ONE function, so
86+
* the two doors of one engine ask the same question and answer it in the same
87+
* words — a second spelling of it is how they disagreed.
88+
*
89+
* Returns the located `fields[]` entry (ADR-0114 `invalid_type`) for a value
90+
* that is not an object, `undefined` otherwise. `undefined` counts as ABSENT,
91+
* never mis-shaped: `JSON.stringify` drops such a key, so no HTTP caller can
92+
* send one, and the in-process spelling `{ key: maybeUndefined }` means "not
93+
* sent". An explicit `null` IS refused — JSON can express it, and it is the
94+
* value that used to be dropped most quietly of all.
95+
*
96+
* The received-type wording is spelled here rather than borrowed from the
97+
* resume arm's `jsonTypeOf`, whose note keeps that namer local to the arm: what
98+
* is shared is this one refusal, not a formatter.
99+
*/
100+
function objectValueFailure(
101+
field: string,
102+
value: unknown,
103+
): { field: string; code: 'invalid_type'; message: string } | undefined {
104+
if (value === undefined) return undefined;
105+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) return undefined;
106+
const received = value === null ? 'null' : Array.isArray(value) ? 'an array' : `a ${typeof value}`;
107+
return {
108+
field,
109+
code: 'invalid_type',
110+
message: `expected an object (a map of names to values), received ${received}`,
111+
};
112+
}
113+
82114
/**
83115
* Translate a trigger request body into the canonical `AutomationContext` the
84116
* engine expects, and forward the caller's resolved identity.
@@ -114,9 +146,31 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr
114146
* [#19846] It also states `callerParamKeys` — which keys of the params bag the
115147
* caller supplied, as opposed to the row id this builder seeds — so every route
116148
* sharing it carries the same caller-provenance signal.
149+
*
150+
* [#25821] A `params` that is not a JSON object is refused here, thrown as the
151+
* validation failure both dispatcher exits and the endpoint executor answer as
152+
* `400 VALIDATION_FAILED` + `fields[]`. Every door that builds its context here
153+
* calls this before `execute()`, so a refused trigger dispatches nothing and
154+
* leaves no run row. See {@link objectValueFailure}.
117155
*/
118156
export function buildAutomationContext(body: any, context: HttpProtocolContext): Record<string, unknown> {
119157
const ctxBody = body && typeof body === 'object' ? body : {};
158+
// [#25821] The params bag must be an object before anything is read from
159+
// it. The read below used to be `params && typeof params === 'object'`, so
160+
// a string became an EMPTY bag and the flow ran with no inputs, an array
161+
// was spread into an index-keyed bag, and a falsy value (`null`, `0`,
162+
// `false`, `""`) took the flat-body branch, which forwarded the body's
163+
// other keys instead. The resume door refuses the same class on its own
164+
// object-valued keys (#9416); this asks its question through the same
165+
// function. `undefined` is absent, as there — the flat-body branch below
166+
// still serves a body with no `params` key.
167+
const paramsFailure = objectValueFailure('params', ctxBody.params);
168+
if (paramsFailure) {
169+
throw validationFailure(
170+
`Invalid trigger body — \`params\` ${paramsFailure.message}`,
171+
[paramsFailure],
172+
);
173+
}
120174
// `{recordId, objectName, params}` (the UI/SDK request shape) → the
121175
// canonical AutomationContext shape:
122176
// - `recordId` is exposed in `params.recordId` AND aliased to
@@ -125,13 +179,11 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext):
125179
// - `objectName` maps to the canonical `object` field.
126180
const recordId = ctxBody.recordId;
127181
const objectName = ctxBody.objectName ?? ctxBody.object;
128-
const baseParams: Record<string, any> = (ctxBody.params && typeof ctxBody.params === 'object')
129-
? { ...ctxBody.params }
130-
: {};
182+
const baseParams: Record<string, any> = ctxBody.params !== undefined ? { ...ctxBody.params } : {};
131183
// Back-compat: when callers POST a flat body (no `params` wrapper),
132184
// forward unknown top-level keys as flow params so the original
133185
// `{ foo: 'bar' }` payload is not silently dropped.
134-
if (!ctxBody.params) {
186+
if (ctxBody.params === undefined) {
135187
const reserved = new Set(['recordId', 'objectName', 'object', 'event', 'params']);
136188
for (const [k, v] of Object.entries(ctxBody)) {
137189
if (reserved.has(k)) continue;
@@ -3352,17 +3404,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
33523404
// commit a4331227b pinned; before `resume()`, so nothing reaches the
33533405
// engine until the body is legal and the suspension stays
33543406
// intact for a corrected retry.
3407+
//
3408+
// The object-valued keys go through `objectValueFailure`, the
3409+
// one check the trigger door's `params` asks too (#25821).
33553410
const valueFailures: Array<{ field: string; code: 'invalid_type'; message: string }> = [];
33563411
for (const key of ['inputs', 'variables', 'output']) {
3357-
const v = b[key];
3358-
if (v === undefined) continue;
3359-
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
3360-
valueFailures.push({
3361-
field: key,
3362-
code: 'invalid_type',
3363-
message: `expected an object (a map of names to values), received ${jsonTypeOf(v)}`,
3364-
});
3365-
}
3412+
const failure = objectValueFailure(key, b[key]);
3413+
if (failure) valueFailures.push(failure);
33663414
}
33673415
if (b.branchLabel !== undefined && typeof b.branchLabel !== 'string') {
33683416
valueFailures.push({

0 commit comments

Comments
 (0)