Repository navigation
fix(runtime): an api action whose target names its record is refused 400 naming recordId when called without one, never forwarded to a path with a hole in it - #29005
Conversation
…naming recordId when none is given, never forwarded with a hole in its path Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nNCtq276yJtjqKq8B
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nNCtq276yJtjqKq8B
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 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 20f25a5b4e9b78f9b62ffabb986cf491686d4853 && git checkout 20f25a5b4e9b78f9b62ffabb986cf491686d4853
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce f71c6e23761c50c33312e94eb5b3338760a8b44c && git checkout -B drift-repro 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce && git merge --no-ff f71c6e23761c50c33312e94eb5b3338760a8b44c
node scripts/docs-audit/affected-docs.mjs --json 5d7cfe6a5ea3c31301f57af52ab72f3f86e593ce
|
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #28932. 4 files (+196 / −7): buildApiActionRequest and two private helpers in runtime/src/action-execution.ts, a unit file, a real-stack verify case and a @objectstack/runtime patch changeset. Metadata-protocol: none. No export, no packages/spec/**, no gate, no new error code (VALIDATION_ERROR / 400, the door's existing refusal). |
| Rule | A target record token the dispatch inputs cannot fill (an unresolved ${ctx.recordId}, an empty recordId counting as none, or a {field} left in the path) is refused 400 naming recordId, or the field the row lacks, before any forward. Both doors (REST /actions and MCP run_action) share it. A field with no value is no longer blanked into the path. {id} with no row fills from the routed id only; the caller-side apiForwardSubjectRecord already supplies {id} when a recordId is given, so a denied subject forwards as before. |
| Premise | Every record-token api target in this repo names {id} alone (9 sys_approval_request, 2 sys_api_key, 1 dev console page; 2 datasource actions use ${ctx.recordId}); examples/ and hotcrm have none. So the no-blanking change reaches no shipped target. |
| Evidence | Unit: 4 red on base, 73/73 on head. Real stack: the card's repro (404 ENDPOINT_NOT_FOUND) on base, 400 on head; the unknown-id control still answers 404 REQUEST_NOT_FOUND and nothing reaches the decision route. Ablation red on both, restore byte-clean. Full runtime suite (8272), typecheck and lint for runtime and verify, 60/61 derived gates (check:dual-build-cjs-loads left to CI). CI on head is running; auto-merge waits for it. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #28932
Clause-②: no
Metadata-protocol: none
维护者速读
type: 'api'动作的target里带记录占位({id}、${ctx.recordId}、{字段})时,调用方没传recordId(或传了空串),动作入口现在直接回 400VALIDATION_ERROR,消息点名缺的是recordId(记录上缺字段则点名那个字段),不再转发。REST 动作入口和 MCPrun_action共用同一处判断。ENDPOINT_NOT_FOUND「端点不存在」,会以为路由坏了,而不是自己漏了参数。REQUEST_NOT_FOUND)照旧转发。回滚即撤销本 PR。packages/runtime/src/action-execution.ts(buildApiActionRequest第 1 步路径拼装里新增的两处拒收和两个小函数);packages/runtime/src/domains/api-action-forward.test.ts(文件末尾新增的一组用例);packages/verify/src/action-door-api-forward-denied-subject.test.ts(真实启动栈上的两条新用例)。pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts,预期 73/73 通过(main 上新增用例 4 条红)。Why
An integration calling a record-anchored
apiaction over REST or MCP without itsrecordIdwas told the endpoint does not exist (404ENDPOINT_NOT_FOUND), so the cost of finding a missing parameter landed on the caller. The door now says which input is missing.What changes
The seam.
buildApiActionRequest(packages/runtime/src/action-execution.ts) assembles the forward for a same-origintype: 'api'action, and both action doors call it: RESTPOST /api/v1/actions/:object/:action(domains/actions.ts) and the MCPrun_actionbridge (invokeBusinessAction). Step 1 of it, the path, now refuses a record token it cannot fill, before anything is forwarded:${ctx.recordId}left unresolved. An emptyrecordIdnow counts as none (input.recordId || record.id || undefined), so${ctx.recordId}stays in place instead of being blanked to''. It is refused namingrecordId, ahead of the browser-only token refusal that would otherwise claim it with a sentence saying the server does interpolate${ctx.recordId}.{field}left unresolved. A field the row has no value for (null,undefinedor'') is left in place, never blanked. After the browser-only refusal (so a{{page.x}}keeps that refusal), any{field}token still in the path is refused: namingrecordIdwhen the caller named no record, otherwise naming the field. Without a row,{id}is filled from the routedrecordId, the same sourcerecordIdParamalready uses. Both doors always pass a row, so that branch is reachable only by a direct caller of the function.{ status: 400, message }, as its other refusals do. The REST door answers it throughdeps.error(message, 400)and the MCP door throws it withstandardErrorCodeForHttpStatus(400), so both answerVALIDATION_ERROR. No new error code. No runtime string carries a tracker number..changeset/api-action-missing-record-id.md:patchon@objectstack/runtime, with both declaration lines.Step 1 measurement (the order's ruling 2). Every
apiaction in this repo whosetargetcarries a{field}token names{id}alone: the ninesys_approval_requestdecision actions inpackages/plugins/plugin-approvals/src/sys-approval-request.object.ts(approve,reject,reassign,revise,request-info,remind,recall,resubmit,comment) and the twosys_api_keyactions inpackages/platform-objects/src/identity/sys-api-key.object.ts.ui/apps/console/src/dev/DevRecordHeaderActions.tsxhas one more{id}target (a dev-only page). Two actions use${ctx.recordId}instead:packages/services/service-datasource/src/datasource-admin-plugin.ts(/test,/migrate-credential).examples/has no record-token api target; its only{{categoryId}}hit is a QA step inexamples/app-showcase/qa/platform-smoke.test.json, not an action. hotcrm (d4ffe99) has none.Tests
packages/runtime/src/domains/api-action-forward.test.ts(5 tests):/api/v1/approvals/requests/{id}/approve) with{ params: {} }and with{ recordId: '', params: {} }: 400, codeVALIDATION_ERROR(pinned againststandardErrorCodeForHttpStatus(400)and literally), the message names`recordId`, and the in-process request is never called.${ctx.recordId}target (aDELETE) and a{email}target, each with no id and with'': the same refusal, nothing forwarded.REQUEST_NOT_FOUNDis relayed; a target with no token is forwarded with no id (200).run_action: the approve shape with no id and with''is thrown 400VALIDATION_ERRORnamingrecordId, nothing forwarded.buildApiActionRequest: a row withoutemail, withemail: nulland withemail: ''is refused naming`email`; no row at all namesrecordId;{id}without a row is filled from the routed id, percent-encoded.packages/verify/src/action-door-api-forward-denied-subject.test.ts(booted stack, real approvals routes, a non-admin pending approver):POST /api/v1/actions/sys_approval_request/approval_approvewith{ params: {} }and with{ recordId: '' }: 400VALIDATION_ERRORnamingrecordId, and the request is untouched (no approve row, slate unchanged).REQUEST_NOT_FOUND. The existing case in the same file already pins the real-id 200.f7c545ad. Unit file withaction-execution.tschecked out from base: 4 failed, 69 passed (the four refusal tests; all controls green). Verify file with runtimedistrebuilt from base: 1 failed, 4 passed, the failure reading{"code":"ENDPOINT_NOT_FOUND","message":"Not found","httpStatus":404}, which is the card's repro. Both trees restored:git status --porcelainempty, blob equal to HEAD, runtime rebuilt.Ablation (one-off, from committed HEAD)
Both guard lines replaced through
node scripts/ablation-replace.mjs(anchor 1 → 0 each): the${ctx.recordId}check by one that tests for the markerABLATED-28932-A, and the{field}check by one that tests forABLATED-28932-B, so the helper stays referenced and the build still type-checks.action-execution.tsfrom source): 4 failed, 69 passed, the same four tests as on base.dist):pnpm --filter @objectstack/runtime build, thennode scripts/ablation-dist-preflight.mjs @objectstack/runtime 'ABLATED-28932-B'answered "marker present in 2 built files". 1 failed, 4 passed. With the guard ablated, the{id}token is no longer blanked, so the forward reaches the route with{id}literally and the route answers 400FILTER_TOKEN_UNKNOWN; the pin still reds because the code is notVALIDATION_ERROR.git checkout HEAD -- PATH,git diff HEAD0 bytes, blob12811782c09aequal to HEAD; rebuilt, andablation-dist-preflight.mjs --absentanswered "marker absent from all 12 built files" and "working tree clean against HEAD".Acceptance notes
main: a forward to a path with a hole in it, answered 404 by the router (or, for an{id}the approvals route read literally, 400FILTER_TOKEN_UNKNOWN). HenceClause-②: no. One odd shape moves from forwarded to refused: a target written with a scope-less${name}or{{name}}whose{name}part matched no row field used to be blanked to$or{}and forwarded; it now stays in place and gets the existing browser-only refusal. No such target exists in this repo,examples/or hotcrm (measurement above).ui/resolves its own click-time tokens; this PR changes only the server's forward.Verification
Package tests and typecheck
All readings at head
f71c6e23(basef7c545ad), afterpnpm install --frozen-lockfileandpnpm --filter '@objectstack/verify^...' build(which covers the runtime closure).pnpm --filter @objectstack/runtime test: 513 files passed, 8272 tests passed, 19 skipped. Lock verdictcommand-exit 0.pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts: 5/5 passed.pnpm --filter @objectstack/runtime typecheck: exit 0,check:test-typecheck: OKwith its ledger held (27 files / 188 errors / 68 signatures, unchanged).pnpm --filter @objectstack/verify typecheck: exit 0,check:test-typecheck: OK(0 / 0 / 0).eslint --no-inline-config packages/runtime packages/verify: exit 0.Derived gates
node scripts/pm/dispatch-gates.mjs --commandsatf71c6e23: 61 commands. 60 ran and exited 0.dispatch-gates --ran: 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET (it needs a full repo build; only the verify closure was built). CI runs it.check-plugin-teardown-shape --self-testfirst answered exit 3 on the shallow clone; aftergit fetch --depth=1of the commit it reads, it answered exit 0 (48 cases), and that is the code recorded.test:repo, the repo-wide lint, and the families outside the derivation. The PR's CI check-runs carry them.!, andClause-②has no(narrowing)arm.Human Minimum
None: the seat reviews and lands it.
Generated by Claude Code