Skip to content

Commit e84aeb3

Browse files
feat(spec): declare IWebhookService.handleRedeliver, the optional transport-neutral webhook redeliver member (#22767)
Fixes #22754 Clause-②: yes (widening) ## What this does Segment 1 of the webhooks bridge in #22564's stage 2, under the maintainer's ruling on #22438 (director record `6079645593`, verbatim 「A + 扫类」). It declares the forward target that a dispatcher domain will call for `POST /api/v1/webhooks/redeliver`. On a kernel with no `http.server` (the hosted shape), that endpoint answers `404 ROUTE_NOT_FOUND` today (stage-1 measurement `6093135788`, accepted at `6093165613`). Nothing implements or calls the member yet. The runtime dispatcher domain and the plugin-webhooks implementation are the next segments. - `packages/spec/src/contracts/webhook-service.ts` (new): the contract `IWebhookService` for the `webhooks` service slot. It has one optional member, `handleRedeliver?(request: Request)`, which returns a `Promise` of a web-standard `Response`. Its docblock states five things: - **What it serves.** The same answers as the plugin's self-hosted route: `401 UNAUTHENTICATED` with no signed-in user; `400 INVALID_REQUEST` for a non-JSON body; `400 MISSING_REQUIRED_FIELD` without a usable `deliveryId`; `404 RESOURCE_NOT_FOUND` for a row outside the caller's active organization; `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay; `500 INTERNAL_ERROR` otherwise; and `200` with the row's id and new status. - **Authentication and tenant.** It resolves the caller from the request's own credentials, through the kernel's `auth` service, and scopes the replay to the caller's active organization. That is the self-hosted rule, so both kernel shapes judge the same caller by one rule in one place. It takes no `ExecutionContext`. - **Transport neutrality.** There is no Hono, raw-app or `http.server` type in the signature. - **Its one caller.** That is the runtime `HttpDispatcher`'s exact `POST /webhooks/redeliver` domain, which forwards the request with its body unread. The path is not on the ADR-0069 allow-list, so the dispatcher door stays stricter than the self-hosted mount, never looser. The plugin's raw-app mount is not a caller, so the path is never mounted twice. - **The absence rule.** If the `webhooks` slot or the member is absent, the caller answers a typed 404 or 501, never `ROUTE_NOT_FOUND`. - `packages/spec/src/contracts/index.ts`: exports the new contract from `@objectstack/spec/contracts`. - `packages/spec/src/contracts/webhook-service.test.ts`: the type-level pin. - `RedeliverIsOptional` uses the `{} extends Pick` form, because `toEqualTypeOf` cannot tell an optional member from a required one typed with `undefined`. - `RedeliverTakesOneRequest` asserts the parameters are exactly one `Request`. - `RedeliverAnswersAResponse` asserts the return type is a `Promise` of `Response`. - `BarrelExportsTheContract` asserts the barrel exports this same contract. - The value-level pin is that an empty object typed as `IWebhookService` compiles and carries no member. - No docblock prose is pinned, because no consumer parses it. - `packages/spec/api-surface/contracts.json` and `packages/spec/export-origins/contracts.json`: one regenerated line each, for the new export. - `.changeset/22754-spec-webhooks-redeliver-member.md`: `@objectstack/spec` `minor` (pre mode `next`), with its own `Clause-②: yes (widening)` line. ## The slot: a new `webhooks` contract, not the messaging service's The card makes the slot this segment's contract call, decided on the four axes. The PM's suggested route was the messaging service's contract, since `redeliverHttp` lives there. **This PR departs from that suggestion, for the reasons below.** ### What was measured first 1. **Confirmed.** plugin-webhooks registers exactly one service, `webhook.autoEnqueuer` (`webhook-outbox-plugin.ts:317`). `redeliverHttp` has 0 hits in `packages/spec/src` (git grep exit 1). The messaging service registers `messaging` and `notification` (`messaging-service-plugin.ts:213`, `:222`). The candidate slot keys are therefore `webhooks` (the plugin's platform capability token, `platform-capabilities.ts`; registered by nothing yet) and `messaging`. 2. **Split, so the premise does not hold as stated.** The messaging service owns the replay OPERATION: `MessagingService.redeliverHttp(id, { tenantId })` resets a finished row to `pending`, applies the tenant wall and the parked-row refusal, consults each producer's veto, and wakes the dispatcher. plugin-webhooks owns the DOOR: - it requires a signed-in user; - it resolves the tenant from the session's active organization; - it reads the JSON body and maps each refusal code to a status; - it installs the veto that refuses to replay a webhook row whose subscription or signing secret is gone (`installRedeliverGuard`, called before the auto-enqueuer writes its first row). One more finding: **there is no messaging service contract to declare on.** `ServiceSlotContracts` has no `messaging` entry, and no `IMessagingService` exists. The messaging route would mint a new contract file too, so both options cost one new contract. 3. **Read.** The handler (`registerAdminRoutes`) takes the session's `user.id` and `session.activeOrganizationId` and a JSON body `{ deliveryId }`. It answers the envelope listed above. The docblock describes that behaviour, and the plugin is unchanged. ### The four axes - **Real business need.** The measured need is the door on a hosted kernel that composes plugin-webhooks. On the `webhooks` slot, the door exists exactly where self-hosted has it. A door on the `messaging` slot would also exist on a kernel without plugin-webhooks. Nobody asked for that door, and the webhook veto is never installed there. - **Long-term soundness.** The ruling's shape is "a dispatcher domain to the service slot", which forwards and does not reimplement; `/auth` and `/approvals/act` are the precedents (ADR-0076 D11). Under this choice, plugin-webhooks keeps the door's rules in one place for both kernel shapes. On the `messaging` slot, the runtime domain would hold a second copy of the authentication, body parsing and refusal-to-status mapping, beside the plugin's raw mount. - **Preventing AI mistakes.** There is one copy of the door's rules, so a later edit cannot drift one shape from the other. The absence rule is a typed 501 from an empty slot. The tenant requirement stays where it is already enforced: plugin-webhooks' typed view of `redeliverHttp` makes its `tenantId` a required property typed as string-or-undefined, so a caller must decide it. The messaging route's real advantage was a typed `tenantId` at the contract. It is not lost here; it stays at the one place that calls the operation. - **Startup-stage focus.** Each option mints one contract with one optional member. This one adds a slot key that the plugin registers in its own segment, the same window `handleActionPage` had before its implementation landed. The messaging route would also need the dispatcher to detect plugin-webhooks' presence some other way, and no existing registration answers that: `webhook.autoEnqueuer` is registered only when auto-enqueue starts. **The precedent is kept, not departed from.** The #22575 member shape (a web-standard `Request` in, `Response` out), its docblock structure, its pin form and its changeset level are all reused. ### Why `handleRedeliver` - The verb `handle…` is this package's spelling for a function from a `Request` to a `Response`: `IAuthService.handleRequest`, `IRealtimeService.handleUpgrade?` and `IApprovalService.handleActionPage?`. - `Redeliver` is the route's own last segment and the plugin's own word ("redeliver endpoint", `registerRedeliverGuard`). - `IWebhookService` / `handleRedeliver` had 0 hits anywhere outside this diff before it: objectstack git grep exit 1 (control `IApprovalService` exit 0), and objectui at `023f00d46` exit 1 (control `IAuthService`: 2 hits). ## Verification Heavy runs went through `scripts/pm/os-verify-lock.sh`. The `VERDICT` line is quoted from each. The final head is `06119a91ff`: two merges of `origin/main` (`e86530088a`, then `7098acaef9`) on top of the two change commits. The second merge touched no `packages/spec` file. - **Build.** `pnpm --filter @objectstack/spec build` at `740277750d`, and again after the first merge at `bbac3326b9`: `VERDICT command-exit 0` both times. The build wrote no tracked file. `handleRedeliver` has 1 hit each in `dist/contracts/index.d.ts` and `.d.mts`, and 0 hits in any emitted `.js` / `.mjs` / `.cjs`, so the change is type-only. After the second merge, the packages it touched were rebuilt (`turbo run build` filtered to core, cli, plugin-email, service-sms and verify): `Tasks: 59 successful, 59 total`. - **`check:generated`.** Before regeneration, it found exactly two stale artifacts: `api-surface/` ("0 breaking (removed/narrowed), 1 added", `+ IWebhookService (interface)`) and `export-origins/`. After `gen:api-surface` and `gen:export-origins`, at `06119a91ff`: "All 14 generated artifacts are up to date". - **Typecheck at `06119a91ff`.** `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck`): `VERDICT command-exit 0`. `check:test-typecheck` reports OK. `tsc -p tsconfig.test.json --listFiles` lists both `webhook-service.ts` and `webhook-service.test.ts`, with 0 diagnostics in either. - **Tests.** - At `06119a91ff`: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/webhook-service.test.ts src/contracts/approval-service.test.ts src/contracts/core-service-contracts.test.ts` gave `Test Files 3 passed (3)`, `Tests 19 passed (19)`. - At `bbac3326b9`, the whole spec suite (`vitest run --project local --maxWorkers=2`) gave `Test Files 644 passed (644)`, `Tests 19213 passed | 1 todo`. Since then the only commit is the second merge, which touches no spec file. - Consumers' tests were not run. Nothing outside `packages/spec` imports the new contract (git grep exit 1), and the change emits no JavaScript. - **Gates at `06119a91ff`.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; change set derived from the merge base `7098acaef9`, 6 paths, 164 changed lines) derived 87 commands, and all 87 exited 0. They include `check:api-surface` ("public API surface + factory signatures unchanged"), `check:export-origins`, `check:generated`, `check:docs`, `check:authorable-surface`, `check:exported-any`, `check:spec-docblock-symbol-anchors`, `check:adr-0087-registration` ("this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring`, `check:issue-citations` ("every citation this change adds resolves", 5 of 5), `check:nul-bytes`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. - `--ran` reconciliation, on a record carrying each exit code: "87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED". - The ADR-0087 disposition: the changeset is `minor` with the `(widening)` arm, which the gate reads as non-breaking, so no disposition marker is owed and none is written. - **Reverse verification**, run from the committed `740277750d` through `scripts/ablation-replace.mjs` (wrap mode, plus an outer `trap` restore on EXIT, INT and TERM). - The mutation made the member required: `handleRedeliver?(request: Request)` became `handleRedeliver(request: Request)`. On disk the anchor went 1 → 0, the replacement 0 → 1, and the blob `3ed2ead393de` → `28539f95c0f1`. - The subject is imported relatively by the test (`./webhook-service`), so the type program reads `src`, and no dist rebuild was involved. - **Expected direction: red. Observed: red.** spec's test program reported `webhook-service.test.ts(30,42): error TS2344: Type 'false' does not satisfy the constraint 'true'` (`RedeliverIsOptional`) and `(46,15): error TS2741: Property 'handleRedeliver' is missing` (the value pin). - No consumer outside spec could turn red, because nothing implements the contract yet. - **Restore leg.** The blob after restore is `3ed2ead393de`, equal to the HEAD blob, and `git diff HEAD` is empty. ## Acceptance notes - **`ServiceSlotContracts` gains no `webhooks` entry here.** That ledger says an entry "is only made where the binding is evidenced — by the provider that registers the slot, or by dispatcher work that already proved the correspondence", and nothing registers `webhooks` until the plugin segment. That segment, or the first one to type the lookup, adds `webhooks: IWebhookService` (the approvals precedent has no entry either). - **For the plugin segment (read only, not filed).** `registerAdminRoutes` mounts the self-hosted route whenever the `http-server` slot and the messaging service are both present. The webhook veto is installed only inside `bootAutoEnqueue`, which returns early when `autoEnqueue` is `false` or realtime is absent. On those compositions, the self-hosted door runs without the veto. Whether a webhook-sourced row can exist there was not measured. The plugin segment edits exactly these functions, and the slot registration it adds should sit beside the veto, not beside the raw mount. Carrier: #22564's plugin segment. - **For the dispatcher segment.** The `@objectstack/hono` catch-all already leaves the raw request readable for every body (it parses a clone, `packages/adapters/hono/src/index.ts`), so the domain can forward the JSON body unread with no adapter change. Its `approvals.ts` sibling is the template: an exact route, `POST` only, and `501` on an empty slot or absent member. - `check:api-surface` records the new interface's name and kind, not its members, so the snapshot shows `IWebhookService (interface)` only. - **Not run locally; these are CI's.** The `dispatch-gates` artifact-roster block (50 families), the declared wide-population families, the CI type-check lanes (workspace turbo typecheck, `./examples/*`, `downstream-contract`) and the path-scheduled jobs (Test Core, Dogfood, Build Core, Temporal Conformance) were not run. Three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) need this PR to exist and were not run here. - **Labels: none written.** The dispatch's write budget was a push, one draft `pr_create` and one report comment, and it names no label. This diff carries a changeset, so `skip-changeset` does not apply. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 896a434 commit e84aeb3

6 files changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `@objectstack/spec/contracts` exports a new contract, `IWebhookService`, for the `webhooks` service slot that `@objectstack/plugin-webhooks` fills. It has one optional member, `handleRedeliver?(request: Request): Promise<Response>`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again.
10+
- **What it serves.** The same answers as the plugin's self-hosted route. It authenticates the caller from the request's own credentials (`401 UNAUTHENTICATED` when nobody is signed in). It reads `{ "deliveryId": string }` from a JSON body (`400` otherwise). It replays the row through the messaging service, scoped to the caller's active organization (`404 RESOURCE_NOT_FOUND` for a row outside it). It answers `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay, and `200` with the row's id and new status when it succeeds.
11+
- **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's `POST /webhooks/redeliver` domain is the member's one caller.
12+
- **Why a webhooks slot, not the messaging service's.** The messaging service owns the replay itself. The door (who may press it, whose rows it reaches, what each refusal answers, and the veto over replaying a webhook whose subscription or secret is gone) belongs to the webhook plugin, so the door exists exactly where that plugin is composed.
13+
- **Absence.** The member is optional, so a webhook service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `webhooks` service or this member is absent, never `ROUTE_NOT_FOUND`.
14+
- **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `webhooks` slot or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted route stays as it is.

‎packages/spec/api-surface/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"ISmsTransport (interface)",
164164
"IStorageService (interface)",
165165
"ITeamGraphService (interface)",
166+
"IWebhookService (interface)",
166167
"ImportObjectOpts (interface)",
167168
"ImportObjectResult (interface)",
168169
"InboxListResult (interface)",

‎packages/spec/export-origins/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)",
164164
"IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)",
165165
"ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)",
166+
"IWebhookService": "src/contracts/webhook-service.ts#IWebhookService (interface)",
166167
"ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)",
167168
"ImportObjectResult": "src/contracts/external-datasource-service.ts#ImportObjectResult (interface)",
168169
"InboxListResult": "src/contracts/notification-service.ts#InboxListResult (interface)",

‎packages/spec/src/contracts/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export * from './share-link-service.js';
6060
// Reports are `report` metadata (`ReportSchema`); an ad-hoc object query is a
6161
// ListView.
6262
export * from './approval-service.js';
63+
export * from './webhook-service.js';
6364
export * from './package-service.js';
6465
export * from './knowledge-service.js';
6566
export * from './knowledge-adapter.js';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22754] `IWebhookService.handleRedeliver`: the webhook redeliver door,
4+
// declared on the `webhooks` slot's contract transport-neutrally (the first
5+
// segment of the #22564 bridge for `POST /api/v1/webhooks/redeliver`).
6+
//
7+
// The card asks for a type-level pin, and these aliases are it: the contract
8+
// carries the member, OPTIONAL, at the web-standard
9+
// `(request: Request) => Promise<Response>` shape (no Hono context, no raw app,
10+
// no `http.server` type, no execution context beside the request), and the
11+
// `contracts` barrel exports the same contract the next segments import. The
12+
// value-level pin below is the optionality's other half: a service object
13+
// without the member still satisfies `IWebhookService`.
14+
//
15+
// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf`
16+
// cannot tell an optional member from a REQUIRED one typed `T | undefined`,
17+
// while `{} extends Pick<…>` can. The aliases are exported so TS6196 does not
18+
// flag them as unused; `check:test-typecheck` compiles this file.
19+
20+
import { describe, it, expect } from 'vitest';
21+
import type { IWebhookService } from './webhook-service';
22+
import type { IWebhookService as IWebhookServiceFromBarrel } from './index';
23+
24+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
25+
type Assert<T extends true> = T;
26+
27+
type HandleRedeliver = NonNullable<IWebhookService['handleRedeliver']>;
28+
29+
/** Optional: a service may omit it. A required member turns this alias red. */
30+
export type RedeliverIsOptional = Assert<{} extends Pick<IWebhookService, 'handleRedeliver'> ? true : false>;
31+
32+
/** One web-standard `Request` in: the whole input, with no execution context beside it. */
33+
export type RedeliverTakesOneRequest = Assert<Eq<Parameters<HandleRedeliver>, [request: Request]>>;
34+
35+
/** A web-standard `Response` out, asynchronously. */
36+
export type RedeliverAnswersAResponse = Assert<Eq<ReturnType<HandleRedeliver>, Promise<Response>>>;
37+
38+
/** The barrel (`@objectstack/spec/contracts`) exports this contract, not a look-alike. */
39+
export type BarrelExportsTheContract = Assert<Eq<IWebhookServiceFromBarrel, IWebhookService>>;
40+
41+
describe('IWebhookService.handleRedeliver: the webhook redeliver door, declared transport-neutrally', () => {
42+
it('is optional: a service object without it still satisfies IWebhookService', () => {
43+
// The population the optionality is for: a webhook service with no
44+
// redeliver door, which the caller must answer with a typed refusal
45+
// rather than call.
46+
const service: IWebhookService = {};
47+
48+
expect(service.handleRedeliver).toBeUndefined();
49+
});
50+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/spec/contracts/webhook-service
5+
*
6+
* Cross-package contract for the outbound-webhook plugin's service slot. The
7+
* implementation lives in `@objectstack/plugin-webhooks` and is registered as
8+
* the `webhooks` service. The slot is named after the plugin's platform
9+
* capability token (`webhooks`), as the `approvals` slot is named after
10+
* plugin-approvals'.
11+
*
12+
* Webhook DELIVERY is not on this contract. A delivery is a row on the
13+
* messaging service's shared outbound-HTTP outbox (ADR-0018 M3), and
14+
* `@objectstack/service-messaging` owns sending it, retrying it and replaying
15+
* it. This slot carries what the webhook plugin owns on top of that outbox:
16+
* today, one door.
17+
*
18+
* [#22754] Why the redeliver door lives on this slot and not on the messaging
19+
* service's: the bridged behaviour is the door, and the door is this plugin's.
20+
* The plugin decides who may press it (any signed-in user), whose rows it
21+
* reaches (the caller's active organization), and what each refusal answers.
22+
* It is also what installs the messaging service's veto over replaying a
23+
* webhook row whose subscription or signing secret is gone, before it
24+
* enqueues its first row. So the door exists exactly where this plugin is
25+
* composed. A door on the messaging slot would also exist on a kernel without
26+
* this plugin, where that veto is never installed, and the dispatcher would
27+
* hold a second copy of the door's rules.
28+
*/
29+
30+
/**
31+
* The webhook plugin's service: the occupant of the `webhooks` slot.
32+
*
33+
* Every member is optional. A kernel without `@objectstack/plugin-webhooks`
34+
* leaves the slot empty, and a caller probes for the member it needs before
35+
* calling it.
36+
*/
37+
export interface IWebhookService {
38+
/**
39+
* **The webhook redeliver door, served transport-neutrally.** It is
40+
* `POST /api/v1/webhooks/redeliver`, the operator button that sends a
41+
* finished delivery again (#22564; declared here as the first segment of
42+
* the bridge that the maintainer's ruling A on #22438 directs for this
43+
* endpoint).
44+
*
45+
* What it serves, exactly as the plugin's self-hosted mount serves it today:
46+
* - It authenticates the caller from the request's own credentials, through
47+
* the kernel's `auth` service. Any signed-in user may press it; with no
48+
* signed-in user it answers `401 UNAUTHENTICATED`.
49+
* - It reads a JSON body `{ "deliveryId": string }`. A body that is not
50+
* JSON answers `400 INVALID_REQUEST`, and a `deliveryId` that is
51+
* missing, not a string or blank answers `400 MISSING_REQUIRED_FIELD`.
52+
* - It replays the row through the messaging service's redelivery, scoped
53+
* to the caller's active organization (#10740). A row outside that
54+
* organization is not found: `404 RESOURCE_NOT_FOUND`. ⛔ The tenant is
55+
* never dropped and never bypassed.
56+
* - A row the outbox refuses to replay answers `409` with the refusal's own
57+
* code: `DELIVERY_NOT_ELIGIBLE` (the row is not finished, or the
58+
* producer's veto refused it) or `DELIVERY_NEVER_SENT` (the row is a
59+
* parked record that was never sent, so replaying it would send it
60+
* unsigned).
61+
* - Any other failure answers `500 INTERNAL_ERROR`.
62+
* - A replayed row answers `200` with `{ success: true, data: { id, status } }`:
63+
* the row's id and its new status. Every refusal is JSON in the
64+
* `{ success: false, error: { code, message } }` envelope.
65+
*
66+
* **Transport-neutral, deliberately.** A web-standard `Request` goes in and
67+
* a `Response` comes out. There is no Hono context, no raw app and no
68+
* `http.server` type. A hosted tenant kernel owns no socket, so it has no
69+
* raw app to mount the route on. The dispatcher reaches that kernel
70+
* instead, and this signature is the one a dispatcher can call.
71+
*
72+
* **It takes no `ExecutionContext`.** It resolves the caller from the
73+
* request, which is the rule the self-hosted mount applies, so both kernel
74+
* shapes judge the same caller by one rule, in one place.
75+
*
76+
* **Its one caller** is the runtime `HttpDispatcher`'s exact
77+
* `POST /webhooks/redeliver` domain (the next segment of #22564). That
78+
* domain forwards the request with its body unread and returns this
79+
* member's `Response` as it is. The path is not on the ADR-0069 auth-gate
80+
* allow-list, so on that shape a signed-in caller whose session still owes
81+
* an authentication step is stopped before this member runs: the
82+
* dispatcher door is stricter than the self-hosted mount, never looser. The
83+
* plugin's self-hosted raw-app mount is not a caller, and the path is never
84+
* mounted twice.
85+
*
86+
* **Optional, and its absence is loud.** A webhook service without the door
87+
* still satisfies this contract, so the caller probes for presence. An
88+
* empty `webhooks` slot or an absent member answers a typed 404 or 501,
89+
* ⛔ never `ROUTE_NOT_FOUND`: that answer would tell an operator the URL is
90+
* wrong, when what is missing is the service behind it.
91+
*
92+
* @param request - The forwarded `POST`: the caller's credentials are in
93+
* its headers, and the `deliveryId` is in its JSON body
94+
* @returns The JSON answer for the operator
95+
*/
96+
handleRedeliver?(request: Request): Promise<Response>;
97+
}

0 commit comments

Comments
 (0)