Repository navigation
Commit a3014cb
fix(rest)!: the approvals actions door answers 404 REQUEST_NOT_FOUND for an unknown or invisible request, as its parent door does (#27790)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D2T3MYD69CaE3sEdpqrhNgW)_
Fixes #27451
Fixes #27493
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill or doc teaches this door's answer for an
unknown or invisible request id; `content/docs/api/client-sdk.mdx` names
the `approvals.listActions(requestId)` call only
## 维护者速读
- 改了什么:`GET /api/v1/approvals/requests/:id/actions`
遇到不存在、或调用者无权看到的审批单,改为回 404 `REQUEST_NOT_FOUND`(与父接口 `GET …/requests/:id`
同一个查找、同一个响应体),不再回 200 空列表。
- 为什么改:「没有动作记录」与「没有这张审批单」以前无法区分;分诊裁定「404 `REQUEST_NOT_FOUND`, the same
as the parent door.」。
- 风险与回滚:BREAKING(服务面收窄),只影响对不可读审批单读动作的调用方,SDK 的 `approvals.listActions`
会改为抛错;可见审批单的答复不变。回滚即 revert 本 PR 一个提交。
- 先看:`packages/rest/src/approvals-routes.ts`(`readableRequestOr404` 与
actions
处理器那一行),`packages/rest/src/rest-approvals-actions-unknown-request.test.ts`,`.changeset/27451-approvals-actions-unknown-request-404.md`。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run
src/rest-approvals-actions-unknown-request.test.ts`,预期 3 passed。
- 需要你做的:无,席位落地。
**BREAKING** (a served-set narrowing of one REST door): reading the
action trail of an approval request id that does not exist, or of a
request the caller is not allowed to see, now answers `404
REQUEST_NOT_FOUND` instead of `200 {"data":[]}`. The changeset ships
`patch` with the ADR-0087 disposition `not-required
(no-migration-prescription)`: no metadata key changes, only the HTTP
answer for a request the caller cannot read.
## What changes
| request | before | after |
|---|---|---|
| `GET /api/v1/approvals/requests/UNKNOWN/actions` | `200 {"data":[]}` |
`404 {"code":"REQUEST_NOT_FOUND","error":"Approval request 'UNKNOWN' not
found"}` |
| `GET …/requests/ID/actions`, ID exists but the caller cannot see it |
`200 {"data":[]}` | the same `404` the parent door gives for ID |
| `GET …/requests/ID/actions`, ID visible to the caller | `200
{"data":[...]}` | unchanged |
The handler now resolves the parent request before listing. The lookup
and the `404` envelope are the parent door's own: they move into one
local helper, `readableRequestOr404`, inside
`registerApprovalsEndpoints`, and both `GET …/requests/:id` and `GET
…/requests/:id/actions` call it. There is no second copy of the rule.
The parent door's answer is byte-for-byte what it was.
`IApprovalService.getRequest` answers `null` both for an id it does not
hold and for a request outside the caller's visibility
(`ApprovalService.loadRequest`, `enforceVisibility: true`). So the two
cases still answer alike, and the door reveals nothing about which
requests exist (the no-leak point of #27493).
Consumers: `ObjectStackClient.approvals.listActions` throws on any
non-2xx answer, so it now rejects for such an id instead of resolving
`[]`. In-repo UI callers: `RecordApprovalsPanel.tsx` already wraps each
`listApprovalActions(id)` in `.catch(() => [])`;
`approvalRequestsDataSource.ts`'s timeline read and `approvalsApi.ts`
read the actions of a request they have already resolved. Each reads ids
from the request list or the request read, which apply the same
visibility rule.
## Tests
- New:
`packages/rest/src/rest-approvals-actions-unknown-request.test.ts`, the
real `RestServer` routes over an approvals stub that models the
service's rule (`getRequest` is `null` for an unknown id and for a
non-participant; `listActions` is gated by `getRequest`, as
`ApprovalService` gates it).
- Unknown id: actions `404`, `code: REQUEST_NOT_FOUND`, body deep-equal
to the parent door's body, and `listActions` is never called.
- Existing request, caller cannot see it: actions `404`, body deep-equal
to the parent door's body for the same id. Same status and code as an
unknown id, and the message differs only by the id it names.
`listActions` is never called.
- Visible request: `200 {"data": ROWS}` with the rows unchanged, and
`listActions` called once with the id and the caller's context. The
parent door still answers `200` with the row.
## Acceptance notes
- The fix adds one `getRequest` read before `listActions`, which runs
its own `getRequest` as its tenant gate (`approval-service.ts`,
`listActions`). That is two reads of the parent per actions request. The
service's gate stays as defence in depth for non-REST callers. It is not
removed here, because `packages/plugins/plugin-approvals` is outside
this card's file surface.
- The parent door's handler changed only in shape: its lookup and `404`
now come from the shared helper. That was needed to reuse the rule
without copying it. Its answer is unchanged. No test drove the parent
door's `GET` before, so the new file pins it too: its `404` (unknown and
invisible) and its `200` row (visible).
## Verification
Final head `acf67294`, base `481ade61` (`origin/main`). The commit after
`3e605bac` adds assertions to the new test file only.
<details><summary>Pins before the fix (b21c97f, test only): 2 failed, 1
passed</summary>
```
× an unknown request id is 404 REQUEST_NOT_FOUND with the parent door's envelope, and no rows are listed
× an existing request the caller cannot see answers the same 404 as the parent, indistinguishable from an unknown id
AssertionError: expected 200 to be 404 // Object.is equality
Tests 2 failed | 1 passed (3)
```
</details>
<details><summary>Ablation at 3e605ba: parent lookup removed, 2 failed,
1 passed; restored blob == HEAD</summary>
Through `scripts/ablation-replace.mjs` in wrap mode. The anchor `if
(!(await readableRequestOr404(svc, req.params.id, context, res)))
return;` went from 1 to 0 on disk, and the marker went from 0 to 1. The
subject is imported from `src` by a relative path, so no `dist` leg
applies.
```
× an unknown request id is 404 REQUEST_NOT_FOUND ...
× an existing request the caller cannot see answers the same 404 as the parent ...
AssertionError: expected 200 to be 404 // Object.is equality
Tests 2 failed | 1 passed (3)
ablation-replace: ok restored: blob == HEAD (6dc9976) and `git diff HEAD` is empty
```
</details>
- **Changed-package tests**: `pnpm --filter @objectstack/rest test` at
`3e605bac`: Test Files 343 passed (343), Tests 6695 passed, 326 skipped
(7021), `VERDICT command-exit 0`. The new file again at `acf67294`:
Tests 3 passed (3).
- **Typecheck**: `pnpm --filter @objectstack/rest typecheck` (tsc plus
`check:test-typecheck`, which compiles the test layer under
`tsconfig.test.json`): exit 0.
- **Lint**: `eslint --no-inline-config` on both touched source files:
exit 0. The repo-wide `pnpm lint` is left to CI.
- **Dogfood battery** at `acf67294` (owed by the `(narrowing)` arm),
under `os-verify-lock` with CI's command `pnpm --filter
@objectstack/dogfood test` (no shard, no path), after building its
closure: `VERDICT command-exit 0`. Test Files 355 passed, 3 skipped
(358). Tests 2867 passed, 20 skipped (2887). No failure.
- **Derived gate set** at `acf67294` (`dispatch-gates --commands`,
re-derived after the last commit, same 59 families as at `3e605bac`):
reconciled with `--ran`, `59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN`.
Every run family exited 0, including `check-adr-0087-registration` (one
declared-breaking changeset, `not-required
(no-migration-prescription)`), `check-changeset-no-major`,
`check-empty-changeset`, `check:nul-bytes`, `check:doc-authoring`,
`check:route-envelope` and `check:rest-log-spy-declared`. The two NOT
MEASURED, each exit 3 (PREREQUISITE NOT MET):
- `node scripts/check-plugin-teardown-shape.mjs --self-test`: its
fixture is pinned to a commit that this shallow checkout cannot reach.
CI runs it.
- `pnpm check:dual-build-cjs-loads`: it needs every package's `dist/`.
CI's `Lint & Repo Gates` runs it.
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f5dd057 commit a3014cb
3 files changed
Lines changed: 174 additions & 5 deletions
File tree
- .changeset
- packages/rest/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
114 | 123 | | |
115 | 124 | | |
116 | 125 | | |
| |||
325 | 334 | | |
326 | 335 | | |
327 | 336 | | |
328 | | - | |
329 | | - | |
330 | | - | |
331 | | - | |
332 | | - | |
| 337 | + | |
| 338 | + | |
333 | 339 | | |
334 | 340 | | |
335 | 341 | | |
| |||
581 | 587 | | |
582 | 588 | | |
583 | 589 | | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
584 | 593 | | |
585 | 594 | | |
586 | 595 | | |
| |||
| 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 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
0 commit comments