Skip to content

Commit 481ade6

Browse files
objectstack-fleet[bot]os-salesclaude
authored
fix(runtime)!: a same-origin type: 'api' action is dispatched through the action doors, so its packaged-action switch holds (#27506) (#27649)
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVpEmthvkRbuHGjC6oSQZvd)_ Fixes #27506 Ruling-ref: #27506 (comment) (Sales 「同意」 — option C in three steps; this PR is step 2, the server step). Lands after #27610 (the ADR-0066 D4 / ADR-0126 §8 / spec-docblock text this behaviour rests on). Step 3 is #27611 (the console sends api clicks through this door). 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, so no skill line changes. **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<string, string>`. *Now:* `Record<string, string | string[]>`; 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. ## What changed 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 MCP `run_action` refused it outright — "Ban User switched off" still banned, and an agent could not do what a human click could. - **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 — the sign-in floor, ADR-0066 D4 `requiredPermissions`, the ADR-0126 §8 activation consult, the param contract, the subject-row load — and then **forwards the request in-process** to the declared target (`IHttpServer.getRawApp()`'s request injector, the seam `@objectstack/verify` drives) with the caller's own credentials (`Authorization`, `Cookie`, `x-admin-session-token`, tenant and proxy headers) and the body assembled from the declaration 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` 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; its status and body are the door's answer (`data` unwrapped, as the console's direct fetch handed `result.data`), and a credential rotation it emits is relayed: on REST every `Set-Cookie` line plus `set-auth-token` / `set-admin-session-token`, so `impersonate_user` works through the door on the cookie lane and the bearer lane; over MCP only the bearer pair (`set-auth-token` / `set-admin-session-token`) — a `Set-Cookie` value never enters a tool result. The forward URL is built from the incoming `Host` alone (`x-forwarded-*` cross only as headers, for the target's own `trustProxy` reading), and a 2xx target answer that is not JSON is refused `502` (`EXTERNAL_SERVICE_ERROR`) naming its content-type. - **A disabled api action answers 409 `ACTION_DISABLED`** and its target is never called. A forwarded request that lands back on an action door is refused (400); a `${ctx.selection.*}` or `{{page.<var>}}` token (the console's own `{{page.*}}` grammar; any other `{{…}}` passes through literally) the server cannot resolve is refused 400 by name; a kernel with no in-process request path answers 503. - **Off-origin api actions stay browser-only, and cannot be switched.** Both doors refuse them (400, naming the origin), MCP does not list them, and `POST /actions/_activation/:object/:action` refuses to flip them (409) with the reason — a ledger row nothing would consult is not recorded. - **`HttpDispatcherResult.response.headers` accepts `string[]`** (one header line per element) and both in-repo Hono adapters append an array instead of comma-joining it (`@objectstack/plugin-hono-server`'s route `res.header`, and `@objectstack/hono`'s `toResponse` on its response, event-stream and raw-stream arms) — the only correct spelling for several `Set-Cookie` lines. Measured: `/admin/impersonate-user` answers six. File surface beyond the claim's three (`action-execution.ts`, `domains/actions.ts`, `domains/mcp.ts`): `http-dispatcher.ts` (the header type, 1 line), `plugin-hono-server/src/adapter.ts` (the array append and the `Host` backfill) and `packages/adapters/hono/src/index.ts` with its new test `hono-response-header-array.test.ts` — all needed for the cookie lane; the probe before the red tests showed six `Set-Cookie` lines that a `Record<string, string>` channel could not carry. The pre-existing pin `http-dispatcher.actions-type-dispatch.test.ts` is rewritten to the new contract (forward through the injector; 503 without `http.server`), and `packages/runtime/test-typecheck-debt.json` shrinks that file's TS18048 row 24 → 22. ## Evidence - Probe first (per the claim): in-process `app.request` to `/admin/ban-user`, `/admin/unban-user` and `/admin/impersonate-user` with the caller's bearer, and with the caller's cookie — all 200; `sys_user.banned` flips; impersonate answers 6 `Set-Cookie` + `set-auth-token` (+ `set-admin-session-token` on the bearer lane), and the caller's old bearer dies (#8243). - Unit, `packages/runtime/src/domains/api-action-forward.test.ts` (new) + `action-activation-dispatch.test.ts` + `action-declarative-update.test.ts` + `action-execution-destructive.test.ts` (its "gate 2 excludes the 13 identity actions" pin flips to "gate 2 admits"): 128/128. - Dogfood, `packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts` (new, real showcase + better-auth admin plugin + live MCP): ban_user off → 409 and not banned, on → banned; unban_user; set_user_password (the target signs in with the new password); a member's 403 relayed, anonymous 401; the off-origin flip refused 409 with no ledger row and the run refused 400; MCP `run_action` on a same-origin api action reaches the exposure gate instead of the type refusal; impersonate_user on the cookie lane (≥2 `Set-Cookie` lines, one per line) and the bearer lane (rotation headers, old token 401): 8/8. - Ablation A (`isHeadlessInvokableAction`'s api arm → `false`): 17 red (both doors + the destructive pin), restored blob == HEAD. Ablation B (adapter array append → comma-join): the cookie-lane dogfood case red with the six cookies joined into one line, restored blob == HEAD. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 112 families (93 for the diff + 19 the `scripts/check-route-envelope.mjs` ledger edit adds); every command run with its exit captured before any pipe; `--ran` reconciliation: 112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN. Four prerequisites cleared and re-run to 0 (teardown-shape self-test after `git fetch --unshallow`; dual-build-cjs-loads and skill-examples after building the 8 dists the closure leaves out); two findings fixed in-branch (ADR-0087 disposition marker on the changeset; the route-envelope ledger now declares `actions.ts` handBuilt 3 — the 405 and the forward's two header-carrying answers). - Dogfood battery on the final head: final-closure head f424504 (merge of origin/main 9c51314 onto the fix; the two later commits 80fc341 / 75d7d9e change only the changeset text and the route-envelope ledger script) · os-verify-lock VERDICT command-exit 0 · held 35m38s · Test Files 352 passed | 3 skipped (355) · Tests 2830 passed | 20 skipped (2850) · 0 failures. ## 维护者速读(席位终稿) - 改了什么:同源 `type: 'api'` 动作改为经 `/actions` 门与 MCP `run_action` 派发:过完现有各闸后在进程内转发到声明的 `target`,请求体按控制台规则在服务端组装,调用方凭据带过去,目标的 Set-Cookie / 令牌轮换带回来;外站 api 动作两门仍拒、写入门拒绝切换并说明原因;响应头通道支持多值。 - 为什么改:#27506 关掉「封禁用户」照样封禁——api 动作从不经过激活判定;裁决 C(6499781492)要求人和 agent 走同一扇门。 - 风险与回滚:**破坏性**两处——写入门对外站 api 动作的切换改为 409(原先 200 且记账但不生效);`HttpDispatcherResult.response.headers` 放宽为 `string | string[]`(外部适配器一行修法见 BREAKING 段)。MCP 只回传 bearer 令牌对,Set-Cookie 不进工具结果。控制台直调不受影响直到 #27611;回滚即 revert 本 PR,无数据迁移。 - 先看:`packages/runtime/src/action-execution.ts` 的 `buildApiActionRequest` / `forwardApiAction`,`domains/actions.ts` 的 api 分支与写入门 3b,`plugin-hono-server/src/adapter.ts` 的数组追加。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/api-action-door-forward.dogfood.test.ts`(需先 build 闭包)。 - 需要你做的:先合 #27610(Tier H,ADR 文本),席位随后把本 PR 转 ready 入队;#27611 等本 PR。MCP 对 sys_* 动作的拒绝另立 #27681 待裁。 席位意见:契约复核两轮(75d7d9e1 FAIL → a87722d PASS,记录 6504462099),三条必改与六条应改均在树上修复并有钉;建议在 #27610 合并后落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw --------- Co-authored-by: os-sales <sales@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef359b9 commit 481ade6

17 files changed

Lines changed: 1634 additions & 53 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/plugin-hono-server': patch
4+
'@objectstack/hono': patch
5+
---
6+
7+
fix(runtime)!: a same-origin `type: 'api'` action is dispatched through the action doors, so its packaged-action switch holds (#27506)
8+
9+
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.
10+
Metadata-protocol: none
11+
<!-- adr-0087: not-required (no-migration-prescription) nothing an author writes is retired, renamed or re-shaped: no metadata key, accepted value or stored row moves, and `objectstack migrate meta` has nothing to rewrite. What narrows is a door's answer (409 where a flip of an off-origin api action used to be recorded) and a published TypeScript member's value type (`HttpDispatcherResult.response.headers`, widened to `string | string[]`), whose affected party is a code consumer reached by its own compiler, not a metadata upgrader; the member's declaration is neither a Zod projection nor referenced by any `packages/spec` surface. No ADR-0087 id is named or touched (not registered or already-registered), the packages publish (not unpublished), the widened member was concretely typed at base (not type-surface-only), and no export is retired (not runtime-export-retired). -->
12+
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.
13+
14+
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):
15+
16+
- **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://<Host header><path>`, 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.<var>}}` 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.
17+
- **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.
18+
- **MCP lists same-origin api actions** on the same terms as every other action (`ai.exposed`, the caller's permissions, no `sys_*` object).
19+
- **`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`).
20+
21+
**BREAKING** at two published runtime surfaces (`@objectstack/runtime`).
22+
23+
- **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.
24+
- **The type `HttpDispatcherResult.response.headers`** (`packages/runtime/src/http-dispatcher.ts`). *Before:* `Record<string, string>`. *Now:* `Record<string, string | string[]>`; 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.

