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
14 changes: 14 additions & 0 deletions .changeset/22754-spec-webhooks-redeliver-member.md
Original file line number Diff line number Diff line change
@@ -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<Response>`. 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.
1 change: 1 addition & 0 deletions packages/spec/api-surface/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
"ISmsTransport (interface)",
"IStorageService (interface)",
"ITeamGraphService (interface)",
"IWebhookService (interface)",
"ImportObjectOpts (interface)",
"ImportObjectResult (interface)",
"InboxListResult (interface)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions packages/spec/src/contracts/webhook-service.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>` 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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;

type HandleRedeliver = NonNullable<IWebhookService['handleRedeliver']>;

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

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

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

/** The barrel (`@objectstack/spec/contracts`) exports this contract, not a look-alike. */
export type BarrelExportsTheContract = Assert<Eq<IWebhookServiceFromBarrel, IWebhookService>>;

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();
});
});
97 changes: 97 additions & 0 deletions packages/spec/src/contracts/webhook-service.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
}
Loading