Repository navigation
Commit 481ade6
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
File tree
- .changeset
- content/docs
- ai
- ui
- packages
- adapters/hono/src
- plugins/plugin-hono-server/src
- qa/dogfood/test
- runtime
- src
- domains
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
111 | | - | |
| 111 | + | |
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | | - | |
247 | | - | |
248 | | - | |
249 | | - | |
250 | | - | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
251 | 255 | | |
252 | 256 | | |
253 | 257 | | |
| |||
393 | 397 | | |
394 | 398 | | |
395 | 399 | | |
396 | | - | |
| 400 | + | |
397 | 401 | | |
398 | 402 | | |
399 | 403 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
575 | 575 | | |
576 | 576 | | |
577 | 577 | | |
578 | | - | |
579 | | - | |
| 578 | + | |
| 579 | + | |
580 | 580 | | |
581 | 581 | | |
582 | 582 | | |
| |||
596 | 596 | | |
597 | 597 | | |
598 | 598 | | |
599 | | - | |
| 599 | + | |
600 | 600 | | |
601 | 601 | | |
602 | 602 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
0 commit comments