You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2bfdc42
Browse filesBrowse the repository at this point in the historyBrowse files
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)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XL51wryBuAmngcxc4MhBYSV)_Fixes#28932
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么**:`type: 'api'` 动作的 `target`
里带记录占位(`{id}`、`${ctx.recordId}`、`{字段}`)时,调用方没传
`recordId`(或传了空串),动作入口现在直接回 400 `VALIDATION_ERROR`,消息点名缺的是
`recordId`(记录上缺字段则点名那个字段),不再转发。REST 动作入口和 MCP `run_action` 共用同一处判断。
- **为什么改**:原来占位被清空或原样留在路径里转发出去,路由匹配不上,调用方收到 404
`ENDPOINT_NOT_FOUND`「端点不存在」,会以为路由坏了,而不是自己漏了参数。
- **风险与回滚**:低。只把原来就失败的调用(404)改成 400 并说清原因;带真实 id 的调用(200)和未知 id
的调用(目标自己的 404 `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 `api` action over REST or MCP
without its `recordId` was told the endpoint does not exist (404
`ENDPOINT_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-origin `type: 'api'` action, and both action doors call it: REST
`POST /api/v1/actions/:object/:action` (`domains/actions.ts`) and the
MCP `run_action` bridge (`invokeBusinessAction`). Step 1 of it, the
path, now refuses a record token it cannot fill, before anything is
forwarded:
- **`${ctx.recordId}` left unresolved.** An empty `recordId` now counts
as none (`input.recordId || record.id || undefined`), so
`${ctx.recordId}` stays in place instead of being blanked to `''`. It is
refused naming `recordId`, 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`, `undefined` or `''`) 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: naming `recordId` when the
caller named no record, otherwise naming the field. Without a row,
`{id}` is filled from the routed `recordId`, the same source
`recordIdParam` already uses. Both doors always pass a row, so that
branch is reachable only by a direct caller of the function.
- **The refusal shape is the existing one.** The function returns `{
status: 400, message }`, as its other refusals do. The REST door answers
it through `deps.error(message, 400)` and the MCP door throws it with
`standardErrorCodeForHttpStatus(400)`, so both answer
`VALIDATION_ERROR`. No new error code. No runtime string carries a
tracker number.
- **Changeset** `.changeset/api-action-missing-record-id.md`: `patch` on
`@objectstack/runtime`, with both declaration lines.
**Step 1 measurement (the order's ruling 2).** Every `api` action in
this repo whose `target` carries a `{field}` token names `{id}` alone:
the nine `sys_approval_request` decision actions in
`packages/plugins/plugin-approvals/src/sys-approval-request.object.ts`
(`approve`, `reject`, `reassign`, `revise`, `request-info`, `remind`,
`recall`, `resubmit`, `comment`) and the two `sys_api_key` actions in
`packages/platform-objects/src/identity/sys-api-key.object.ts`.
`ui/apps/console/src/dev/DevRecordHeaderActions.tsx` has 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 in
`examples/app-showcase/qa/platform-smoke.test.json`, not an action.
hotcrm (`d4ffe99`) has none.
## Tests
- **Unit, new block at the end of
`packages/runtime/src/domains/api-action-forward.test.ts`** (5 tests):
- REST: the approve shape (`/api/v1/approvals/requests/{id}/approve`)
with `{ params: {} }` and with `{ recordId: '', params: {} }`: 400, code
`VALIDATION_ERROR` (pinned against `standardErrorCodeForHttpStatus(400)`
and literally), the message names `` `recordId` ``, and the in-process
request is never called.
- REST: a `${ctx.recordId}` target (a `DELETE`) and a `{email}` target,
each with no id and with `''`: the same refusal, nothing forwarded.
- REST controls: a real id is forwarded (200, the id in the URL); an
unknown id is forwarded and the target's 404 `REQUEST_NOT_FOUND` is
relayed; a target with no token is forwarded with no id (200).
- MCP `run_action`: the approve shape with no id and with `''` is thrown
400 `VALIDATION_ERROR` naming `recordId`, nothing forwarded.
- `buildApiActionRequest`: a row without `email`, with `email: null` and
with `email: ''` is refused naming `` `email` ``; no row at all names
`recordId`; `{id}` without a row is filled from the routed id,
percent-encoded.
- **Real stack, two new cases in
`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_approve` with `{
params: {} }` and with `{ recordId: '' }`: 400 `VALIDATION_ERROR` naming
`recordId`, and the request is untouched (no approve row, slate
unchanged).
- Control: an id that names no request answers the decision route's 404
`REQUEST_NOT_FOUND`. The existing case in the same file already pins the
real-id 200.
- **Red on base `f7c545ad`.** Unit file with `action-execution.ts`
checked out from base: 4 failed, 69 passed (the four refusal tests; all
controls green). Verify file with runtime `dist` rebuilt 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 --porcelain`
empty, blob equal to HEAD, runtime rebuilt.
- **Green on head.** Unit file 73/73. Verify file 5/5.
<details><summary>Ablation (one-off, from committed HEAD)</summary>
Both guard lines replaced through `node scripts/ablation-replace.mjs`
(anchor 1 → 0 each): the `${ctx.recordId}` check by one that tests for
the marker `ABLATED-28932-A`, and the `{field}` check by one that tests
for `ABLATED-28932-B`, so the helper stays referenced and the build
still type-checks.
- Unit file (reads `action-execution.ts` from source): 4 failed, 69
passed, the same four tests as on base.
- Verify file (reads runtime `dist`): `pnpm --filter
@objectstack/runtime build`, then `node
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 400 `FILTER_TOKEN_UNKNOWN`; the pin still reds because the code
is not `VALIDATION_ERROR`.
- Restore: `git checkout HEAD -- PATH`, `git diff HEAD` 0 bytes, blob
`12811782c09a` equal to HEAD; rebuilt, and `ablation-dist-preflight.mjs
--absent` answered "marker absent from all 12 built files" and "working
tree clean against HEAD".
</details>
## Acceptance notes
- **Accept set.** Every call refused now was already a failure on
`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, 400
`FILTER_TOKEN_UNKNOWN`). Hence `Clause-②: 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).
- **The console path is not touched.** `ui/` resolves its own click-time
tokens; this PR changes only the server's forward.
- **Ordering of refusals.** The record-token refusal runs before the
same-origin check and the body checks, so a call that is missing its id
is told that first.
## Verification
<details><summary>Package tests and typecheck</summary>
All readings at head `f71c6e23` (base `f7c545ad`), after `pnpm install
--frozen-lockfile` and `pnpm --filter '@objectstack/verify^...' build`
(which covers the runtime closure).
- `pnpm --filter @objectstack/runtime test`: 513 files passed, 8272
tests passed, 19 skipped. Lock verdict `command-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: OK` with 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.
</details>
<details><summary>Derived gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands` at `f71c6e23`: 61
commands. 60 ran and exited 0.
- `dispatch-gates --ran`: 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.
- NOT MEASURED: `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-test` first answered exit 3 on the
shallow clone; after `git fetch --depth=1` of the commit it reads, it
answered exit 0 (48 cases), and that is the code recorded.
- Not run locally, as the os-dev scope says: `test:repo`, the repo-wide
lint, and the families outside the derivation. The PR's CI check-runs
carry them.
- No dogfood battery: the title has no `!`, and `Clause-②` has no
`(narrowing)` arm.
</details>
## Human Minimum
None: the seat reviews and lands it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5nNCtq276yJtjqKq8B)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(runtime): an `api` action whose `target` names its record is refused `400` naming `recordId` when called with no record id, instead of being forwarded to a path that reaches no route
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
A same-origin `type: 'api'` action whose `target` carries a record token (`{id}`, `${ctx.recordId}`, or a `{field}` read from the record, for example `approval_approve` → `/api/v1/approvals/requests/{id}/approve`) used to be forwarded even when the caller gave no `recordId`, or an empty one: the token was blanked or left as written, the forwarded path matched no route, and the action door answered `404 ENDPOINT_NOT_FOUND`, telling the caller the endpoint did not exist.
11
+
12
+
Both action doors (REST `POST /api/v1/actions/:object/:action` and the MCP `run_action` tool) now refuse that call `400 VALIDATION_ERROR` before any forward, naming the missing input: `recordId` when no record was named, otherwise the field the record has no value for. A call with a real id, and a call with an id the target does not know, are forwarded and answered by the target as before.
it('REST, the controls: a real id is forwarded (200), an unknown id gets the target\'s 404 REQUEST_NOT_FOUND, a tokenless target is forwarded with no id',async()=>{
it('MCP `run_action`: the approve shape with no `recordId`, or an empty one, is thrown 400 VALIDATION_ERROR naming it, and nothing is forwarded',async()=>{
0 commit comments