Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27738-api-action-door-refusal-and-decision-outputs.md
Original file line number Diff line number Diff line change
@@ -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.<key>` 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.<key>` 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.
46 changes: 46 additions & 0 deletions docs/adr/0104-field-runtime-value-shape-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`
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
Expand Down Expand Up @@ -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.<key>` 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.<key>` (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.<key>` 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
Expand Down
108 changes: 100 additions & 8 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,43 @@ function carriesPageToken(value: unknown): boolean {
return false;
}

/**
* [#3447] The decision-output param family: `outputs.<key>`, 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.<key>` 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.<key>` 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
Expand Down Expand Up @@ -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<string, unknown> = wrap ? { [wrap]: { ...input.params } } : { ...input.params };
// [#3447] decision outputs: `outputs.<key>` 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<string, unknown>)[k.slice('outputs.'.length)] = value;
((body.outputs ??= {}) as Record<string, unknown>)[k.slice(API_DECISION_OUTPUT_PREFIX.length)] = value;
}
}
// [#8018] `recordIdParam`, seeded from the row or refused — never silently
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> = {};
for (const [key, value] of Object.entries(error as Record<string, unknown>)) {
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;
Expand All @@ -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 } : {}) });
}

/**
Expand Down Expand Up @@ -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.<key>` 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<string, unknown>): Record<string, unknown> {
if (!foldsApiDecisionOutputs(action)) return bag;
const declared = new Set(resolved.map((p) => p.name));
const judged: Record<string, unknown> = {};
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
Expand Down Expand Up @@ -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.<key>` 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,
Expand All @@ -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()) {
Expand Down
53 changes: 53 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,59 @@ describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
});
});

describe('[#27738] the `outputs.<key>` 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');
Expand Down
Loading
Loading