diff --git a/.changeset/29050-flow-start-record-admission.md b/.changeset/29050-flow-start-record-admission.md new file mode 100644 index 00000000000..10355477ecb --- /dev/null +++ b/.changeset/29050-flow-start-record-admission.md @@ -0,0 +1,26 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): every door that starts a flow by name loads the record it hands the run in the caller's own scope, and refuses a record the caller cannot read before the run exists (#29050) + +Clause-②: no +Metadata-protocol: none + +**The rule.** A start that names a record hands the run that record as the row it addresses. Every door that starts a flow by name now loads each such record key in the caller's own scope first, the load the action door already makes for its routed record, and refuses a key that scope does not deliver 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. Nothing runs: no run record, no node, no side effect. + +**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), where a record key the caller's own `params` supply is now judged as the routed record already was. + +**Which keys.** The row slots the door itself fills: `recordId`, the `Id` alias (`crm_lead` → `crmLeadId`) and an action's declared `recordIdParam`, each with the value the run receives, a caller's own `params` value included. A record key names one record by its id; any other value is refused `400 VALIDATION_FAILED` naming the key. + +**The object.** The object the start names: `objectName` (or `object`) in a trigger or endpoint request body, and the action's own object at the action door. A start that names a record key but no object cannot be checked: a flow declared `runAs: 'system'` refuses it `400 VALIDATION_FAILED` naming `objectName`, and a flow that runs as the user starts as before. + +**What stays as it was.** + +- A start whose caller can read the record. +- A start that names no record. +- The system principal (an in-process caller, such as a job). +- The action door's routed record: its load, its refusal and the record-less and new-record actions are unchanged. +- An action whose `recordIdField` names a field other than `id`: its row slots carry that field's value, not a record id, and are not loaded. + +**If a start now answers 404 or 400.** Start it on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 38091144e20..b9e33eb31c6 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2473,22 +2473,23 @@ curl -b cookies.txt -X POST \ | `POST /api/v1/automation/:name/runs/:runId/restore-suspension` | Re-arm a run stranded by a failed resume — and, for a nested run, every ancestor whose pause that strand's cascade consumed, deepest first, from whichever member you name. Same gate as `cancel`; body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb | **Failures answer real HTTP status codes.** A flow that does not run is not a -`200`. The route classifies seven outcomes. Five of them it reads off the engine's -own verdict — never the message text; the two `403`s are the door's own admission -check, made before anything is dispatched: +`200`. The route classifies eight outcomes. Five of them it reads off the engine's +own verdict — never the message text; the two `403`s and the record `404` are the +door's own admission check, made before anything is dispatched: | Status | `error.code` | What happened | What to do | |:---|:---|:---|:---| | `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched | | `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) | | `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 | +| `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` | | `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds | | `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help | | `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 | | `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node | -The first six never dispatched anything: no node executed, no record was -written, and there is no run to look up. Only `400` describes a run. The +The first seven never dispatched anything: no node executed, no record was +written, and there is no run to look up. Only `400` `FLOW_FAILED` describes a run. The `FLOW_INPUT_SCHEMA_INVALID` verdict is a pure function of the flow definition — re-running the guard cannot change the answer — so a `retry` strategy does not burn its budget on it: the run log holds exactly one refusal row. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 144fc551e8c..27a1f4f61ff 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -202,7 +202,7 @@ One of the largest consumers of the flag; | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring` | | 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` | -| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal` | +| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` | | 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` | | 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `assertDeploymentAuthority` | @@ -625,6 +625,7 @@ and CI refuses one that differs from what the tree produces. | `packages/runtime/src/action-execution.ts#actionPermissionError` | 1 | 49 | | `packages/runtime/src/action-execution.ts#callData` | 1 | 48 | | `packages/runtime/src/action-execution.ts#refuseUnexposedDataAction` | 1 | 48 | +| `packages/runtime/src/action-execution.ts#refuseUnreadableFlowStartRecords` | 1 | 57 | | `packages/runtime/src/domains/actions.ts#handleActionsRequest` | 1 | 52 | | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationAuthoring` | 1 | 56 | | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite` | 1 | 56 | diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index eba998277c1..95b8960d90c 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -65,7 +65,8 @@ import { // Who may START a flow by name — the one rule the trigger door, this door and // the declared endpoint share (the maintainer's rulings, letters B and A, and // the flow's own `requiredPermissions`). -import { flowStartRefusal } from './flow-start-admission.js'; +import { flowRunsElevated, flowStartRefusal } from './flow-start-admission.js'; +import { validationFailure } from './validation-failure.js'; // [ADR-0066 D4] The capability arithmetic the action gate and the flow start // gate share. import { missingCapabilities } from './required-capabilities.js'; @@ -2278,6 +2279,41 @@ function flowActionCallerParamKeys(action: any, objectName: string, params: Reco return Object.keys(params).filter((key) => !rowIdKeys.has(key)); } +/** + * The row slots the CALLER's own `params` fill at this door, as flow start + * admission's third question reads them ({@link refuseUnreadableFlowStartRecords}): + * each row-id key ({@link flowActionRowIdKeys}) the caller's bag names, whose + * value reaches the run over the seeded id because the caller's params win + * ({@link seedFlowActionParams}). Two cases name none: + * + * - an action whose `recordIdField` names a field other than `id`: the door + * seeds every row slot with that field's VALUE, never an id of this + * object, so there is no row of this object to load a slot's value as; + * - a value equal to the routed `recordId` this door's subject load already + * delivered (an object-bound action): that row was judged by that load. + */ +function flowActionCallerRecordKeys( + action: any, + objectName: string, + params: Record, + recordId: string | undefined, + subject: ActionSubjectRecordLoad, +): Array { + const idField: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id'; + if (idField !== 'id') return []; + const judged = recordId !== undefined && recordId !== '' && !isObjectLessActionKey(objectName) && !subject.recordLoadDenied + ? recordId + : undefined; + const keys: Array = []; + for (const key of flowActionRowIdKeys(action, objectName)) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + const value = params[key]; + if (judged !== undefined && (typeof value === 'string' || typeof value === 'number') && String(value) === judged) continue; + keys.push([key, value]); + } + return keys; +} + /** * Brand for {@link FlowActionRefusal} — `Symbol.for`, so recognition keeps * working even if two module instances of this file ever coexist (src vs @@ -2494,6 +2530,21 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, code: startRefusal.code, }); } + // Flow start admission's third question, the row the start addresses + // (`flow-start-admission.ts`): the routed row was judged by this door's + // subject load upstream, so what is asked here is every row slot the + // CALLER's own params fill, whose value reaches the run over that row's id. + // Each is loaded in the caller's scope by the read the subject load makes + // (the request's own driver, environment and identity, as both entrances + // load the routed row), and refused with that load's 404 before the run + // exists. Thrown with `code` + `status`, like the refusals above. + await refuseUnreadableFlowStartRecords( + automation, + action.target, + { objectName, recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject) }, + ec, + (object, id) => callData(deps, requestContext, 'get', { object, id }, requestContext?.dataDriver, envId, ec), + ); // Pass a proper AutomationContext (the engine never read the former // `triggerData` envelope). const result: any = await automation.execute(action.target, { @@ -3568,6 +3619,95 @@ export function apiForwardSubjectRecord( return recordId ? { id: recordId } : {}; } +/** + * The row-slot keys a start hands a run, each with the value the run will + * receive under it — what {@link refuseUnreadableFlowStartRecords} loads. + */ +export type FlowStartRecordKeys = ReadonlyArray; + +/** + * Flow start admission's THIRD question, the row a start addresses + * (`flow-start-admission.ts`), as the ONE function every door that starts a + * flow by name calls after `flowStartRefusal` and before `execute()`. + * + * ## The rule + * + * Every record key a door hands the run in a row slot the door defines + * (`recordId`, the `Id` alias, an action's declared `recordIdParam`) + * is loaded first, in the CALLER's own scope, through + * {@link loadActionSubjectRecord}, and a key that scope does not deliver is + * refused by {@link refuseDeniedSubjectLoad}: the action door's own + * `404 RECORD_NOT_FOUND`, one envelope for a row the caller cannot read and an + * id that names nothing, so no door is an existence oracle the action door + * declines to be. Before `execute()`, so a refused start persists no run. + * + * It holds for every run, as the action door's load does: a persisted run is + * built only on a row the caller has shown it can read. A `runAs: 'system'` + * run is where the rule bites, because its nodes read and write past the + * caller's own access. + * + * ## The object a key is loaded from + * + * `objectName` is the object the start names: the action's own object at the + * action door, the request's `objectName` (or `object`) at the trigger routes + * and a declared endpoint, the object the door hands the run as `object` and + * derives the `Id` alias from. A start whose keys come with no object + * to load them from (none named, or an object-less action key) cannot be + * judged: + * + * - an ELEVATED run ({@link flowRunsElevated}) is refused + * `400 VALIDATION_FAILED` naming `objectName`, the one thing the caller + * adds to be judged; + * - a user run starts as before: it reads in the caller's own scope. + * + * A key whose value is not one record id (a string or a number) is never + * loaded and is refused `400 VALIDATION_FAILED` naming the key: only an id + * names one row. + * + * ## What stays as it was + * + * - **The system principal** (`isSystem`, never set on inbound HTTP) starts + * every flow on every row, as the two admission rules let it. + * - **A start that names no record key** loads nothing. + * - **A key the door already loaded** is not passed here: the action door's + * subject load judged the routed id, and its tests stay as they are. + * + * `readRecord` is the door's own caller-scope read, the `callData('get', …)` + * its subject load uses, so there is no second loader. + */ +export async function refuseUnreadableFlowStartRecords( + automation: unknown, + flowName: string, + start: { objectName: unknown; recordKeys: FlowStartRecordKeys }, + executionContext: { isSystem?: boolean } | null | undefined, + readRecord: (objectName: string, recordId: string) => Promise, +): Promise { + if (executionContext?.isSystem === true) return; + const named = start.recordKeys.filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (named.length === 0) return; + const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName) + ? start.objectName + : undefined; + if (objectName === undefined) { + if (!(await flowRunsElevated(automation, flowName))) return; + const message = 'A start that names a record must also name the record\'s object (`objectName`), ' + + 'so the caller\'s access to that record can be checked before the flow starts.'; + throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]); + } + const loaded = new Set(); + for (const [key, value] of named) { + if (typeof value !== 'string' && typeof value !== 'number') { + const message = `The record key \`${key}\` must name one record by its id.`; + throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]); + } + const recordId = String(value); + if (loaded.has(recordId)) continue; + const subject = await loadActionSubjectRecord(objectName, recordId, () => readRecord(objectName, recordId)); + refuseDeniedSubjectLoad(objectName, recordId, subject); + loaded.add(recordId); + } +} + /** * [#15079] The prior/next value pair a successful declarative update hands back * when the action declares `undoable: true` — the anchor that key never had. diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index 4a0588bba3a..1dc76587339 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -372,6 +372,9 @@ const TASK_ROWS = [ { id: 'tsk_2', name: 'Ship the thing', status: 'done' }, ]; +/** The row the flow endpoint below is launched on. */ +const INQUIRY_ROWS = [{ id: 'inq_1', subject: 'Old inquiry' }]; + const ADMIN_SET = { id: 'ps-admin', name: 'admin_full_access', @@ -415,6 +418,9 @@ function executionServicesPlugin(): Plugin { return ids.includes('ps-admin') ? [ADMIN_SET] : []; } if (object === 'showcase_task') return TASK_ROWS; + // The row the flow endpoint is launched on, readable by its + // caller: the door loads it in the caller's scope first. + if (object === 'showcase_inquiry') return INQUIRY_ROWS; return []; }, }); diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 05622de1ecb..d19b542ded5 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -794,7 +794,10 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { // Direct delegate calls — `dispatch()` would re-resolve identity off // the auth-less mock kernel and overwrite the seeded executionContext, // the same reason the `/keys` test above bypasses it. - const dispatcher = makeDispatcher({ automation }); + // The caller reads `lead-9`: a door that starts a flow on a record loads + // that record in the caller's own scope before the run exists. + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const dispatcher = makeDispatcher({ automation, protocol }); await dispatcher.handleAutomation('/trigger/nurture', 'POST', body, ctx); await dispatcher.handleAutomation('/nurture/trigger', 'POST', body, ctx); 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 db3713635b0..e279e074bbe 100644 --- a/packages/runtime/src/domains/automation-trigger-params-shape.test.ts +++ b/packages/runtime/src/domains/automation-trigger-params-shape.test.ts @@ -46,7 +46,10 @@ const FLOW = 'showcase_notify_owner'; function makeDispatcher() { 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 } }; + // The caller reads the launched row (`a record launch` below): a door that + // starts a flow on a record loads it in the caller's own scope first. + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const services: Record = { automation: { execute, getFlow }, protocol }; const resolve = (name: string) => services[name]; const kernel: any = { getService: resolve, diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 509d0dd0af5..d822c9c7007 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -61,7 +61,16 @@ import { import { flowStartRefusal } from '../flow-start-admission.js'; // ADR-0143 D5 · ruling A (record 6548764795) — which credential admitted the // caller, read the way the api-action forward table reads it. -import { apiForwardCredentialKind } from '../action-execution.js'; +// +// Flow start admission's third question, the row a start addresses: the action +// door's caller-scope read and refusal, asked by every door that starts a flow +// by name (`../flow-start-admission.ts`). +import { + apiForwardCredentialKind, + callData, + refuseUnreadableFlowStartRecords, + type FlowStartRecordKeys, +} from '../action-execution.js'; // [#12156] ADR-0126 §7.1 clone — the whole-definition copy, the keys it must // not carry forward, the same-name refusal and the references notice. import { @@ -188,9 +197,7 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext): // `Id` alias) stay out even when the caller's own bag names // them: the console mirrors the launched row's id into `params.recordId`, // and that is the row being addressed, not a screen being answered. - const alias = objectName - ? `${String(objectName).replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase())}Id` - : undefined; + const alias = recordIdAlias(objectName); const callerParamKeys = Object.keys(baseParams).filter((k) => k !== 'recordId' && k !== alias); if (recordId !== undefined && baseParams.recordId === undefined) { baseParams.recordId = recordId; @@ -225,6 +232,39 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext): return automationContext; } +/** + * The camelCase `Id` alias {@link buildAutomationContext} seeds the + * row id under (`crm_lead` → `crmLeadId`), or `undefined` when the start names + * no object. ONE spelling, read by the builder to seed it and by + * {@link flowStartRecordKeys} to judge what it carries. + */ +function recordIdAlias(objectName: unknown): string | undefined { + return objectName + ? `${String(objectName).replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase())}Id` + : undefined; +} + +/** + * What a context {@link buildAutomationContext} built hands the run as the row + * it addresses: the object the start names (`object`) and the value under each + * row slot this builder defines, `params.recordId` and the `Id` alias, + * read off the BUILT bag, so a caller's own `params` value in a slot is the one + * judged, exactly as the run receives it. Read by the trigger routes and a + * declared `type: 'flow'` endpoint, which share the builder, for flow start + * admission's third question (`refuseUnreadableFlowStartRecords`). + */ +export function flowStartRecordKeys(automationContext: Record): { + objectName: unknown; + recordKeys: FlowStartRecordKeys; +} { + const params = (automationContext.params ?? {}) as Record; + const objectName = automationContext.object; + const alias = recordIdAlias(objectName); + const recordKeys: Array = [['recordId', params.recordId]]; + if (alias !== undefined) recordKeys.push([alias, params[alias]]); + return { objectName, recordKeys }; +} + export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/automation', @@ -2680,7 +2720,25 @@ async function respondToFlowTrigger( }), }; } - const result = await automationService.execute(flowName, buildAutomationContext(body, context)); + const automationContext = buildAutomationContext(body, context); + // Flow start admission's third question, the row this start addresses: + // every row slot the built context carries is loaded in the caller's own + // scope, by the read the action door's subject load makes, and refused with + // its `404 RECORD_NOT_FOUND` BEFORE `execute()`, so a refused start has no + // run. Answered through `errorFromThrown`, the exit the action door serves + // the same refusal through, so the two doors answer one envelope. + try { + await refuseUnreadableFlowStartRecords( + automationService, + flowName, + flowStartRecordKeys(automationContext), + context?.executionContext, + (object, id) => callData(deps, context, 'get', { object, id }, context?.dataDriver, context?.environmentId, context?.executionContext), + ); + } catch (err) { + return { handled: true, response: deps.errorFromThrown(err, 500) }; + } + const result = await automationService.execute(flowName, automationContext); const refusal = classifyFlowRefusal(flowName, result); if (refusal) { // The run's own artefacts ride the 400 arm ONLY — they describe a run diff --git a/packages/runtime/src/endpoint-executor.test.ts b/packages/runtime/src/endpoint-executor.test.ts index 6b7c6c674a5..ff1d43a6a6a 100644 --- a/packages/runtime/src/endpoint-executor.test.ts +++ b/packages/runtime/src/endpoint-executor.test.ts @@ -402,7 +402,10 @@ describe('flow delegates to IAutomationService.execute with the trigger route’ it('calls execute(target, buildAutomationContext(body, ctx)) and answers like the trigger route', async () => { const execute = vi.fn().mockResolvedValue({ success: true, runId: 'run-1' }); - const deps = depsWith({ automationService: { execute } }); + // The caller reads `rec-1`: a flow endpoint loads the record it hands the + // run in the caller's own scope, through this seam, before the run exists. + const callData = vi.fn().mockResolvedValue({ object: 'showcase_inquiry', id: 'rec-1', record: { id: 'rec-1' } }); + const deps = depsWith({ automationService: { execute }, callData } as Partial); const ctx = contextFor(FLOW(), { method: 'POST', body: { recordId: 'rec-1', objectName: 'showcase_inquiry', params: { reason: 'spam' } }, diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index f7a5ba2133f..116834a16c1 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -53,7 +53,10 @@ import { INTERNAL_ERROR_MESSAGE, looksLikeInternalErrorLeak, resolveThrownHttpEr import { apiErrorResponse } from './error-envelope.js'; import { isServiceServeable } from './service-serveable.js'; import { validationFailure } from './validation-failure.js'; -import { buildAutomationContext } from './domains/automation.js'; +import { buildAutomationContext, flowStartRecordKeys } from './domains/automation.js'; +// The row a start addresses — flow start admission's third question, the action +// door's caller-scope load and refusal, asked by every door that starts a flow. +import { refuseUnreadableFlowStartRecords } from './action-execution.js'; import { classifyFlowRefusal, flowFailureDetails, @@ -577,6 +580,20 @@ async function executeFlow( } const automationContext = buildAutomationContext(ctx.body, ctx.protocolContext) as AutomationContext; + // The third question every door that starts a flow by name asks, after the + // two above and before `execute()`: each row slot the built context carries + // (the same builder, so the same slots the trigger route judges) is loaded + // in the caller's own scope through this endpoint's own data seam, and + // refused with the action door's `404 RECORD_NOT_FOUND`, so a refused start + // has no run. Thrown, and mapped by `executeEndpointTarget`'s one error path + // like the builder's own refusal. + await refuseUnreadableFlowStartRecords( + service, + plan.flow, + flowStartRecordKeys(automationContext as Record), + ctx.executionContext, + (object, id) => deps.callData('get', { object, id }, ctx.dataDriver, ctx.environmentId, ctx.executionContext), + ); const result = await automation.execute(plan.flow, automationContext); // [#9462] Rows 2-4, read off the PRODUCER's classification through the one diff --git a/packages/runtime/src/flow-caller-param-keys.test.ts b/packages/runtime/src/flow-caller-param-keys.test.ts index 69dad00af6f..9b5be245e08 100644 --- a/packages/runtime/src/flow-caller-param-keys.test.ts +++ b/packages/runtime/src/flow-caller-param-keys.test.ts @@ -238,7 +238,10 @@ describe('[#19846] the trigger door states callerParamKeys', () => { ]) { it(`${route.label} hands the engine the signal`, async () => { const automation = makeAutomation(); - const resolve = (n: string) => (n === 'automation' ? automation : undefined); + // The caller reads the launched row: a door that starts a flow on a record + // loads that record in the caller's own scope first, and refuses it unread. + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const resolve = (n: string) => (n === 'automation' ? automation : n === 'protocol' ? protocol : undefined); const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; await new HttpDispatcher(kernel).handleAutomation( route.path, 'POST', diff --git a/packages/runtime/src/flow-run-locale.test.ts b/packages/runtime/src/flow-run-locale.test.ts index 8e9f6562c45..28afbd8c608 100644 --- a/packages/runtime/src/flow-run-locale.test.ts +++ b/packages/runtime/src/flow-run-locale.test.ts @@ -83,7 +83,10 @@ describe('[#22450] the trigger door forwards the caller\'s locale', () => { ]) { it(`${route.label} hands the engine the locale`, async () => { const automation = makeAutomation(); - const resolve = (n: string) => (n === 'automation' ? automation : undefined); + // The caller reads the launched row: a door that starts a flow on a record + // loads that record in the caller's own scope first, and refuses it unread. + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const resolve = (n: string) => (n === 'automation' ? automation : n === 'protocol' ? protocol : undefined); const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; await new HttpDispatcher(kernel).handleAutomation( route.path, 'POST', diff --git a/packages/runtime/src/flow-run-posture.test.ts b/packages/runtime/src/flow-run-posture.test.ts index 1cea0b846a3..587ada0aa1e 100644 --- a/packages/runtime/src/flow-run-posture.test.ts +++ b/packages/runtime/src/flow-run-posture.test.ts @@ -101,7 +101,10 @@ describe('[#28099] the trigger door forwards the caller\'s posture rung', () => ]) { it(`${route.label} hands the engine the rung`, async () => { const automation = makeAutomation(); - const resolve = (n: string) => (n === 'automation' ? automation : undefined); + // The caller reads the launched row: a door that starts a flow on a record + // loads that record in the caller's own scope first, and refuses it unread. + const protocol = { getData: async ({ object, id }: any) => ({ object, id, record: { id } }) }; + const resolve = (n: string) => (n === 'automation' ? automation : n === 'protocol' ? protocol : undefined); const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; await new HttpDispatcher(kernel).handleAutomation( route.path, 'POST', diff --git a/packages/runtime/src/flow-start-admission.ts b/packages/runtime/src/flow-start-admission.ts index 9f81b3b1570..bf0e130bc34 100644 --- a/packages/runtime/src/flow-start-admission.ts +++ b/packages/runtime/src/flow-start-admission.ts @@ -38,6 +38,28 @@ * gate, which answers first (`actionPermissionError`), so a caller at that * door needs both lists. * + * ## The third question: the row a start addresses + * + * The two rules above judge the caller against the FLOW. A start that names a + * record also hands the run a row, and a `runAs: 'system'` run reads and + * writes that row past the caller's own access; a user run is asked the same, + * as the action door's load asks it of every run, so a persisted run is built + * only on a row its caller has shown it can read. So every door asks a third + * question after them, through ONE function, + * `refuseUnreadableFlowStartRecords` (`action-execution.ts`): every record key + * the door hands the run in a row slot it defines (`recordId`, the + * `Id` alias, an action's declared `recordIdParam`) is first loaded in + * the CALLER's own scope, and a key that scope does not deliver is refused + * with the action door's own `404 RECORD_NOT_FOUND`, before `execute()`, so a + * refused start persists no run. It is the action door's subject load + * (`loadActionSubjectRecord`) and its refusal (`refuseDeniedSubjectLoad`), + * asked of the keys the action door did not load itself, and asked at the + * trigger routes and a declared endpoint too: a door that hands a run a row + * the caller has not shown it can read is the gap this module exists to close. + * It lives beside the load it reuses, so this module imports nothing from + * `action-execution.ts` and the two stay a one-way edge; the declaration it + * reads is this module's ({@link flowRunsElevated}). + * * ## Why a module, and why every door asks it * * The same reason `flow-dispatch-status.ts` is one: a rule each door spells @@ -135,6 +157,25 @@ export function isElevatedSelfTriggered(flow: { type?: FlowParsed['type']; runAs return flow.runAs === 'system' && flow.type !== undefined && SELF_TRIGGERED_FLOW_TYPES.has(flow.type); } +/** + * Whether `flowName` is declared `runAs: 'system'`, read through the + * automation service's own `getFlow`, the probe {@link flowStartRefusal} reads. + * `false` when the service cannot be asked (no `getFlow`) or serves no such + * flow: an undeclared run is the parsed default, `user`, and a door that + * cannot read the declaration dispatches as before. + * + * The third question's one reading of the declaration + * (`refuseUnreadableFlowStartRecords`): a start that names a record key but + * no object to load it from cannot be judged, which is refused for an + * elevated run only. A user run reads in the caller's own scope. + */ +export async function flowRunsElevated(automation: unknown, flowName: string): Promise { + const svc = automation as { getFlow?: (name: string) => Promise } | null | undefined; + if (typeof svc?.getFlow !== 'function') return false; + const flow = (await svc.getFlow(flowName)) as { runAs?: unknown } | null | undefined; + return flow?.runAs === 'system'; +} + /** * THE CHECK every door that starts a flow by name asks: the refusal it * answers for this caller starting `flowName`, or `null` when the start is diff --git a/packages/runtime/src/flow-start-record-admission.test.ts b/packages/runtime/src/flow-start-record-admission.test.ts new file mode 100644 index 00000000000..fa383735ad4 --- /dev/null +++ b/packages/runtime/src/flow-start-record-admission.test.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Flow start admission's third question, the row a start addresses + * (`flow-start-admission.ts`, `refuseUnreadableFlowStartRecords`): every door + * that starts a flow by name loads each record key it hands the run in a row + * slot, in the CALLER's own scope, and refuses a key that scope does not + * deliver with the action door's own `404 RECORD_NOT_FOUND`, before the run + * exists. + * + * What is pinned, door by door: + * + * 1. **The trigger routes**, both spellings, driven through `dispatch()` so + * the answer is the wire envelope: a key the caller cannot read and an id + * that names nothing are ONE refusal, the action door's, and the engine is + * never asked (no run). Every row slot the built context carries is + * judged, the caller's own `params` value included. + * 2. **A declared `type: 'flow'` endpoint**, which shares the trigger + * route's context builder: the same refusal, no run. + * 3. **The action door's flow branch**, REST and MCP: the routed row is + * judged by the door's subject load as before; a row slot the caller's own + * `params` fill is judged here too. + * 4. **What stays as it was**: a readable key starts the run (the firing + * control for every zero above), the system principal, a start with no + * record key, a user run that names no object. + * + * The data double is ROW-SCOPED, the one point it must be faithful on: each + * row is visible to its owner and to nobody else, and no other id exists, so + * the real `callData('get', …)` answers the real `RECORD_NOT_FOUND` a + * row-level rule produces. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; +import { callData, invokeBusinessAction } from './action-execution.js'; +import { buildEndpointExecutionContext, executeEndpointTarget } from './endpoint-executor.js'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; + +const OBJECT = 'crm_case'; +const MEMBER = 'usr_member'; +const OTHER = 'usr_other'; +/** A row the member owns, and so reads. */ +const READABLE = 'case_member'; +/** A row only the other user reads. */ +const UNREADABLE = 'case_other'; +/** An id that names nothing. */ +const GHOST = 'case_ghost'; + +const ELEVATED_SCREEN = 'case_elevated_screen'; +const ELEVATED_API = 'case_elevated_api'; +const USER_SCREEN = 'case_user_screen'; +const FLOWS: Record = { + [ELEVATED_SCREEN]: { name: ELEVATED_SCREEN, type: 'screen', runAs: 'system' }, + [ELEVATED_API]: { name: ELEVATED_API, type: 'api', runAs: 'system' }, + [USER_SCREEN]: { name: USER_SCREEN, type: 'screen', runAs: 'user' }, +}; + +const FLOW_ACTION = { + name: 'escalate_case', + label: 'Escalate', + objectName: OBJECT, + type: 'flow', + target: ELEVATED_SCREEN, + ai: { exposed: true, description: 'Escalate a case.' }, +}; +/** The same flow behind an action whose row slots carry a field value, not an id. */ +const TOKEN_ACTION = { + ...FLOW_ACTION, + name: 'rotate_case_token', + recordIdField: 'token', + recordIdParam: 'caseToken', +}; +const OBJECT_DEF = { name: OBJECT, actions: [FLOW_ACTION, TOKEN_ACTION] }; + +/** The acting principal, as `resolveExecutionContext` builds one. */ +function ec(userId: string) { + return { userId, tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] }; +} + +/** Row-scoped reads: each row to its owner, nothing else exists. */ +function makeQl() { + const rows: Record = { + [READABLE]: { id: READABLE, owner_id: MEMBER, token: 'tok_member' }, + [UNREADABLE]: { id: UNREADABLE, owner_id: OTHER, token: 'tok_other' }, + }; + const schemaOf = (n: string) => (n === OBJECT ? OBJECT_DEF : undefined); + return { + executeAction: vi.fn(async () => ({ ok: true })), + getSchema: schemaOf, + registry: { getObject: schemaOf, getItem: () => undefined }, + find: vi.fn(async (object: string, options?: any) => { + if (object !== OBJECT) return []; + const caller = options?.context?.isSystem ? '*' : options?.context?.userId; + const wanted = options?.where?.id; + return Object.values(rows).filter((r) => (wanted === undefined || r.id === wanted) + && (caller === '*' || r.owner_id === caller)); + }), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + } as any; +} + +function makeAutomation() { + return { + handlerReady: true, + execute: vi.fn(async (_flow: string, _context?: any) => ({ + success: true, status: 'paused', runId: 'run_test', durationMs: 1, + })), + getFlow: vi.fn(async (name: string) => FLOWS[name] ?? null), + }; +} + +function makeMetadata() { + return { + 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)), + }; +} + +function boot(principal: Record) { + const ql = makeQl(); + const automation = makeAutomation(); + const metadata = makeMetadata(); + const resolve = (n: string): unknown => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? automation + : undefined; + const kernel: any = { + getService: resolve, + getServiceAsync: async (n: string) => resolve(n), + context: { getService: resolve }, + }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch` writes `context.executionContext` from. + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...principal }); + const context = (): HttpProtocolContext => + ({ request: {}, environmentId: 'platform', executionContext: { ...principal } }) as any; + return { + ql, + automation, + dispatcher, + /** The wire answer through the registered route. */ + dispatch: (method: string, path: string, body?: unknown) => + dispatcher.dispatch(method, path, body, {}, { request: {}, environmentId: 'platform' } as HttpProtocolContext), + /** The REST action door, as its own suite drives it. */ + actions: (path: string, body?: unknown) => (dispatcher as any).handleActions(path, 'POST', body ?? {}, context()), + /** The MCP `run_action` bridge, on the REAL `callData`. */ + runAction: (name: string, input: Record) => { + const deps: any = { + resolveService: async (_ctx: any, service: string) => (service === 'automation' ? automation : undefined), + getObjectQL: async () => ql, + }; + const requestContext: any = { request: {}, environmentId: 'platform' }; + return invokeBusinessAction(deps, requestContext, name, input as any, { + driver: undefined, + envId: 'platform', + ec: { ...principal }, + getMeta: () => metadata, + callData: (action, params, dataDriver, scopeId, execCtx) => + callData(deps, requestContext, action, params, dataDriver, scopeId, execCtx), + }); + }, + }; +} + +const statusOf = (r: any): unknown => r?.response?.status; +/** The reads of the object's rows — the caller-scope loads under test. */ +const rowReads = (h: { ql: any }): unknown[][] => h.ql.find.mock.calls.filter((c: unknown[]) => c[0] === OBJECT); +const errorOf = (r: any): any => r?.response?.body?.error ?? {}; +/** The refusal envelope with the id normalised out, so two ids compare equal. */ +const envelopeOf = (r: any, id: string) => ({ + status: statusOf(r), + code: errorOf(r).code, + message: String(errorOf(r).message ?? '').split(id).join(''), +}); + +const ROUTES: Array<{ label: string; path: (flow: string) => string }> = [ + { label: 'POST /automation/:name/trigger', path: (f) => `/automation/${f}/trigger` }, + { label: 'legacy POST /automation/trigger/:name', path: (f) => `/automation/trigger/${f}` }, +]; + +// ─────────────────────────────────────────────────────────────────────────── +// 1. The trigger routes +// ─────────────────────────────────────────────────────────────────────────── + +describe('the trigger routes load every row slot in the caller\'s scope before the run exists', () => { + for (const route of ROUTES) { + describe(route.label, () => { + for (const flow of [ELEVATED_SCREEN, ELEVATED_API]) { + it(`an elevated '${FLOWS[flow].type}' flow given a record key the caller cannot read: 404 RECORD_NOT_FOUND, no run`, async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(flow), { recordId: UNREADABLE, objectName: OBJECT }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + } + + it('an unreadable row and an id that names nothing are one refusal', async () => { + const denied = await boot(ec(MEMBER)).dispatch('POST', route.path(ELEVATED_SCREEN), { recordId: UNREADABLE, objectName: OBJECT }); + const ghost = boot(ec(MEMBER)); + const nothing = await ghost.dispatch('POST', route.path(ELEVATED_SCREEN), { recordId: GHOST, objectName: OBJECT }); + + expect(envelopeOf(nothing, GHOST)).toEqual(envelopeOf(denied, UNREADABLE)); + expect(ghost.automation.execute).not.toHaveBeenCalled(); + }); + + it('a record key the caller can read starts the run, the row key in place: the firing control', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { recordId: READABLE, objectName: OBJECT }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(h.automation.execute.mock.calls[0]![1].params).toMatchObject({ recordId: READABLE, crmCaseId: READABLE }); + }); + + it('the caller\'s own `params.recordId` is the value the run receives, and it is the one judged', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { + recordId: READABLE, objectName: OBJECT, params: { recordId: UNREADABLE }, + }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('the `Id` slot is judged too', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { + recordId: READABLE, objectName: OBJECT, params: { crmCaseId: UNREADABLE }, + }); + + expect(statusOf(res)).toBe(404); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('an elevated flow given a record key and no object is refused 400 naming `objectName`, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { recordId: UNREADABLE }); + + expect(statusOf(res)).toBe(400); + expect(String(errorOf(res).message)).toContain('`objectName`'); + expect(JSON.stringify(errorOf(res).details ?? {})).toContain('objectName'); + expect(h.automation.execute).not.toHaveBeenCalled(); + expect(rowReads(h)).toHaveLength(0); + }); + + it('a record key that is not one id is refused 400 naming the key, never loaded, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { + objectName: OBJECT, params: { recordId: { id: READABLE } }, + }); + + expect(statusOf(res)).toBe(400); + expect(String(errorOf(res).message)).toContain('`recordId`'); + expect(rowReads(h)).toHaveLength(0); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('a user-run flow is judged alike when the start names its object', async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(USER_SCREEN), { recordId: UNREADABLE, objectName: OBJECT }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + }); + } +}); + +describe('what the trigger routes keep exactly as they were', () => { + for (const route of ROUTES) { + it(`${route.label}: a user-run flow given a record key and no object starts as before, nothing loaded`, async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(USER_SCREEN), { recordId: UNREADABLE }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(rowReads(h)).toHaveLength(0); + }); + + it(`${route.label}: a start that names no record key loads nothing`, async () => { + const h = boot(ec(MEMBER)); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { objectName: OBJECT, params: { note: 'x' } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(rowReads(h)).toHaveLength(0); + }); + + it(`${route.label}: the system principal starts on any row, nothing loaded`, async () => { + const h = boot({ isSystem: true }); + const res = await h.dispatch('POST', route.path(ELEVATED_SCREEN), { recordId: UNREADABLE, objectName: OBJECT }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(rowReads(h)).toHaveLength(0); + }); + } +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. A declared `type: 'flow'` endpoint +// ─────────────────────────────────────────────────────────────────────────── + +describe('a declared `type: flow` endpoint asks the same question', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'case_escalate', + path: '/api/v1/apps/cases/escalate', + method: 'POST', + type: 'flow', + target: ELEVATED_SCREEN, + }); + + async function run(userId: string, body: unknown) { + const ql = makeQl(); + const automation = makeAutomation(); + const deps: any = { resolveService: async () => undefined, getObjectQL: async () => ql }; + const protocolContext: any = { request: {}, environmentId: 'platform', executionContext: ec(userId) }; + const match: ApiEndpointMatch = { endpoint, params: {} }; + const answer = await executeEndpointTarget( + buildEndpointExecutionContext({ + request: { method: 'POST', path: endpoint.path, query: {}, headers: {}, body }, + match, + executionContext: ec(userId) as any, + environmentId: 'platform', + }), + { + automationService: automation, + callData: (action, params, dataDriver, scopeId, execCtx) => + callData(deps, protocolContext, action, params, dataDriver, scopeId, execCtx), + }, + ); + return { answer, automation }; + } + + it('a record key the caller cannot read: 404 RECORD_NOT_FOUND, no run', async () => { + const { answer, automation } = await run(MEMBER, { recordId: UNREADABLE, objectName: OBJECT }); + + expect(answer.status).toBe(404); + expect((answer.body as any)?.error?.code).toBe('RECORD_NOT_FOUND'); + expect(automation.execute).not.toHaveBeenCalled(); + }); + + it('a record key the caller can read starts the run: the firing control', async () => { + const { answer, automation } = await run(MEMBER, { recordId: READABLE, objectName: OBJECT }); + + expect(answer.status).toBe(200); + expect(automation.execute).toHaveBeenCalledTimes(1); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. The action door's flow branch +// ─────────────────────────────────────────────────────────────────────────── + +describe('the action door judges the row slots the caller\'s own params fill', () => { + it('REST: a readable routed row with an unreadable `params.recordId`: 404 RECORD_NOT_FOUND, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${OBJECT}/${FLOW_ACTION.name}/${READABLE}`, { params: { recordId: UNREADABLE } }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('REST: no routed row and an unreadable `params.recordId`: refused alike', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${OBJECT}/${FLOW_ACTION.name}`, { params: { recordId: UNREADABLE } }); + + expect(statusOf(res)).toBe(404); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('MCP: the same caller is thrown the same refusal, no run', async () => { + const h = boot(ec(MEMBER)); + + await expect(h.runAction(FLOW_ACTION.name, { objectName: OBJECT, recordId: READABLE, params: { crmCaseId: UNREADABLE } })) + .rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('a `params` value equal to the routed row starts the run, that row loaded once', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${OBJECT}/${FLOW_ACTION.name}/${READABLE}`, { params: { recordId: READABLE } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(rowReads(h)).toHaveLength(1); + }); + + it('an action whose row slots carry a field value, not an id, is not judged as an id', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${OBJECT}/${TOKEN_ACTION.name}/${READABLE}`, { params: { caseToken: 'tok_other' } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4. One refusal at every door +// ─────────────────────────────────────────────────────────────────────────── + +describe('the trigger route and the action door answer one refusal for one row', () => { + it('same status, code and message for the same caller and the same unreadable row', async () => { + const viaTrigger = await boot(ec(MEMBER)).dispatch('POST', `/automation/${ELEVATED_SCREEN}/trigger`, { + recordId: UNREADABLE, objectName: OBJECT, + }); + const viaAction = await boot(ec(MEMBER)).actions(`/${OBJECT}/${FLOW_ACTION.name}/${UNREADABLE}`); + + expect(envelopeOf(viaTrigger, UNREADABLE)).toEqual(envelopeOf(viaAction, UNREADABLE)); + expect(envelopeOf(viaTrigger, UNREADABLE)).toMatchObject({ status: 404, code: 'RECORD_NOT_FOUND' }); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts b/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts index ac6487b8177..e63d850112c 100644 --- a/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts +++ b/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts @@ -192,11 +192,14 @@ describe('REST /actions — flow dispatch (#3915)', () => { it('lets an explicit param win over every seeded key', async () => { const execute = vi.fn(async () => ({ success: true })); - const { dispatcher } = makeDispatcher({ + const { dispatcher, ql } = makeDispatcher({ objectDef: { name: 'crm_lead', actions: [flowAction] }, automation: { execute }, record: { id: 'lead_1' }, }); + // The caller reads both rows: a record key the caller's own params hand + // the run is loaded in the caller's scope too, as the routed row is. + ql.find.mockImplementation(async (_object: string, opts: any) => [{ id: opts?.where?.id }]); await dispatcher.handleActions( '/crm_lead/convert_lead/lead_1',