diff --git a/.changeset/27738-api-action-door-refusal-and-decision-outputs.md b/.changeset/27738-api-action-door-refusal-and-decision-outputs.md new file mode 100644 index 00000000000..411b4c76814 --- /dev/null +++ b/.changeset/27738-api-action-door-refusal-and-decision-outputs.md @@ -0,0 +1,15 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a forwarded `type: 'api'` action keeps the target's whole refusal, and an approve or reject carrying decision outputs passes the param check + +Clause-②: yes (widening) +Metadata-protocol: none + +Both action doors (`POST /api/v1/actions/:object/:action` and the MCP `run_action` tool) forward a same-origin `type: 'api'` action to its declared `target`. Two parts of that forward lost what a direct call to the target keeps: + +- **The target's refusal is relayed whole.** When the target answers a non-2xx with the ObjectStack envelope (`{ success: false, error: { code, message, … } }`), the REST door now answers the same `code` and `message` and every member beside them, unchanged: `details` (a flow endpoint's `innerRefusal` and run `summary`; an entitlement refusal's `upgrade_url`, `contact_url`, `plan`, `current`, `limit`), `userMessage`, `declaredCode`. It used to rebuild the envelope from `code` alone, so those were dropped. At `run_action` the envelope's `details` ride the thrown refusal and render beside its code. A refusal the target did not envelope (a stock better-auth `{ code, message }`, a non-JSON body) is answered as before. +- **`outputs.` params pass the ADR-0104 D2 param check on a forwarded `type: 'api'` action.** These are the approval decision outputs, one param per output the approval node declares, which the request already folds into its body's `outputs` object. Their keys belong to the pending request, so no action can declare them, and an Approve or Reject that carried one was refused with `400 Unknown action param`. The check now leaves undeclared `outputs.` params out when the action's request folds them (`type: 'api'`, not a declarative update, no `bodyShape.wrap`); the target judges their values. Every other undeclared key is refused as before, and the family stays refused on every other action. A declared param is judged as declared, whatever its name. ADR-0104 D2 records the amendment (2026-10-30). + +No export of the package changes. Patch, not minor: the metadata an author writes is unchanged (`Metadata-protocol: none`), and a change that does not move the metadata protocol ships `patch` whatever its arm. diff --git a/docs/adr/0104-field-runtime-value-shape-contract.md b/docs/adr/0104-field-runtime-value-shape-contract.md index b03a6a0c7d5..4bb226fcf6b 100644 --- a/docs/adr/0104-field-runtime-value-shape-contract.md +++ b/docs/adr/0104-field-runtime-value-shape-contract.md @@ -34,6 +34,12 @@ VALIDATION_FAILED` naming each such field with the ADR-0114 code `invalid_value`, and nothing is written. See "The field refusal" in the 2026-10-26 addendum. + · **Amended** (2026-10-30, #27738) — D2's unknown-key refusal admits one + undeclared family, on a forwarded `type: 'api'` action only: `outputs.` + params, the per-request approval decision outputs that the action's + request folds into its body's `outputs` object. Every other undeclared key, + and the same family on any action whose request does not fold it, is + refused as before. See the 2026-10-30 note under D2 item 1. - **Date**: 2026-07-22 - **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 @@ -287,6 +293,46 @@ free. Three changes: > a thrown code). No other decision in D2 changes: the code and shape > above correct the record, they add no rule. + > **Amended 2026-10-30 (#27738) — a forwarded `type: 'api'` action + > admits the `outputs.` decision-output family.** Both action doors + > forward a same-origin `type: 'api'` action in process to its declared + > `target`, after this item's gate, and build the request body from the + > declaration (`packages/runtime/src/action-execution.ts#buildApiActionRequest`). + > That build folds every param named `outputs.` (the prefix and a + > non-empty key) into a nested `outputs` object, which is the body the + > approvals decide routes read (#3447). Each such param is one decision + > output an approval node declares (`decisionOutputs`), and the approver + > fills it in while deciding. The key set belongs to the pending request, + > not to the action, so no `params` list can declare it, and "unknown + > param keys are a validation error" refused every approve or reject that + > carried one. + > + > **What is admitted, by whom, for which actions.** The gate + > (`packages/runtime/src/action-execution.ts#enforceActionParams`, at the + > REST door and the MCP `run_action` door alike) judges the bag without + > its undeclared `outputs.` params when the action's request folds + > them, and only then. One predicate answers "folds them" + > (`foldsApiDecisionOutputs`): `type: 'api'`, not a declarative update, + > and no `bodyShape.wrap` (under a wrap the params are nested and the + > fold never reaches them). One predicate answers membership + > (`isApiDecisionOutputParam`), and the fold asks the same one. The + > forwarded target judges the admitted values against the request's own + > `decisionOutputs` (required, type), as it judges the rest of the body it + > receives (ADR-0066 D4). + > + > **What does not change.** A declared param is judged as declared, + > whatever its name. Every other undeclared key is refused with the same + > `400 VALIDATION_ERROR` at both doors. The family is refused on every + > other action (script, flow, declarative update, a wrapped api body), + > where nothing folds it. `OS_ALLOW_LAX_ACTION_PARAMS` is untouched. The + > spec's validator + > (`packages/spec/src/ui/action-params.zod.ts#validateActionParams`) and + > its built-in keys do not change: the admission lives in the runtime gate + > beside the fold it mirrors. Pinned in + > `packages/runtime/src/action-params-enforcement.test.ts` (the gate) and + > `packages/runtime/src/domains/api-action-forward.test.ts` (both doors, + > and the fold). + 2. **Typed handler surface.** `defineAction` today validates config only. It gains a typed companion so registered handlers stop being `(ctx: any) => any`: `ctx.params` is typed by mapping each declared param diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ddb33cf5a40..eefb03c010f 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1185,6 +1185,43 @@ function carriesPageToken(value: unknown): boolean { return false; } +/** + * [#3447] The decision-output param family: `outputs.`, one param per + * output an approval node declares (`decisionOutputs`), filled in by the + * approver while deciding. The key set belongs to the pending request, not to + * the action, so no `params` list can declare it. A `type: 'api'` request + * folds the family into its body's `outputs` object + * ({@link buildApiActionRequest}), the body the approvals decide routes read, + * and the param contract admits exactly what folds + * ({@link foldsApiDecisionOutputs}; ADR-0104 D2, amended 2026-10-30). The + * console's api handler folds the same family by the same rule. + */ +const API_DECISION_OUTPUT_PREFIX = 'outputs.'; + +/** Is `key` a member of the `outputs.` family — the prefix and a non-empty key? */ +export function isApiDecisionOutputParam(key: string): boolean { + return key.startsWith(API_DECISION_OUTPUT_PREFIX) && key.length > API_DECISION_OUTPUT_PREFIX.length; +} + +/** The `bodyShape.wrap` key an api request nests its params under, when the declaration names one. */ +function apiBodyWrap(action: any): string | undefined { + return action?.bodyShape && typeof action.bodyShape === 'object' && typeof action.bodyShape.wrap === 'string' && action.bodyShape.wrap + ? action.bodyShape.wrap + : undefined; +} + +/** + * [#27738] Does this action's request fold the `outputs.` family? Only a + * forwarded `type: 'api'` action whose params sit flat in its body. Under + * `bodyShape.wrap` the params are nested and the fold never reaches them, and + * a declarative update (`operation: 'update'`, whatever its `type` says) + * writes its bag to the row instead of sending it anywhere — so for both, and + * for every other type, the family stays an unknown param. + */ +export function foldsApiDecisionOutputs(action: any): boolean { + return !isDeclarativeUpdateAction(action) && action?.type === 'api' && apiBodyWrap(action) === undefined; +} + /** * Assemble the request a same-origin `type: 'api'` action makes — method, * path and JSON body — from its DECLARATION and the dispatch inputs, by the @@ -1268,18 +1305,15 @@ export function buildApiActionRequest( `the action from the page that owns the variable.`, }; } - const wrap: string | undefined = - action?.bodyShape && typeof action.bodyShape === 'object' && typeof action.bodyShape.wrap === 'string' && action.bodyShape.wrap - ? action.bodyShape.wrap - : undefined; + const wrap = apiBodyWrap(action); const body: Record = wrap ? { [wrap]: { ...input.params } } : { ...input.params }; // [#3447] decision outputs: `outputs.` params fold into `outputs`. for (const k of Object.keys(body)) { - if (k.startsWith('outputs.') && k.length > 'outputs.'.length) { + if (isApiDecisionOutputParam(k)) { const value = body[k]; delete body[k]; if (value === undefined || value === '') continue; - ((body.outputs ??= {}) as Record)[k.slice('outputs.'.length)] = value; + ((body.outputs ??= {}) as Record)[k.slice(API_DECISION_OUTPUT_PREFIX.length)] = value; } } // [#8018] `recordIdParam`, seeded from the row or refused — never silently @@ -1527,10 +1561,40 @@ export function apiActionResultOf(answer: ApiActionForwardAnswer): unknown { : body; } +/** + * [#27738] The members the target's refusal ENVELOPE carries beside `code`, + * `message` and `httpStatus` — `details`, `userMessage`, `declaredCode`, any + * other — exactly as the target wrote them; `undefined` when the target did + * not envelope its refusal (`{ success: false, error: { code, … } }`): a stock + * better-auth `{ code, message }`, a non-JSON or an empty body. + * + * Both doors relay these WHOLE. The REST door used to rebuild the envelope + * from `code` alone, which dropped everything a caller branches on beside + * the code: a flow endpoint's `details.innerRefusal` and `userMessage`, an + * entitlement refusal's `details` (`upgrade_url`, `contact_url`, `plan`, + * `current`, `limit`). The forward carries the caller's own credentials + * through the same HTTP stack, so this is the answer the caller gets by + * calling the target itself: relaying it whole discloses nothing new, and + * the target's own boundary has already applied its message discipline. + */ +export function apiActionRefusalMembers(answer: ApiActionForwardAnswer): Record | undefined { + const body = answer.body; + if (!body || typeof body !== 'object' || Array.isArray(body) || body.success !== false) return undefined; + const error = body.error; + if (!error || typeof error !== 'object' || Array.isArray(error) || typeof error.code !== 'string' || !error.code) return undefined; + const members: Record = {}; + for (const [key, value] of Object.entries(error as Record)) { + if (key !== 'code' && key !== 'message' && key !== 'httpStatus') members[key] = value; + } + return members; +} + /** * The thrown shape for a forward the target REFUSED (a non-2xx), for the MCP * bridge: `code` + `status` ride the error so `resolveThrownHttpError` serves * the target's own status and the agent reads the target's own sentence. + * [#27738] The envelope's `details` ride it too ({@link apiActionRefusalMembers}), + * which `run_action` renders beside the code, as the REST door relays them. */ export function apiActionForwardRefusal(action: any, answer: ApiActionForwardAnswer): Error { const body = answer.body; @@ -1542,7 +1606,8 @@ export function apiActionForwardRefusal(action: any, answer: ApiActionForwardAns (body && typeof body === 'object' && typeof body.error?.code === 'string' && body.error.code) || (body && typeof body === 'object' && typeof body.code === 'string' && body.code) || standardErrorCodeForHttpStatus(answer.status); - return Object.assign(new Error(message), { code, status: answer.status }); + const details = apiActionRefusalMembers(answer)?.details; + return Object.assign(new Error(message), { code, status: answer.status, ...(details !== undefined ? { details } : {}) }); } /** @@ -2344,6 +2409,26 @@ export function resolveDeclaredActionParams(_deps: ActionExecutionDeps, action: return out; } +/** + * The bag ADR-0104 D2 judges: the request's own, less its UNDECLARED + * `outputs.` decision outputs when this action's request folds them + * ({@link foldsApiDecisionOutputs}; D2 amended 2026-10-30, #27738). Those + * keys are per request, so no declaration can name them; the forwarded target + * (the approvals decide route) judges their values against the request's own + * `decisionOutputs`. A declared param stays judged as declared, whatever its + * name, and every other undeclared key is refused exactly as before. + */ +function paramBagUnderContract(action: any, resolved: ResolvedActionParam[], bag: Record): Record { + if (!foldsApiDecisionOutputs(action)) return bag; + const declared = new Set(resolved.map((p) => p.name)); + const judged: Record = {}; + for (const [key, value] of Object.entries(bag)) { + if (isApiDecisionOutputParam(key) && !declared.has(key)) continue; + judged[key] = value; + } + return judged; +} + /** * Enforce an action's declared param contract against the request bag * BEFORE the handler runs (ADR-0104 D2). Returns a `400`-worthy error message @@ -2377,6 +2462,13 @@ export function resolveDeclaredActionParams(_deps: ActionExecutionDeps, action: * built-ins: the same message, the same 400 at the REST door, the same * `OS_ALLOW_LAX_ACTION_PARAMS` escape hatch a declared-params action gets. No * switch of its own. + * + * ## One undeclared family a forwarded api action admits (#27738) + * + * A `type: 'api'` action whose request folds the `outputs.` decision + * outputs is judged without them ({@link paramBagUnderContract}), so an + * Approve or Reject carrying the approver's decision outputs reaches the fold + * instead of a 400. Every other key is judged as above. */ export function enforceActionParams(deps: ActionExecutionDeps, action: any, @@ -2387,7 +2479,7 @@ export function enforceActionParams(deps: ActionExecutionDeps, const declaresParams = Array.isArray(action?.params) && action.params.length > 0; if (!declaresParams && !isDeclarativeUpdateAction(action)) return null; const resolved = resolveDeclaredActionParams(deps, action, obj); - const issues = validateActionParams(resolved, bag); + const issues = validateActionParams(resolved, paramBagUnderContract(action, resolved, bag)); if (issues.length === 0) return null; const summary = issues.map((i) => i.message).join('; '); if (!laxActionParams()) { diff --git a/packages/runtime/src/action-params-enforcement.test.ts b/packages/runtime/src/action-params-enforcement.test.ts index 8a5c7367db9..f3e8bc67b63 100644 --- a/packages/runtime/src/action-params-enforcement.test.ts +++ b/packages/runtime/src/action-params-enforcement.test.ts @@ -113,6 +113,59 @@ describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => { }); }); + describe('[#27738] the `outputs.` decision-output family is admitted for a forwarded `type: \'api\'` action only', () => { + // ADR-0104 D2, amended 2026-10-30. The family is the one the api + // request folds into its body's `outputs` (`buildApiActionRequest`), so + // the admission reaches exactly as far as the fold and no further. + const APPROVE = { + name: 'approval_approve', type: 'api', target: '/api/v1/approvals/requests/{id}/approve', + params: [ + { name: 'comment', type: 'textarea', required: false }, + { name: 'attachments', type: 'file', multiple: true, required: false }, + ], + }; + const DECISION = { 'outputs.next_reviewers': ['u_2'], 'outputs.reason': 'fits budget', 'outputs.note': '' }; + + it('admits the family beside the declared params', () => { + expect(enforceActionParams(NO_DEPS, APPROVE, NO_OBJECT, { comment: 'ok', ...DECISION }, WHERE)).toBeNull(); + }); + + it('still refuses every key outside the family, and the bare `outputs.` prefix, naming only those', () => { + for (const key of ['bogus', 'outputs.', 'output.reason', 'outputs_reason']) { + const error = enforceActionParams(NO_DEPS, APPROVE, NO_OBJECT, { ...DECISION, [key]: 'x' }, WHERE); + expect(error, key).toContain(`Unknown action param "${key}"`); + expect(error, key).not.toContain('outputs.next_reviewers'); + } + }); + + it('still judges the declared params themselves', () => { + const error = enforceActionParams(NO_DEPS, APPROVE, NO_OBJECT, { attachments: 'not-a-list', ...DECISION }, WHERE); + expect(error).toContain('"attachments"'); + }); + + it('refuses the family on every action whose request does not fold it', () => { + const notFolded = [ + { ...APPROVE, type: 'script' }, + { ...APPROVE, type: undefined }, + { ...APPROVE, type: 'flow' }, + // A declarative update writes its bag to the row: `operation` decides, whatever `type` says. + { ...APPROVE, operation: 'update', patch: { status: 'approved' } }, + // Under `bodyShape.wrap` the params are nested and the fold never sees them. + { ...APPROVE, bodyShape: { wrap: 'data' } }, + ]; + for (const action of notFolded) { + const error = enforceActionParams(NO_DEPS, action, NO_OBJECT, { 'outputs.reason': 'x' }, WHERE); + expect(error, JSON.stringify(action)).toContain('Unknown action param "outputs.reason"'); + } + }); + + it('a param an api action DECLARES under a dotted `outputs.` name is judged as declared', () => { + const declared = { ...APPROVE, params: [...APPROVE.params, { name: 'outputs.reason', type: 'text', required: true }] }; + expect(enforceActionParams(NO_DEPS, declared, NO_OBJECT, {}, WHERE)).toContain('"outputs.reason" is required'); + expect(enforceActionParams(NO_DEPS, declared, NO_OBJECT, { 'outputs.reason': 'x' }, WHERE)).toBeNull(); + }); + }); + it('is STRICT by default — no environment variable needed to reject (#3438)', () => { vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', ''); expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params'); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index da0ea3439e2..41f3c3c4c87 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -75,7 +75,7 @@ import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } // [#25976] The one value-shape check the run doors share — the trigger and // resume doors ask it of their input too. import { objectValueFailure } from '../object-value-shape.js'; -import { buildApiError } from '../error-envelope.js'; +import { apiErrorResponse, buildApiError } from '../error-envelope.js'; // [ADR-0126 §5] The shared activation write-authority gates — the same two // tiers `/automation`'s toggle door passes, one implementation. import { @@ -846,10 +846,15 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string return { handled: true, response: { ...deps.success(actionExec.apiActionResultOf(answer)), ...relayed } }; } const refusal = actionExec.apiActionForwardRefusal(actionDef, answer); - return { - handled: true, - response: { ...deps.error(refusal.message, answer.status, { code: (refusal as any).code }), ...relayed }, - }; + // [#27738] An enveloped refusal is relayed WHOLE: its code and + // sentence, and every member beside them (`details`, `userMessage`, + // …) as the target wrote them — see `apiActionRefusalMembers`. Only + // a refusal the target did not envelope is rebuilt from its code. + const members = actionExec.apiActionRefusalMembers(answer); + const refused = members + ? apiErrorResponse({ message: refusal.message, httpStatus: answer.status, code: (refusal as any).code, extra: members }) + : deps.error(refusal.message, answer.status, { code: (refusal as any).code }); + return { handled: true, response: { ...refused, ...relayed } }; } const actionContext: any = { diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 8649502e2b1..5a2337a32bd 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -53,6 +53,25 @@ const DELETE_LEAD = { name: 'purge_lead_remote', label: 'Purge', objectName: OBJECT, type: 'api', method: 'DELETE', target: '/api/v1/data/crm_lead/${ctx.recordId}', }; +/** [#27738] The showcase's recalc shape: a flow endpoint that answers `400 FLOW_FAILED` when its run is refused. */ +const RECALC_LEAD = { + name: 'recalc_lead', label: 'Recalculate', objectName: OBJECT, type: 'api', + target: '/api/v1/apps/showcase/recalc', recordIdParam: 'recordId', + ai: { exposed: true, description: 'Recalculate the lead.' }, +}; +/** + * [#27738] `plugin-approvals`' `approval_approve` shape: declared params, and the + * per-request decision outputs the console sends as `outputs.` params. + */ +const APPROVE_LEAD = { + name: 'approve_lead', label: 'Approve', objectName: OBJECT, type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', + params: [ + { name: 'comment', label: 'Comment', type: 'textarea', required: false }, + { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false }, + ], + ai: { exposed: true, description: 'Approve the lead.' }, +}; type Injected = { url: string; init: RequestInit }; @@ -77,7 +96,7 @@ function boot(opts: { }); const server = { getRawApp: () => ({ request }) }; const executeAction = vi.fn(async () => ({ ran: 'script' })); - const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD] }; + const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD, RECALC_LEAD, APPROVE_LEAD] }; const ql: any = { executeAction, getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), @@ -415,7 +434,188 @@ describe('door 2 — the MCP `run_action` bridge forwards a same-origin api acti }); }); +// [#27738] G1: the target's refusal envelope is relayed WHOLE. The door used +// to rebuild it from `code` alone, so everything a console branch reads beside +// the code was dropped on the way: a flow endpoint's `details.innerRefusal` and +// `userMessage` (the refusal its data node met), and the entitlement members a +// coded 403 carries in `details`. Each path is pinned with the target's member +// deep-equal on the door's answer, never a single key. +/** A flow endpoint's run refused by its data node: `400 FLOW_FAILED`, as the endpoint door answers it. */ +const FLOW_FAILED_ENVELOPE = { + success: false, + error: { + code: 'FLOW_FAILED', + message: "Node 'update_task' failed: update_record(showcase_task) failed: Permission denied", + httpStatus: 400, + userMessage: 'You cannot edit this task.', + details: { + errorMessage: "Node 'update_task' failed: update_record(showcase_task) failed: Permission denied", + summary: { selected: 0, acted: 0, skipped: 0, nodes: [], gates: [] }, + innerRefusal: { code: 'PERMISSION_DENIED', message: 'Permission denied' }, + }, + }, +}; +/** A coded entitlement refusal, the shape the console's entitlement dialog reads (`error.details`). */ +const ENTITLEMENT_ENVELOPE = { + success: false, + error: { + code: 'DEV_ENV_LIMIT', + message: 'Development environment limit reached (3 of 3).', + httpStatus: 403, + details: { + current: 3, limit: 3, plan: 'team', + upgrade_url: 'https://cloud.example.test/billing/upgrade', + contact_url: 'https://cloud.example.test/contact', + }, + }, +}; + +describe('[#27738] G1 — the door relays the target\'s refusal envelope whole', () => { + it('G1a: a flow endpoint\'s 400 FLOW_FAILED keeps `details.innerRefusal` and `userMessage`', async () => { + const { dispatcher } = boot({ answer: { status: 400, body: FLOW_FAILED_ENVELOPE } }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${RECALC_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(400); + const error = res?.response?.body?.error; + expect(res?.response?.body?.success).toBe(false); + expect(error?.code).toBe('FLOW_FAILED'); + expect(error?.message).toBe(FLOW_FAILED_ENVELOPE.error.message); + expect(error?.httpStatus).toBe(400); + expect(error?.details).toEqual(FLOW_FAILED_ENVELOPE.error.details); + expect(error?.userMessage).toBe(FLOW_FAILED_ENVELOPE.error.userMessage); + }); + + it('G1b: a coded 403 keeps its entitlement members — upgrade_url, contact_url, plan, current, limit', async () => { + const { dispatcher } = boot({ answer: { status: 403, body: ENTITLEMENT_ENVELOPE } }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(403); + const error = res?.response?.body?.error; + expect(error?.code).toBe('DEV_ENV_LIMIT'); + expect(error?.message).toBe(ENTITLEMENT_ENVELOPE.error.message); + expect(error?.details).toEqual(ENTITLEMENT_ENVELOPE.error.details); + }); + + it('a refusal the target did NOT envelope keeps today\'s answer: its status, code and sentence, and nothing invented', async () => { + // better-auth answers `{ code, message }` with no ObjectStack envelope; + // there is no `details` to relay, so none appears. + const { dispatcher } = boot({ answer: { status: 404, body: { code: 'USER_NOT_FOUND', message: 'User not found' } } }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(404); + expect(codeOf(res)).toBe('USER_NOT_FOUND'); + expect(messageOf(res)).toBe('User not found'); + expect(res?.response?.body?.error?.details).toBeUndefined(); + expect(res?.response?.body?.error?.userMessage).toBeUndefined(); + }); + + it('MCP `run_action`: the target\'s `details` ride the throw, so the tool error carries them as the REST answer does', async () => { + const h = boot({ answer: { status: 400, body: FLOW_FAILED_ENVELOPE } }); + const deps: any = { + resolveService: async (_ctx: unknown, service: string) => + service === 'metadata' ? h.metadata + : service === 'automation' ? null + : service === 'http.server' ? { getRawApp: () => ({ request: h.request }) } + : h.ql, + getObjectQL: async () => h.ql, + }; + + const thrown = await invokeBusinessAction(deps, CTX() as any, RECALC_LEAD.name, { recordId: 'u_target' }, { + driver: h.ql, + ec: { userId: 'u_agent', tenantId: 'org_1', systemPermissions: [] }, + getMeta: () => h.metadata, + callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }), + }).catch((e) => e); + + expect((thrown as any).status).toBe(400); + expect((thrown as any).code).toBe('FLOW_FAILED'); + expect((thrown as any).details).toEqual(FLOW_FAILED_ENVELOPE.error.details); + }); +}); + +// [#27738] G2: the `outputs.` decision-output family is admitted by the +// param contract for a forwarded `type: 'api'` action (ADR-0104 D2, amended +// 2026-10-30). The console sends one such param per output an approval node +// declares; the key set is per request, so no action can declare them, and the +// door already folds them into the body's `outputs`. Refused here, an Approve +// carrying a decision output never reached that fold. +describe('[#27738] G2 — an approve click with a decision output passes the param contract and is folded', () => { + const PARAMS = { comment: 'ok', 'outputs.next_reviewers': ['u_2', 'u_3'], 'outputs.note': '' }; + + it('REST: the `outputs.` params pass and reach the target folded into `outputs` (a blank one omitted)', async () => { + const { dispatcher, injected } = boot({ answer: { body: { success: true, data: { request: { id: 'u_target' } } } } }); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${APPROVE_LEAD.name}`, 'POST', { recordId: 'u_target', params: PARAMS }, CTX(), + ); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(injected).toHaveLength(1); + expect(injected[0].url).toBe('http://app.example.test/api/v1/approvals/requests/u_target/approve'); + expect(JSON.parse(String(injected[0].init.body))).toEqual({ comment: 'ok', outputs: { next_reviewers: ['u_2', 'u_3'] } }); + }); + + it('REST: an undeclared key outside the family is still refused with today\'s message, and nothing is forwarded', async () => { + const { dispatcher, request } = boot(); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${APPROVE_LEAD.name}`, 'POST', { recordId: 'u_target', params: { ...PARAMS, bogus: 1 } }, CTX(), + ); + + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_ERROR'); + expect(messageOf(res)).toContain('Unknown action param "bogus"'); + // The admitted family is not among the offenders. + expect(messageOf(res)).not.toContain('outputs.'); + expect(request).not.toHaveBeenCalled(); + }); + + it('MCP `run_action`: the same params pass and reach the target folded', async () => { + const h = boot({ answer: { body: { success: true, data: { request: { id: 'u_target' } } } } }); + const deps: any = { + resolveService: async (_ctx: unknown, service: string) => + service === 'metadata' ? h.metadata + : service === 'automation' ? null + : service === 'http.server' ? { getRawApp: () => ({ request: h.request }) } + : h.ql, + getObjectQL: async () => h.ql, + }; + + const result: any = await invokeBusinessAction(deps, CTX() as any, APPROVE_LEAD.name, { recordId: 'u_target', params: PARAMS }, { + driver: h.ql, + ec: { userId: 'u_agent', tenantId: 'org_1', systemPermissions: [] }, + getMeta: () => h.metadata, + callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }), + }); + + expect(result.ok).toBe(true); + expect(JSON.parse(String(h.injected[0].init.body))).toEqual({ comment: 'ok', outputs: { next_reviewers: ['u_2', 'u_3'] } }); + }); +}); + describe('buildApiActionRequest — the declaration\'s request, by the console\'s own rules', () => { + it('[#27738] folds the `outputs.` family into `outputs`, omits a blank one, and leaves every other key flat', () => { + // The fold the G2 admission relies on, pinned on its own: the console's + // `apiHandler` folds the same family by the same rule. + const action = { name: 'approve', type: 'api', target: '/api/v1/approvals/requests/r1/approve' }; + const req = buildApiActionRequest(action, { + params: { + comment: 'ok', 'outputs.next_reviewers': ['u_2'], 'outputs.reason': 'fits budget', + 'outputs.note': '', 'outputs.skipped': undefined, 'outputs.': 'no key', 'meta.source': 'console', + }, + ec: {}, + }); + expect((req as any).body).toEqual({ + comment: 'ok', outputs: { next_reviewers: ['u_2'], reason: 'fits budget' }, 'outputs.': 'no key', 'meta.source': 'console', + }); + // Under `bodyShape.wrap` the params are nested, and nothing is folded. + const wrapped = buildApiActionRequest({ ...action, bodyShape: { wrap: 'data' } }, { params: { 'outputs.reason': 'x' }, ec: {} }); + expect((wrapped as any).body).toEqual({ data: { 'outputs.reason': 'x' } }); + }); + const ec = { userId: 'u1', tenantId: 'org_9' }; it('wraps params under `bodyShape.wrap`, keeps `recordIdParam` flat, merges `bodyExtra` last', () => {