‎content/docs/ai/actions-as-tools.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ back fail-closed.
108108
|:---|:---|:---|
109109
| `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS |
110110
| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered |
111-
| `api` | HTTP call to `action.target` via a configured `apiClient` | **ObjectOS** runtime only |
111+
| `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 |
112112

113113
Console-only types (`url`, `modal`, `form`) are always skipped.
114114

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,15 @@ can reach is the **default exposure policy (v1)**:
243243
are blocked fail-closed.
244244
- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an
245245
`ai.description` of at least 40 characters, and the action must be callable
246-
without a UI. In the open framework that means exactly two types — `script`
247-
(with an inline body or a registered handler) and `flow` (with the automation
248-
service registered). `url`, `modal`, `form`, and `api` have no headless
249-
dispatch here. See [Actions as Tools](/docs/ai/actions-as-tools) and
250-
[Actions](/docs/ui/actions).
246+
without a UI. In the open framework that means three types — `script`
247+
(with an inline body or a registered handler), `flow` (with the automation
248+
service registered), and `api` whose `target` is a **same-origin path**
249+
(starting with `/`): `run_action` dispatches it in-process through the
250+
action door to that target, after the door's gates. `url`, `modal`, and
251+
`form` have no headless dispatch here, and neither does an `api` action
252+
whose `target` is on another origin — the browser calls that origin
253+
directly, so it is not exposed. See [Actions as Tools](/docs/ai/actions-as-tools)
254+
and [Actions](/docs/ui/actions).
251255

252256
<Callout type="info">
253257
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure
@@ -393,7 +397,7 @@ skill and a guided `/objectstack:connect` command.
393397
| 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 |
394398
| `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 |
395399
| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |
396-
| 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` |
400+
| 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` |
397401
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |
398402

399403
## Related

‎content/docs/ui/actions.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The types you'll actually use:
1919
| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow |
2020
| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) |
2121
| `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`) |
22-
| `api` | Call an HTTP endpoint directly | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request |
22+
| `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 |
2323
| `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=…` |
2424

2525
<Callout type="warn">
@@ -575,8 +575,8 @@ Failures speak HTTP — the status code is the signal (#3962):
575575
- **400** — the action ran and **rejected** (a business rule said no, or
576576
validation failed — then with `error.details.fields[]` to anchor the input).
577577
- **404 / 403 / 503** — it never dispatched: no such action, denied, service
578-
unavailable. A `url`/`modal`/`form`/`api` type with no server dispatch is
579-
also a 400.
578+
unavailable. A `url`/`modal`/`form` type, or an `api` type whose `target`
579+
is on another origin, has no server dispatch and is also a 400.
580580
- **500** — it **crashed**: a `TypeError` in your handler, a driver error, a
581581
sandbox timeout. A deliberate `throw new Error('…')` is a 400 rejection,
582582
not a crash.
@@ -596,7 +596,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
596596
|:---|:---|
597597
| `script` | Runs the registered handler / inline body. |
598598
| `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. |
599-
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
599+
| `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.<var>}}` 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). |
600600
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |
601601
602602
## Headless actions: declare it, then hide it
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27506] `toResponse` emits a `string[]` header value as one header line
5+
* per element, on the `response` arm and on the `stream` arms.
6+
*
7+
* `HttpDispatcherResult.response.headers` accepts `string | string[]` since
8+
* #27506: the `/actions` door relays a forwarded target's credential rotation,
9+
* and `/admin/impersonate-user` answers several `Set-Cookie` lines. This
10+
* adapter used to cast every value `as string` — an array reaches `c.header`
11+
* as its `toString()`, one comma-joined line, which a browser parses as ONE
12+
* cookie whose value contains the others. These cases pin the repeated line.
13+
*
14+
* Same harness as `hono-result-response-passthrough.test.ts`: the dispatcher
15+
* is a fixture, so the arms can be driven with exactly the header shapes the
16+
* real door produces.
17+
*/
18+
19+
import { describe, it, expect, vi, beforeEach } from 'vitest';
20+
import type { Hono } from 'hono';
21+
22+
const mockDispatcher = {
23+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
24+
handleAuth: vi.fn(),
25+
dispatch: vi.fn(),
26+
};
27+
28+
vi.mock('@objectstack/runtime', () => ({
29+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
30+
}));
31+
32+
import { createHonoApp } from './index';
33+
34+
const PREFIX = '/api/v1';
35+
const PATH = `${PREFIX}/actions/sys_user/impersonate_user`;
36+
37+
const kernel = { name: 'test-kernel' } as any;
38+
const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX });
39+
40+
const COOKIES = [
41+
'better-auth.session_token=new; Path=/; HttpOnly',
42+
'better-auth.admin_session=adm; Path=/; HttpOnly',
43+
];
44+
45+
const setCookieLines = (res: Response): string[] =>
46+
typeof (res.headers as any).getSetCookie === 'function' ? (res.headers as any).getSetCookie() : [];
47+
48+
describe('#27506: a string[] header value is one header line per element', () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
mockDispatcher.handleAuth.mockResolvedValue({ handled: false });
52+
});
53+
54+
it('`response.headers` — every Set-Cookie line reaches the caller separately, a string value as before', async () => {
55+
mockDispatcher.dispatch.mockResolvedValue({
56+
handled: true,
57+
response: {
58+
status: 200,
59+
body: { success: true, data: { ok: true } },
60+
headers: { 'set-cookie': COOKIES, 'set-auth-token': 'tok_impersonated' },
61+
},
62+
});
63+
64+
const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });
65+
66+
expect(res.status).toBe(200);
67+
expect(setCookieLines(res)).toEqual(COOKIES);
68+
expect(res.headers.get('set-auth-token')).toBe('tok_impersonated');
69+
await expect(res.json()).resolves.toEqual({ success: true, data: { ok: true } });
70+
});
71+
72+
it('`result.type: stream` with a raw stream — array headers append, strings set', async () => {
73+
const stream = new ReadableStream({
74+
start(controller) { controller.enqueue(new TextEncoder().encode('data: x\n\n')); controller.close(); },
75+
});
76+
mockDispatcher.dispatch.mockResolvedValue({
77+
handled: true,
78+
result: { type: 'stream', stream, headers: { 'set-cookie': COOKIES, 'x-one': 'line' } },
79+
});
80+
81+
const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });
82+
83+
expect(res.status).toBe(200);
84+
expect(setCookieLines(res)).toEqual(COOKIES);
85+
expect(res.headers.get('x-one')).toBe('line');
86+
});
87+
88+
it('`result.type: stream` with event frames — array headers append on the built Headers', async () => {
89+
mockDispatcher.dispatch.mockResolvedValue({
90+
handled: true,
91+
result: {
92+
type: 'stream',
93+
events: (async function* () { yield { type: 'text', value: 'hi' }; })(),
94+
headers: { 'set-cookie': COOKIES, 'x-one': 'line' },
95+
},
96+
});
97+
98+
const res = await bootApp().request(`http://localhost${PATH}`, { method: 'POST' });
99+
100+
expect(res.status).toBe(200);
101+
expect(setCookieLines(res)).toEqual(COOKIES);
102+
expect(res.headers.get('x-one')).toBe('line');
103+
});
104+
});

0 commit comments

Comments
 (0)