Skip to content

Commit 3d0eeef

Browse files
feat(plugin-approvals): ApprovalService.handleActionPage serves the ADR-0043 action page from a Request (segment 4 of #22438) (#22641)
Fixes #22578 Clause-②: yes Segment 4 of 4 of the maintainer's ruling A on #22438 (director record `6079645593`, 「A + 扫类」; split request `6092059305`). It implements segment 1's member (#22575, landed as `b27b89474d`). Dispatched by the `domain:services` seat 1 (seat post #6021), claim `6094944537`, session `session_013j5gkUCpqQiti4GgPqqmnt`. ## What changes - **`ApprovalService.handleActionPage(request)`** (`packages/plugins/plugin-approvals/src/approval-service.ts`) implements the optional `IApprovalService.handleActionPage` member: the ADR-0043 action page from a web-standard `Request` to a `Response`. - `GET` reads `token` from the query string and renders the confirm page, or the result page for a dead link. It never decides. - `POST` reads `token` from the form body and redeems it. A repeated `token` field 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". - A dead link (unknown, expired, consumed, decided, reassigned) is told on the page with `200`. - Any other method: `405` with `Allow: GET, POST`. Neither the contract docblock nor ADR-0043 names a third method, so this is the narrowest honest answer. - It goes through the same `peekActionToken` / `redeemActionToken` and 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 or `Authorization` header. - The confirm page's form posts to `/api/v1/approvals/act`, the mount's path. `actionLinkUrl` now reads the same module constant (its output bytes are unchanged). - **The self-hosted raw-app mount** (`approvals-plugin.ts`, `mountActionPages`): its route handlers are byte-unchanged and it does not call the member. The one edit is one `info` line in the no-raw-app branch, saying the pages are served through the HTTP dispatcher's `/approvals/act` domain (it was silent). There is no double mount. - **Test infrastructure:** `hono` joins `plugin-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:** `.changeset/22578-approvals-action-page-member.md`, `minor`. ## Parity evidence `src/action-page-member.integration.test.ts` boots a real `ObjectKernel`: `ObjectQLPlugin`, the real `ApprovalsServicePlugin`, and `@objectstack/driver-sql` on in-memory better-sqlite3. A host plugin gives `http.server` a real `new Hono()` as its raw app, so the mount is the plugin's own `mountActionPages`, registered at `kernel:ready`. The member is read off the started `approvals` slot. Both doors sit on one token store. For each case, the same `Request` goes to the member, then to `app.fetch`. The test compares `status`, the full header list and the body as a `Uint8Array`: - **GET:** a live approve token and a live reject token (the confirm page, which carries the token and the form action), and an unknown, an expired and a consumed token. - **POST:** an unknown, an expired and a consumed token; a JSON body; a `text/plain` body; and a repeated `token` field whose last value is dead (both doors answer "invalid", and the live first value stays live at both). - **POST, live:** two twin requests (same flow, step, record title and approver), one token each, one redeemed per door. The pages are byte-equal, and both requests are decided as the bound approver. **The mount on `main`, measured directly.** The parity and one-store pins were also run with `approvals-plugin.ts` put back to `main`'s exact file. `scripts/ablation-replace.mjs` deleted the one added line. The on-disk blob was `51fd00eb78`, equal to `main` `e8c6666870`'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 blob `76cfb41fe7`, and `git diff HEAD` was empty. So the member's bytes equal the bytes of the mount that ships on `main`, not only of this branch's copy. ## Pins 1. **The member renders and redeems from a `Request`.** It covers a live token's confirm page (`200`, `text/html; charset=utf-8`, form action `/api/v1/approvals/act`). `GET` never decides: after 5 GETs the request is still `pending` with no decision row, and the token is still live (the next `POST` decides). A `POST` approve and a `POST` reject decide as the bound approver: the action row has `actor_id` and `acted_as` set to the approver, not a system actor, and the flow resumes down the matching edge. Dead links (unknown, expired, consumed) answer `200` with the result page through `GET` and `POST`. A non-form body is told "invalid". `PUT`, `DELETE`, `PATCH`, `HEAD` and `OPTIONS` answer `405` with `Allow: GET, POST`. 2. **Parity:** see above. 3. **One token store:** a token consumed through the member is `consumed` through 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. 4. **The token is the only credential:** a `POST` that carries `Authorization` and `Cookie` headers for someone else decides as the bound approver. A spy over the request's `Headers` records no `authorization` or `cookie` read. 5. **The composition:** the member is on the started service with a raw app and without one. With a raw app, `app.routes` holds exactly `GET` and `POST` on 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 renamed `async handleActionPage(` to `async handleActionPage_ablated(`. The anchor went from 1 hit to 0, and the blob went from `39ec14d8d5` to `3c1005a2d3`. The run was `vitest run src/action-page-member.integration.test.ts`: **12 of 12 failed**. Ten failed with `TypeError: svc.handleActionPage is not a function`, and the two composition pins failed on `typeof` being `undefined`. Restored: the blob is back to `39ec14d8d5`, equal to HEAD's, and `git diff HEAD` is empty. The ablation ran at `caed7aa69f`. `approval-service.ts`, `approvals-plugin.ts` and the test file have the same blobs at the final HEAD `60b4b97f37`. ## 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 is `open`. On `origin/main` `1b99388505`, `git grep -i approvals packages/runtime/src/http-dispatcher.ts` exits 1 with 0 hits; the control `git grep -c -i auth` on 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/main` merged at `f368b7e980`) - `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-typecheck` is OK, and the new test file is in the `tsconfig.test.json` program (`--listFiles` counts 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/objectstack` derives 79 commands, and all 79 ran. `--ran` reports: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - `check:dual-build-cjs-loads` and `check:i18n` first refused with `PREREQUISITE 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. - Not run locally, as the derivation lists them: CI's own job steps (Test Core shards, Dogfood, Build Core, Temporal Conformance, the type-check lanes) and the workflow-valued families. CI owns those. ## Acceptance notes - **File surface:** the claim lists `approval-service.ts`, `approvals-plugin.ts`, tests and the changeset. This PR also touches `packages/plugins/plugin-approvals/package.json` (devDependency `hono`) and `pnpm-lock.yaml` (+3 lines). The dispatch's pin 2 requires a real Hono raw app, and `hono` was not resolvable from this package. Nothing in `dist` or `files[]` changes because of it. - **The new log line names the dispatcher domain, which lands with #22576.** Until it does, a kernel with no raw app logs that line while the dispatcher still has no `/approvals/act` domain. That gap ends when segment 2 lands. #22438 remains open until all four segments are on `main`. - **`HEAD`.** On the mount, Hono answers `HEAD` itself, at the app level, as a `GET` without a body; the mount's code plays no part. The member answers a forwarded `HEAD` with `405`. Whether a `HEAD` reaches the member as a `GET` depends on the front in front of the dispatcher. Not measured here. - **Errors.** A storage failure inside `peekActionToken` / `redeemActionToken` rejects the member's promise, just as it escapes the mount's handler to Hono's error handler. Mapping it is the caller's job (#22576). It is not a pinned parity case. - `.changeset/22578-approvals-action-page-member.md` is `minor` and carries `Clause-②: yes`. The PR owes a contract-review-tier record before the queue, which the PM arranges. - No translation bundle, `sys-approval-token.object.ts` (#22616's file), `packages/spec`, `packages/runtime` or `plugin-hono-server` is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc305a3 commit 3d0eeef

6 files changed

Lines changed: 504 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
---
4+
5+
feat(plugin-approvals): `ApprovalService.handleActionPage` serves the ADR-0043 action page from a web-standard `Request` (#22578)
6+
7+
Clause-②: yes
8+
9+
- **What is new.** `ApprovalService` implements the optional `IApprovalService.handleActionPage(request)` member: the session-less page an approver reaches from the approve or reject link in an e-mail or IM message, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` field of the form body as the approver the token binds, and answers the result page. A dead link (unknown, expired, consumed, decided or reassigned) is told on the page with `200`. Any other method answers `405` with `Allow: GET, POST`.
10+
- **The same pages, the same checks.** The member answers what the plugin's self-hosted pages answer for the same token and method: the same status, `Content-Type: text/html; charset=utf-8` and bytes. It goes through the same `peekActionToken` / `redeemActionToken` and the same page renderers, and both doors share one token store, so a link used at one is dead at the other. The token is the only credential: no session, cookie or `Authorization` header is read.
11+
- **Its one caller** is the runtime HTTP dispatcher's `/approvals/act` domain (#22576), which reaches a hosted tenant kernel that owns no socket.
12+
- **The self-hosted mount is unchanged.** Where `http.server` exposes a raw app, the plugin mounts `GET` and `POST /api/v1/approvals/act` exactly as before, and the path is never mounted twice. Where there is none, the plugin now logs one `info` line at boot saying the pages are served through the dispatcher's `/approvals/act` domain; it was silent.

‎packages/plugins/plugin-approvals/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@objectstack/service-automation": "workspace:*",
3636
"@objectstack/trigger-record-change": "workspace:*",
3737
"@types/node": "^26.6.3",
38+
"hono": "^4.13.9",
3839
"tsx": "^4.23.15",
3940
"typescript": "^6.0.3",
4041
"vitest": "^4.1.11"

0 commit comments

Comments
 (0)