Repository navigation
fix(rest)!: the approvals actions door answers 404 REQUEST_NOT_FOUND for an unknown or invisible request, as its parent door does - #27790
Conversation
… parent request Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL
…for an unreadable parent request GET /approvals/requests/:id/actions listed rows without resolving the parent request, so an unknown id, and an existing request the caller cannot see, answered 200 with an empty list while the parent door answered 404. Both doors now share one lookup and one 404 envelope. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL
…side the actions door Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 19 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 be1ac699f5e556aebfffb9a00d08d3f80a802fa1 && git checkout be1ac699f5e556aebfffb9a00d08d3f80a802fa1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 968cacfd10fdec64b6089d0da4f721f1a8a10925 acf67294ccc7bf52a92ac5963d4131bcd180f458 && git checkout -B drift-repro 968cacfd10fdec64b6089d0da4f721f1a8a10925 && git merge --no-ff acf67294ccc7bf52a92ac5963d4131bcd180f458
node scripts/docs-audit/affected-docs.mjs --json 968cacfd10fdec64b6089d0da4f721f1a8a10925
|
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head acf6729 · 2026-10-29T23:19Z |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract reviewServed-tier: Inputs read: cards #27451 and #27493 with every comment (triage 6491511316 and 6495966619), the PR body and file list, the net diff ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing makes the service's own refusal real at the sub-resource. Consumers tried, in the cited code:
Findings:
No must-fix. Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL 维护者速读
席位意见:ACCEPT。 席位核对Head-sha:
|
Requested by Elon · project thread
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.mdxnames theapprovals.listActions(requestId)call only维护者速读
GET /api/v1/approvals/requests/:id/actions遇到不存在、或调用者无权看到的审批单,改为回 404REQUEST_NOT_FOUND(与父接口GET …/requests/:id同一个查找、同一个响应体),不再回 200 空列表。REQUEST_NOT_FOUND, the same as the parent door.」。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_FOUNDinstead of200 {"data":[]}. The changeset shipspatchwith the ADR-0087 dispositionnot-required (no-migration-prescription): no metadata key changes, only the HTTP answer for a request the caller cannot read.What changes
GET /api/v1/approvals/requests/UNKNOWN/actions200 {"data":[]}404 {"code":"REQUEST_NOT_FOUND","error":"Approval request 'UNKNOWN' not found"}GET …/requests/ID/actions, ID exists but the caller cannot see it200 {"data":[]}404the parent door gives for IDGET …/requests/ID/actions, ID visible to the caller200 {"data":[...]}The handler now resolves the parent request before listing. The lookup and the
404envelope are the parent door's own: they move into one local helper,readableRequestOr404, insideregisterApprovalsEndpoints, and bothGET …/requests/:idandGET …/requests/:id/actionscall it. There is no second copy of the rule. The parent door's answer is byte-for-byte what it was.IApprovalService.getRequestanswersnullboth 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.listActionsthrows on any non-2xx answer, so it now rejects for such an id instead of resolving[]. In-repo UI callers:RecordApprovalsPanel.tsxalready wraps eachlistApprovalActions(id)in.catch(() => []);approvalRequestsDataSource.ts's timeline read andapprovalsApi.tsread 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
packages/rest/src/rest-approvals-actions-unknown-request.test.ts, the realRestServerroutes over an approvals stub that models the service's rule (getRequestisnullfor an unknown id and for a non-participant;listActionsis gated bygetRequest, asApprovalServicegates it).404,code: REQUEST_NOT_FOUND, body deep-equal to the parent door's body, andlistActionsis never called.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.listActionsis never called.200 {"data": ROWS}with the rows unchanged, andlistActionscalled once with the id and the caller's context. The parent door still answers200with the row.Acceptance notes
getRequestread beforelistActions, which runs its owngetRequestas 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, becausepackages/plugins/plugin-approvalsis outside this card's file surface.404now 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'sGETbefore, so the new file pins it too: its404(unknown and invisible) and its200row (visible).Verification
Final head
acf67294, base481ade61(origin/main). The commit after3e605bacadds assertions to the new test file only.Pins before the fix (b21c97f, test only): 2 failed, 1 passed
Ablation at 3e605ba: parent lookup removed, 2 failed, 1 passed; restored blob == HEAD
Through
scripts/ablation-replace.mjsin wrap mode. The anchorif (!(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 fromsrcby a relative path, so nodistleg applies.pnpm --filter @objectstack/rest testat3e605bac: Test Files 343 passed (343), Tests 6695 passed, 326 skipped (7021),VERDICT command-exit 0. The new file again atacf67294: Tests 3 passed (3).pnpm --filter @objectstack/rest typecheck(tsc pluscheck:test-typecheck, which compiles the test layer undertsconfig.test.json): exit 0.eslint --no-inline-configon both touched source files: exit 0. The repo-widepnpm lintis left to CI.acf67294(owed by the(narrowing)arm), underos-verify-lockwith CI's commandpnpm --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.acf67294(dispatch-gates --commands, re-derived after the last commit, same 59 families as at3e605bac): reconciled with--ran,59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN. Every run family exited 0, includingcheck-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-envelopeandcheck: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'sdist/. CI'sLint & Repo Gatesruns it.🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL