Repository navigation
Commit 3d0eeef
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
- .changeset
- packages/plugins/plugin-approvals
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
| |||
0 commit comments