Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22575-spec-approvals-action-page-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
---

feat(spec): the approvals service contract declares an optional, transport-neutral action-page member (`IApprovalService.handleActionPage`)

Clause-②: yes (widening)

- **What is new.** `IApprovalService` in `@objectstack/spec/contracts` gains one optional member, `handleActionPage?(request: Request): Promise<Response>`. It serves the ADR-0043 action page, the session-less page an approver reaches from the approve or reject link in an e-mail or IM message. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` form field and answers the result page.
- **The token is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. The decision is audited as the approver the token binds.
- **Transport-neutral.** A web-standard `Request` in, a `Response` out: no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount pages on, and the runtime dispatcher's `/approvals/act` domain is the member's one caller.
- **Absence.** The member is optional, so an approvals implementation without action pages still satisfies the contract. Its caller answers a typed 404 or 501 when the `approvals` service or this member is absent, never `ROUTE_NOT_FOUND`.
- **Nothing changes for existing implementers.** No existing member moved. Nothing implements or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted pages stay as they are.
36 changes: 36 additions & 0 deletions packages/spec/src/contracts/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,39 @@ describe('continueRestoredRun — the approvals operator repair verb, declared',
expect(doc).toMatch(/refused a REST\/CLI route/);
});
});

// ---------------------------------------------------------------------------
// [#22575] `handleActionPage` — the ADR-0043 action page, declared on the
// contract transport-neutrally (segment 1 of the maintainer's ruling A on
// #22438).
//
// The card names two pins and these are they: the contract carries the member,
// OPTIONAL, at the web-standard `(request: Request) => Promise<Response>` shape
// (no Hono context, no raw app, no `http.server` type); and a service object
// without it still satisfies `IApprovalService`. The type-level identities are
// exported aliases for the TS6196 reason the blocks above give;
// `check:test-typecheck` compiles this file.
// ---------------------------------------------------------------------------

type HandleActionPage = NonNullable<IApprovalService['handleActionPage']>;

/** Optional: a service may omit it. A required member turns this alias red. */
export type ActionPageIsOptional = Assert<{} extends Pick<IApprovalService, 'handleActionPage'> ? true : false>;

/** One web-standard `Request` in — the whole input, with no execution context beside it. */
export type ActionPageTakesOneRequest = Assert<Eq<Parameters<HandleActionPage>, [request: Request]>>;

/** A web-standard `Response` out, asynchronously. */
export type ActionPageAnswersAResponse = Assert<Eq<ReturnType<HandleActionPage>, Promise<Response>>>;

describe('handleActionPage — the ADR-0043 action page, declared transport-neutrally', () => {
it('is optional: a service object without it still satisfies IApprovalService', () => {
// `minimalService()` declares every REQUIRED member and nothing else, so
// this is the population the optionality is for: an approvals
// implementation with no action pages, before which the caller must
// answer a typed refusal rather than call.
const service: IApprovalService = minimalService();

expect(service.handleActionPage).toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,4 +1090,60 @@ export interface IApprovalService {
/** Set only on the tolerated non-failure: a concurrent resume already had it. */
resumeError?: string;
}>;

/**
* **The ADR-0043 action page, served transport-neutrally** — the
* session-less page an approver reaches from the approve / reject link in an
* e-mail or IM message (#22438; the maintainer's ruling A, 2026-10-09,
* declared here as its first segment).
*
* What it serves, exactly as the plugin's self-hosted pages serve it today:
* - `GET` renders. It reads the raw token from the `token` query parameter
* and answers the confirm page — the request summary and a form that
* `POST`s the token back — or, for a token that is invalid, expired,
* consumed or no longer live, the explanatory result page. ⛔ A `GET`
* never decides: mail gateways prefetch every link in a message, and a
* `GET` that executes gets requests approved by robots.
* - `POST` redeems. It reads the token from the `token` field of the form
* body, runs ADR-0043's whole redemption chain (hash lookup, not
* consumed, not expired, request still `pending`, approver still holding
* the slot, consume, then decide AS that approver), and answers the
* result page.
* - Both answer an HTML page. A dead link is told on the page, never as an
* error status: ADR-0043 answers "this link is no longer valid", not an
* error code.
*
* **The token is the only credential.** This member takes no
* {@link ExecutionContext} and reads no session, cookie or `Authorization`
* header: the holder of an action link has no session by design, and the
* token carries the whole authorization, which ADR-0043 narrows on every
* axis — one request, one action, one approver, single use, a TTL. The
* decision is audited as the approver the token binds, never as a system
* actor.
*
* **Transport-neutral, deliberately**: a web-standard `Request` in, a
* `Response` out — no Hono context, no raw app, no `http.server` type. A
* hosted tenant kernel owns no socket, so it has no raw app to mount pages
* on; the dispatcher reaches it, and this signature is the one a dispatcher
* can call.
*
* **Its one caller** is the runtime `HttpDispatcher`'s `/approvals/act`
* domain (#22438, segment 2). That domain forwards `GET` and `POST` to the
* request kernel's `approvals` service and returns this member's `Response`
* as it is; it forwards the `POST` body unread, because the token rides in
* it. The plugin's self-hosted raw-app mount is not a caller: it stays as it
* is, and the path is never mounted twice.
*
* **Optional — and its absence is loud.** An approvals implementation with
* no action pages still satisfies this contract, so the caller probes for
* presence: an absent `approvals` slot or an absent member answers a typed
* 404 or 501, ⛔ never `ROUTE_NOT_FOUND`, which would tell the holder of a
* live link that the URL is wrong when what is missing is the service
* behind it.
*
* @param request - The forwarded request: on `GET` the token is in its query
* string, on `POST` in its form body
* @returns The page to show the approver
*/
handleActionPage?(request: Request): Promise<Response>;
}
Loading