Repository navigation
fix(runtime)!: a same-origin type: 'api' action is dispatched through the action doors, so its packaged-action switch holds (#27506) - #27649
Conversation
…he action doors, so its packaged switch holds `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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude <noreply@anthropic.com>
…n the route-envelope ledger Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37a7f242a78b59541767b9e4befdb96811ceda0f && git checkout 37a7f242a78b59541767b9e4befdb96811ceda0f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6bc764332aecfb63345e2abd5d2a3c1b550e114f 533f8da14dabc67b793283120aaa297e10b753bf && git checkout -B drift-repro 6bc764332aecfb63345e2abd5d2a3c1b550e114f && git merge --no-ff 533f8da14dabc67b793283120aaa297e10b753bf
node scripts/docs-audit/affected-docs.mjs --json 6bc764332aecfb63345e2abd5d2a3c1b550e114f
|
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head 75d7d9e · 2026-10-29T16:16Z |
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Contract reviewServed-tier: Check-runs on the head, read 2026-10-29T16:28Z (first read 2026-10-29T16:18Z): ① Derived judgmentsWidenings the diff implies — each named right:
Narrowings the diff implies — each named right, with one wrong carrier:
Published surface:
Declaration line: ② Semver level
What the changeset owes before it is right (all on the same file, one edit):
Prose check: ③ Boundary flagsEvery flag in the dev's os-dev-report (6502065277) and the ruling's execution text, answered or escalated:
④ Design judgmentJudged against the ruling's scenario (one system for people and agents: a switched-off packaged api action refused at every door, an on api action invocable headlessly through the same doors) and against the code each hunk cites.
Tests — Must-fix (each a FAIL on its own):
Should-fix: Implemented-by: VERDICT: FAIL Reviewed 2026-10-29T16:33Z. Landing stays behind #27610 whatever the next record says. |
… for non-JSON, {{page.*}}-only refusal; adapters append array headers
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://<Host header><path>`, 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.<path>}}` 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 <noreply@anthropic.com>
type: 'api' action is dispatched through the action doors, so its packaged-action switch holds (#27506)
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head f72f7d0 · 2026-10-29T17:32Z |
…ns; test-typecheck debt re-recorded downward 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 <noreply@anthropic.com>
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head a87722d · 2026-10-29T17:42Z |
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Contract reviewServed-tier: Check-runs on ① Derived judgmentsThe previous record's findings, each against the delta:
No new accept-set or published-surface change rides the delta beyond those: ② Semver level
③ Boundary flags
④ Design judgmentEach delta hunk against the code it cites and the ruling's scenario:
No must-fix remains from the previous record and the two commits introduce none. Should-fix, none on the tree: the three stale PR-body sentences named under ② (the seat's ACCEPT write), and the Implemented-by: VERDICT: PASS Reviewed 2026-10-29T18:01Z. Landing stays behind #27610. |
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw 维护者速读(席位终稿)
席位意见:接受,落地排在 #27610 之后。契约复核两轮:75d7d9e1 FAIL(6502515189),a87722dd PASS(6504462099)。三条必改和六条应改都已在树上修复,每条都有测试钉住。 席位核对 — ACCEPT · domain:server#1 · head a87722d · 2026-10-29T18:05Z
|
…osure policy + troubleshooting row, actions-as-tools dispatch table) 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 <noreply@anthropic.com>
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw Regen-provenance: 6504462099 · Prose arm: the head moved by one docs commit ( |
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw 维护者速读(席位终稿)
席位意见:接受,落地排在 #27610 之后。契约复核两轮:75d7d9e1 FAIL(6502515189),a87722dd PASS(6504462099)。三条必改和六条应改都已在树上修复,每条都有测试钉住。 席位核对 — ACCEPT (refresh on the docs head; supersedes 6504559626) · domain:server#1 · head 533f8da · 2026-10-29T18:57Z
|
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw
Requested by Sales · project thread
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/specchanges. Two published runtime surfaces narrow for their consumers:POST /actions/_activation/:object/:actionnow refuses (409) to flip an off-origintype: 'api'action it accepted and recorded before, and theheadersmember of@objectstack/runtime'sHttpDispatcherResult.responseis widened fromstringvalues tostring | string[], which a reader that assigned a value to astringno longer compiles against. An api action that reachesPOST /actions/:object/:actionor MCPrun_actionnow 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(thetypetable and the "Call it over REST" dispatch table) now state the same-origin forward and the off-origin refusal; no file underskills/**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).POST /api/v1/actions/_activation/:object/:action. Before: flipping atype: 'api'action whosetargetis 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 refused409(error.codeRESOURCE_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'stargetonto 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.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/actionsdoor emits one forSet-Cookie. Who sees it: an adapter or host outside this repository that reads the member and hands each value to astring-typed header setter (error TS2345). The remedy, one line: branch onArray.isArray(value)and emit each element as its own header line (Hono:c.header(name, line, { append: true }); FetchHeaders:headers.append(name, line)); joining the array with,is NOT equivalent forSet-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'stargetdirectly, the/actionsdoor answered 400 for the type, and MCPrun_actionrefused it outright — "Ban User switched off" still banned, and an agent could not do what a human click could.POST /api/v1/actions/:object/:actionand MCPrun_actionaccept atype: 'api'action whosetargetis a path on this origin. The door runs every gate it already runs — the sign-in floor, ADR-0066 D4requiredPermissions, 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/verifydrives) 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/recordIdFieldfrom the subject row, refused by name when the row cannot supply it;bodyShape.wrap; theoutputs.*fold;organizationIdfor an/api/v1/auth/target;bodyExtralast;method, no body onDELETE;${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 (dataunwrapped, as the console's direct fetch handedresult.data), and a credential rotation it emits is relayed: on REST everySet-Cookieline plusset-auth-token/set-admin-session-token, soimpersonate_userworks through the door on the cookie lane and the bearer lane; over MCP only the bearer pair (set-auth-token/set-admin-session-token) — aSet-Cookievalue never enters a tool result. The forward URL is built from the incomingHostalone (x-forwarded-*cross only as headers, for the target's owntrustProxyreading), and a 2xx target answer that is not JSON is refused502(EXTERNAL_SERVICE_ERROR) naming its content-type.ACTION_DISABLEDand 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.POST /actions/_activation/:object/:actionrefuses to flip them (409) with the reason — a ledger row nothing would consult is not recorded.HttpDispatcherResult.response.headersacceptsstring[](one header line per element) and both in-repo Hono adapters append an array instead of comma-joining it (@objectstack/plugin-hono-server's routeres.header, and@objectstack/hono'stoResponseon its response, event-stream and raw-stream arms) — the only correct spelling for severalSet-Cookielines. Measured:/admin/impersonate-useranswers 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 theHostbackfill) andpackages/adapters/hono/src/index.tswith its new testhono-response-header-array.test.ts— all needed for the cookie lane; the probe before the red tests showed sixSet-Cookielines that aRecord<string, string>channel could not carry. The pre-existing pinhttp-dispatcher.actions-type-dispatch.test.tsis rewritten to the new contract (forward through the injector; 503 withouthttp.server), andpackages/runtime/test-typecheck-debt.jsonshrinks that file's TS18048 row 24 → 22.Evidence
app.requestto/admin/ban-user,/admin/unban-userand/admin/impersonate-userwith the caller's bearer, and with the caller's cookie — all 200;sys_user.bannedflips; impersonate answers 6Set-Cookie+set-auth-token(+set-admin-session-tokenon the bearer lane), and the caller's old bearer dies (better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243).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.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; MCPrun_actionon a same-origin api action reaches the exposure gate instead of the type refusal; impersonate_user on the cookie lane (≥2Set-Cookielines, one per line) and the bearer lane (rotation headers, old token 401): 8/8.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.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 112 families (93 for the diff + 19 thescripts/check-route-envelope.mjsledger edit adds); every command run with its exit captured before any pipe;--ranreconciliation: 112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN. Four prerequisites cleared and re-run to 0 (teardown-shape self-test aftergit 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 declaresactions.tshandBuilt 3 — the 405 and the forward's two header-carrying answers).维护者速读(席位终稿)
type: 'api'动作改为经/actions门与 MCPrun_action派发:过完现有各闸后在进程内转发到声明的target,请求体按控制台规则在服务端组装,调用方凭据带过去,目标的 Set-Cookie / 令牌轮换带回来;外站 api 动作两门仍拒、写入门拒绝切换并说明原因;响应头通道支持多值。HttpDispatcherResult.response.headers放宽为string | string[](外部适配器一行修法见 BREAKING 段)。MCP 只回传 bearer 令牌对,Set-Cookie 不进工具结果。控制台直调不受影响直到 Console: send a same-origintype: 'api'action click throughPOST /actions/:object/:actioninstead of fetching itstargetdirectly (ruling on #27506, step 3 of 3) #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 闭包)。type: 'api'actions are dispatched by the platform action door (ruling on #27506, step 1 of 3) #27610(Tier H,ADR 文本),席位随后把本 PR 转 ready 入队;Console: send a same-origintype: 'api'action click throughPOST /actions/:object/:actioninstead of fetching itstargetdirectly (ruling on #27506, step 3 of 3) #27611 等本 PR。MCP 对 sys_* 动作的拒绝另立 mcp: run_action refuses every action on a sys_* object, so an agent cannot reach the identity actions (ban_user, impersonate_user, set_user_password) that REST POST /actions serves #27681 待裁。席位意见:契约复核两轮(75d7d9e1 FAIL → a87722d PASS,记录 6504462099),三条必改与六条应改均在树上修复并有钉;建议在 #27610 合并后落地。
Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw