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 f198861ec42e881c8ab582b07bf3ea36ea5a0ff5 && git checkout f198861ec42e881c8ab582b07bf3ea36ea5a0ff5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0561a988cb0b8b73becf80bbac8b3a407ed16e7e a87722dd95a350b6ed0592c4698ebb23b6dee758 && git checkout -B drift-repro 0561a988cb0b8b73becf80bbac8b3a407ed16e7e && git merge --no-ff a87722dd95a350b6ed0592c4698ebb23b6dee758
node scripts/docs-audit/affected-docs.mjs --json 0561a988cb0b8b73becf80bbac8b3a407ed16e7e
|
|
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 |
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; an api action that reaches the doors now passes their gates 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.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 — everySet-Cookieline,set-auth-token,set-admin-session-token— is relayed, soimpersonate_userworks through the door on the cookie lane and the bearer lane.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 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 the Hono adapter's routeres.headerappends an array instead of comma-joining it — 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) andplugin-hono-server/src/adapter.ts(the array append, 10 lines) — both needed for the cookie lane; the probe before the red tests showed sixSet-Cookielines that aRecord<string, string>channel could not carry.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 动作两门仍拒、写入门拒绝切换并说明原因;响应头通道支持多值。type: '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,再合本 PR;Console: send a same-origintype: 'api'action click throughPOST /actions/:object/:actioninstead of fetching itstargetdirectly (ruling on #27506, step 3 of 3) #27611 等本 PR。Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw