Skip to content

Commit dfbe34d

Browse files
fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from (#29365)
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9M4Y7Yr5yLRtgGtSGF4kcmk)_ Refs #29050 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:flow 定义里明确把某个启动输入当作「某个固定对象的记录 id」来读的地方(CRUD 节点的 `filter.id` / `$eq` / `$in`、screen 编辑表单的 `recordId`,以及经 `subflow` / `map` 传下去的输入),所有按名字启动 flow 的入口在启动前,都按调用者自己的权限从「flow 读的那个对象」加载这个输入;读不到回 `404 RECORD_NOT_FOUND`,值不是 id(或不是 id 列表)回 `400 VALIDATION_FAILED` 并点名该输入,不产生运行记录。REST action 入口的 body `recordId` 不是单个 id 时回 400 点名 `recordId`。 - 为什么改:规则跟着 flow 定义里声明的读取走:定义把某个启动输入当作某对象的记录 id 读时,就从那个对象按调用者权限判断。只实现定义里已写明的读取,不改 spec,不加新键。 - 风险与回滚:调用者能读的记录、定义里没有声明为 id 读取的输入(包括计算出来的值)、系统身份、action 入口的主记录字段和非 `id` 的 `recordIdField` 都不变。新增拒绝只针对定义里声明的 id 读取。回滚即撤掉本 PR。 - 先看:`packages/runtime/src/flow-start-admission.ts` 的 `flowDeclaredRecordReads`;`packages/runtime/src/action-execution.ts` 的 `refuseUnreadableFlowStartRecords` 与 `flowActionStartInputs`;`domains/actions.ts` 的 `recordId` 形状检查。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/flow-start-declared-record-reads.test.ts`,应看到 48 条全过。 - 需要你做的:无,席位落地。 ## What changes Flow start admission's third question (#29227, `refuseUnreadableFlowStartRecords`) now also asks it of **the object the flow reads**, wherever the flow's own definition declares that read. **The rule.** Where a flow's definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from that object before the run exists: - a value the caller cannot read there, or an id that names nothing, is refused with the action door's own `404 RECORD_NOT_FOUND` (one envelope for both); - the input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads a list; any other value is refused `400 VALIDATION_FAILED` naming the input, and nothing is loaded; - this holds whatever object the start names, or none, and whatever the flow runs as; - nothing runs on a refusal. **Where a definition declares such a read** (`flowDeclaredRecordReads`, `flow-start-admission.ts`), across every region of the flow (`collectFlowGraphs`): - a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list); - a `screen` node with a literal `objectName` whose `recordId` is the input; - a `subflow` or `map` node with a literal `flowName` that hands the input (or, for a `map` iterating it, each item of it) to a callee input the callee reads so, followed up to 8 levels and never re-entering a flow already on the path. "Is the input" means a CEL value whose whole source is the input's name, bare or through `vars.`. A computed value is not a declared read, and a name a `loop` or `map` node binds as its item or index variable is not read as a start input. **Which values each door judges.** - Trigger routes (both spellings) and a declared `type: 'flow'` endpoint: every key of the built `params` bag (`flowStartRecordKeys` now also returns it as `inputs`). - The action door's flow branch, REST and MCP (`flowActionStartInputs`): each key of the caller's own `params`, and the routed record id under the row slots. The routed record keeps its subject load against the action's object, and is now also judged against the object the flow reads. Left out: the subject record's own fields, and the row slots of an action whose `recordIdField` names a non-`id` field. **The REST action route's routed record is one id** (`domains/actions.ts`). A body `recordId` that is not a string or a number is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. Absent, `null` and `''` stay the record-less call. **Unchanged.** A start whose caller can read the record in the object the flow reads it from; an input the definition does not read as an id; the system principal; the #29227 checks on the start's named object, which run first as before. Changeset: `.changeset/29050-flow-declared-record-reads.md`, a `patch` for `@objectstack/runtime`, rule-only. Docs, in the changeset's words: - `content/docs/automation/flows.mdx`: the trigger route's `404 RECORD_NOT_FOUND` row also states that a start input the flow's definition reads as the id of a record of a fixed object is loaded in the caller's own scope from that object, and must be one id or a list of ids (`400` naming the input otherwise). - `content/docs/ui/actions.mdx`: a body `recordId` names one record by its id, a string or a number, or the route answers `400 VALIDATION_FAILED` naming `recordId`. ## Tests - **New** `packages/runtime/src/flow-start-declared-record-reads.test.ts` (48 tests), over a row-scoped data double with two objects: - the reading: each declaring place, the `vars.` spelling, nodes inside a loop body and a try region, subflow and map hand-offs, a self-calling flow, and what declares nothing (a non-input, a computed value, a non-id filter key, a non-literal object, a shadowed loop item, a service with no `getFlow`); - both trigger spellings: an input the caller cannot read in the object the flow reads is refused 404 with no run; the firing control starts the run; - a custom input: 404 with no run, the firing control, and 400 naming the input for an object, a list or a boolean, with nothing loaded; - a list input: all readable starts, one unreadable is 404, and one id or a list holding an object is 400; - reads declared through a subflow, a map and a screen edit form: 404 with no object named, including a user-run parent; - an unreadable id and an id that names nothing get one envelope; - a declared endpoint: 404 and the firing control; - the action door, REST and MCP: the routed record and a declared action param are judged against the object the flow reads, with firing controls, and the trigger route and the action door answer one envelope; - the REST action route: a body `recordId` that is an object, a list or a boolean is 400 naming `recordId` for a flow and a script action, with nothing loaded or run; a string and a number pass the shape check; - unchanged: a computed read, the system principal, an input the flow does not read as an id. - **Forced test edits: none.** The 50 existing runtime test files that drive the changed doors pass unchanged. ## Acceptance notes - **Built only from what the definition declares.** No `packages/spec` change and no new keys. A read the definition does not state (a computed id, a non-id filter key, a node that is not one of the above) is not judged; those are left for a decision that needs the spec. - **The routed record's shape.** The check sits at the REST action route. The MCP `run_action` tool already accepts only a string `recordId`. - **Order.** The #29227 checks on the start's named object run first, unchanged; the declared reads run after them and before `execute()`. Every refusal is asserted with `execute` never called. - **Open PRs re-read** before opening: #29283 touches `action-execution.ts` and `domains/actions.ts`, and #29097 touches `domains/automation.ts`; none of their hunks overlap this diff. ## Verification Head `1ffeb77c`: the two docs pages on top of `5f06db07` (the merge of `origin/main` at `5780fa49` onto `e92495c0`). The code readings were taken on `5f06db07`; `1ffeb77c` changes no code. <details><summary>Runtime package: typecheck, tests, lint</summary> - `pnpm --filter @objectstack/runtime test` through `os-verify-lock.sh` on head `5f06db07`: Test Files 517 passed (517), Tests 8419 passed, 19 skipped (8438). VERDICT command-exit 0. - `pnpm typecheck` in `packages/runtime` (includes `check:test-typecheck`): exit 0; the test-typecheck debt ledger held unchanged. - `vitest run src/flow-start-declared-record-reads.test.ts`: Test Files 1 passed (1), Tests 48 passed (48). - Before the merge of `origin/main`, the 50 existing runtime test files that drive the trigger routes, the endpoint and the action door (every test file naming `handleActions`, `dispatchFlowAction`, `invokeBusinessAction`, `flowStartRecordKeys` or `refuseUnreadableFlowStartRecords`): Test Files 50 passed (50), Tests 1326 passed (1326), with no edit. - `eslint packages/runtime --no-inline-config` (the root `pnpm lint` command, scoped): exit 0, no output. </details> Each pin goes red with its check removed (seven ablations, each restored to the HEAD blob). <details><summary>Dispatch gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `1ffeb77c` derived 92 commands (the 62 of `5f06db07` plus 30 docs families). Each ran with `< /dev/null`: 92 exit 0. - `dispatch-gates.mjs --repo objectstack-ai/objectstack --ran <file>`: 92 derived famil(ies) accounted for, 92 run, 0 NOT-MEASURED (a DERIVED zero). Changed lines 967 (+937 / −30) against the 3000 threshold. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 925e860 commit dfbe34d

8 files changed

Lines changed: 937 additions & 30 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a start input a flow's definition reads as a record id is loaded in the caller's own scope from the object the flow reads it from, and must be an id, before the run exists (#29050)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**The rule.** Where a flow's own definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from THAT object before the run exists, and refuses a value the caller cannot read there with the action door's own `404 RECORD_NOT_FOUND` (ADR-0112 envelope). A record the caller cannot read and an id that names nothing get the same answer. The input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads it as a list; any other value is refused `400 VALIDATION_FAILED` naming the input. Nothing runs on a refusal: no run record, no node, no side effect. This holds whatever object the start names, or none, and whatever the flow runs as.
11+
12+
**Where a definition reads an input as a record id.** Across every region of the flow (loop bodies, parallel branches, try and catch regions):
13+
14+
- a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list of ids);
15+
- a `screen` node with a literal `objectName` whose `recordId` is the input;
16+
- a `subflow` node with a literal `flowName` that hands the input to a callee input the callee reads so, and a `map` node that does the same with the input itself or with each item of it (a `map` iterating the input reads it as a list of ids).
17+
18+
"Is the input" means a CEL value whose whole source is the input's name (`caseId` or `vars.caseId`).
19+
20+
**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; and an action of `type: 'flow'` (the REST action route and the MCP `run_action` tool), for each key of the caller's own `params` and for the routed record id under the row slots, so the routed record is judged against the object the flow reads as well as the action's own object.
21+
22+
**The REST action route's routed record is one id.** A body `recordId` that is not one id (a string or a number) is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. A path segment is always one id; an absent, `null` or empty `recordId` is the record-less call, as before.
23+
24+
**What stays as it was.**
25+
26+
- A start whose caller can read the record in the object the flow reads it from.
27+
- An input the definition does not read as a record id, including a value the flow computes from an input or derives elsewhere.
28+
- A name a `loop` or `map` node binds as its item or index variable, which no longer holds the start's value once that node runs.
29+
- The system principal (an in-process caller, such as a job).
30+
- At the action door, the subject record's own fields, and the row slots of an action whose `recordIdField` names a field other than `id`.
31+
32+
**If a start now answers 404 or 400.** Hand the input the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids).

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2482,7 +2482,7 @@ door's own admission check, made before anything is dispatched:
24822482
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
24832483
| `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) |
24842484
| `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it |
2485-
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId` |
2485+
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) |
24862486
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
24872487
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
24882488
| `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop |

‎content/docs/ui/actions.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,11 @@ the server resolves your declaration by name and derives the handler key from
571571
it. Rename the underlying function freely; as long as the declaration's
572572
`target` follows, the public URL is unchanged.
573573

574+
A body `recordId` names one record by its id, a string or a number. Any other
575+
value is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared
576+
param contract, the record load and dispatch, for every action type. An absent,
577+
`null` or empty `recordId` is the record-less call.
578+
574579
Failures speak HTTP — the status code is the signal (#3962):
575580

576581
- **400** — the action ran and **rejected** (a business rule said no, or

‎packages/runtime/src/action-execution.ts‎

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
// Who may START a flow by name — the one rule the trigger door, this door and
6666
// the declared endpoint share (the maintainer's rulings, letters B and A, and
6767
// the flow's own `requiredPermissions`).
68-
import { flowRunsElevated, flowStartRefusal } from './flow-start-admission.js';
68+
import { flowDeclaredRecordReads, flowRunsElevated, flowStartRefusal } from './flow-start-admission.js';
6969
import { validationFailure } from './validation-failure.js';
7070
// [ADR-0066 D4] The capability arithmetic the action gate and the flow start
7171
// gate share.
@@ -2386,6 +2386,33 @@ function flowActionCallerRecordKeys(
23862386
return keys;
23872387
}
23882388

2389+
/**
2390+
* The inputs this door hands a run from the REQUEST, each with the value the
2391+
* run receives under it ({@link seedFlowActionParams}'s bag), as flow start
2392+
* admission's third question judges them against the object the flow's own
2393+
* definition reads them from ({@link refuseUnreadableFlowStartRecords}): every
2394+
* key of the caller's own `params`, and each row slot carrying the routed
2395+
* record id. Two kinds of key are left out, because their values come from a
2396+
* row the caller has already shown it can read, not from the request:
2397+
*
2398+
* - the subject row's own fields, which the bag carries under their names;
2399+
* - the row slots of an action whose `recordIdField` names a field other than
2400+
* `id`, which carry that field's value.
2401+
*/
2402+
function flowActionStartInputs(
2403+
action: any,
2404+
objectName: string,
2405+
params: Record<string, unknown>,
2406+
runParams: Record<string, unknown>,
2407+
): Array<readonly [string, unknown]> {
2408+
const keys = new Set<string>(Object.keys(params));
2409+
const idField: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id';
2410+
if (idField === 'id') {
2411+
for (const key of flowActionRowIdKeys(action, objectName)) keys.add(key);
2412+
}
2413+
return [...keys].map((key) => [key, runParams[key]] as const);
2414+
}
2415+
23892416
/**
23902417
* Brand for {@link FlowActionRefusal} — `Symbol.for`, so recognition keeps
23912418
* working even if two module instances of this file ever coexist (src vs
@@ -2610,10 +2637,21 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
26102637
// (the request's own driver, environment and identity, as both entrances
26112638
// load the routed row), and refused with that load's 404 before the run
26122639
// exists. Thrown with `code` + `status`, like the refusals above.
2640+
//
2641+
// The same question then follows every value this door hands the run's
2642+
// inputs that came from the request (the caller's params, and the routed
2643+
// id under the row slots) to the object the flow's own definition reads it
2644+
// from, where it declares one: the routed row was judged against the
2645+
// action's object, which is not always the object the flow reads.
2646+
const runParams = seedFlowActionParams(deps, action, { objectName, record, params, recordId });
26132647
await refuseUnreadableFlowStartRecords(
26142648
automation,
26152649
action.target,
2616-
{ objectName, recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject) },
2650+
{
2651+
objectName,
2652+
recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject),
2653+
inputs: flowActionStartInputs(action, objectName, params, runParams),
2654+
},
26172655
ec,
26182656
(object, id) => callData(deps, requestContext, 'get', { object, id }, requestContext?.dataDriver, envId, ec),
26192657
);
@@ -2643,7 +2681,7 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
26432681
// twin (`buildAutomationContext`): a refused run's message renders in
26442682
// it. See `AutomationContext.locale`.
26452683
...(typeof ec?.locale === 'string' && ec.locale.length > 0 ? { locale: ec.locale } : {}),
2646-
params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }),
2684+
params: runParams,
26472685
// [#19846] Which of those keys the CALLER supplied, stated here where it
26482686
// is known rather than inferred downstream from the merged bag.
26492687
callerParamKeys: flowActionCallerParamKeys(action, objectName, params),
@@ -3769,48 +3807,97 @@ export type FlowStartRecordKeys = ReadonlyArray<readonly [key: string, value: un
37693807
* loaded and is refused `400 VALIDATION_FAILED` naming the key: only an id
37703808
* names one row.
37713809
*
3810+
* ## The object the flow reads a start input from
3811+
*
3812+
* The object the start names is not always the object the run reads. A flow's
3813+
* own definition can state that it reads a start input as the id of a row of
3814+
* a fixed object ({@link flowDeclaredRecordReads}: a CRUD node's `filter.id`,
3815+
* a screen edit form's `recordId`, handed on through `subflow` and `map`
3816+
* inputs). Every such input the start hands the run (`start.inputs`, the
3817+
* `params` the run's input variables are bound from, row slots included) is
3818+
* judged against THAT object too, by the same load and the same refusals: the
3819+
* value must be one record id (a list of ids where the flow reads a list), or
3820+
* the start is refused `400 VALIDATION_FAILED` naming the input, and each id
3821+
* is loaded in the caller's own scope from the object the flow reads it from,
3822+
* or the start is refused `404 RECORD_NOT_FOUND` for that object. This holds
3823+
* whether or not the start names an object, and whatever object it names.
3824+
*
37723825
* ## What stays as it was
37733826
*
37743827
* - **The system principal** (`isSystem`, never set on inbound HTTP) starts
37753828
* every flow on every row, as the two admission rules let it.
3776-
* - **A start that names no record key** loads nothing.
3777-
* - **A key the door already loaded** is not passed here: the action door's
3778-
* subject load judged the routed id, and its tests stay as they are.
3829+
* - **A start that names no record key**, to a flow that declares no record
3830+
* read of what it was handed, loads nothing.
3831+
* - **A key the door already loaded** is not passed in `recordKeys`: the
3832+
* action door's subject load judged the routed id against the action's
3833+
* object, and its tests stay as they are.
37793834
*
37803835
* `readRecord` is the door's own caller-scope read, the `callData('get', …)`
37813836
* its subject load uses, so there is no second loader.
37823837
*/
37833838
export async function refuseUnreadableFlowStartRecords(
37843839
automation: unknown,
37853840
flowName: string,
3786-
start: { objectName: unknown; recordKeys: FlowStartRecordKeys },
3841+
start: { objectName: unknown; recordKeys: FlowStartRecordKeys; inputs?: FlowStartRecordKeys },
37873842
executionContext: { isSystem?: boolean } | null | undefined,
37883843
readRecord: (objectName: string, recordId: string) => Promise<unknown>,
37893844
): Promise<void> {
37903845
if (executionContext?.isSystem === true) return;
3791-
const named = start.recordKeys.filter(([, value]) => value !== undefined && value !== null && value !== '');
3792-
if (named.length === 0) return;
3793-
const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName)
3794-
? start.objectName
3795-
: undefined;
3796-
if (objectName === undefined) {
3797-
if (!(await flowRunsElevated(automation, flowName))) return;
3798-
const message = 'A start that names a record must also name the record\'s object (`objectName`), '
3799-
+ 'so the caller\'s access to that record can be checked before the flow starts.';
3800-
throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]);
3801-
}
38023846
const loaded = new Set<string>();
3803-
for (const [key, value] of named) {
3804-
if (typeof value !== 'string' && typeof value !== 'number') {
3805-
const message = `The record key \`${key}\` must name one record by its id.`;
3806-
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
3807-
}
3808-
const recordId = String(value);
3809-
if (loaded.has(recordId)) continue;
3847+
const loadOrRefuse = async (objectName: string, recordId: string): Promise<void> => {
3848+
const once = `${objectName}\u0000${recordId}`;
3849+
if (loaded.has(once)) return;
38103850
const subject = await loadActionSubjectRecord(objectName, recordId, () => readRecord(objectName, recordId));
38113851
refuseDeniedSubjectLoad(objectName, recordId, subject);
3812-
loaded.add(recordId);
3852+
loaded.add(once);
3853+
};
3854+
const named = start.recordKeys.filter(([, value]) => isNamedStartValue(value));
3855+
if (named.length > 0) {
3856+
const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName)
3857+
? start.objectName
3858+
: undefined;
3859+
if (objectName === undefined) {
3860+
if (await flowRunsElevated(automation, flowName)) {
3861+
const message = 'A start that names a record must also name the record\'s object (`objectName`), '
3862+
+ 'so the caller\'s access to that record can be checked before the flow starts.';
3863+
throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]);
3864+
}
3865+
} else {
3866+
for (const [key, value] of named) {
3867+
await loadOrRefuse(objectName, oneRecordIdOrRefuse(key, value));
3868+
}
3869+
}
3870+
}
3871+
const inputs = (start.inputs ?? []).filter(([, value]) => isNamedStartValue(value));
3872+
if (inputs.length === 0) return;
3873+
const reads = await flowDeclaredRecordReads(automation, flowName);
3874+
for (const [key, value] of inputs) {
3875+
for (const read of reads.get(key) ?? []) {
3876+
const ids = read.many ? recordIdListOrRefuse(key, value) : [oneRecordIdOrRefuse(key, value)];
3877+
for (const recordId of ids) await loadOrRefuse(read.object, recordId);
3878+
}
3879+
}
3880+
}
3881+
3882+
/** Whether a start names something under a key: absent, `null` and `''` name nothing. */
3883+
function isNamedStartValue(value: unknown): boolean {
3884+
return value !== undefined && value !== null && value !== '';
3885+
}
3886+
3887+
/** One record id (a string or a number), or the `400` naming the key: only an id names one row. */
3888+
function oneRecordIdOrRefuse(key: string, value: unknown): string {
3889+
if (typeof value === 'string' || typeof value === 'number') return String(value);
3890+
const message = `The record key \`${key}\` must name one record by its id.`;
3891+
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
3892+
}
3893+
3894+
/** A list of record ids, or the `400` naming the key: a flow that reads a list of ids reads nothing else. */
3895+
function recordIdListOrRefuse(key: string, value: unknown): string[] {
3896+
if (Array.isArray(value) && value.every((id) => typeof id === 'string' || typeof id === 'number')) {
3897+
return value.map(String);
38133898
}
3899+
const message = `The record key \`${key}\` must name a list of records by their ids.`;
3900+
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
38143901
}
38153902

38163903
/**

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,18 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
730730
const paramsFailure = objectValueFailure('params', reqBody.params);
731731
if (paramsFailure) return invalidBody(`Invalid action body — \`params\` ${paramsFailure.message}`, paramsFailure);
732732
const recordId = recordIdFromPath ?? reqBody.recordId;
733+
// The routed record is named by ONE id, a string or a number: the subject
734+
// load below reads the row whose `id` equals it, and every branch hands it
735+
// on as the row's id. Any other value names no one row, so it is refused
736+
// here, before the param contract, the load and dispatch, with the `400`
737+
// flow start admission answers for a record key that is not one id at the
738+
// other doors (`refuseUnreadableFlowStartRecords`). A path segment is
739+
// always a string; absent, `null` and `''` stay the record-less call.
740+
if (recordId !== undefined && recordId !== null && recordId !== ''
741+
&& typeof recordId !== 'string' && typeof recordId !== 'number') {
742+
const message = 'The record key `recordId` must name one record by its id.';
743+
return invalidBody(`Invalid action body — ${message}`, { field: 'recordId', code: 'invalid_type', message });
744+
}
733745
const reqParams: Record<string, unknown> = reqBody.params ?? {};
734746

735747
// [ADR-0104 D2] Enforce the declared param contract before the handler

0 commit comments

Comments
 (0)