Repository navigation
feat(plugin-approvals): ApprovalService.handleActionPage serves the ADR-0043 action page from a Request (segment 4 of #22438) - #22641
Conversation
…DR-0043 action page from a Request The transport-neutral member the approvals contract declares: GET renders the confirm page, POST redeems, from a web-standard Request to a Response, through the same peek/redeem methods and the same renderers the self-hosted raw-app mount uses. The mount's route handlers are unchanged; its no-raw-app branch now says once where the pages are served instead. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…provals-action-page-member
📓 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:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 7041c60bca4c1609c8eed634c6e11bab0684d2ff && git checkout 7041c60bca4c1609c8eed634c6e11bab0684d2ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b99388505d33c940757e85685ca925d6cd57f04 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304 && git checkout -B drift-repro 1b99388505d33c940757e85685ca925d6cd57f04 && git merge --no-ff 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304
node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04
|
Contract reviewServed-tier: PR #22641 on card #22578, segment 4 of the maintainer's ruling A on #22438 (director record Check-runs on the head: 35 check names, latest run per name, all completed: 33 ① Derived judgments
② Semver levelClause-②: yes
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered 2026-10-10T08:23Z on the head named above; the check-run reading is the one declared in the summary line. |
Fixes #22578
Clause-②: yes
Segment 4 of 4 of the maintainer's ruling A on #22438 (director record
6079645593, 「A + 扫类」; split request6092059305). It implements segment 1's member (#22575, landed asb27b89474d). Dispatched by thedomain:servicesseat 1 (seat post #6021), claim6094944537, sessionsession_013j5gkUCpqQiti4GgPqqmnt.What changes
ApprovalService.handleActionPage(request)(packages/plugins/plugin-approvals/src/approval-service.ts) implements the optionalIApprovalService.handleActionPagemember: the ADR-0043 action page from a web-standardRequestto aResponse.GETreadstokenfrom the query string and renders the confirm page, or the result page for a dead link. It never decides.POSTreadstokenfrom the form body and redeems it. A repeatedtokenfield resolves to its last value, the way the mount's body parser reads it. A body that is not a form carries no token and is told "invalid".200.405withAllow: GET, POST. Neither the contract docblock nor ADR-0043 names a third method, so this is the narrowest honest answer.peekActionToken/redeemActionTokenand the same renderers (action-link-pages.ts, unchanged) as the mount. There is no second token check, no second page text and no copied redemption chain. It reads no context, session, cookie orAuthorizationheader./api/v1/approvals/act, the mount's path.actionLinkUrlnow reads the same module constant (its output bytes are unchanged).approvals-plugin.ts,mountActionPages): its route handlers are byte-unchanged and it does not call the member. The one edit is oneinfoline in the no-raw-app branch, saying the pages are served through the HTTP dispatcher's/approvals/actdomain (it was silent). There is no double mount.honojoinsplugin-approvals' devDependencies (the workspace's^4.13.9; the lockfile resolves it to the existing 4.13.12), so pin 2 drives the mount through a real Hono app..changeset/22578-approvals-action-page-member.md,minor.Parity evidence
src/action-page-member.integration.test.tsboots a realObjectKernel:ObjectQLPlugin, the realApprovalsServicePlugin, and@objectstack/driver-sqlon in-memory better-sqlite3. A host plugin giveshttp.servera realnew Hono()as its raw app, so the mount is the plugin's ownmountActionPages, registered atkernel:ready. The member is read off the startedapprovalsslot. Both doors sit on one token store.For each case, the same
Requestgoes to the member, then toapp.fetch. The test comparesstatus, the full header list and the body as aUint8Array:text/plainbody; and a repeatedtokenfield whose last value is dead (both doors answer "invalid", and the live first value stays live at both).The mount on
main, measured directly. The parity and one-store pins were also run withapprovals-plugin.tsput back tomain's exact file.scripts/ablation-replace.mjsdeleted the one added line. The on-disk blob was51fd00eb78, equal tomaine8c6666870's blob for that path. The result was 5 passed, 7 skipped by-t "parity|one token store". The file was then restored to the HEAD blob76cfb41fe7, andgit diff HEADwas empty. So the member's bytes equal the bytes of the mount that ships onmain, not only of this branch's copy.Pins
Request. It covers a live token's confirm page (200,text/html; charset=utf-8, form action/api/v1/approvals/act).GETnever decides: after 5 GETs the request is stillpendingwith no decision row, and the token is still live (the nextPOSTdecides). APOSTapprove and aPOSTreject decide as the bound approver: the action row hasactor_idandacted_asset to the approver, not a system actor, and the flow resumes down the matching edge. Dead links (unknown, expired, consumed) answer200with the result page throughGETandPOST. A non-form body is told "invalid".PUT,DELETE,PATCH,HEADandOPTIONSanswer405withAllow: GET, POST.consumedthrough it again, with exactly one decision row. A token redeemed through the mount is dead through the member (GET and POST), and the other way round.POSTthat carriesAuthorizationandCookieheaders for someone else decides as the bound approver. A spy over the request'sHeadersrecords noauthorizationorcookieread.app.routesholds exactlyGETandPOSTon the path, plus the existing "mounted at" line. Without one, nothing is mounted and the new line is logged once.Ablation (this layer)
The member was removed with
scripts/ablation-replace.mjs, which renamedasync handleActionPage(toasync handleActionPage_ablated(. The anchor went from 1 hit to 0, and the blob went from39ec14d8d5to3c1005a2d3. The run wasvitest run src/action-page-member.integration.test.ts: 12 of 12 failed. Ten failed withTypeError: svc.handleActionPage is not a function, and the two composition pins failed ontypeofbeingundefined. Restored: the blob is back to39ec14d8d5, equal to HEAD's, andgit diff HEADis empty. The ablation ran atcaed7aa69f.approval-service.ts,approvals-plugin.tsand the test file have the same blobs at the final HEAD60b4b97f37.NOT MEASURED: the cross-segment ablation
The card's third pin ("with the member absent, segment 2's typed refusal answers") is NOT MEASURED. The reason: #22576 (segment 2,
domain:cli) has not landed. It isopen. Onorigin/main1b99388505,git grep -i approvals packages/runtime/src/http-dispatcher.tsexits 1 with 0 hits; the controlgit grep -c -i authon the same file at the same ref exits 0 with 104. It belongs to #22576's pins, since the refusal is that domain's.Verification (HEAD
60b4b97f37;origin/mainmerged atf368b7e980)pnpm --filter @objectstack/plugin-approvals test: 69 files, 1009 tests passed. The new file passes 12 of 12.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0.check:test-typecheckis OK, and the new test file is in thetsconfig.test.jsonprogram (--listFilescounts 1) with zero errors.pnpm --filter @objectstack/spec run test:repo: 54 files, 915 tests passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 79 commands, and all 79 ran.--ranreports: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:i18nfirst refused withPREREQUISITE NOT MET(exit 3). After a full build (turbo run build --filter='!@objectstack/docs', 69 of 72 tasks cached) both exit 0: 107 require entry points load, and all 9 i18n packages are in sync.Acceptance notes
approval-service.ts,approvals-plugin.ts, tests and the changeset. This PR also touchespackages/plugins/plugin-approvals/package.json(devDependencyhono) andpnpm-lock.yaml(+3 lines). The dispatch's pin 2 requires a real Hono raw app, andhonowas not resolvable from this package. Nothing indistorfiles[]changes because of it./approvals/actdomain. That gap ends when segment 2 lands. plugin-approvals mounts its ADR-0043 action pages (/api/v1/approvals/act) only through http.server getRawApp, so a hosted tenant kernel, which has none, answers 404 ROUTE_NOT_FOUND to every approval e-mail link #22438 remains open until all four segments are onmain.HEAD. On the mount, Hono answersHEADitself, at the app level, as aGETwithout a body; the mount's code plays no part. The member answers a forwardedHEADwith405. Whether aHEADreaches the member as aGETdepends on the front in front of the dispatcher. Not measured here.peekActionToken/redeemActionTokenrejects the member's promise, just as it escapes the mount's handler to Hono's error handler. Mapping it is the caller's job (runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576). It is not a pinned parity case..changeset/22578-approvals-action-page-member.mdisminorand carriesClause-②: yes. The PR owes a contract-review-tier record before the queue, which the PM arranges.sys-approval-token.object.ts(plugin-approvals:sys_approval_token(the action-link tokens) is served on the generic data door with no read narrowing, while the approvals door serves its rows to nobody #22616's file),packages/spec,packages/runtimeorplugin-hono-serveris touched.Generated by Claude Code