From 418189f3366976e605458129841c173388f5ea24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:26:57 +0000 Subject: [PATCH 1/2] feat(spec): declare IWebhookService.handleRedeliver, the optional transport-neutral webhook redeliver member A new contract for the `webhooks` service slot (plugin-webhooks), with one optional member that serves POST /api/v1/webhooks/redeliver as a web-standard Request -> Response, so a dispatcher domain can reach the door on a kernel with no raw app. Type-level pins and a minor changeset. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../22754-spec-webhooks-redeliver-member.md | 14 +++ packages/spec/src/contracts/index.ts | 1 + .../src/contracts/webhook-service.test.ts | 50 ++++++++++ .../spec/src/contracts/webhook-service.ts | 97 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 .changeset/22754-spec-webhooks-redeliver-member.md create mode 100644 packages/spec/src/contracts/webhook-service.test.ts create mode 100644 packages/spec/src/contracts/webhook-service.ts diff --git a/.changeset/22754-spec-webhooks-redeliver-member.md b/.changeset/22754-spec-webhooks-redeliver-member.md new file mode 100644 index 00000000000..32785e18072 --- /dev/null +++ b/.changeset/22754-spec-webhooks-redeliver-member.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`) + +Clause-②: yes (widening) + +- **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`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again. +- **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. +- **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. +- **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. +- **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`. +- **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. diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 580d48bac47..e32b16f2112 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -60,6 +60,7 @@ export * from './share-link-service.js'; // Reports are `report` metadata (`ReportSchema`); an ad-hoc object query is a // ListView. export * from './approval-service.js'; +export * from './webhook-service.js'; export * from './package-service.js'; export * from './knowledge-service.js'; export * from './knowledge-adapter.js'; diff --git a/packages/spec/src/contracts/webhook-service.test.ts b/packages/spec/src/contracts/webhook-service.test.ts new file mode 100644 index 00000000000..ae5d5b86ff4 --- /dev/null +++ b/packages/spec/src/contracts/webhook-service.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22754] `IWebhookService.handleRedeliver`: the webhook redeliver door, +// declared on the `webhooks` slot's contract transport-neutrally (the first +// segment of the #22564 bridge for `POST /api/v1/webhooks/redeliver`). +// +// The card asks for a type-level pin, and these aliases are it: the contract +// carries the member, OPTIONAL, at the web-standard +// `(request: Request) => Promise` shape (no Hono context, no raw app, +// no `http.server` type, no execution context beside the request), and the +// `contracts` barrel exports the same contract the next segments import. The +// value-level pin below is the optionality's other half: a service object +// without the member still satisfies `IWebhookService`. +// +// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf` +// cannot tell an optional member from a REQUIRED one typed `T | undefined`, +// while `{} extends Pick<…>` can. The aliases are exported so TS6196 does not +// flag them as unused; `check:test-typecheck` compiles this file. + +import { describe, it, expect } from 'vitest'; +import type { IWebhookService } from './webhook-service'; +import type { IWebhookService as IWebhookServiceFromBarrel } from './index'; + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; + +type HandleRedeliver = NonNullable; + +/** Optional: a service may omit it. A required member turns this alias red. */ +export type RedeliverIsOptional = Assert<{} extends Pick ? true : false>; + +/** One web-standard `Request` in: the whole input, with no execution context beside it. */ +export type RedeliverTakesOneRequest = Assert, [request: Request]>>; + +/** A web-standard `Response` out, asynchronously. */ +export type RedeliverAnswersAResponse = Assert, Promise>>; + +/** The barrel (`@objectstack/spec/contracts`) exports this contract, not a look-alike. */ +export type BarrelExportsTheContract = Assert>; + +describe('IWebhookService.handleRedeliver: the webhook redeliver door, declared transport-neutrally', () => { + it('is optional: a service object without it still satisfies IWebhookService', () => { + // The population the optionality is for: a webhook service with no + // redeliver door, which the caller must answer with a typed refusal + // rather than call. + const service: IWebhookService = {}; + + expect(service.handleRedeliver).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/contracts/webhook-service.ts b/packages/spec/src/contracts/webhook-service.ts new file mode 100644 index 00000000000..3ed2ead393d --- /dev/null +++ b/packages/spec/src/contracts/webhook-service.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/spec/contracts/webhook-service + * + * Cross-package contract for the outbound-webhook plugin's service slot. The + * implementation lives in `@objectstack/plugin-webhooks` and is registered as + * the `webhooks` service. The slot is named after the plugin's platform + * capability token (`webhooks`), as the `approvals` slot is named after + * plugin-approvals'. + * + * Webhook DELIVERY is not on this contract. A delivery is a row on the + * messaging service's shared outbound-HTTP outbox (ADR-0018 M3), and + * `@objectstack/service-messaging` owns sending it, retrying it and replaying + * it. This slot carries what the webhook plugin owns on top of that outbox: + * today, one door. + * + * [#22754] Why the redeliver door lives on this slot and not on the messaging + * service's: the bridged behaviour is the door, and the door is this plugin's. + * The plugin decides who may press it (any signed-in user), whose rows it + * reaches (the caller's active organization), and what each refusal answers. + * It is also what installs the messaging service's veto over replaying a + * webhook row whose subscription or signing secret is gone, before it + * enqueues its first row. So the door exists exactly where this plugin is + * composed. A door on the messaging slot would also exist on a kernel without + * this plugin, where that veto is never installed, and the dispatcher would + * hold a second copy of the door's rules. + */ + +/** + * The webhook plugin's service: the occupant of the `webhooks` slot. + * + * Every member is optional. A kernel without `@objectstack/plugin-webhooks` + * leaves the slot empty, and a caller probes for the member it needs before + * calling it. + */ +export interface IWebhookService { + /** + * **The webhook redeliver door, served transport-neutrally.** It is + * `POST /api/v1/webhooks/redeliver`, the operator button that sends a + * finished delivery again (#22564; declared here as the first segment of + * the bridge that the maintainer's ruling A on #22438 directs for this + * endpoint). + * + * What it serves, exactly as the plugin's self-hosted mount serves it today: + * - It authenticates the caller from the request's own credentials, through + * the kernel's `auth` service. Any signed-in user may press it; with no + * signed-in user it answers `401 UNAUTHENTICATED`. + * - It reads a JSON body `{ "deliveryId": string }`. A body that is not + * JSON answers `400 INVALID_REQUEST`, and a `deliveryId` that is + * missing, not a string or blank answers `400 MISSING_REQUIRED_FIELD`. + * - It replays the row through the messaging service's redelivery, scoped + * to the caller's active organization (#10740). A row outside that + * organization is not found: `404 RESOURCE_NOT_FOUND`. ⛔ The tenant is + * never dropped and never bypassed. + * - A row the outbox refuses to replay answers `409` with the refusal's own + * code: `DELIVERY_NOT_ELIGIBLE` (the row is not finished, or the + * producer's veto refused it) or `DELIVERY_NEVER_SENT` (the row is a + * parked record that was never sent, so replaying it would send it + * unsigned). + * - Any other failure answers `500 INTERNAL_ERROR`. + * - A replayed row answers `200` with `{ success: true, data: { id, status } }`: + * the row's id and its new status. Every refusal is JSON in the + * `{ success: false, error: { code, message } }` envelope. + * + * **Transport-neutral, deliberately.** 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 owns no socket, so it has no + * raw app to mount the route on. The dispatcher reaches that kernel + * instead, and this signature is the one a dispatcher can call. + * + * **It takes no `ExecutionContext`.** It resolves the caller from the + * request, which is the rule the self-hosted mount applies, so both kernel + * shapes judge the same caller by one rule, in one place. + * + * **Its one caller** is the runtime `HttpDispatcher`'s exact + * `POST /webhooks/redeliver` domain (the next segment of #22564). That + * domain forwards the request with its body unread and returns this + * member's `Response` as it is. The path is not on the ADR-0069 auth-gate + * allow-list, so on that shape a signed-in caller whose session still owes + * an authentication step is stopped before this member runs: the + * dispatcher door is stricter than the self-hosted mount, never looser. The + * plugin's self-hosted raw-app mount is not a caller, and the path is never + * mounted twice. + * + * **Optional, and its absence is loud.** A webhook service without the door + * still satisfies this contract, so the caller probes for presence. An + * empty `webhooks` slot or an absent member answers a typed 404 or 501, + * ⛔ never `ROUTE_NOT_FOUND`: that answer would tell an operator the URL is + * wrong, when what is missing is the service behind it. + * + * @param request - The forwarded `POST`: the caller's credentials are in + * its headers, and the `deliveryId` is in its JSON body + * @returns The JSON answer for the operator + */ + handleRedeliver?(request: Request): Promise; +} From 740277750d3dcf2f21b7ba0f872f7a6ea8cae611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:36:55 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface and export-origins for IWebhookService Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index bcec4d90b5f..7e7735d1c30 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport (interface)", "IStorageService (interface)", "ITeamGraphService (interface)", + "IWebhookService (interface)", "ImportObjectOpts (interface)", "ImportObjectResult (interface)", "InboxListResult (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 533eb49dd30..c4e276a13e3 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)", "IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)", "ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)", + "IWebhookService": "src/contracts/webhook-service.ts#IWebhookService (interface)", "ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)", "ImportObjectResult": "src/contracts/external-datasource-service.ts#ImportObjectResult (interface)", "InboxListResult": "src/contracts/notification-service.ts#InboxListResult (interface)",