From c1fded65d2436927bef5f0cbdd7b992223ebb4f6 Mon Sep 17 00:00:00 2001 From: os-sales Date: Thu, 29 Oct 2026 15:16:31 +0000 Subject: [PATCH 1/6] fix(runtime): a same-origin type:'api' action is dispatched through the action doors, so its packaged switch holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /actions/:object/:action` and MCP `run_action` accept a `type: 'api'` action whose `target` is a same-origin path, run their existing gates (the sign-in floor, D4, the ADR-0126 §8 activation consult, the param contract, the subject load) and forward the request in-process to the declared target with the caller's credentials, the body assembled from the declaration by the console's own rules, and the target's credential rotation relayed back (every Set-Cookie line, set-auth-token, set-admin-session-token). An off-origin api action stays browser-only: both doors refuse it, MCP does not list it, and the activation write door refuses to flip it with the reason. `HttpDispatcherResult.response.headers` accepts `string[]` (one header line per element); the Hono adapter's route `res.header` appends an array instead of comma-joining it. Pinned over fakes at both doors (`domains/api-action-forward.test.ts`) and on the real stack (`api-action-door-forward.dogfood.test.ts`: ban_user off → 409 and not banned, on → banned; unban_user; set_user_password; impersonate_user on the cookie and bearer lanes; a member's 403 and the anonymous 401; the off-origin flip and run refusals; the MCP type gate). Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- .changeset/27506-api-action-door-forward.md | 17 + content/docs/ui/actions.mdx | 8 +- .../plugins/plugin-hono-server/src/adapter.ts | 16 +- .../api-action-door-forward.dogfood.test.ts | 273 ++++++++++ .../src/action-execution-destructive.test.ts | 30 +- packages/runtime/src/action-execution.ts | 488 +++++++++++++++++- packages/runtime/src/domains/actions.ts | 45 ++ .../src/domains/api-action-forward.test.ts | 397 ++++++++++++++ packages/runtime/src/domains/mcp.ts | 9 +- packages/runtime/src/http-dispatcher.ts | 8 +- 10 files changed, 1263 insertions(+), 28 deletions(-) create mode 100644 .changeset/27506-api-action-door-forward.md create mode 100644 packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts create mode 100644 packages/runtime/src/domains/api-action-forward.test.ts diff --git a/.changeset/27506-api-action-door-forward.md b/.changeset/27506-api-action-door-forward.md new file mode 100644 index 00000000000..d8e60fca5af --- /dev/null +++ b/.changeset/27506-api-action-door-forward.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': patch +'@objectstack/plugin-hono-server': 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; 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) 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 target still authorizes the caller itself (ADR-0066 D4); its status and body are the door's answer (its `data` unwrapped, as the console's direct fetch handed `result.data`), and a credential rotation it emits — every `Set-Cookie` line, `set-auth-token`, `set-admin-session-token` — is relayed, so `impersonate_user` works through the door on both the cookie and the bearer lane. 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.*}` or `{{page.}}` token the server cannot resolve (400, naming it); a kernel with no in-process request path answers 503. +- **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 adapter's route `res.header` appends an array instead of comma-joining it (the only correct spelling for `Set-Cookie`). 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/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..a832f6a4f6e 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,440 @@ 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 !== '\\'; +} + +/** Is this declaration a `type: 'api'` action the server forwards? */ +export function isSameOriginApiAction(action: any): boolean { + return action?.type === 'api' && isSameOriginApiTarget(action?.target); +} + +/** + * 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, for a target that rate-limits or logs by it. + '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)); + }); +} + +/** Does any string in this value still carry a `{{page.x}}` token? */ +function carriesPageToken(value: unknown): boolean { + if (typeof value === 'string') return value.includes('{{'); + 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; a non-JSON answer is carried as `{ raw }`. */ + 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, but this deployment's HTTP ` + + `server exposes no in-process request path to forward it through (IHttpServer.getRawApp). Call its ` + + `\`target\` (${typeof action?.target === 'string' ? `'${action.target}'` : 'endpoint'}) directly.` + ); +} + +/** 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's origin is the CALLER's (`Host` + `X-Forwarded-Proto`), so a target + * that reads `new URL(request.url).origin` — better-auth's trusted-origins + * check does — sees the deployment's own origin, exactly as a direct browser + * fetch would. 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 (or `{ raw }` for a non-JSON + * answer), 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. + */ +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 headers['x-forwarded-host'] === 'string' && headers['x-forwarded-host'] + ? headers['x-forwarded-host'] + : typeof incoming.host === 'string' && incoming.host ? incoming.host : 'localhost'; + const proto = typeof headers['x-forwarded-proto'] === 'string' && /^https?$/i.test(headers['x-forwarded-proto']) + ? headers['x-forwarded-proto'].toLowerCase() + : 'http'; + const res = await inject(`${proto}://${host}${req.path}`, init); + + const text = await res.text(); + let body: any; + if (text.length === 0) { + body = null; + } else { + try { + body = JSON.parse(text); + } catch { + body = { raw: text }; + } + } + 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 +1488,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 +3245,42 @@ 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. + ...(Object.keys(answer.headers).length > 0 ? { headers: answer.headers } : {}), + }; + } + // [#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..95a159b918b --- /dev/null +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -0,0 +1,397 @@ +// 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; 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 }); + return new Response(JSON.stringify(answer.body ?? { success: true, data: { ok: true } }), { + status: answer.status ?? 200, + headers: [['content-type', 'application/json'], ...(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; + // The caller's origin, so a target that reads `request.url` sees the deployment, not localhost. + expect(url).toBe('https://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('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('https://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('https://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('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.}}'); + }); + + 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.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) } From 80fc341a5c841afb076c8f2a1f872d6009ba5a8d Mon Sep 17 00:00:00 2001 From: os-sales Date: Thu, 29 Oct 2026 15:22:44 +0000 Subject: [PATCH 2/6] chore(changeset): ADR-0087 disposition for the #27506 changeset Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- .changeset/27506-api-action-door-forward.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/27506-api-action-door-forward.md b/.changeset/27506-api-action-door-forward.md index d8e60fca5af..18a5a8ab03c 100644 --- a/.changeset/27506-api-action-door-forward.md +++ b/.changeset/27506-api-action-door-forward.md @@ -7,6 +7,7 @@ fix(runtime): a same-origin `type: 'api'` action is dispatched through the actio Clause-②: no (narrowing) — no key, accepted value or export of `@objectstack/spec` changes; 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): From 75d7d9e115f02073fd761a170cad7eece9367359 Mon Sep 17 00:00:00 2001 From: os-sales Date: Thu, 29 Oct 2026 15:56:59 +0000 Subject: [PATCH 3/6] chore(gates): declare the api forward's two header-carrying answers in the route-envelope ledger Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- scripts/check-route-envelope.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 From f72f7d042f9977d1f55fd6e76ffe19c544f05074 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:47:12 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(runtime):=20api=20forward=20=E2=80=94?= =?UTF-8?q?=20host-only=20URL,=20bearer-only=20MCP=20relay,=20502=20for=20?= =?UTF-8?q?non-JSON,=20{{page.*}}-only=20refusal;=20adapters=20append=20ar?= =?UTF-8?q?ray=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review findings on the same-origin `type: 'api'` forward (#27506): - The MCP `run_action` arm relays only the bearer-lane pair (`set-auth-token` / `set-admin-session-token`) into the tool result; no `Set-Cookie` line enters it. The MCP door admits cookie sessions and a tool result is JSON text whatever receives it can read, so an `HttpOnly` session cookie's value must not travel there. The REST door still relays every cookie line on the response. - The forwarded URL is `http://`, the URL the direct request has at this process. `X-Forwarded-Host` / `X-Forwarded-Proto` are no longer consulted for it (caller input unless `server.trustProxy`, which this seam cannot see); they still cross to the target as headers. - A target that answers 2xx with a non-JSON body is refused 502 naming its content-type instead of relayed as `{ raw }` under 200. - Only the console's `{{page.}}` syntax is a browser-only body token; any other `{{…}}` is the author's literal text. - A kernel with no in-process request path answers 503 addressed to the operator and no longer tells the caller to call the target directly. - The unused `isSameOriginApiAction` export is removed. - The published `@objectstack/hono` adapter's `toResponse` emits a `string[]` header value as one line per element on its `response` arm and both `stream` arms, as the Hono server plugin already does. - The stale pin in `http-dispatcher.actions-type-dispatch.test.ts` that asserted the 400 "call the target yourself" answer is rewritten for the new contract: forward when the kernel can, 503 when it cannot. - Changeset: BREAKING section for the activation write door's 409 on an off-origin api action and the widened `HttpDispatcherResult.response.headers` value type, with the one-line consumer fix; `@objectstack/hono` added to the bump. Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- .changeset/27506-api-action-door-forward.md | 16 ++- .../src/hono-response-header-array.test.ts | 104 +++++++++++++++ packages/adapters/hono/src/index.ts | 21 +++- packages/runtime/src/action-execution.ts | 118 ++++++++++++++---- .../src/domains/api-action-forward.test.ts | 98 +++++++++++++-- ...p-dispatcher.actions-type-dispatch.test.ts | 75 +++++++++-- 6 files changed, 382 insertions(+), 50 deletions(-) create mode 100644 packages/adapters/hono/src/hono-response-header-array.test.ts diff --git a/.changeset/27506-api-action-door-forward.md b/.changeset/27506-api-action-door-forward.md index 18a5a8ab03c..c3a7dd44545 100644 --- a/.changeset/27506-api-action-door-forward.md +++ b/.changeset/27506-api-action-door-forward.md @@ -1,18 +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) +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; 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 +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) 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 target still authorizes the caller itself (ADR-0066 D4); its status and body are the door's answer (its `data` unwrapped, as the console's direct fetch handed `result.data`), and a credential rotation it emits — every `Set-Cookie` line, `set-auth-token`, `set-admin-session-token` — is relayed, so `impersonate_user` works through the door on both the cookie and the bearer lane. 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.*}` or `{{page.}}` token the server cannot resolve (400, naming it); a kernel with no in-process request path answers 503. +- **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 adapter's route `res.header` appends an array instead of comma-joining it (the only correct spelling for `Set-Cookie`). +- **`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/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/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index a832f6a4f6e..658fa6c93e7 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1071,11 +1071,6 @@ export function isSameOriginApiTarget(target: unknown): target is string { return second !== '/' && second !== '\\'; } -/** Is this declaration a `type: 'api'` action the server forwards? */ -export function isSameOriginApiAction(action: any): boolean { - return action?.type === 'api' && isSameOriginApiTarget(action?.target); -} - /** * Why the activation ledger cannot switch THIS api action — `null` when it * can (every non-api action, and every same-origin api action). @@ -1108,7 +1103,14 @@ const API_FORWARD_HEADER_ALLOWLIST: ReadonlySet = new Set([ // 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, for a target that rate-limits or logs by it. + // 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', ]); @@ -1150,9 +1152,16 @@ function substituteApiTargetTokens(template: string, scopes: Record}}`, 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 value.includes('{{'); + 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; @@ -1303,7 +1312,12 @@ export function buildApiActionRequest( /** What the in-process forward answered — status, parsed JSON body, relayed headers. */ export type ApiActionForwardAnswer = { status: number; - /** The target's JSON body; a non-JSON answer is carried as `{ raw }`. */ + /** + * 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). @@ -1342,12 +1356,43 @@ export async function resolveInProcessRequest( /** 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, but this deployment's HTTP ` + - `server exposes no in-process request path to forward it through (IHttpServer.getRawApp). Call its ` + - `\`target\` (${typeof action?.target === 'string' ? `'${action.target}'` : 'endpoint'}) directly.` + `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 ( @@ -1361,19 +1406,26 @@ export function apiActionForwardLoopError(action: any, mark: string): string { * Forward the assembled request to the declared target, in-process, with the * caller's own credentials, and bring the answer back. * - * The URL's origin is the CALLER's (`Host` + `X-Forwarded-Proto`), so a target - * that reads `new URL(request.url).origin` — better-auth's trusted-origins - * check does — sees the deployment's own origin, exactly as a direct browser - * fetch would. Only the allow-listed caller headers cross + * 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 (or `{ raw }` for a non-JSON - * answer), and the credential-rotation headers a target like - * `/admin/impersonate-user` emits — `set-auth-token` / + * 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. + * 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, @@ -1398,25 +1450,35 @@ export async function forwardApiAction( headers['content-type'] = 'application/json'; init.body = JSON.stringify(req.body); } - const host = typeof headers['x-forwarded-host'] === 'string' && headers['x-forwarded-host'] - ? headers['x-forwarded-host'] - : typeof incoming.host === 'string' && incoming.host ? incoming.host : 'localhost'; - const proto = typeof headers['x-forwarded-proto'] === 'string' && /^https?$/i.test(headers['x-forwarded-proto']) - ? headers['x-forwarded-proto'].toLowerCase() - : 'http'; - const res = await inject(`${proto}://${host}${req.path}`, init); + 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); @@ -3277,7 +3339,9 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // 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. - ...(Object.keys(answer.headers).length > 0 ? { headers: answer.headers } : {}), + // 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 } : {}; })(), }; } diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 95a159b918b..8649502e2b1 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -59,7 +59,7 @@ 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; headers?: Array<[string, string]> }; + answer?: { status?: number; body?: unknown; text?: string; headers?: Array<[string, string]> }; noServer?: boolean; } = {}) { const disabled = new Set(opts.disabled ?? []); @@ -67,9 +67,12 @@ function boot(opts: { const answer = opts.answer ?? {}; const request = vi.fn(async (url: string, init: RequestInit) => { injected.push({ url, init }); - return new Response(JSON.stringify(answer.body ?? { success: true, data: { ok: true } }), { + // `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: [['content-type', 'application/json'], ...(answer.headers ?? [])], + headers: [...(raw ? [] : [['content-type', 'application/json'] as [string, string]]), ...(answer.headers ?? [])], }); }); const server = { getRawApp: () => ({ request }) }; @@ -181,8 +184,9 @@ describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); expect(injected).toHaveLength(1); const [{ url, init }] = injected; - // The caller's origin, so a target that reads `request.url` sees the deployment, not localhost. - expect(url).toBe('https://app.example.test/api/v1/auth/admin/ban-user'); + // `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'); @@ -228,6 +232,41 @@ describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin 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' } } }, @@ -278,7 +317,7 @@ describe('door 1 — REST `POST /actions/:object/:action` forwards a same-origin const res = await dispatcher.handleActions(`/${OBJECT}/${DELETE_LEAD.name}`, 'POST', { recordId: 'lead 7' }, CTX()); expect(statusOf(res)).toBe(200); - expect(injected[0].url).toBe('https://app.example.test/api/v1/data/crm_lead/lead%207'); + 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(); }); @@ -322,11 +361,40 @@ describe('door 2 — the MCP `run_action` bridge forwards a same-origin api acti 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('https://app.example.test/api/v1/auth/admin/ban-user'); + 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' } } } }); @@ -386,6 +454,22 @@ describe('buildApiActionRequest — the declaration\'s request, by the console\' ); 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', () => { 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..550c39ba4b2 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,66 @@ 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' })); + expect(res.response.status).toBe(200); + // The target's own envelope is unwrapped once: `data` is its `data`. + expect(res.response.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.status).toBe(503); expect(res.response.body.error.message).toContain('/api/v1/auth/organization/create-team'); + expect(res.response.body.error.message).toContain('getRawApp'); + expect(res.response.body.error.message).not.toMatch(/not found/i); expect(executeAction).not.toHaveBeenCalled(); }); }); From a87722dd95a350b6ed0592c4698ebb23b6dee758 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:41:43 +0000 Subject: [PATCH 5/6] test(runtime): narrow `res.response` in the rewritten api-dispatch pins; test-typecheck debt re-recorded downward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two #27506 cases in http-dispatcher.actions-type-dispatch.test.ts read `res.response.…` unnarrowed, which grew the ledgered TS18048 debt for the file (24 recorded, 28 measured). They now bind the envelope once and refuse loudly when it is absent, and the pin they replaced carried two of the ledgered accesses, so the file's debt shrinks 24 -> 22; `gen:test-typecheck-debt` re-recorded that number (no other entry moves). Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- ...http-dispatcher.actions-type-dispatch.test.ts | 16 ++++++++++------ packages/runtime/test-typecheck-debt.json | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) 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 550c39ba4b2..ac6487b8177 100644 --- a/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts +++ b/packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts @@ -399,9 +399,11 @@ describe('REST /actions — client-side action types (#3915)', () => { // `/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' })); - expect(res.response.status).toBe(200); + 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(res.response.body.data).toEqual({ id: 'team_1' }); + 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 () => { @@ -417,10 +419,12 @@ describe('REST /actions — client-side action types (#3915)', () => { const res = await dispatcher.handleActions('/sys_team/create_team', 'POST', {}, ctxFor()); - expect(res.response.status).toBe(503); - expect(res.response.body.error.message).toContain('/api/v1/auth/organization/create-team'); - expect(res.response.body.error.message).toContain('getRawApp'); - expect(res.response.body.error.message).not.toMatch(/not found/i); + 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/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": { From 533f8da14dabc67b793283120aaa297e10b753bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:37:40 +0000 Subject: [PATCH 6/6] docs(ai): MCP docs state the same-origin api forward (connect-mcp exposure policy + troubleshooting row, actions-as-tools dispatch table) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sentences that #27506 made stale, aligned with content/docs/ui/actions.mdx: the headless-callable set is script, flow and a same-origin `type: 'api'` action (dispatched in-process through the action door by run_action); an api action whose target is on another origin stays browser-only and is not listed; sys_* objects stay held back for every type; a credential rotation reaches an MCP tool result as the bearer-lane headers only, never Set-Cookie. Prose only — no code, test or changeset semantics move. Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude --- content/docs/ai/actions-as-tools.mdx | 2 +- content/docs/ai/connect-mcp.mdx | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) 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