diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ddb33cf5a40..a809a3f76e9 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1519,6 +1519,22 @@ export async function forwardApiAction( * envelope (the console's `apiHandler` unwraps the same way, so a * `resultDialog` path reads identically on both paths), else the body itself * (a stock better-auth answer is not enveloped). + * + * [#27918] A 2xx is read as success whatever its body says: `success: false` + * on a 2xx is not a refusal here, so `{ success: false, data }` answers `data` + * and `{ success: false, message }` answers the body itself. The status is + * the target's verdict and this door relays it rather than sniffing a body. + * Measured 2026-10-30 against every same-origin `type: 'api'` target declared + * in this repository (the `sys_*` identity actions over better-auth and the + * plugin-auth mounts, the approvals routes, the datasource admin routes, the + * data door, the showcase's flow endpoint, print and form routes, and the + * objectui declarations): none answers a 2xx with a top-level + * `success: false`. Each refusal is non-2xx, and the nested + * `{ success: true, data: { success: false } }` that a flow endpoint's + * unclassified result or a zero-row delete can answer comes back through + * this door as the same bytes. A target that starts answering 2xx + * `success: false` has the defect itself (failures speak HTTP, #3962), and + * the fix goes in that target, never in a body check here (PD #12). */ export function apiActionResultOf(answer: ApiActionForwardAnswer): unknown { const body = answer.body; diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 8649502e2b1..35018b4bd05 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -31,6 +31,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { invokeBusinessAction, + apiActionResultOf, buildApiActionRequest, isHeadlessInvokableAction, headlessActionTypeError, @@ -279,6 +280,29 @@ describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin expect(messageOf(res)).toBe('Platform admin only'); }); + // [#27918] Today's reading, pinned: the STATUS is the target's verdict, so a + // 2xx is relayed as a result even when its body says `success: false`. No + // same-origin target in the repository answers that shape (the measured + // census is on `apiActionResultOf`'s docblock); one that starts to is fixed + // at the target, so a change here is a decision, not a cleanup. + it('relays a 2xx `{ success: false, … }` answer as a 200 result, never as a refusal', async () => { + const enveloped = boot({ answer: { status: 200, body: { success: false, data: { reason: 'already_banned' } } } }); + const viaData = await enveloped.dispatcher.handleActions( + `/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX(), + ); + expect(statusOf(viaData)).toBe(200); + expect(viaData?.response?.body?.success).toBe(true); + expect(viaData?.response?.body?.data).toEqual({ reason: 'already_banned' }); + + const bare = boot({ answer: { status: 200, body: { success: false, message: 'not applied' } } }); + const viaMessage = await bare.dispatcher.handleActions( + `/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX(), + ); + expect(statusOf(viaMessage)).toBe(200); + expect(viaMessage?.response?.body?.success).toBe(true); + expect(viaMessage?.response?.body?.data).toEqual({ success: false, message: 'not applied' }); + }); + it('an OFF-ORIGIN api action is refused 400 before any forward — the browser-only case', async () => { const { dispatcher, request } = boot(); @@ -323,6 +347,23 @@ describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin }); }); +describe('apiActionResultOf — a 2xx answer read as the action\'s result', () => { + const at200 = (body: unknown) => ({ status: 200, body, headers: {} }); + + it('unwraps `data` from a `{ success: false, data }` answer: the 2xx decides, not the flag', () => { + expect(apiActionResultOf(at200({ success: false, data: { reason: 'already_banned' } }))).toEqual({ reason: 'already_banned' }); + }); + + it('answers a `{ success: false, message }` answer (no `data`) as the body itself', () => { + expect(apiActionResultOf(at200({ success: false, message: 'not applied' }))).toEqual({ success: false, message: 'not applied' }); + }); + + it('still unwraps the ordinary success envelope and passes an unenveloped body through', () => { + expect(apiActionResultOf(at200({ success: true, data: { ok: true } }))).toEqual({ ok: true }); + expect(apiActionResultOf(at200({ status: true }))).toEqual({ status: true }); + }); +}); + describe('door 2 — the MCP `run_action` bridge forwards a same-origin api action', () => { const runViaMcp = async (h: ReturnType, name: string, input: any = {}) => { const deps: any = {