diff --git a/.changeset/27506-api-action-door-forward.md b/.changeset/27506-api-action-door-forward.md new file mode 100644 index 00000000000..c3a7dd44545 --- /dev/null +++ b/.changeset/27506-api-action-door-forward.md @@ -0,0 +1,24 @@ +--- +'@objectstack/runtime': patch +'@objectstack/plugin-hono-server': patch +'@objectstack/hono': patch +--- + +fix(runtime)!: a same-origin `type: 'api'` action is dispatched through the action doors, so its packaged-action switch holds (#27506) + +Clause-②: no (narrowing) — no key, accepted value or export of `@objectstack/spec` changes. Two published runtime surfaces narrow for their consumers: `POST /actions/_activation/:object/:action` now refuses (409) to flip an off-origin `type: 'api'` action it accepted and recorded before, and the `headers` member of `@objectstack/runtime`'s `HttpDispatcherResult.response` is widened from `string` values to `string | string[]`, which a reader that assigned a value to a `string` no longer compiles against. An api action that reaches `POST /actions/:object/:action` or MCP `run_action` now passes the doors' gates (the sign-in floor, `requiredPermissions`, the activation switch, the param contract, the subject-row load) before its target runs, where the console's direct fetch passed none of them. +Metadata-protocol: none + +Skills-docs: `content/docs/ui/actions.mdx` (the `type` table and the "Call it over REST" dispatch table) now state the same-origin forward and the off-origin refusal; no file under `skills/**` teaches that an api action is refused by the doors or invisible to MCP (`skills/objectstack-ui/rules/actions.md` shows an api action only as a declaration), so no skill line changes. + +Switching off a `type: 'api'` packaged action in the ADR-0126 §8 ledger did not stop it: the console fetched the action's `target` directly, the `/actions` door answered 400 for the type, and the MCP `run_action` bridge refused it outright — so "Ban User switched off" still banned, and an agent could not do what a human click could. Ruled on #27506 (option C, in three steps; this is the server step, ADR text in #27610, the console in #27611): + +- **Same-origin api actions are server-dispatched.** `POST /api/v1/actions/:object/:action` and MCP `run_action` accept a `type: 'api'` action whose `target` is a path on this origin. The door runs every gate it already runs, then **forwards the request in-process** to the declared target (`IHttpServer.getRawApp()`'s request injector) with the caller's own credentials (`Authorization`, `Cookie`, the admin-session recovery header, tenant and proxy headers, the proxy headers carried as the caller sent them for the target's own `server.trustProxy` rule to judge) and the body the declaration assembles by the console's own rules — `recordIdParam` / `recordIdField` from the subject row (refused by name when the row cannot supply it), `bodyShape.wrap`, the `outputs.*` fold, `organizationId` for an `/api/v1/auth/` target, `bodyExtra` merged last, `method` (no body on `DELETE`), `${param.*}` / `${ctx.recordId}` / `${ctx.user.id}` / `${ctx.org.id}` / `{field}` in the path. The forwarded URL is `http://`, the URL the direct request has at this process; a caller-sent `X-Forwarded-Host` / `X-Forwarded-Proto` never chooses it. The target still authorizes the caller itself (ADR-0066 D4); its status and JSON body are the door's answer (its `data` unwrapped, as the console's direct fetch handed `result.data`), and a credential rotation it emits is relayed: over REST every `Set-Cookie` line plus `set-auth-token` / `set-admin-session-token`, so `impersonate_user` works through the door on both the cookie and the bearer lane; over MCP **only the bearer pair** — no `Set-Cookie` line enters a tool result, because the MCP door admits cookie sessions and a tool result is JSON text whatever receives it can read. A disabled api action answers **409 `ACTION_DISABLED`** and the target is never called; a forwarded request that lands back on an action door is refused (400), as is a `${ctx.selection.*}` target token or a `{{page.}}` body token the server cannot resolve (400, naming it; any other `{{…}}` is the author's literal text); a target that answers 2xx with a non-JSON body is refused **502** naming its content-type (the console's direct fetch read such an answer as `{}`); a kernel with no in-process request path answers 503 and does not send the caller around the door. +- **Off-origin api actions stay browser-only, and cannot be switched.** A `target` on another host is still refused by both doors (400, naming the origin), MCP does not list it, and `POST /actions/_activation/:object/:action` refuses to flip it (**409**) with the reason — a ledger row nothing would consult is not recorded. +- **MCP lists same-origin api actions** on the same terms as every other action (`ai.exposed`, the caller's permissions, no `sys_*` object). +- **`HttpDispatcherResult.response.headers`** accepts a `string[]` value, emitted as one header line per element; the Hono server plugin's route `res.header` and the published `@objectstack/hono` adapter's `toResponse` (its `response` arm and both `stream` arms) append an array instead of comma-joining it (the only correct spelling for `Set-Cookie`). + +**BREAKING** at two published runtime surfaces (`@objectstack/runtime`). + +- **The activation write door, `POST /api/v1/actions/_activation/:object/:action`.** *Before:* flipping a `type: 'api'` action whose `target` is on another origin was accepted and recorded as a ledger row, which nothing ever consulted — the switch read as set and did nothing. *Now:* that request is refused `409` (`error.code` `RESOURCE_CONFLICT`) with a message naming the origin and saying why (the browser calls that origin directly and this server never sees the request), and no row is written. Same-origin api actions, and every other action type, flip exactly as before. *Who sees it:* a Setup caller or script that switched an off-origin api action off and read the 2xx as "stopped". *The remedy:* none at the door — move the action's `target` onto this origin (a path starting with `/`) to make it switchable, or remove the action from the app; a script that treated the 2xx as success now reads the 409 reason. +- **The type `HttpDispatcherResult.response.headers`** (`packages/runtime/src/http-dispatcher.ts`). *Before:* `Record`. *Now:* `Record`; an array is one header line per element, and the `/actions` door emits one for `Set-Cookie`. *Who sees it:* an adapter or host outside this repository that reads the member and hands each value to a `string`-typed header setter (`error TS2345`). *The remedy, one line:* branch on `Array.isArray(value)` and emit each element as its own header line (Hono: `c.header(name, line, { append: true })`; Fetch `Headers`: `headers.append(name, line)`); joining the array with `, ` is NOT equivalent for `Set-Cookie`. The two in-repo adapters (`@objectstack/plugin-hono-server`, `@objectstack/hono`) ship that change in this release. diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 2841110e5cc..1a03cd2ecfd 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -108,7 +108,7 @@ back fail-closed. |:---|:---|:---| | `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS | | `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered | -| `api` | HTTP call to `action.target` via a configured `apiClient` | **ObjectOS** runtime only | +| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed | Console-only types (`url`, `modal`, `form`) are always skipped. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 78ff6091e62..36bc98e3367 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -242,11 +242,15 @@ can reach is the **default exposure policy (v1)**: are blocked fail-closed. - **Actions require the author's opt-in**: `ai: { exposed: true }` plus an `ai.description` of at least 40 characters, and the action must be callable - without a UI. In the open framework that means exactly two types — `script` - (with an inline body or a registered handler) and `flow` (with the automation - service registered). `url`, `modal`, `form`, and `api` have no headless - dispatch here. See [Actions as Tools](/docs/ai/actions-as-tools) and - [Actions](/docs/ui/actions). + without a UI. In the open framework that means three types — `script` + (with an inline body or a registered handler), `flow` (with the automation + service registered), and `api` whose `target` is a **same-origin path** + (starting with `/`): `run_action` dispatches it in-process through the + action door to that target, after the door's gates. `url`, `modal`, and + `form` have no headless dispatch here, and neither does an `api` action + whose `target` is on another origin — the browser calls that origin + directly, so it is not exposed. See [Actions as Tools](/docs/ai/actions-as-tools) + and [Actions](/docs/ui/actions). This is the default policy, not a ceiling. A finer, **metadata-declared** exposure @@ -392,7 +396,7 @@ skill and a guided `/objectstack:connect` command. | stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback | | `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling | | `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope | -| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (only `script` with a body/handler and `flow` with an automation service are — `url`, `modal`, `form`, and `api` never appear), it targets a `sys_*` object, or the caller fails its `requiredPermissions` | +| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets a `sys_*` object (held back fail-closed for every type, api included — #27681 tracks that gap), or the caller fails its `requiredPermissions` | | Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console | ## Related diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index b1d6fe662f0..fd352674c8b 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -19,7 +19,7 @@ The types you'll actually use: | `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow | | `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) | | `modal` | Open a modal page — client-side only, no server dispatch | `target` names the modal page (to collect input *and* run logic, use `script` + `params`) | -| `api` | Call an HTTP endpoint directly | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request | +| `api` | Call an HTTP endpoint | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request. A same-origin `target` is dispatched through the action doors (REST and MCP), which forward it in-process after their gates; an off-origin one is called by the browser directly | | `form` | Open a form view, prefilled with the current record — in-shell, and the submit lands on the created record ([contract](/docs/protocol/objectui/actions#what-type-form-promises)) | `target` names the FormView; routed to `/forms/:target?recordId=…` | @@ -575,8 +575,8 @@ Failures speak HTTP — the status code is the signal (#3962): - **400** — the action ran and **rejected** (a business rule said no, or validation failed — then with `error.details.fields[]` to anchor the input). - **404 / 403 / 503** — it never dispatched: no such action, denied, service - unavailable. A `url`/`modal`/`form`/`api` type with no server dispatch is - also a 400. + unavailable. A `url`/`modal`/`form` type, or an `api` type whose `target` + is on another origin, has no server dispatch and is also a 400. - **500** — it **crashed**: a `TypeError` in your handler, a driver error, a sandbox timeout. A deliberate `throw new Error('…')` is a 400 rejection, not a crash. @@ -596,7 +596,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP |:---|:---| | `script` | Runs the registered handler / inline body. | | `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). A target declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) is refused **403** `PERMISSION_DENIED` to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's `requiredPermissions` does not open it. A target that declares its own `requiredPermissions` is refused **403** `PERMISSION_DENIED` to a caller who does not hold every capability it names, even when the action declares none. See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. | -| `api` | **400** — it dispatches on `target`; call that endpoint directly. | +| `api` | **Same-origin `target`** (a path starting with `/`): the door runs its gates — the sign-in floor, `requiredPermissions`, the packaged-action switch, the param contract, the subject-row load — and then **forwards the request in-process** to `target` with your own credentials, assembling the body exactly as the Console does (`recordIdParam` / `recordIdField` from the subject row, `bodyShape`, `bodyExtra` merged last, `organizationId` for an `/api/v1/auth/` target, `method`). The target endpoint still authorizes you itself; its status and body come back as the answer (its `data` unwrapped), and a credential rotation it emits (`Set-Cookie`, `set-auth-token`) is relayed. A `${ctx.selection.*}` or `{{page.}}` token exists only in the browser and is refused **400** by name. **Off-origin `target`** (another host): **400** — the browser calls it directly, and such an action cannot be switched off in the activation ledger (the write door refuses, **409**, with the reason). | | `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. | ## Headless actions: declare it, then hide it diff --git a/packages/adapters/hono/src/hono-response-header-array.test.ts b/packages/adapters/hono/src/hono-response-header-array.test.ts new file mode 100644 index 00000000000..3573c6deb24 --- /dev/null +++ b/packages/adapters/hono/src/hono-response-header-array.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27506] `toResponse` emits a `string[]` header value as one header line + * per element, on the `response` arm and on the `stream` arms. + * + * `HttpDispatcherResult.response.headers` accepts `string | string[]` since + * #27506: the `/actions` door relays a forwarded target's credential rotation, + * and `/admin/impersonate-user` answers several `Set-Cookie` lines. This + * adapter used to cast every value `as string` — an array reaches `c.header` + * as its `toString()`, one comma-joined line, which a browser parses as ONE + * cookie whose value contains the others. These cases pin the repeated line. + * + * Same harness as `hono-result-response-passthrough.test.ts`: the dispatcher + * is a fixture, so the arms can be driven with exactly the header shapes the + * real door produces. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Hono } from 'hono'; + +const mockDispatcher = { + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), + handleAuth: vi.fn(), + dispatch: vi.fn(), +}; + +vi.mock('@objectstack/runtime', () => ({ + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, +})); + +import { createHonoApp } from './index'; + +const PREFIX = '/api/v1'; +const PATH = `${PREFIX}/actions/sys_user/impersonate_user`; + +const kernel = { name: 'test-kernel' } as any; +const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX }); + +const COOKIES = [ + 'better-auth.session_token=new; Path=/; HttpOnly', + 'better-auth.admin_session=adm; Path=/; HttpOnly', +]; + +const setCookieLines = (res: Response): string[] => + typeof (res.headers as any).getSetCookie === 'function' ? (res.headers as any).getSetCookie() : []; + +describe('#27506: a string[] header value is one header line per element', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDispatcher.handleAuth.mockResolvedValue({ handled: false }); + }); + + it('`response.headers` — every Set-Cookie line reaches the caller separately, a string value as before', async () => { + mockDispatcher.dispatch.mockResolvedValue({ + handled: true, + response: { + status: 200, + body: { success: true, data: { ok: true } }, + headers: { 'set-cookie': COOKIES, 'set-auth-token': 'tok_impersonated' }, + }, + }); + + const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' }); + + expect(res.status).toBe(200); + expect(setCookieLines(res)).toEqual(COOKIES); + expect(res.headers.get('set-auth-token')).toBe('tok_impersonated'); + await expect(res.json()).resolves.toEqual({ success: true, data: { ok: true } }); + }); + + it('`result.type: stream` with a raw stream — array headers append, strings set', async () => { + const stream = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('data: x\n\n')); controller.close(); }, + }); + mockDispatcher.dispatch.mockResolvedValue({ + handled: true, + result: { type: 'stream', stream, headers: { 'set-cookie': COOKIES, 'x-one': 'line' } }, + }); + + const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' }); + + expect(res.status).toBe(200); + expect(setCookieLines(res)).toEqual(COOKIES); + expect(res.headers.get('x-one')).toBe('line'); + }); + + it('`result.type: stream` with event frames — array headers append on the built Headers', async () => { + mockDispatcher.dispatch.mockResolvedValue({ + handled: true, + result: { + type: 'stream', + events: (async function* () { yield { type: 'text', value: 'hi' }; })(), + headers: { 'set-cookie': COOKIES, 'x-one': 'line' }, + }, + }); + + const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' }); + + expect(res.status).toBe(200); + expect(setCookieLines(res)).toEqual(COOKIES); + expect(res.headers.get('x-one')).toBe('line'); + }); +}); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index bc08bee77ff..7a6ea09e958 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -757,11 +757,23 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { ); }; + /** + * One dispatcher header onto the Hono context. A `string[]` value is one + * header line per element (`HttpDispatcherResult.response.headers` declares + * it, #27506): `Set-Cookie` is the header that needs it — a comma-joined + * string is one unparseable cookie to a browser — and `c.header` with + * `append` is Hono's spelling of a repeated line. + */ + const setResponseHeader = (c: any, name: string, value: unknown) => { + if (Array.isArray(value)) for (const line of value) c.header(name, String(line), { append: true }); + else c.header(name, value as string); + }; + const toResponse = (c: any, result: HttpDispatcherResult) => { if (result.handled) { if (result.response) { if (result.response.headers) { - Object.entries(result.response.headers).forEach(([k, v]) => c.header(k, v as string)); + Object.entries(result.response.headers).forEach(([k, v]) => setResponseHeader(c, k, v)); } return c.json(result.response.body, result.response.status); } @@ -834,7 +846,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); - for (const [k, v] of Object.entries(res.headers || {})) headers.set(k, String(v)); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) for (const line of v) headers.append(k, String(line)); + else headers.set(k, String(v)); + } const stream = new ReadableStream({ async start(controller) { try { @@ -858,7 +873,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } if (res.type === 'stream' && res.stream) { if (res.headers) { - Object.entries(res.headers).forEach(([k, v]) => c.header(k, v as string)); + Object.entries(res.headers).forEach(([k, v]) => setResponseHeader(c, k, v)); } return new Response(res.stream, { status: 200 }); } diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 07747f9d2ce..576e123bef3 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -813,9 +813,19 @@ export class HonoHttpServer implements IHttpServer { } }, status: (code: number) => { c.status(code); return res; }, - header: (name: string, value: string) => { - c.header(name, value); - streamHeaders[name] = value; + header: (name: string, value: string | string[]) => { + // [#27506] An array is one header line per element — the + // contract's own signature (`IHttpResponse.header`), and the + // only correct spelling for `Set-Cookie`: the Fetch `Headers` + // comma-join a repeated `set` would produce is parsed by a + // browser as ONE cookie whose attributes carry the rest. + if (Array.isArray(value)) { + for (const v of value) c.header(name, v, { append: true }); + streamHeaders[name] = value.join(', '); + } else { + c.header(name, value); + streamHeaders[name] = value; + } return res; }, write: (chunk: string | Uint8Array) => { diff --git a/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts b/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts new file mode 100644 index 00000000000..f055157920a --- /dev/null +++ b/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27506] A `type: 'api'` packaged action switched off in the ADR-0126 §8 +// ledger must STOP — pinned at the layer the card names: the real HTTP stack +// of a booted showcase with better-auth's admin plugin on, the real +// `/admin/*` targets, and the live MCP transport. +// +// ## The defect, and the ruling this file encodes +// +// Before #27506 the console fetched an api action's `target` directly and the +// two action doors refused the type outright, so the activation consult never +// saw a "Ban User" click: the panel showed the action off, the click banned. +// Ruling 6499781492 (option C): a SAME-ORIGIN api action is dispatched through +// `POST /actions/:object/:action` and the MCP `run_action` bridge, which run +// their gates and then forward the request in-process to the declared target +// with the caller's own credentials. An OFF-ORIGIN api action stays +// browser-only and cannot be switched at all. +// +// ## What each case pins +// +// - the switch: `ban_user` off → 409 ACTION_DISABLED through the door and +// `sys_user.banned` stays false; on → 200 and the user IS banned; +// `unban_user` through the same door un-bans; +// - the body the declaration assembles reaches the target: +// `set_user_password` (params + `recordIdParam` + `bodyExtra`) really +// changes the password — the target user signs in with the new one; +// - the credential lanes of `impersonate_user`, the hardest target (it +// mints a session INSIDE better-auth's endpoint context): a cookie caller +// gets every `Set-Cookie` line back, one per line; a bearer caller gets +// `set-auth-token` + `set-admin-session-token` and its old token dies; +// - the target still authorizes: a plain member is refused by ban-user's +// own 403 through the door; an anonymous caller meets the 401 floor; +// - the off-origin case: the activation write door refuses to flip it, and +// the action door refuses to run it; +// - MCP: the type gate is open — `run_action` on a same-origin api action +// now reaches the exposure gate instead of "cannot be invoked via MCP". +// +// `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without +// better-auth's admin plugin, and that env knob is the one that reaches it +// (see `admin-platform-admin-standing.dogfood.test.ts`). The file is +// ISOLATED (an env toggle + a derived configuration), per the vitest config. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); +const activation = (object: string, action: string) => `/actions/_activation/${object}/${action}`; + +const TARGET_EMAIL = 'door.forward.target@example.com'; +const TARGET_PASS = 'Target-Pass-123'; +const MEMBER_EMAIL = 'door.forward.member@example.com'; + +/** + * The one off-origin api action this file needs and no shipped app declares: + * a probe object added to the showcase for this boot only. Its `target` names + * a host the platform never sees, which is exactly the case the ruling keeps + * browser-only. + */ +const RemoteProbe = ObjectSchema.create({ + name: 'probe_remote_thing', + label: 'Remote Probe', + sharingModel: 'public_read_write', + fields: { name: Field.text({ label: 'Name' }) }, + actions: [ + { + name: 'ping_remote', + label: 'Ping Remote', + type: 'api', + target: 'https://example.invalid/ping', + locations: ['record_header'], + }, + ], +} as any); + +type Answer = { status: number; text: string; json: any; headers: Headers }; + +describe('[#27506] a same-origin api action is dispatched through the action doors, so its switch holds', () => { + let stack: VerifyStack; + let ql: any; + let admin: string; + let targetUserId: string; + let priorScim: string | undefined; + + const call = async ( + auth: { token?: string; cookie?: string } | null, + method: string, + path: string, + body?: unknown, + ): Promise => { + const headers: Record = { 'Content-Type': 'application/json' }; + if (auth?.token) headers.Authorization = `Bearer ${auth.token}`; + if (auth?.cookie) headers.Cookie = auth.cookie; + const res = await stack.api(path, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }); + const text = await res.text(); + let json: any; + try { json = JSON.parse(text); } catch { json = undefined; } + return { status: res.status, text, json, headers: res.headers }; + }; + const flip = async (object: string, action: string, enabled: boolean) => { + const res = await call({ token: admin }, 'POST', activation(object, action), { enabled }); + expect(res.status, res.text).toBe(200); + }; + const targetRow = async () => + rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS }))[0]; + const isBanned = async () => { + const row = await targetRow(); + return row?.banned === true || row?.banned === 1; + }; + /** `tools/call run_action` over the live MCP transport; the tool's text content, parsed. */ + const runActionTool = async (actionName: string, args: Record = {}) => { + const res = await stack.api('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${admin}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'run_action', arguments: { actionName, ...args } } }), + }); + expect(res.status).toBe(200); + const rpc: any = await res.json(); + expect(rpc.error, JSON.stringify(rpc.error)).toBeUndefined(); + // A refusal with an envelope is JSON; a bare thrown sentence is text. + const text: string = String(rpc.result?.content?.[0]?.text ?? ''); + let payload: any; + try { payload = JSON.parse(text); } catch { payload = { message: text }; } + return { isError: rpc.result?.isError === true, payload }; + }; + /** A cookie-authenticated admin: the sign-in route's own `Set-Cookie`, replayed as `Cookie`. */ + const adminCookie = async (): Promise => { + const res = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + expect(res.status).toBe(200); + const cookies: string[] = (res.headers as any).getSetCookie(); + expect(cookies.length, 'sign-in set no cookie').toBeGreaterThan(0); + return cookies.map((c) => c.split(';')[0]).join('; '); + }; + + beforeAll(async () => { + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + const declared = showcaseStack as unknown as { objects?: unknown[] }; + stack = await bootShowcase( + { extraPlugins: [new MCPServerPlugin()] }, + { ...(showcaseStack as unknown as Record), objects: [...(declared.objects ?? []), RemoteProbe] }, + ); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + await stack.signUp(TARGET_EMAIL, TARGET_PASS); + const [target] = rowsOf(await ql.find('sys_user', { where: { email: TARGET_EMAIL }, limit: 1 }, { context: SYS })); + targetUserId = String(target.id); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + it('the switch holds: ban_user OFF answers 409 ACTION_DISABLED through the door and the user is NOT banned', async () => { + expect(await isBanned(), 'setup: target already banned').toBe(false); + await flip('sys_user', 'ban_user', false); + + const refused = await call({ token: admin }, 'POST', '/actions/sys_user/ban_user', { + recordId: targetUserId, params: { banReason: 'dogfood' }, + }); + + expect(refused.status, refused.text).toBe(409); + expect(refused.json?.error?.code).toBe('ACTION_DISABLED'); + expect(String(refused.json?.error?.message)).toContain('sys_metadata_activation'); + expect(await isBanned(), 'a switched-off Ban User still banned — the #27506 defect').toBe(false); + }, 120_000); + + it('switched back ON, the same request is forwarded to ban-user and the user IS banned; unban_user un-bans', async () => { + await flip('sys_user', 'ban_user', true); + + const banned = await call({ token: admin }, 'POST', '/actions/sys_user/ban_user', { + recordId: targetUserId, params: { banReason: 'dogfood' }, + }); + expect(banned.status, banned.text).toBe(200); + // The target's `data`, unwrapped — what the console's direct fetch handed `result.data`. + expect(banned.json?.data).toMatchObject({ userId: targetUserId, banned: true }); + expect(await isBanned()).toBe(true); + + const unbanned = await call({ token: admin }, 'POST', '/actions/sys_user/unban_user', { recordId: targetUserId }); + expect(unbanned.status, unbanned.text).toBe(200); + expect(await isBanned()).toBe(false); + }, 120_000); + + it('the declaration\'s body reaches the target: set_user_password through the door changes the password', async () => { + const res = await call({ token: admin }, 'POST', '/actions/sys_user/set_user_password', { + recordId: targetUserId, params: { newPassword: 'Target-New-Pass-456', mustChangePassword: false }, + }); + expect(res.status, res.text).toBe(200); + + // The proof is the sign-in, not the 200. + const token = await stack.signIn(TARGET_EMAIL, 'Target-New-Pass-456'); + expect(token).toBeTruthy(); + await expect(stack.signIn(TARGET_EMAIL, TARGET_PASS)).rejects.toThrow(/signIn failed/); + }, 120_000); + + it('the target still authorizes: a plain member is refused by ban-user\'s own 403; anonymous meets the 401 floor', async () => { + const member = await stack.signUp(MEMBER_EMAIL, 'Member-Pass-123'); + const refused = await call({ token: member }, 'POST', '/actions/sys_user/ban_user', { recordId: targetUserId }); + expect(refused.status, refused.text).toBe(403); + expect(await isBanned()).toBe(false); + + const anonymous = await call(null, 'POST', '/actions/sys_user/ban_user', { recordId: targetUserId }); + expect(anonymous.status, anonymous.text).toBe(401); + }, 120_000); + + it('off-origin: the write door refuses to switch `ping_remote`, and the action door refuses to run it', async () => { + const flipAttempt = await call({ token: admin }, 'POST', activation('probe_remote_thing', 'ping_remote'), { enabled: false }); + expect(flipAttempt.status, flipAttempt.text).toBe(409); + expect(String(flipAttempt.json?.error?.message)).toContain('never sees the request'); + const rows: any[] = await ql.find('sys_metadata_activation', { where: { metadata_type: 'action' }, context: SYS }); + expect(rows.filter((r) => r.name === 'ping_remote'), 'a refused flip wrote a ledger row').toHaveLength(0); + + const run = await call({ token: admin }, 'POST', '/actions/probe_remote_thing/ping_remote', {}); + expect(run.status, run.text).toBe(400); + expect(String(run.json?.error?.message)).toContain('off-origin'); + }, 120_000); + + it('MCP: the type gate is open — run_action on a same-origin api action reaches the exposure gate, not a type refusal', async () => { + // `showcase_recalc_estimate` is a same-origin api action on a non-system + // object that does NOT opt into AI. Before #27506 the bridge answered + // "cannot be invoked via MCP (type='api')" ahead of every other gate. + const refused = await runActionTool('showcase_recalc_estimate'); + expect(refused.isError).toBe(true); + const message = String(refused.payload?.error?.message ?? refused.payload?.message ?? JSON.stringify(refused.payload)); + expect(message).not.toContain('cannot be invoked via MCP'); + expect(message).toContain('not exposed to AI'); + }, 120_000); + + it('impersonate_user, cookie lane: every Set-Cookie line the target mints comes back through the door', async () => { + const cookie = await adminCookie(); + + const res = await call({ cookie }, 'POST', '/actions/sys_user/impersonate_user', { recordId: targetUserId }); + + expect(res.status, res.text).toBe(200); + expect(res.json?.data?.session?.userId).toBe(targetUserId); + const cookies: string[] = (res.headers as any).getSetCookie(); + // Measured 2026-10-29 against the target directly: six lines. One + // comma-joined `Set-Cookie` would be one unparseable cookie — the shape + // the array relay exists to prevent. + expect(cookies.length, cookies.join('\n')).toBeGreaterThanOrEqual(2); + expect(cookies.some((c) => c.startsWith('better-auth.admin_session='))).toBe(true); + expect(cookies.some((c) => c.startsWith('better-auth.session_token='))).toBe(true); + expect(cookies.every((c) => !c.includes(', better-auth.'))).toBe(true); + }, 120_000); + + it('impersonate_user, bearer lane: the rotation headers come back and the caller\'s old token dies (#8243)', async () => { + // Ordered LAST on purpose: this rotates `admin`. + const res = await call({ token: admin }, 'POST', '/actions/sys_user/impersonate_user', { recordId: targetUserId }); + + expect(res.status, res.text).toBe(200); + expect(res.json?.data?.session?.userId).toBe(targetUserId); + expect(res.headers.get('set-auth-token'), 'no set-auth-token relayed').toBeTruthy(); + expect(res.headers.get('set-admin-session-token'), 'no set-admin-session-token relayed').toBeTruthy(); + + const stale = await stack.apiAs(admin, 'GET', '/auth/get-session'); + expect(stale.status).toBe(401); + admin = await stack.signIn(); + }, 120_000); +}); diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts index 89f2666b311..4ae827b0974 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -23,19 +23,20 @@ * `summarizeAction` ever runs: * * 1. fail-closed on `sys_*` objects (`isSystemObjectName`) - * 2. `isHeadlessInvokableAction` — only `type: 'script'` (with `target` or - * `body`) or `type: 'flow'` (with `target` + an automation service) has - * a headless dispatch path at all + * 2. `isHeadlessInvokableAction` — `type: 'script'` (with `target` or + * `body`), `type: 'flow'` (with `target` + an automation service), or — + * since #27506 — a `type: 'api'` action with a SAME-ORIGIN `target`, + * which the doors forward in-process * 3. `actionAiExposureError` — excluded unless the author set * `ai.exposed: true` * * All 14 of #7309's identity actions lived on `sys_*` objects (13 still do), are - * `type: 'api'` (not `script`/`flow`, so gate 2 excludes them independently - * of gate 1), and none declares `ai.exposed`. So today's real declarations - * are excluded on THREE independent grounds before `actionLooksDestructive` - * ever runs on them (the #7828 triage comment names two; gate 2 is a third, - * confirmed below against the real objects) — this fix has zero observable - * effect on any request a caller can make today. + * `type: 'api'` with same-origin targets, and none declares `ai.exposed`. So + * today's real declarations are excluded on TWO independent grounds before + * `actionLooksDestructive` ever runs on them (the #7828 triage comment's two; + * gate 2 was a third until #27506 admitted same-origin api actions, which is + * pinned below so the count of grounds is a measurement, not a memory) — this + * fix has zero observable effect on any request a caller can make today. * * That is a fact about *today's platform objects*, not about the heuristic — * a future `ai.exposed`, non-`sys_*`, `script`/`flow` action carrying only @@ -277,8 +278,10 @@ describe('the classification IS live-reachable for a shape gate 1/2/3 all pass // --------------------------------------------------------------------------- // Documents (with real assertions, not narration) why today's 13 identity // actions never reach the classifier via the live MCP path — confirming the -// #7828 triage comment's "double-gated dead" plus the third gate found while -// verifying this card (isHeadlessInvokableAction: all 13 are `type: 'api'`). +// #7828 triage comment's "double-gated dead". The third gate this file once +// recorded (isHeadlessInvokableAction: all 13 are `type: 'api'`) no longer +// holds: #27506 made a same-origin api action server-dispatched, so gate 2 +// ADMITS all 13 and gates 1 and 3 carry the exclusion alone. // #7309 converted 14; `sys_organization.change_slug` left the platform object. // --------------------------------------------------------------------------- @@ -312,10 +315,11 @@ describe('today, all 13 #7309 identity actions the platform still declares are e expect(isSystemObjectName(objName)).toBe(true); }); - it.each(allThirteen)('%s is excluded by isHeadlessInvokableAction (gate 2: type is not script/flow)', (_label, _objName, obj, name) => { + it.each(allThirteen)('%s is ADMITTED by isHeadlessInvokableAction since #27506 (gate 2: a same-origin api action is server-dispatched)', (_label, _objName, obj, name) => { const action = actionByName(obj, name); expect(action.type).toBe('api'); - expect(isHeadlessInvokableAction(deps, action, true)).toBe(false); + expect(String(action.target).startsWith('/')).toBe(true); + expect(isHeadlessInvokableAction(deps, action, true)).toBe(true); }); it.each(allThirteen)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index df33d88ceb4..658fa6c93e7 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -20,7 +20,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr // declaration its type names, so the refusal and the type cannot drift apart. import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; +import type { IHttpServer, IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it // is imported, never hand-spelled: the door that refuses and the client that @@ -1010,6 +1010,10 @@ export function isHeadlessInvokableAction(_deps: ActionExecutionDeps, action: an const type: string = action?.type ?? 'script'; if (type === 'script') return Boolean(action?.target || action?.body); if (type === 'flow') return Boolean(action?.target) && hasAutomation; + // [#27506] A same-origin `api` action IS server-dispatched: the door + // forwards it in-process to its declared `target` (see + // {@link forwardApiAction}). An off-origin one stays browser-only. + if (type === 'api') return isSameOriginApiTarget(action?.target); return false; } @@ -1027,6 +1031,502 @@ export function isHeadlessInvokableAction(_deps: ActionExecutionDeps, action: an */ const SERVER_DISPATCHED_ACTION_TYPES: ReadonlySet = new Set(['script', 'flow']); +// ── `type: 'api'` — the same-origin forward (#27506) ───────────────────────── +// +// Ruling 6499781492 on #27506 (Sales, 2026-10-29, option C in three steps): +// a `type: 'api'` action whose `target` is a SAME-ORIGIN path is dispatched +// through the two action doors like every other declared action. The door +// runs its existing gates — the anonymous floor, the ADR-0066 D4 capability +// gate, the ADR-0126 §8 activation consult, the param contract, the subject +// load — and then forwards the request IN-PROCESS to the declared `target`, +// with the body assembled from the declaration (`recordIdParam` / +// `recordIdField` / `bodyShape` / `bodyExtra` / `method`, the console's own +// rules) and the caller's credentials carried across. The target endpoint +// still authorizes the caller itself (ADR-0066 D4), so the forward adds a +// consult point, never a privilege. +// +// Why this exists: before this change the switch in `sys_metadata_activation` +// could not reach an api action at all — the console fetched `target` +// directly, so "Ban User switched off" still banned (#27506), and the MCP +// `run_action` bridge refused the type outright, so an agent could not do +// what a human click could (North Star §4: one system for people and +// agents). ADR-0066 D4's 2026-07 scope note ("fetched by the browser +// directly and never passes through that route") is amended by #27610; this +// PR lands after it. +// +// What stays browser-only: an api action whose `target` names ANOTHER origin +// (`https://…`). The platform never sees that request, so the door refuses +// it (`headlessActionTypeError`), MCP does not list it, and the activation +// write door refuses to flip it with the reason spelled out +// (`offOriginApiActivationRefusal`) rather than recording a switch that +// would never take effect. + +/** `true` for a `target` the server can forward to: a path on this origin. */ +export function isSameOriginApiTarget(target: unknown): target is string { + if (typeof target !== 'string' || target.length < 2) return false; + if (!target.startsWith('/')) return false; + // `//host` is protocol-relative (another origin); `/\` is the same thing + // spelled for browsers that treat a backslash as a slash. + const second = target.charAt(1); + return second !== '/' && second !== '\\'; +} + +/** + * Why the activation ledger cannot switch THIS api action — `null` when it + * can (every non-api action, and every same-origin api action). + * + * An off-origin api action is executed by the browser against a host the + * platform never sees, so a ledger row for it would be a switch nobody + * consults: the panel would show it off and the click would keep working, + * which is exactly the #27506 defect in a second spelling. The write door + * refuses the flip with this sentence instead (ADR-0126 §8 item 2 names a + * consult point AT DISPATCH; this action has no dispatch on this server). + */ +export function offOriginApiActivationRefusal(action: any, objectName?: string): string | null { + if (action?.type !== 'api' || isSameOriginApiTarget(action?.target)) return null; + const name: string = action?.name ?? 'unknown'; + const on = objectName ? ` on '${objectName}'` : ''; + const target: string | undefined = typeof action?.target === 'string' ? action.target : undefined; + return ( + `Action '${name}'${on} is \`type: 'api'\` with an off-origin \`target\`${target ? ` ('${target}')` : ''}: ` + + `the browser calls that origin directly and this server never sees the request, so the packaged-metadata ` + + `activation ledger (sys_metadata_activation, ADR-0126 §8) has no dispatch point at which to refuse it. ` + + `The switch is refused rather than recorded as one that would not take effect. Point the action at a ` + + `same-origin path (starting with \`/\`) to make it switchable, or remove the action from the app.` + ); +} + +/** The request headers the forward carries across from the caller, lower-case. */ +const API_FORWARD_HEADER_ALLOWLIST: ReadonlySet = new Set([ + // Identity — the target authorizes the SAME principal the door admitted. + 'authorization', 'cookie', 'x-admin-session-token', 'x-tenant-id', 'x-environment-id', 'x-api-key', + // What a browser's own fetch would carry, so a target's origin / CSRF + // check sees the caller's browser, not the server talking to itself. + 'origin', 'referer', 'user-agent', 'accept-language', + // Proxy provenance, carried AS THE CALLER SENT IT for a target that + // rate-limits or logs by it — the target applies its own `server.trustProxy` + // to these, exactly as it does to a direct request. ⛔ This door never + // reads them itself: the forwarded URL's host is the `Host` header (see + // {@link forwardApiAction}), because a caller-controlled + // `X-Forwarded-Host` / `X-Forwarded-Proto` is attacker input unless the + // deployment declared `server.trustProxy` (`security/inbound-rate-limit.ts`), + // and this seam has no view of that declaration. + 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'x-request-id', +]); + +/** + * Marks a forwarded request so a `target` that is itself an action door + * cannot loop: the door refuses a request already carrying it. + */ +export const API_FORWARD_MARK_HEADER = 'x-objectstack-action-forward'; + +/** The response headers relayed back to the caller — the credential rotations. */ +const API_FORWARD_RELAYED_RESPONSE_HEADERS: readonly string[] = [ + // better-auth's bearer lane (`bearer()` emits `set-auth-token`; #8243's + // rotation emits `set-admin-session-token`). + 'set-auth-token', 'set-admin-session-token', +]; + +export type ApiActionRequest = { + method: string; + path: string; + body?: Record; +}; + +export type ApiActionRequestRefusal = { status: 400; message: string }; + +/** The token grammar the console's runner resolves (`${scope.path}`), one scope map. */ +function substituteApiTargetTokens(template: string, scopes: Record): string { + if (template.indexOf('${') === -1) return template; + const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g'); + return template.replace(pattern, (match, scope: string, path: string) => { + const value = path.split('.').reduce( + (acc, key) => (acc == null ? undefined : (acc as Record)[key]), + scopes[scope], + ); + // A `ctx` path the server does not carry (`ctx.selection.*`, a page's + // own keys) is LEFT IN PLACE so the caller refuses it by name, where + // the console blanks it; a missing `param` is blank on both. + if (value === undefined && scope === 'ctx') return match; + return value == null ? '' : encodeURIComponent(String(value)); + }); +} + +/** + * The console's page-variable syntax (`ui/packages/.../resolvePageVarTokens.ts`): + * `{{page.}}`, whitespace tolerated inside the braces. Only this spelling + * is a browser-only token; any other `{{…}}` is the author's literal text. + */ +const PAGE_VAR_TOKEN_RE = /\{\{\s*page\.[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\}\}/; + +/** Does any string in this value still carry a `{{page.x}}` token? */ +function carriesPageToken(value: unknown): boolean { + if (typeof value === 'string') return PAGE_VAR_TOKEN_RE.test(value); + if (Array.isArray(value)) return value.some(carriesPageToken); + if (value && typeof value === 'object') return Object.values(value as Record).some(carriesPageToken); + return false; +} + +/** + * Assemble the request a same-origin `type: 'api'` action makes — method, + * path and JSON body — from its DECLARATION and the dispatch inputs, by the + * same rules the console's api handler applies at a click + * (`useConsoleActionRuntime.tsx` `apiHandler`, kept in step by #27611): + * + * 1. `target` interpolation: `${param.X}` from the params bag, `${ctx.X}` from + * the caller (`ctx.recordId`, `ctx.user.id`, `ctx.org.id`; `ctx.origin` / + * `ctx.apiBase` are `''` on the server, as they are for a same-origin + * console), values percent-encoded; then `{field}` from the subject row. + * 2. The body: the params bag, flat or under `bodyShape.wrap`; the `outputs.*` + * fold (#3447); `recordIdParam` seeded from the subject row's + * `recordIdField` (default `id`) — or REFUSED when the row cannot supply it + * (#8018: a request that names no record is not sent and hoped about); + * `organizationId` for an `/api/v1/auth/` target from the caller's active + * organization; `bodyExtra` merged LAST. + * 3. `method` (default `POST`); no body on `DELETE`. + * + * Two things a click can resolve and the server cannot are REFUSED, not + * blanked: a `${ctx.selection.*}` / unknown token left in the path, and a + * `{{page.}}` token in the params or `bodyExtra` — those are page + * variables that exist only in the browser. The refusal names them so the + * author (or #27611's console path) knows what to supply. + */ +export function buildApiActionRequest( + action: any, + input: { + recordId?: string; + record?: Record | null; + params: Record; + ec: any; + }, +): ApiActionRequest | ApiActionRequestRefusal { + const name: string = action?.name ?? 'unknown'; + const target: string = action.target; + const ec = input.ec ?? {}; + const organizationId: string | undefined = + typeof ec.tenantId === 'string' && ec.tenantId ? ec.tenantId + : typeof ec.organizationId === 'string' && ec.organizationId ? ec.organizationId + : undefined; + + // ── 1. the path ── + let path = substituteApiTargetTokens(target, { + param: input.params, + ctx: { + origin: '', + apiBase: '', + recordId: input.recordId ?? (input.record as any)?.id, + user: { id: ec.userId, email: ec.email }, + org: { id: organizationId }, + }, + }); + if (input.record && /\{[a-z_][a-z0-9_]*\}/i.test(path)) { + const row = input.record; + path = path.replace(/\{([a-z_][a-z0-9_]*)\}/gi, (_m, k: string) => { + const v = row[k]; + return v == null ? '' : encodeURIComponent(String(v)); + }); + } + if (/\$\{[\w.]+\}|\{\{/.test(path)) { + return { + status: 400, + message: + `Action '${name}' has a \`target\` ('${target}') carrying a token this door cannot resolve ` + + `(the server interpolates \`\${param.*}\`, \`\${ctx.recordId}\`, \`\${ctx.user.id}\`, ` + + `\`\${ctx.org.id}\` and \`{field}\` from the subject row; a selection or page-variable token ` + + `exists only in the browser). Pass the value as a param, or call the target from the console.`, + }; + } + if (!isSameOriginApiTarget(path)) { + return { status: 400, message: `Action '${name}' interpolated its \`target\` to '${path}', which is not a same-origin path.` }; + } + + // ── 2. the body ── + if (carriesPageToken(input.params) || carriesPageToken(action?.bodyExtra)) { + return { + status: 400, + message: + `Action '${name}' carries a \`{{page.}}\` token in its params or \`bodyExtra\` — a page variable ` + + `exists only in the browser, so this door cannot resolve it. Pass the value as a plain param, or run ` + + `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 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) { + const value = body[k]; + delete body[k]; + if (value === undefined || value === '') continue; + ((body.outputs ??= {}) as Record)[k.slice('outputs.'.length)] = value; + } + } + // [#8018] `recordIdParam`, seeded from the row or refused — never silently + // left out. Without a row (a record-less dispatch) only `id` can be seeded, + // from the routed `recordId`; anything else has no source and the request + // goes without it, exactly as a console click from a record-less location. + const recordIdParam: string | undefined = typeof action?.recordIdParam === 'string' && action.recordIdParam ? action.recordIdParam : undefined; + if (recordIdParam) { + const field: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id'; + const row: Record | undefined = + input.record && typeof input.record === 'object' ? input.record + : input.recordId && field === 'id' ? { id: input.recordId } + : undefined; + if (row) { + const label: string = action?.label || name; + if (!(field in row)) { + return { + status: 400, + message: + `Action "${label}" identifies its record by "${field}", but that field is not present on the ` + + `record — running without it would send a request that names no record.`, + }; + } + const value = row[field]; + if (value == null) { + return { + status: 400, + message: + `Action "${label}" identifies its record by "${field}", but this record has no value for it — ` + + `running without it would send a request that names no record.`, + }; + } + body[recordIdParam] = value; + } + } + if (/\/api\/v1\/auth\//.test(path) && body.organizationId === undefined && organizationId) { + body.organizationId = organizationId; + } + if (action?.bodyExtra && typeof action.bodyExtra === 'object' && !Array.isArray(action.bodyExtra)) { + Object.assign(body, action.bodyExtra); + } + + // ── 3. the method ── + const method = String(action?.method || 'POST').toUpperCase(); + return method === 'GET' || method === 'DELETE' ? { method, path } : { method, path, body }; +} + +/** What the in-process forward answered — status, parsed JSON body, relayed headers. */ +export type ApiActionForwardAnswer = { + status: number; + /** + * The target's JSON body (`null` for an empty one). A 2xx answer that is + * not JSON is turned into the door's own 502 refusal before it gets here + * (see {@link forwardApiAction}); a non-2xx non-JSON answer carries its + * text as `{ raw }` so the refusal can quote it. + */ + body: any; + /** + * Response headers relayed to the caller (credential rotations). + * `set-cookie` is a `string[]` — one line per cookie, never comma-joined. + */ + headers: Record; +}; + +/** + * Resolve the in-process request injector: the host server's framework-native + * app behind `IHttpServer.getRawApp()` (Hono's `app.request(url, init)` — the + * same seam `@objectstack/verify`'s harness drives every test request + * through). `null` on a kernel with no `http.server` (a hosted tenant kernel + * that owns no socket) or an adapter that does not expose its app: the + * forward then answers 503, never a silent pass. + */ +export async function resolveInProcessRequest( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, + envId?: string, +): Promise<((url: string, init: RequestInit) => Promise) | null> { + let server: IHttpServer | undefined; + try { + server = await deps.resolveService(requestContext, 'http.server', envId); + } catch { + return null; + } + // `getRawApp` is the contract's one declared framework-specific escape + // hatch and returns `any` on purpose (its docblock says why). + const app = typeof server?.getRawApp === 'function' ? server.getRawApp() : undefined; + if (app && typeof app.request === 'function') return (url, init) => app.request(url, init); + if (app && typeof app.fetch === 'function') return (url, init) => app.fetch(new Request(url, init)); + return null; +} + +/** The 503 for a kernel that cannot forward (no raw app to inject into). */ +export function apiActionForwardUnavailableError(action: any): string { + return ( + `Action '${action?.name ?? 'unknown'}' is a same-origin \`type: 'api'\` action whose \`target\` ` + + `(${typeof action?.target === 'string' ? `'${action.target}'` : 'endpoint'}) this door forwards to in-process, ` + + `but this deployment's HTTP server exposes no in-process request path (IHttpServer.getRawApp). ` + + `Operator: run an HTTP server plugin that implements getRawApp; the action is not dispatched around the door.` + ); +} + +/** + * The 502 for a target that answered 2xx with something other than JSON (a + * file, HTML, a stream): the door's answer is a JSON envelope and the console's + * direct fetch read such a body as `{}` — a silent loss this door refuses by name. + */ +export function apiActionNonJsonAnswerError(actionName: string, status: number, contentType: string | null): string { + return ( + `Action '${actionName}' — its target answered HTTP ${status} with ` + + `${contentType ? `content-type '${contentType}'` : 'no content-type'}, not JSON. The action doors relay JSON ` + + `answers only; a target that serves a file or a page is not a \`type: 'api'\` action this door can carry.` + ); +} + +/** + * The subset of a forward's relayed headers an MCP tool result may carry: the + * bearer lane's pair ({@link API_FORWARD_RELAYED_RESPONSE_HEADERS}), which are + * JS-visible by design. ⛔ Never `Set-Cookie`: the MCP door admits cookie + * sessions too, and a tool result is JSON text that whatever script receives + * it can read — an `HttpOnly` session cookie's value must not travel in it. + * The REST door relays cookies on the response, where only the browser sees them. + */ +export function mcpRelayableForwardHeaders(headers: Record): Record { + const out: Record = {}; + for (const name of API_FORWARD_RELAYED_RESPONSE_HEADERS) { + const v = headers[name]; + if (typeof v === 'string' && v) out[name] = v; + } + return out; +} + +/** The refusal for a forwarded request arriving back at an action door (a loop). */ +export function apiActionForwardLoopError(action: any, mark: string): string { + return ( + `Action '${action?.name ?? 'unknown'}' was reached by the forward of action '${mark}' — a \`type: 'api'\` ` + + `action whose \`target\` is itself an action door would loop. Point the target at the endpoint that ` + + `does the work.` + ); +} + +/** + * Forward the assembled request to the declared target, in-process, with the + * caller's own credentials, and bring the answer back. + * + * The URL is `http://` — the URL the direct request has at + * this process (the in-process server builds its own requests from `Host`; + * TLS terminates upstream), so a target that reads `request.url` sees what + * it would have seen had the browser called it. ⛔ `X-Forwarded-Host` / + * `X-Forwarded-Proto` are NOT consulted for it: they are caller input unless + * `server.trustProxy` says otherwise, and this seam cannot see that + * declaration — they cross to the target as headers, where its own trust + * rule judges them. Only the allow-listed caller headers cross + * ({@link API_FORWARD_HEADER_ALLOWLIST}); `Content-Length` / `Host` are the + * injector's to set. The forward is marked ({@link API_FORWARD_MARK_HEADER}) + * so a door reached by it refuses instead of looping. + * + * Relayed back: the status, the JSON body, and the credential-rotation + * headers a target like `/admin/impersonate-user` emits — `set-auth-token` / + * `set-admin-session-token` (the bearer lane) and `Set-Cookie` (the cookie + * lane). Everything else about the answer stays the target's business. A 2xx + * answer that is not JSON is refused as 502 here: this door answers JSON + * envelopes (the console's direct fetch read such an answer as `{}`, which + * is a silent loss, not a result), and a declaration whose target streams a + * file is not an action this door can carry. + */ +export async function forwardApiAction( + inject: (url: string, init: RequestInit) => Promise, + requestContext: HttpProtocolContext, + req: ApiActionRequest, + mark: { objectName: string; actionName: string }, +): Promise { + const incoming: Record = + requestContext?.request?.headers && typeof requestContext.request.headers === 'object' + ? requestContext.request.headers + : {}; + const headers: Record = {}; + for (const [k, v] of Object.entries(incoming)) { + const key = k.toLowerCase(); + if (!API_FORWARD_HEADER_ALLOWLIST.has(key) || v == null) continue; + headers[key] = Array.isArray(v) ? v.join(', ') : String(v); + } + headers.accept = 'application/json'; + headers[API_FORWARD_MARK_HEADER] = `${mark.objectName}/${mark.actionName}`; + const init: RequestInit = { method: req.method, headers }; + if (req.body !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(req.body); + } + const host = typeof incoming.host === 'string' && incoming.host ? incoming.host : 'localhost'; + const res = await inject(`http://${host}${req.path}`, init); + + const text = await res.text(); + let body: any; + let isJson = true; + if (text.length === 0) { + body = null; + } else { + try { + body = JSON.parse(text); + } catch { + isJson = false; + body = { raw: text }; + } + } + if (!isJson && res.status >= 200 && res.status < 300) { + return { + status: 502, + body: { + success: false, + error: { + code: standardErrorCodeForHttpStatus(502), + message: apiActionNonJsonAnswerError(mark.actionName, res.status, res.headers.get('content-type')), + }, + }, + headers: {}, + }; + } + const relayed: Record = {}; + for (const name of API_FORWARD_RELAYED_RESPONSE_HEADERS) { + const value = res.headers.get(name); + if (value) relayed[name] = value; + } + // The cookie lane: `/admin/impersonate-user` answers SIX `Set-Cookie` + // lines (measured 2026-10-29: session_token, session_data, dont_remember, + // admin_session, and the deletions of the old ones). They go back as an + // array, one line each — `Headers.get('set-cookie')` would comma-join + // them into one unparseable cookie. + const cookies: string[] = + typeof (res.headers as any).getSetCookie === 'function' ? (res.headers as any).getSetCookie() : []; + if (cookies.length > 0) relayed['set-cookie'] = cookies; + return { status: res.status, body, headers: relayed }; +} + +/** + * The payload a successful forward answers as the action's RESULT: the + * target's `data` when it answered the ObjectStack `{ success, data }` + * 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). + */ +export function apiActionResultOf(answer: ApiActionForwardAnswer): unknown { + const body = answer.body; + return body && typeof body === 'object' && !Array.isArray(body) && 'data' in body && 'success' in body + ? (body as { data: unknown }).data + : body; +} + +/** + * 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. + */ +export function apiActionForwardRefusal(action: any, answer: ApiActionForwardAnswer): Error { + const body = answer.body; + const message: string = + (body && typeof body === 'object' && typeof body.error?.message === 'string' && body.error.message) || + (body && typeof body === 'object' && typeof body.message === 'string' && body.message) || + `Action '${action?.name ?? 'unknown'}' — its target answered HTTP ${answer.status}`; + const code: string = + (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 }); +} + /** * Explain why a declared action has NO server-side dispatch — `null` when it * does have one (`script` / `flow`). @@ -1050,13 +1550,21 @@ export function headlessActionTypeError(_deps: ActionExecutionDeps, action: any, if (isDeclarativeUpdateAction(action)) return null; const type: string = action?.type ?? 'script'; if (SERVER_DISPATCHED_ACTION_TYPES.has(type)) return null; + if (type === 'api' && isSameOriginApiTarget(action?.target)) return null; const name: string = action?.name ?? 'unknown'; const on = objectName ? ` on '${objectName}'` : ''; const target: string | undefined = typeof action?.target === 'string' ? action.target : undefined; if (type === 'api') { + // [#27506] Same-origin api actions ARE dispatched here (the door + // forwards them in-process, {@link forwardApiAction}); the predicate + // above already answered `null` for them. What reaches this line is an + // api action whose `target` names ANOTHER origin, or no path at all: + // the platform never sees that request, so there is nothing to run. return ( - `Action '${name}'${on} is \`type: 'api'\` — it dispatches on \`target\`, ` + - `not through the action registry. Call ${target ? `\`${target}\`` : 'its `target` endpoint'} directly` + + `Action '${name}'${on} is \`type: 'api'\` with an off-origin \`target\`` + + `${target ? ` ('${target}')` : ''} — the browser calls that origin directly and the server cannot ` + + `forward to it. Only a same-origin \`target\` (a path starting with \`/\`) is dispatched through ` + + `this door. Call ${target ? `\`${target}\`` : 'its `target` endpoint'} directly` + `${typeof action?.method === 'string' ? ` (${action.method})` : ''}.` ); } @@ -2799,6 +3307,44 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // the defect; refusing after the run was created would only describe it. refuseDeniedSubjectLoad(objectName, recordId, subject); + // ── api forward (#27506) ── a same-origin `type: 'api'` action is + // forwarded in-process to its declared `target` with the caller's own + // credentials, AFTER every gate above (exposure, D4, the activation + // consult, the param contract, confirmation, the subject load) and + // BEFORE the trusted-mode plumbing below, which an api action never gets: + // the target endpoint authorizes the caller itself (ADR-0066 D4). The + // REST `/actions` door runs the identical sequence (`domains/actions.ts`). + if (action?.type === 'api') { + const mark = requestContext?.request?.headers?.[API_FORWARD_MARK_HEADER]; + if (typeof mark === 'string' && mark.length > 0) { + throw Object.assign(new Error(apiActionForwardLoopError(action, mark)), { + code: standardErrorCodeForHttpStatus(400), status: 400, + }); + } + const inject = await resolveInProcessRequest(deps, requestContext, envId); + if (!inject) { + throw Object.assign(new Error(apiActionForwardUnavailableError(action)), { + code: standardErrorCodeForHttpStatus(503), status: 503, + }); + } + const built = buildApiActionRequest(action, { recordId, record, params, ec }); + if ('message' in built) { + throw Object.assign(new Error(built.message), { code: standardErrorCodeForHttpStatus(built.status), status: built.status }); + } + const answer = await forwardApiAction(inject, requestContext, built, { objectName, actionName: action.name }); + if (answer.status < 200 || answer.status >= 300) throw apiActionForwardRefusal(action, answer); + return { + ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), + result: apiActionResultOf(answer), + // A credential rotation the target emitted (impersonation) — the + // bearer caller's own new tokens, which only a header could carry + // over HTTP; over MCP the tool result is the one channel there is. + // Bearer pair ONLY — see {@link mcpRelayableForwardHeaders} for why + // no `Set-Cookie` line may enter a tool result. + ...(() => { const h = mcpRelayableForwardHeaders(answer.headers); return Object.keys(h).length > 0 ? { headers: h } : {}; })(), + }; + } + // [#5372] One shared producer for the user shape (`security/actor-user.ts`), // the same one the REST `/actions` route and the AI routes use. What stood // here was `name: ec.userName ?? ec.userDisplayName ?? ec.userId` — a `??` diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index c05a8917e8f..da0ea3439e2 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -236,6 +236,13 @@ async function handleActionActivationWrite( }; } + // ── 3b. switchability (#27506) ────────────────────────────────────────── + // An api action whose `target` is on ANOTHER origin has no dispatch on + // this server, so a ledger row for it would be a switch nothing consults. + // Refused in both directions — there is nothing to switch on either. + const unswitchable = actionExec.offOriginApiActivationRefusal(declaration.action, objectName); + if (unswitchable) return { handled: true, response: deps.error(unswitchable, 409) }; + // ── 4. ambiguity ──────────────────────────────────────────────────────── const ambiguity = await refuseAmbiguousActionActivation(deps, context, actionName, objectName); if (ambiguity) return ambiguity; @@ -807,6 +814,44 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // demonstrated read access to the subject row. actionExec.refuseDeniedSubjectLoad(objectName, recordId, subject); + // ── api forward (#27506, ruling 6499781492) ── a same-origin + // `type: 'api'` action is forwarded IN-PROCESS to its declared + // `target`, with the body assembled from the declaration and the + // caller's own credentials carried across — after every gate above + // (the 401 floor, D4, the ADR-0126 §8 consult, the param contract, the + // subject load) and BEFORE the trusted-mode context below, which an + // api action never receives: the target endpoint authorizes the caller + // itself (ADR-0066 D4). The MCP `run_action` bridge runs the identical + // sequence (`invokeBusinessAction`). The target's status and body come + // back as this door's answer (its `data` unwrapped, as the console's + // direct fetch did), and a credential rotation it emitted + // (`Set-Cookie` / `set-auth-token` / `set-admin-session-token`, the + // impersonation case) is relayed on the response headers. + if (actionType === 'api') { + const mark = _context?.request?.headers?.[actionExec.API_FORWARD_MARK_HEADER]; + if (typeof mark === 'string' && mark.length > 0) { + return { handled: true, response: deps.error(actionExec.apiActionForwardLoopError(actionDef, mark), 400) }; + } + const inject = await actionExec.resolveInProcessRequest(deps, _context, _context?.environmentId); + if (!inject) { + return { handled: true, response: deps.error(actionExec.apiActionForwardUnavailableError(actionDef), 503) }; + } + const built = actionExec.buildApiActionRequest(actionDef, { recordId, record, params: reqParams, ec }); + if ('message' in built) { + return { handled: true, response: deps.error(built.message, built.status) }; + } + const answer = await actionExec.forwardApiAction(inject, _context, built, { objectName, actionName }); + const relayed = Object.keys(answer.headers).length > 0 ? { headers: answer.headers } : {}; + if (answer.status >= 200 && answer.status < 300) { + 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 }, + }; + } + const actionContext: any = { record, // [commit f19475c0a] The caller-scope load's verdict — see diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts new file mode 100644 index 00000000000..8649502e2b1 --- /dev/null +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -0,0 +1,481 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27506] A same-origin `type: 'api'` action is dispatched through the two +// action doors — the REST `POST /actions/:object/:action` route and the MCP +// `run_action` bridge — and forwarded IN-PROCESS to its declared `target` +// after the gates that door already runs. Ruling 6499781492 (option C). +// +// ## What this file pins, and why each half is load-bearing +// +// The defect was that the ADR-0126 §8 switch could not reach an api action: +// the console fetched `target` directly, so "Ban User switched off" still +// banned. So the FIRST pin is the refusal — a disabled api action answers +// 409 ACTION_DISABLED and the target is never called. The positive half is +// not optional either: an api action that the door refused for its TYPE (the +// pre-#27506 400) would also "never call the target" and still be the defect +// in a second spelling, so an enabled action must be forwarded and its answer +// relayed — method, path, the body the declaration assembles, the caller's +// credentials across, and the target's credential rotation back. +// +// What stays browser-only is pinned too: an off-origin target is refused by +// both doors and cannot be switched by the activation write door. +// +// The real HTTP surface (a booted showcase, better-auth's admin endpoints, +// the cookie lane) is pinned in `packages/qa/dogfood/test/api-action-door- +// forward.dogfood.test.ts`; this file drives the doors over fakes so each +// forwarded byte can be asserted. + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { + invokeBusinessAction, + buildApiActionRequest, + isHeadlessInvokableAction, + headlessActionTypeError, + offOriginApiActivationRefusal, + API_FORWARD_MARK_HEADER, +} from '../action-execution.js'; + +const OBJECT = 'crm_lead'; +const BAN_LEAD = { + name: 'ban_lead', label: 'Ban Lead', objectName: OBJECT, type: 'api', + target: '/api/v1/auth/admin/ban-user', recordIdParam: 'userId', + params: [{ name: 'banReason', label: 'Reason', type: 'text', required: false }], + ai: { exposed: true, description: 'Ban the lead.' }, +}; +const REMOTE_PING = { + name: 'ping_remote', label: 'Ping Remote', objectName: OBJECT, type: 'api', + target: 'https://example.invalid/ping', ai: { exposed: true, description: 'Ping a remote host.' }, +}; +const DELETE_LEAD = { + name: 'purge_lead_remote', label: 'Purge', objectName: OBJECT, type: 'api', method: 'DELETE', + target: '/api/v1/data/crm_lead/${ctx.recordId}', +}; + +type Injected = { url: string; init: RequestInit }; + +function boot(opts: { + disabled?: string[]; + /** What the target answers: status, JSON body, extra response headers. */ + answer?: { status?: number; body?: unknown; text?: string; headers?: Array<[string, string]> }; + noServer?: boolean; +} = {}) { + const disabled = new Set(opts.disabled ?? []); + const injected: Injected[] = []; + const answer = opts.answer ?? {}; + const request = vi.fn(async (url: string, init: RequestInit) => { + injected.push({ url, init }); + // `text` is a raw (non-JSON) body with the caller's own content-type; + // otherwise the JSON `body` under `application/json`. + const raw = typeof answer.text === 'string'; + return new Response(raw ? answer.text : JSON.stringify(answer.body ?? { success: true, data: { ok: true } }), { + status: answer.status ?? 200, + headers: [...(raw ? [] : [['content-type', 'application/json'] as [string, string]]), ...(answer.headers ?? [])], + }); + }); + const server = { getRawApp: () => ({ request }) }; + const executeAction = vi.fn(async () => ({ ran: 'script' })); + const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD] }; + const ql: any = { + executeAction, + getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), + registry: { getObject: (name: string) => (name === OBJECT ? objectDef : undefined), getItem: () => undefined }, + // The subject rows the doors load under the caller's scope. + find: vi.fn(async (_object: string, q: any) => { + const id = q?.where?.id; + return id === 'u_target' || id === 'lead 7' ? [{ id, email: 'target@example.test' }] : []; + }), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + isActionEnabled: (name: string) => !disabled.has(name), + describeDisabledAction: (name: string) => + `Action '${name}' is disabled — switched off in the packaged-metadata activation ledger ` + + `(sys_metadata_activation, ADR-0126 §8).`, + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : (n === 'http.server' || n === 'http-server') && !opts.noServer ? server + : null; + const kernel: any = { + getService: resolve, + getServiceAsync: async (n: string) => resolve(n), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), ql, metadata, executeAction, request, injected }; +} + +/** The caller: a signed-in member whose request carries a bearer AND a cookie. */ +const CTX = (headers: Record = {}): HttpProtocolContext => ({ + request: { + headers: { + host: 'app.example.test', + 'x-forwarded-proto': 'https', + authorization: 'Bearer tok_member', + cookie: 'better-auth.session_token=abc', + 'content-type': 'application/json', + 'content-length': '42', + ...headers, + }, + }, + environmentId: 'platform', + executionContext: { userId: 'u_member', tenantId: 'org_1', systemPermissions: [] }, +} as unknown as HttpProtocolContext); + +const statusOf = (r: any): unknown => r?.response?.status; +const codeOf = (r: any): unknown => r?.response?.body?.error?.code ?? r?.response?.body?.error?.details?.code; +const messageOf = (r: any): string => String(r?.response?.body?.error?.message ?? ''); +const headersOf = (init: RequestInit): Record => init.headers as Record; + +describe('the predicates — a same-origin api action IS server-dispatched, an off-origin one is not', () => { + it('isHeadlessInvokableAction admits a same-origin api target and refuses an off-origin / missing one', () => { + const deps: any = {}; + expect(isHeadlessInvokableAction(deps, BAN_LEAD, false)).toBe(true); + expect(isHeadlessInvokableAction(deps, REMOTE_PING, true)).toBe(false); + expect(isHeadlessInvokableAction(deps, { ...BAN_LEAD, target: '//evil.example/x' }, true)).toBe(false); + expect(isHeadlessInvokableAction(deps, { ...BAN_LEAD, target: undefined }, true)).toBe(false); + }); + + it('headlessActionTypeError is silent for a same-origin api action and names the off-origin case', () => { + const deps: any = {}; + expect(headlessActionTypeError(deps, BAN_LEAD, OBJECT)).toBeNull(); + const msg = headlessActionTypeError(deps, REMOTE_PING, OBJECT); + expect(msg).toContain('off-origin'); + expect(msg).toContain(REMOTE_PING.target); + }); + + it('offOriginApiActivationRefusal explains why the ledger cannot switch an off-origin api action', () => { + expect(offOriginApiActivationRefusal(BAN_LEAD, OBJECT)).toBeNull(); + expect(offOriginApiActivationRefusal({ name: 'x', type: 'script' }, OBJECT)).toBeNull(); + const msg = offOriginApiActivationRefusal(REMOTE_PING, OBJECT); + expect(msg).toContain('sys_metadata_activation'); + expect(msg).toContain('never sees the request'); + }); +}); + +describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin api action', () => { + it('a DISABLED api action answers 409 ACTION_DISABLED and the target is never called (the #27506 defect)', async () => { + const { dispatcher, request } = boot({ disabled: [BAN_LEAD.name] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + expect(request).not.toHaveBeenCalled(); + }); + + it('an ENABLED api action is forwarded: method, caller-origin URL, assembled body, credentials across', async () => { + const { dispatcher, injected } = boot({ + answer: { body: { success: true, data: { userId: 'u_target', banned: true } } }, + }); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target', params: { banReason: 'spam' } }, CTX(), + ); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(injected).toHaveLength(1); + const [{ url, init }] = injected; + // `http://` — the URL the direct request has at this process. The + // caller's `x-forwarded-proto: https` is NOT consulted (see the next case). + expect(url).toBe('http://app.example.test/api/v1/auth/admin/ban-user'); + expect(init.method).toBe('POST'); + const headers = headersOf(init); + expect(headers.authorization).toBe('Bearer tok_member'); + expect(headers.cookie).toBe('better-auth.session_token=abc'); + expect(headers[API_FORWARD_MARK_HEADER]).toBe(`${OBJECT}/${BAN_LEAD.name}`); + // The injector owns the framing headers; a stale `content-length` must not cross. + expect(headers['content-length']).toBeUndefined(); + // The body the DECLARATION assembles: params, `recordIdParam` from the + // subject row's `id`, and `organizationId` for an `/api/v1/auth/` target. + expect(JSON.parse(String(init.body))).toEqual({ banReason: 'spam', userId: 'u_target', organizationId: 'org_1' }); + // The target's `data`, unwrapped — what the console's direct fetch handed `result.data`. + expect(res?.response?.body?.data).toEqual({ userId: 'u_target', banned: true }); + }); + + it('relays the target\'s credential rotation — every Set-Cookie line, and the bearer-lane headers', async () => { + const { dispatcher } = boot({ + answer: { + body: { session: { userId: 'u_target' } }, + headers: [ + ['set-cookie', 'better-auth.session_token=new; Path=/; HttpOnly'], + ['set-cookie', 'better-auth.admin_session=adm; Path=/; HttpOnly'], + ['set-auth-token', 'tok_impersonated'], + ['set-admin-session-token', 'tok_admin_recovery'], + ['x-unrelated', 'not relayed'], + ], + }, + }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(200); + const relayed = res?.response?.headers ?? {}; + // An ARRAY — one header line per cookie. A comma-joined string would be + // parsed by the browser as one cookie, which is the shape this pins out. + expect(relayed['set-cookie']).toEqual([ + 'better-auth.session_token=new; Path=/; HttpOnly', + 'better-auth.admin_session=adm; Path=/; HttpOnly', + ]); + expect(relayed['set-auth-token']).toBe('tok_impersonated'); + expect(relayed['set-admin-session-token']).toBe('tok_admin_recovery'); + expect(relayed['x-unrelated']).toBeUndefined(); + // A stock better-auth body is not enveloped: it comes back as-is. + expect(res?.response?.body?.data).toEqual({ session: { userId: 'u_target' } }); + }); + + it('builds the forwarded URL from `Host` alone — a caller-sent `X-Forwarded-Host` / `-Proto` cannot choose it (they cross as headers only)', async () => { + // `server.trustProxy` is the platform's one rule for believing forwarded + // headers (`security/inbound-rate-limit.ts`), and this seam cannot see + // it; so the URL never follows them. The headers still reach the target + // as the caller sent them, where its own trust rule judges them. + const { dispatcher, injected } = boot(); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, + CTX({ 'x-forwarded-host': 'attacker.example', 'x-forwarded-proto': 'https' }), + ); + + expect(statusOf(res)).toBe(200); + expect(injected[0].url).toBe('http://app.example.test/api/v1/auth/admin/ban-user'); + const headers = headersOf(injected[0].init); + expect(headers['x-forwarded-host']).toBe('attacker.example'); + expect(headers['x-forwarded-proto']).toBe('https'); + }); + + it('refuses (502) a target that answered 2xx with a non-JSON body instead of relaying it as a result', async () => { + // The console's direct fetch read such an answer as `{}` — a silent + // loss. The door names the content-type so the declaration can be fixed. + const { dispatcher, injected } = boot({ + answer: { status: 200, text: 'id,name\n1,ada\n', headers: [['content-type', 'text/csv; charset=utf-8']] }, + }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(injected).toHaveLength(1); + expect(statusOf(res)).toBe(502); + expect(messageOf(res)).toContain('text/csv'); + expect(messageOf(res)).toContain('not JSON'); + expect(res?.response?.body?.data).toBeUndefined(); + }); + + it('relays the target\'s OWN refusal — its status and its code, never a 200', async () => { + const { dispatcher } = boot({ + answer: { status: 403, body: { success: false, error: { code: 'FORBIDDEN', message: 'Platform admin only' } } }, + }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('FORBIDDEN'); + expect(messageOf(res)).toBe('Platform admin only'); + }); + + it('an OFF-ORIGIN api action is refused 400 before any forward — the browser-only case', async () => { + const { dispatcher, request } = boot(); + + const res = await dispatcher.handleActions(`/${OBJECT}/${REMOTE_PING.name}`, 'POST', {}, CTX()); + + expect(statusOf(res)).toBe(400); + expect(messageOf(res)).toContain('off-origin'); + expect(request).not.toHaveBeenCalled(); + }); + + it('a forwarded request that lands back on an action door is refused, not looped', async () => { + const { dispatcher, request } = boot(); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, + CTX({ [API_FORWARD_MARK_HEADER]: 'crm_lead/other_action' }), + ); + + expect(statusOf(res)).toBe(400); + expect(messageOf(res)).toContain('would loop'); + expect(request).not.toHaveBeenCalled(); + }); + + it('a kernel with no in-process request path answers 503, never a silent pass', async () => { + const { dispatcher } = boot({ noServer: true }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, CTX()); + + expect(statusOf(res)).toBe(503); + expect(messageOf(res)).toContain('getRawApp'); + }); + + it('a `DELETE` api action with a `${ctx.recordId}` target carries no body and the interpolated path', async () => { + const { dispatcher, injected } = boot(); + + const res = await dispatcher.handleActions(`/${OBJECT}/${DELETE_LEAD.name}`, 'POST', { recordId: 'lead 7' }, CTX()); + + expect(statusOf(res)).toBe(200); + expect(injected[0].url).toBe('http://app.example.test/api/v1/data/crm_lead/lead%207'); + expect(injected[0].init.method).toBe('DELETE'); + expect(injected[0].init.body).toBeUndefined(); + }); +}); + +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 = { + resolveService: async (_ctx: unknown, service: string) => + service === 'metadata' ? h.metadata + : service === 'automation' ? null + : service === 'http.server' ? (h.request ? { getRawApp: () => ({ request: h.request }) } : null) + : h.ql, + getObjectQL: async () => h.ql, + }; + return invokeBusinessAction(deps, CTX() as any, name, input, { + 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 } }), + }); + }; + + it('a DISABLED api action is refused with the ADR-0112 envelope and the target is never called', async () => { + const h = boot({ disabled: [BAN_LEAD.name] }); + + const thrown = await runViaMcp(h, BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e); + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as any).code).toBe('ACTION_DISABLED'); + expect((thrown as any).status).toBe(409); + expect(h.request).not.toHaveBeenCalled(); + }); + + it('an ENABLED api action is forwarded and its answer returned as the tool result', async () => { + const h = boot({ answer: { body: { success: true, data: { banned: true } }, headers: [['set-auth-token', 'rotated']] } }); + + const result: any = await runViaMcp(h, BAN_LEAD.name, { recordId: 'u_target', params: { banReason: 'spam' } }); + + expect(result.ok).toBe(true); + expect(result.action).toBe(BAN_LEAD.name); + expect(result.result).toEqual({ banned: true }); + expect(result.headers).toEqual({ 'set-auth-token': 'rotated' }); + expect(h.injected[0].url).toBe('http://app.example.test/api/v1/auth/admin/ban-user'); + expect(JSON.parse(String(h.injected[0].init.body))).toEqual({ banReason: 'spam', userId: 'u_target', organizationId: 'org_1' }); + expect(h.executeAction).not.toHaveBeenCalled(); + }); + + it('relays ONLY the bearer-lane pair into the tool result — no Set-Cookie line, ever (the MCP door admits cookie sessions)', async () => { + // A tool result is JSON text that whatever script receives it can read; + // an `HttpOnly` session cookie's value must not travel in it. The REST + // door relays cookies on the response, where only the browser sees them. + const h = boot({ + answer: { + body: { session: { userId: 'u_target' } }, + headers: [ + ['set-cookie', 'better-auth.session_token=new; Path=/; HttpOnly'], + ['set-cookie', 'better-auth.admin_session=adm; Path=/; HttpOnly'], + ['set-auth-token', 'tok_impersonated'], + ['set-admin-session-token', 'tok_admin_recovery'], + ], + }, + }); + + const result: any = await runViaMcp(h, BAN_LEAD.name, { recordId: 'u_target' }); + + expect(result.headers).toEqual({ 'set-auth-token': 'tok_impersonated', 'set-admin-session-token': 'tok_admin_recovery' }); + expect(JSON.stringify(result)).not.toContain('session_token='); + expect(JSON.stringify(result)).not.toContain('HttpOnly'); + }); + + it('a bearer-only rotation with no cookie line reaches the tool result as before', async () => { + const h = boot({ answer: { body: { ok: true }, headers: [['set-auth-token', 'rotated']] } }); + const result: any = await runViaMcp(h, BAN_LEAD.name, { recordId: 'u_target' }); + expect(result.headers).toEqual({ 'set-auth-token': 'rotated' }); + }); + + it('the target\'s refusal rides the throw with its own status and code', async () => { + const h = boot({ answer: { status: 403, body: { success: false, error: { code: 'FORBIDDEN', message: 'nope' } } } }); + + const thrown = await runViaMcp(h, BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e); + + expect((thrown as any).status).toBe(403); + expect((thrown as any).code).toBe('FORBIDDEN'); + expect(String(thrown.message)).toBe('nope'); + }); + + it('an OFF-ORIGIN api action cannot be invoked via MCP', async () => { + const h = boot(); + + const thrown = await runViaMcp(h, REMOTE_PING.name).catch((e) => e); + + expect(String(thrown.message)).toContain('cannot be invoked via MCP'); + expect(h.request).not.toHaveBeenCalled(); + }); +}); + +describe('buildApiActionRequest — the declaration\'s request, by the console\'s own rules', () => { + const ec = { userId: 'u1', tenantId: 'org_9' }; + + it('wraps params under `bodyShape.wrap`, keeps `recordIdParam` flat, merges `bodyExtra` last', () => { + const action = { + name: 'rename_org', type: 'api', target: '/api/v1/auth/organization/update', + bodyShape: { wrap: 'data' }, recordIdParam: 'organizationId', bodyExtra: { source: 'console' }, + }; + const req = buildApiActionRequest(action, { recordId: 'org_9', record: { id: 'org_9' }, params: { name: 'Acme' }, ec }); + expect(req).toEqual({ + method: 'POST', path: '/api/v1/auth/organization/update', + body: { data: { name: 'Acme' }, organizationId: 'org_9', source: 'console' }, + }); + }); + + it('seeds `recordIdParam` from the subject row\'s `recordIdField`, and REFUSES a row that cannot supply it (#8018)', () => { + const action = { name: 'revoke_session', type: 'api', target: '/api/v1/auth/revoke-session', recordIdParam: 'token', recordIdField: 'token' }; + const ok = buildApiActionRequest(action, { recordId: 's1', record: { id: 's1', token: 'tk' }, params: {}, ec }); + expect((ok as any).body).toEqual({ token: 'tk', organizationId: 'org_9' }); + const refused = buildApiActionRequest(action, { recordId: 's1', record: { id: 's1' }, params: {}, ec }); + expect((refused as any).status).toBe(400); + expect((refused as any).message).toContain('"token"'); + }); + + it('interpolates `${param.X}` / `${ctx.X}` and `{field}` in the target, percent-encoded', () => { + const action = { name: 'go', type: 'api', target: '/api/v1/things/${ctx.recordId}/by/${param.who}/{slug}' }; + const req = buildApiActionRequest(action, { recordId: 'r 1', record: { id: 'r 1', slug: 'a/b' }, params: { who: 'x&y' }, ec }); + expect((req as any).path).toBe('/api/v1/things/r%201/by/x%26y/a%2Fb'); + }); + + it('refuses a target token only the browser can resolve, and a `{{page.}}` body token', () => { + const sel = buildApiActionRequest({ name: 'bulk', type: 'api', target: '/api/v1/x?ids=${ctx.selection.ids}' }, { params: {}, ec }); + expect((sel as any).status).toBe(400); + expect((sel as any).message).toContain('cannot resolve'); + const page = buildApiActionRequest( + { name: 'submit', type: 'api', target: '/api/v1/forms/x', bodyExtra: { name: '{{page.inquiryName}}' } }, { params: {}, ec }, + ); + expect((page as any).status).toBe(400); + expect((page as any).message).toContain('{{page.}}'); + // Whitespace inside the braces is the same console syntax. + const spaced = buildApiActionRequest( + { name: 'submit', type: 'api', target: '/api/v1/forms/x', bodyExtra: { name: '{{ page.inquiryName }}' } }, { params: {}, ec }, + ); + expect((spaced as any).status).toBe(400); + }); + + it('passes a literal `{{…}}` that is NOT the console\'s `{{page.*}}` syntax through as the author\'s text', () => { + // Only the page-variable spelling is browser-only; a Handlebars-looking + // literal in a param or `bodyExtra` is content, not a token. + const req = buildApiActionRequest( + { name: 'submit', type: 'api', target: '/api/v1/forms/x', bodyExtra: { template: '{{greeting}} {{ user.name }}' } }, + { params: { note: 'see {{docs}}' }, ec }, + ); + expect((req as any).status).toBeUndefined(); + expect((req as any).body).toEqual({ note: 'see {{docs}}', template: '{{greeting}} {{ user.name }}' }); + }); + + it('does not inject `organizationId` outside `/api/v1/auth/`, and respects an explicit one inside it', () => { + const plain = buildApiActionRequest({ name: 'p', type: 'api', target: '/api/v1/apps/x/recalc' }, { params: { a: 1 }, ec }); + expect((plain as any).body).toEqual({ a: 1 }); + const explicit = buildApiActionRequest({ name: 'q', type: 'api', target: '/api/v1/auth/organization/update' }, { params: { organizationId: 'org_other' }, ec }); + expect((explicit as any).body).toEqual({ organizationId: 'org_other' }); + }); +}); diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index ef3fc0905ce..8c4d15657b6 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -830,10 +830,11 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* if (!actionExec.isHeadlessInvokableAction(deps, action, hasAutomation)) continue; // The script arm — every action `invokeBusinessAction` sends to - // the handler registry, i.e. neither the declarative update nor - // a flow (its branch order, read the same way). The other two - // arms have their own dispatchers and keep their own predicates. - if (!actionExec.isDeclarativeUpdateAction(action) && action?.type !== 'flow' + // the handler registry, i.e. neither the declarative update, nor + // a flow, nor a same-origin `api` action (its branch order, read + // the same way; #27506 added the api forward). The other arms + // have their own dispatchers and keep their own predicates. + if (!actionExec.isDeclarativeUpdateAction(action) && action?.type !== 'flow' && action?.type !== 'api' && !hasHandler(objectName, actionExec.resolveActionHandlerKeys(action))) continue; // [#2849 / ADR-0011] MCP is an AI surface: only actions the // author explicitly opted in via `ai.exposed` are listed. 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 63e0014131c..ac6487b8177 100644 --- a/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts +++ b/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts @@ -15,6 +15,12 @@ * These tests pin the ported dispatch: `flow` → the automation service with * the caller's identity forwarded, `script` → the registry (unchanged), and * the client-side types → an actionable 400 instead of a registry miss. + * + * Since #27506 a same-origin `type: 'api'` action is no longer client-side at + * this door: the route forwards it in-process to its declared `target` (so the + * ADR-0126 §8 switch and the D4 gate hold for api actions too). The api cases + * below pin that forward, and the 503 a kernel answers when it has no HTTP + * server to forward through. */ import { describe, it, expect, vi } from 'vitest'; @@ -42,6 +48,12 @@ function makeDispatcher(opts: { record?: any; /** Simulate a metadata plane that cannot answer (ADR-0110 D3). */ metadataDegraded?: string; + /** + * An in-process request path (`IHttpServer.getRawApp().request`) for the + * same-origin `type: 'api'` forward (#27506). Absent by default, which is + * the shape of a kernel with no HTTP server plugin. + */ + rawRequest?: (url: string, init: RequestInit) => Promise; } = {}) { const executeAction = vi.fn(async () => ({ ran: 'script' })); const objectDef = opts.objectDef ?? { name: 'crm_lead', actions: [] }; @@ -76,6 +88,8 @@ function makeDispatcher(opts: { n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? (opts.automation ?? null) + : (n === 'http.server' || n === 'http-server') && opts.rawRequest + ? { getRawApp: () => ({ request: opts.rawRequest }) } : null, }, }; @@ -347,21 +361,70 @@ describe('REST /actions — client-side action types (#3915)', () => { expect(res.response.status).toBe(403); }); - it('points a `type: api` action at the endpoint it actually dispatches on', async () => { - const apiAction = { - name: 'create_team', - type: 'api', - target: '/api/v1/auth/organization/create-team', - method: 'POST', - }; + // [#27506] A same-origin `type: 'api'` action is NOT a client-side type at + // this door any more: the route forwards it in-process to its declared + // `target`, so the ADR-0126 §8 switch and the D4 gate reach it. The former + // pin here (400 "call the target yourself") asserted the gap the card + // closes, so it is rewritten, not deleted. + const apiAction = { + name: 'create_team', + type: 'api', + target: '/api/v1/auth/organization/create-team', + method: 'POST', + }; + + it('forwards a same-origin `type: api` action in-process to its declared target instead of the script registry', async () => { + const rawRequest = vi.fn(async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ success: true, data: { id: 'team_1' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const { dispatcher, executeAction } = makeDispatcher({ + objectDef: { name: 'sys_team', actions: [apiAction] }, + rawRequest, + }); + + const res = await dispatcher.handleActions( + '/sys_team/create_team', + 'POST', + { params: { name: 'Core' } }, + ctxFor(), + ); + + expect(executeAction).not.toHaveBeenCalled(); + expect(rawRequest).toHaveBeenCalledTimes(1); + const [url, init] = rawRequest.mock.calls[0]; + expect(url).toBe('http://localhost/api/v1/auth/organization/create-team'); + expect(init.method).toBe('POST'); + // `/api/v1/auth/` targets receive the caller's organization scope, as + // the console's api handler sends it. + expect(JSON.parse(String(init.body))).toEqual(expect.objectContaining({ name: 'Core' })); + const answer = res.response; + if (!answer) throw new Error('the door answered without a response envelope'); + expect(answer.status).toBe(200); + // The target's own envelope is unwrapped once: `data` is its `data`. + expect(answer.body.data).toEqual({ id: 'team_1' }); + }); + + it('answers 503 (not a 4xx "call it yourself") for a same-origin `type: api` action when the kernel has no HTTP server to forward through, and still names the target', async () => { + // This fixture's kernel exposes no `http.server` service — the shape of + // a deployment without an HTTP server plugin. The door cannot do the + // work, so the condition is the deployment's (503, with the target + // named so an operator can see what the forward would have reached), + // not the caller's request (4xx). The caller is not sent around the + // door: that would re-open the gap #27506 closes. const { dispatcher, executeAction } = makeDispatcher({ objectDef: { name: 'sys_team', actions: [apiAction] }, }); const res = await dispatcher.handleActions('/sys_team/create_team', 'POST', {}, ctxFor()); - expect(res.response.status).toBe(400); - expect(res.response.body.error.message).toContain('/api/v1/auth/organization/create-team'); + const answer = res.response; + if (!answer) throw new Error('the door answered without a response envelope'); + expect(answer.status).toBe(503); + expect(answer.body.error.message).toContain('/api/v1/auth/organization/create-team'); + expect(answer.body.error.message).toContain('getRawApp'); + expect(answer.body.error.message).not.toMatch(/not found/i); expect(executeAction).not.toHaveBeenCalled(); }); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5518c0da8ac..53b9b855803 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -151,7 +151,13 @@ export interface HttpDispatcherResult { response?: { status: number; body?: any; - headers?: Record; + /** + * Response headers. A `string[]` value is emitted as one header line + * PER ELEMENT (`IHttpResponse.header` accepts the array) — the only + * correct spelling for `Set-Cookie`, which may not be comma-joined + * (#27506: the api-action forward relays a target's cookie rotation). + */ + headers?: Record; }; result?: any; // For flexible return types or direct response objects (Response/NextResponse) } diff --git a/packages/runtime/test-typecheck-debt.json b/packages/runtime/test-typecheck-debt.json index 8b471b8b1cf..90906680f5d 100644 --- a/packages/runtime/test-typecheck-debt.json +++ b/packages/runtime/test-typecheck-debt.json @@ -43,7 +43,7 @@ "TS18048: 'res.response' is possibly 'undefined'.": 14 }, "src/http-dispatcher.actions-type-dispatch.test.ts": { - "TS18048: 'res.response' is possibly 'undefined'.": 24, + "TS18048: 'res.response' is possibly 'undefined'.": 22, "TS2493: Tuple type '…' of length '0' has no element at index '1'.": 6 }, "src/http-dispatcher.kernel-resolver.test.ts": { diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index ce5c2864ceb..2b8c46d8493 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -968,12 +968,17 @@ const SHARED_BUILDER = { const DISPATCHER_DOMAIN_DIR = 'packages/runtime/src/domains'; const DISPATCHER_DOMAINS = { - // Was 0. Kind 1: the handler's one 405 (every verb but POST) must carry - // `Allow:` — RFC 9110 requires it — and `deps.error` takes no headers, the - // same reason `mcp.ts`, `meta.ts` and `ai.ts` hand-roll theirs. + // Was 0, then 1. Kind 1, three times: the handler's one 405 (every verb but + // POST) must carry `Allow:` — RFC 9110 requires it — and `deps.error` takes + // no headers, the same reason `mcp.ts`, `meta.ts` and `ai.ts` hand-roll + // theirs; and the two answers of the `type: 'api'` forward (#27506) must + // carry the TARGET's credential-rotation headers (`Set-Cookie` lines, + // `set-auth-token`, `set-admin-session-token`) — neither `deps.success` nor + // `deps.error` takes headers, so each spreads the helper's own envelope and + // adds `headers`. 'actions.ts': { - handBuilt: 1, - note: 'one 405 for every verb but POST, which must carry an `Allow:` header (`deps.error` takes none); the body is the declared envelope, the code derived from the status', + handBuilt: 3, + note: 'one 405 for every verb but POST, which must carry an `Allow:` header (`deps.error` takes none); and the two answers of the same-origin api forward — the 2xx relay (`deps.success` spread) and the non-2xx relay (`deps.error` spread) — which must carry the target\'s Set-Cookie / set-auth-token / set-admin-session-token headers (neither helper takes any); every body is the helper\'s own declared envelope', }, // [#12160] Not a domain either — the ADR-0126 §5 activation write-authority // gates, shared by the `/automation` toggle door, the `/actions` activation