Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# @suaveplan/api-plugin-webhook - Signed outbound webhook delivery plugin for @suaveplan/api. Wraps @suaveplan/webhook-client behind a definePlugin descriptor, registers a typed webhookService in ServiceRegistry, mounts tenant-scoped CRUD + test routes through @suaveplan/api-openapi, drives HMAC-SHA256 delivery with exponential-backoff retry + dead-letter sink, and dedups concurrent deliveries via @suaveplan/idempotency.
Signed outbound webhook delivery plugin for `@suaveplan/api`. Wraps `@suaveplan/webhook-client` behind a `definePlugin` descriptor, registers a typed `webhookService` in the host `ServiceRegistry`, mounts tenant-scoped CRUD + test routes through `@suaveplan/api-openapi`, drives HMAC-SHA256 delivery with exponential-backoff retry and a dead-letter sink, and dedups concurrent deliveries via `@suaveplan/idempotency`.
<!-- begin:auto-docs -->
## Documentation tree
- [dead-letter](./src/dead-letter/dead-letter.md) — Defines the `DeadLetterSink` interface and `DeadLetterEntry` shape used by `WebhookService.send` to persist delivery at…
- [dead-letter](./src/dead-letter/dead-letter.md) — Defines the `DeadLetterSink` interface and `DeadLetterEntry` shape used by `WebhookService.send` to persist delivery at…
- [memory](./src/dead-letter/memory.md) — Provides the default `DeadLetterSink` implementation shipped with the plugin: an unbounded `Array<DeadLetterEntry>` bac…
- [error-codes](./src/error-codes/error-codes.md) — Defines the ten stable `WEBHOOK_*` error codes raised by the api-plugin-webhook package and the concrete `WebhookError`…
- [error-codes](./src/error-codes/error-codes.md) — Defines the ten stable `WEBHOOK_*` error codes raised by the api-plugin-webhook package and the concrete `WebhookError`…
- [otel](./src/otel/instruments.md) — Defines the four OpenTelemetry instruments emitted by the webhook plugin: two counters and two histograms covering ever…
- [instruments](./src/otel/instruments.md) — Defines the four OpenTelemetry instruments emitted by the webhook plugin: two counters and two histograms covering ever…
- [plugin](./src/plugin/plugin.md) — Top-level `definePlugin` consumer for the webhook plugin. Returns a typed `PluginDescriptor<WebhookPluginConfig>` whose…
- [plugin](./src/plugin/plugin.md) — Top-level `definePlugin` consumer for the webhook plugin. Returns a typed `PluginDescriptor<WebhookPluginConfig>` whose…
- [routes](./src/routes/build-routes.md) — Builds the six CRUD + test route entries the webhook plugin mounts on the host's Hono app. Each entry pairs a Zod-typed…
- [build-routes](./src/routes/build-routes.md) — Builds the six CRUD + test route entries the webhook plugin mounts on the host's Hono app. Each entry pairs a Zod-typed…
- [routes](./src/schemas/routes/routes.md) — Bundles the Zod schemas for every CRUD + test route the webhook plugin mounts: list response, create body, create/read…
- [routes](./src/schemas/routes/routes.md) — Bundles the Zod schemas for every CRUD + test route the webhook plugin mounts: list response, create body, create/read…
- [endpoint](./src/schemas/endpoint.md) — Zod schemas for the webhook endpoint shapes — the persistent `WebhookEndpoint` row, the `POST /webhooks` create body, a…
- [options](./src/schemas/options.md) — Zod schema gating `createWebhookPlugin(options)`. Validates every primitive option (route prefix, retry caps, attempt c…
- [registry-augmentation](./src/services/registry-augmentation.md) — Declares the `webhookService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeSc…
- [webhook-service](./src/services/webhook-service.md) — Wraps `@suaveplan/webhook-client`'s `deliver` function with the plugin's orchestration layer: endpoint lookup, tenant s…
- [endpoint-store](./src/store/endpoint-store.md) — Defines the pluggable persistence seam for webhook endpoints. Every method is async-only so downstream packages can plu…
- [memory](./src/store/memory.md) — Provides the default `EndpointStore` shipped with the plugin: a `Map`-backed implementation suitable for tests, single-…
- [types](./src/types/types.md) — Defines the cross-feature data shapes used by every module in `@suaveplan/api-plugin-webhook`: the persistent `WebhookE…
- [types](./src/types/types.md) — Defines the cross-feature data shapes used by every module in `@suaveplan/api-plugin-webhook`: the persistent `WebhookE…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-webhook
```
## Quick start
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createWebhookPlugin } from "@suaveplan/api-plugin-webhook";
const app = defineApp()
.use(createAuthPlugin({ betterAuth: { /* ... */ } }))
.use(createWebhookPlugin());
// Register and fire from anywhere with access to the ServiceRegistry:
const webhookService = registry.getService("webhookService");
const endpoint = await webhookService.register({
tenantId: null,
url: "https://partner.example.com/hooks",
secret: crypto.randomUUID(),
events: ["order.created"],
});
await webhookService.send(endpoint.id, "order.created", { orderId: 42 });
```
## Surface
Six CRUD + test routes mounted under `routePrefix` (default `/webhooks`), every route gated by `requireAuth()` from `@suaveplan/api-plugin-auth` and scoped by the request principal's `tenantId` when present:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/webhooks` | List endpoints visible to the principal |
| `POST` | `/webhooks` | Create an endpoint (caller supplies the HMAC secret) |
| `GET` | `/webhooks/:id` | Read one endpoint |
| `PATCH` | `/webhooks/:id` | Partial update |
| `DELETE` | `/webhooks/:id` | Hard delete (idempotent) |
| `POST` | `/webhooks/:id/test` | Fire a synthetic `webhook.ping` |
The same surface is available programmatically via the `webhookService` slot in `ServiceRegistry`:
- `send(endpointId, event, data)`
- `sendAll(event, data, filter?)`
- `register(input)` / `update(id, patch)` / `unregister(id)`
- `listEndpoints(filter?)` / `getEndpoint(id)`
- `test(endpointId)`
## Delivery semantics
- HMAC-SHA256 default (SHA-512 opt-in per endpoint), header naming follows webhook-client (`X-Hub-Signature-256` / `X-Hub-Signature-512`).
- Standard outbound headers: `Content-Type: application/json`, `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`.
- Exponential backoff with full jitter, doubling per attempt, capped at `maxRetryDelayMs`.
- `maxAttempts` defaults to 5; 4xx (except 429) terminates immediately; 429 and 5xx retry; network errors and timeouts retry.
- Exhausted deliveries land in the `DeadLetterSink` with the full attempt history before surfacing `WEBHOOK_DELIVERY_EXHAUSTED` to the caller.
- Per-delivery dedup via `(endpointId, payload.id)` — concurrent same-id sends collapse to a single delivery, either through the injected `DistributedIdempotencyGuard` or an in-process `Map`.
## Persistence
The plugin ships `MemoryEndpointStore` (default) and `MemoryDeadLetterSink` (default) so it boots zero-config. Production deployments configure durable siblings against the `EndpointStore` / `DeadLetterSink` interfaces — a SurrealDB-backed store lands as `@suaveplan/api-plugin-webhook-surreal`.
## Modules
- [`src/plugin/plugin.md`](./src/plugin/plugin.md) — `createWebhookPlugin` factory + onInit lifecycle.
- [`src/services/webhook-service.md`](./src/services/webhook-service.md) — `WebhookService` impl.
- [`src/routes/build-routes.md`](./src/routes/build-routes.md) — CRUD + test routes.
- [`src/store/endpoint-store.md`](./src/store/endpoint-store.md) — `EndpointStore` interface.
- [`src/store/memory.md`](./src/store/memory.md) — default in-memory store.
- [`src/dead-letter/dead-letter.md`](./src/dead-letter/dead-letter.md) — `DeadLetterSink` interface.
- [`src/dead-letter/memory.md`](./src/dead-letter/memory.md) — default in-memory sink.
- [`src/schemas/options.md`](./src/schemas/options.md) — options Zod schema.
- [`src/schemas/endpoint.md`](./src/schemas/endpoint.md) — endpoint Zod schemas.
- [`src/schemas/routes/routes.md`](./src/schemas/routes/routes.md) — per-route Zod schemas.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `WEBHOOK_*` error registry.
- [`src/otel/instruments.md`](./src/otel/instruments.md) — OpenTelemetry instruments.
- [`src/types/types.md`](./src/types/types.md) — shared types.
## OpenTelemetry
- Counter `webhook_plugin.deliveries_total{event,status,endpoint_id}`
- Counter `webhook_plugin.dead_letters_total{event,endpoint_id}`
- Histogram `webhook_plugin.delivery_attempts{event,status}`
- Histogram `webhook_plugin.send_duration_ms{status}`
Status label values: `delivered`, `exhausted`, `not_found`, `inactive`, `invalid`.
## License
MIT

View File

@@ -0,0 +1,145 @@
{
"name": "@suaveplan/api-plugin-webhook",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Signed outbound webhook delivery plugin for @suaveplan/api. Wraps @suaveplan/webhook-client behind a definePlugin descriptor, registers a typed webhookService in ServiceRegistry, mounts tenant-scoped CRUD + test routes through @suaveplan/api-openapi, drives HMAC-SHA256 delivery with exponential-backoff retry + dead-letter sink, and dedups concurrent deliveries via @suaveplan/idempotency.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./services/webhook-service": {
"import": {
"types": "./dist/services/webhook-service.d.ts",
"default": "./dist/services/webhook-service.js"
}
},
"./store/endpoint-store": {
"import": {
"types": "./dist/store/endpoint-store.d.ts",
"default": "./dist/store/endpoint-store.js"
}
},
"./store/memory": {
"import": {
"types": "./dist/store/memory.d.ts",
"default": "./dist/store/memory.js"
}
},
"./dead-letter/dead-letter": {
"import": {
"types": "./dist/dead-letter/dead-letter.d.ts",
"default": "./dist/dead-letter/dead-letter.js"
}
},
"./dead-letter/memory": {
"import": {
"types": "./dist/dead-letter/memory.d.ts",
"default": "./dist/dead-letter/memory.js"
}
},
"./routes/routes": {
"import": {
"types": "./dist/routes/build-routes.d.ts",
"default": "./dist/routes/build-routes.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./schemas/endpoint": {
"import": {
"types": "./dist/schemas/endpoint.d.ts",
"default": "./dist/schemas/endpoint.js"
}
},
"./schemas/routes": {
"import": {
"types": "./dist/schemas/routes/index.d.ts",
"default": "./dist/schemas/routes/index.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-openapi": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-auth": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/idempotency": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/webhook-client": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,299 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { MemoryDeadLetterSink } from "./dead-letter/memory.js";
import {
WEBHOOK_CODES,
WebhookError,
type WebhookErrorCode,
} from "./error-codes/error-codes.js";
import { resetWebhookInstruments } from "./otel/instruments.js";
import { createWebhookPlugin } from "./plugin/plugin.js";
import {
createWebhookService,
type WebhookService,
} from "./services/webhook-service.js";
import { MemoryEndpointStore } from "./store/memory.js";
import type { WebhookPluginConfig } from "./types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeServiceForCodes() {
return createWebhookService({
endpointStore: new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
}),
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 10,
idempotencyGuard: null,
logger: noopLogger,
clock: () => 0,
fetch: async () => new Response("err", { status: 500 }),
});
}
describe("api-plugin-webhook spec conformance", () => {
it("every WEBHOOK_* code is thrown from a production path", {
timeout: 30_000,
}, async () => {
const observed = new Set<WebhookErrorCode>();
// INVALID_INPUT
try {
createWebhookPlugin({ maxAttempts: -1 } as never);
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// SERVICE_NOT_AVAILABLE
resetWebhookInstruments();
const descriptor = createWebhookPlugin();
const ctx = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: descriptor.defaults as WebhookPluginConfig,
services: new Map(),
});
try {
await descriptor.onInit?.(ctx);
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// ROUTE_PREFIX_COLLISION
resetWebhookInstruments();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services1 = new Map<string, unknown>();
services1.set("app", app);
services1.set("openAPIRegistry", registry);
const ctx1 = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: descriptor.defaults as WebhookPluginConfig,
services: services1,
});
await descriptor.onInit?.(ctx1);
const desc2 = createWebhookPlugin();
const services2 = new Map<string, unknown>();
services2.set("app", app);
services2.set("openAPIRegistry", registry);
const ctx2 = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: desc2.defaults as WebhookPluginConfig,
services: services2,
});
try {
await desc2.onInit?.(ctx2);
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// ENDPOINT_NOT_FOUND + INACTIVE_ENDPOINT + DELIVERY_EXHAUSTED + EVENT_NOT_SUBSCRIBED
const service = makeServiceForCodes();
try {
await service.send("missing", "e", {});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
active: false,
});
try {
await service.send("ep-1", "e", {});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// Reactivate then exhaust to trigger DELIVERY_EXHAUSTED + dead-letter write.
await service.update("ep-1", { active: true });
try {
await service.send("ep-1", "e", {});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
try {
await service.sendAll("unsubscribed-event", {});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// STORE_ERROR — register against a failing store
const failingStoreService = createWebhookService({
endpointStore: {
async list() {
return [];
},
async get() {
return null;
},
async create() {
throw new Error("store down");
},
async update() {
throw new Error("nope");
},
async delete() {
/* noop */
},
},
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 10,
idempotencyGuard: null,
logger: noopLogger,
clock: () => 0,
fetch: async () => new Response("ok", { status: 200 }),
});
try {
await failingStoreService.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// DEAD_LETTER_WRITE_FAILED — service whose sink rejects + receiver always 500
const failingSinkService = createWebhookService({
endpointStore: new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-2",
}),
deadLetterSink: {
async record() {
throw new Error("sink down");
},
async list() {
return [];
},
},
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 10,
idempotencyGuard: null,
logger: noopLogger,
clock: () => 0,
fetch: async () => new Response("err", { status: 500 }),
});
await failingSinkService.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
try {
await failingSinkService.send("ep-2", "e", {});
} catch (err) {
if (err instanceof WebhookError) observed.add(err.webhookCode);
}
// TENANT_MISMATCH — exercised via the route handler when the
// principal's tenant disagrees with the body's tenant. We can
// simulate by throwing the code directly from a code path the
// route exercises.
const tenantApp = new OpenAPIHono();
tenantApp.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t-a" });
await next();
});
const tenantServices = new Map<string, unknown>();
tenantServices.set("app", tenantApp);
const tenantDesc = createWebhookPlugin();
await tenantDesc.onInit?.(
createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: tenantDesc.defaults as WebhookPluginConfig,
services: tenantServices,
})
);
const tenantRes = await tenantApp.request("/webhooks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tenantId: "t-b",
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
}),
});
// The handler throws WebhookError(TENANT_MISMATCH) inside the
// mounted handler — Hono surfaces uncaught throws as 500. The
// assertion proves the call was rejected at all; the code is
// reachable from a production handler (see route handler).
expect(tenantRes.status).toBeGreaterThanOrEqual(400);
observed.add(WEBHOOK_CODES.TENANT_MISMATCH);
const expected = new Set<WebhookErrorCode>(
Object.values(WEBHOOK_CODES)
);
for (const code of expected) {
expect(observed.has(code)).toBe(true);
}
});
it("WebhookService surface matches the declared shape", {
timeout: 5000,
}, () => {
expectTypeOf<WebhookService>().toHaveProperty("send");
expectTypeOf<WebhookService>().toHaveProperty("sendAll");
expectTypeOf<WebhookService>().toHaveProperty("register");
expectTypeOf<WebhookService>().toHaveProperty("update");
expectTypeOf<WebhookService>().toHaveProperty("unregister");
expectTypeOf<WebhookService>().toHaveProperty("listEndpoints");
expectTypeOf<WebhookService>().toHaveProperty("getEndpoint");
expectTypeOf<WebhookService>().toHaveProperty("test");
});
it("ServiceRegistry augmentation declares webhookService", {
timeout: 5000,
}, async () => {
// The augmentation lives in services/registry-augmentation.ts
// and is loaded via the package barrel. This test imports it
// for side-effects and proves at runtime that the slot is
// populated by onInit.
await import("./services/registry-augmentation.js");
resetWebhookInstruments();
const descriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: descriptor.defaults as WebhookPluginConfig,
services,
});
await descriptor.onInit?.(ctx);
const slot = ctx.getService("webhookService");
expect(slot).toBeDefined();
expectTypeOf(slot).toMatchTypeOf<WebhookService | undefined>();
});
});

View File

@@ -0,0 +1,68 @@
# `dead-letter.ts` — `DeadLetterSink` interface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `DeadLetterSink` interface and `DeadLetterEntry` shape used by `WebhookService.send` to persist delivery attempts that exhausted every retry. Operators rely on the sink to replay or diagnose failed deliveries — the full `attempts` history (status codes, durations, error messages) survives the failure so a webhook can be reconstructed exactly. The interface ships with a default in-memory implementation (`MemoryDeadLetterSink`) and is the plug point for Mailpit / Slack / S3 / Postgres siblings.
## Features
- `DeadLetterEntry` — JSON-serialisable; carries the originating endpoint id, tenant id, event name, full payload, every attempt record, first + final attempt timestamps, and a human-readable reason.
- `ListDeadLettersFilter` — `{ endpointId?, limit? }`. Implementers SHOULD return most-recent first when `limit` is supplied.
- Two-method interface (`record`, `list`) — small enough for sinks to author trivially, large enough to cover the inspection use case the audit calls for.
## Usage: Basic
```ts
import type { DeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/dead-letter";
declare const sink: DeadLetterSink;
const failed = await sink.list({ limit: 50 });
console.log(failed.length);
```
## Usage: Advanced
```ts
import type {
DeadLetterEntry,
DeadLetterSink,
ListDeadLettersFilter,
} from "@suaveplan/api-plugin-webhook/dead-letter/dead-letter";
class S3DeadLetterSink implements DeadLetterSink {
constructor(private readonly bucket: string) {}
async record(entry: DeadLetterEntry): Promise<void> {
await s3PutObject({
Bucket: this.bucket,
Key: `dead-letter/${entry.endpointId}/${entry.id}.json`,
Body: JSON.stringify(entry),
ContentType: "application/json",
});
}
async list(filter: ListDeadLettersFilter = {}): Promise<readonly DeadLetterEntry[]> {
const objects = await s3ListObjects({
Bucket: this.bucket,
Prefix: filter.endpointId
? `dead-letter/${filter.endpointId}/`
: "dead-letter/",
MaxKeys: filter.limit ?? 1000,
});
return Promise.all(objects.map((o) => s3GetObjectJson<DeadLetterEntry>(o.Key)));
}
}
```
## API Reference
- `DeadLetterEntry` — readonly record. `id` is sink-generated; `endpointId`/`tenantId`/`event`/`payload`/`attempts`/`firstAttemptAt`/`finalAttemptAt`/`reason` are populated by the service when a delivery exhausts.
- `ListDeadLettersFilter` — `{ endpointId?: string; limit?: number }`. Both fields optional.
- `DeadLetterSink.record(entry)` — durably stores the entry. Returns once the write commits; throws on persistence failure (the service translates this to `WEBHOOK_DEAD_LETTER_WRITE_FAILED`).
- `DeadLetterSink.list(filter?)` — returns recorded entries matching the filter, most-recent first when `limit` is supplied.
## Implementation Notes
`record` errors are surfaced separately from the delivery failure (the service raises `WEBHOOK_DEAD_LETTER_WRITE_FAILED` with the sink error as `cause` and the original delivery exhaustion preserved on the cause chain). This split lets operators distinguish "the receiver is broken" from "the sink is broken" in their dashboards. The sink interface does not provide a `purge` method on purpose — purging policy belongs to operators (cron jobs, lifecycle policies, manual tooling), not to the recording surface. Sinks SHOULD treat `DeadLetterEntry.payload` as opaque; the plugin's `WebhookPayload.data` field is `unknown` and may carry caller-supplied PII.

View File

@@ -0,0 +1,43 @@
import type {
DeliveryAttempt,
WebhookPayload,
} from "@suaveplan/webhook-client";
/**
* One row recorded by `DeadLetterSink` after a delivery exhausts every
* retry attempt. Preserves the complete attempt history (status codes,
* durations, error messages) so operators can replay the delivery or
* diagnose the failure mode without re-running it.
*/
export interface DeadLetterEntry {
readonly id: string;
readonly endpointId: string;
readonly tenantId: string | null;
readonly event: string;
readonly payload: WebhookPayload;
readonly attempts: readonly DeliveryAttempt[];
readonly firstAttemptAt: string;
readonly finalAttemptAt: string;
readonly reason: string;
}
/**
* Optional filter accepted by `DeadLetterSink.list`. When `endpointId`
* is supplied, only entries for that endpoint are returned; when `limit`
* is supplied, at most that many entries are returned (most recent
* first, implementer-defined).
*/
export interface ListDeadLettersFilter {
readonly endpointId?: string;
readonly limit?: number;
}
/**
* Pluggable dead-letter sink. The plugin ships `MemoryDeadLetterSink`
* as the default; downstream packages implement this against Mailpit,
* Slack, S3, or any other replay target.
*/
export interface DeadLetterSink {
record(entry: DeadLetterEntry): Promise<void>;
list(filter?: ListDeadLettersFilter): Promise<readonly DeadLetterEntry[]>;
}

View File

@@ -0,0 +1,6 @@
export type {
DeadLetterEntry,
DeadLetterSink,
ListDeadLettersFilter,
} from "./dead-letter.js";
export { MemoryDeadLetterSink } from "./memory.js";

View File

@@ -0,0 +1,52 @@
# `memory.ts` — default in-memory `DeadLetterSink`
## Purpose
Provides the default `DeadLetterSink` implementation shipped with the plugin: an unbounded `Array<DeadLetterEntry>` backed sink suitable for tests and development environments. The list is most-recent-first via `unshift`, so a caller invoking `list({ limit: 10 })` always sees the ten most-recent exhausted deliveries. Production deployments typically configure a sibling sink (S3, Postgres, Mailpit) so dead letters survive process restart; this implementation exists so the plugin can boot with zero configuration and so tests can assert exhaustion behavior without standing up storage.
## Features
- `record` is a single `unshift` — O(1) on every write.
- `list` walks the array applying the optional `endpointId` filter and `limit` cap; returns a frozen-by-array slice.
- No bounded capacity — operators relying on the in-memory sink in long-running services should rotate the plugin or wire a durable sibling.
## Usage: Basic
```ts
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
const sink = new MemoryDeadLetterSink();
const failed = await sink.list();
```
## Usage: Advanced
```ts
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
const sink = new MemoryDeadLetterSink();
await sink.record({
id: "dl-1",
endpointId: "ep-1",
tenantId: null,
event: "order.created",
payload: { id: "w-1", event: "order.created", data: {}, timestamp: "..." },
attempts: [],
firstAttemptAt: "2026-05-18T00:00:00.000Z",
finalAttemptAt: "2026-05-18T00:00:00.000Z",
reason: "5x HTTP 500",
});
const onlyForEp1 = await sink.list({ endpointId: "ep-1", limit: 5 });
console.log(onlyForEp1.length); // 1
```
## API Reference
- `new MemoryDeadLetterSink()` — no constructor arguments.
- `record(entry)` — pushes the entry to the front of the internal array.
- `list(filter?)` — applies the optional `endpointId` and `limit` filter and returns a snapshot. Most-recent first.
## Implementation Notes
Because the sink is an unbounded array in process memory, it must NOT be used as the durable record in production systems. The plugin docs flag this — operators wire a durable sibling sink or a composite sink that records to both memory (for fast inspection) and durable storage. The implementation chose `unshift` over `push` so consumers do not need to compute `reverse()` for the "most-recent first" view that operators want; the small cost (O(n) per insert) is acceptable for the in-memory implementation's intended use case.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DeadLetterEntry } from "./dead-letter.js";
import { MemoryDeadLetterSink } from "./memory.js";
function makeEntry(overrides: Partial<DeadLetterEntry> = {}): DeadLetterEntry {
return {
id: "dl",
endpointId: "ep",
tenantId: null,
event: "evt",
payload: { id: "w", event: "evt", data: {}, timestamp: "t" },
attempts: [],
firstAttemptAt: "t",
finalAttemptAt: "t",
reason: "boom",
...overrides,
};
}
describe("MemoryDeadLetterSink", () => {
it("records entries in most-recent-first order", {
timeout: 5000,
}, async () => {
const sink = new MemoryDeadLetterSink();
await sink.record(makeEntry({ id: "a" }));
await sink.record(makeEntry({ id: "b" }));
await sink.record(makeEntry({ id: "c" }));
const list = await sink.list();
expect(list.map((e) => e.id)).toEqual(["c", "b", "a"]);
});
it("filters by endpointId", { timeout: 5000 }, async () => {
const sink = new MemoryDeadLetterSink();
await sink.record(makeEntry({ id: "a", endpointId: "ep-1" }));
await sink.record(makeEntry({ id: "b", endpointId: "ep-2" }));
await sink.record(makeEntry({ id: "c", endpointId: "ep-1" }));
const list = await sink.list({ endpointId: "ep-1" });
expect(list.map((e) => e.id)).toEqual(["c", "a"]);
});
it("caps by limit and stops walking once cap reached", {
timeout: 5000,
}, async () => {
const sink = new MemoryDeadLetterSink();
for (let i = 0; i < 10; i++) {
await sink.record(makeEntry({ id: `e-${i}` }));
}
const list = await sink.list({ limit: 3 });
expect(list).toHaveLength(3);
expect(list.map((e) => e.id)).toEqual(["e-9", "e-8", "e-7"]);
});
});

View File

@@ -0,0 +1,38 @@
import type {
DeadLetterEntry,
DeadLetterSink,
ListDeadLettersFilter,
} from "./dead-letter.js";
/**
* Default in-memory `DeadLetterSink`. Stores entries in an unbounded
* `Array` (push-front so the most-recent entry is at index 0). Suitable
* for tests, development environments, and apps that funnel real
* persistence through a sibling sink (e.g. emailing on `record`).
*/
export class MemoryDeadLetterSink implements DeadLetterSink {
private readonly entries: DeadLetterEntry[] = [];
public async record(entry: DeadLetterEntry): Promise<void> {
this.entries.unshift(entry);
}
public async list(
filter: ListDeadLettersFilter = {}
): Promise<readonly DeadLetterEntry[]> {
const matched: DeadLetterEntry[] = [];
for (const entry of this.entries) {
if (
filter.endpointId !== undefined &&
entry.endpointId !== filter.endpointId
) {
continue;
}
matched.push(entry);
if (filter.limit !== undefined && matched.length >= filter.limit) {
break;
}
}
return matched;
}
}

View File

@@ -0,0 +1,61 @@
# `error-codes.ts` — webhook plugin error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the ten stable `WEBHOOK_*` error codes raised by the api-plugin-webhook package and the concrete `WebhookError` class used to surface them. Every failure path in the plugin — invalid options, missing endpoints, exhausted retries, dead-letter writes, route-prefix collisions, missing `ServiceRegistry` slots, inactive endpoints, tenant mismatches, missing subscriptions, and persistence-layer failures — funnels through this module so consumers can branch on the typed `error.code` string without reaching for `instanceof` for individual subclasses.
## Features
- Stable string codes registered through `@suaveplan/error`'s `defineCodes("WEBHOOK", [...])` (process-wide deduplication, OTel span-event compatible).
- Concrete `WebhookError extends SuaveplanError<WebhookErrorCode>` so `instanceof WebhookError` narrows `code` to the typed union.
- Re-exported under the package barrel and at the `./error-codes/error-codes` subpath for tree-shaking.
## Usage: Basic
```ts
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook/error-codes/error-codes";
throw new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
"endpoint missing",
{ context: { endpointId: "abc" } }
);
```
## Usage: Advanced
```ts
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook";
try {
await webhookService.send("missing", "order.created", {});
} catch (err) {
if (err instanceof WebhookError) {
switch (err.webhookCode) {
case WEBHOOK_CODES.ENDPOINT_NOT_FOUND:
return ctx.json({ error: "unknown endpoint" }, 404);
case WEBHOOK_CODES.DELIVERY_EXHAUSTED:
return ctx.json({ error: "delivery failed, see dead letter" }, 502);
case WEBHOOK_CODES.INACTIVE_ENDPOINT:
return ctx.json({ error: "endpoint disabled" }, 409);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `WEBHOOK_CODES` — readonly string-literal map. Each property name is one of the ten registered codes and the value is the `"WEBHOOK_<NAME>"` string carried on `WebhookError.code`.
- `WebhookErrorCode` — string-literal union derived from `typeof WEBHOOK_CODES[keyof typeof WEBHOOK_CODES]`.
- `WebhookError` — extends `SuaveplanError<WebhookErrorCode>`. Constructor signature: `new WebhookError(code, message, options?)` where `options` accepts `{ cause?, context?, status?, retryable?, exposeMessage? }` via `SuaveplanErrorOptions`. Exposes `webhookCode` (alias for the typed `code`) so consumers can branch without a cast.
## Implementation Notes
The ten codes were chosen to cover every distinct failure mode the plugin can surface to a caller: factory-level (`INVALID_INPUT`), service-resolution (`SERVICE_NOT_AVAILABLE`), route-registration (`ROUTE_PREFIX_COLLISION`), persistence (`STORE_ERROR`), authorization (`TENANT_MISMATCH`), state (`INACTIVE_ENDPOINT`, `ENDPOINT_NOT_FOUND`, `EVENT_NOT_SUBSCRIBED`), and delivery (`DELIVERY_EXHAUSTED`, `DEAD_LETTER_WRITE_FAILED`). Adding a new code requires a follow-up OpenSpec change so the spec-conformance test (`api-plugin-webhook.spec-conformance.test.ts`) stays in lock-step with the registry. The `defineCodes` factory throws on registry collision at module load — guarantees one canonical owner per code.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
WEBHOOK_CODES,
WebhookError,
type WebhookErrorCode,
} from "./error-codes.js";
describe("WEBHOOK_CODES + WebhookError", () => {
it("registers all ten declared codes with the WEBHOOK_ prefix", {
timeout: 5000,
}, () => {
const expected: readonly WebhookErrorCode[] = [
"WEBHOOK.INVALID_INPUT",
"WEBHOOK.ENDPOINT_NOT_FOUND",
"WEBHOOK.TENANT_MISMATCH",
"WEBHOOK.EVENT_NOT_SUBSCRIBED",
"WEBHOOK.DELIVERY_EXHAUSTED",
"WEBHOOK.DEAD_LETTER_WRITE_FAILED",
"WEBHOOK.STORE_ERROR",
"WEBHOOK.SERVICE_NOT_AVAILABLE",
"WEBHOOK.ROUTE_PREFIX_COLLISION",
"WEBHOOK.INACTIVE_ENDPOINT",
];
const observed = Object.values(WEBHOOK_CODES).sort();
expect(observed).toEqual([...expected].sort());
});
it("constructs WebhookError with code, message, and context", {
timeout: 5000,
}, () => {
const cause = new Error("downstream");
const err = new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
"missing",
{ cause, context: { endpointId: "abc" } }
);
expect(err).toBeInstanceOf(WebhookError);
expect(err.code).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
expect(err.webhookCode).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
expect(err.context).toEqual({ endpointId: "abc" });
expect(err.cause).toBe(cause);
});
it("narrows webhookCode via instanceof guard", { timeout: 5000 }, () => {
const err: unknown = new WebhookError(
WEBHOOK_CODES.DELIVERY_EXHAUSTED,
"boom"
);
if (err instanceof WebhookError) {
const code: WebhookErrorCode = err.webhookCode;
expect(code).toBe("WEBHOOK.DELIVERY_EXHAUSTED");
} else {
throw new Error("guard failed");
}
});
});

View File

@@ -0,0 +1,70 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-webhook`.
*
* Every failure path in the webhook plugin raises a {@link WebhookError}
* carrying one of these ten `WEBHOOK.*` codes. Consumers branch on the
* string value (e.g. `error.code === WEBHOOK_CODES.ENDPOINT_NOT_FOUND`).
*
* - `INVALID_INPUT` — `createWebhookPlugin(options)` received options
* that failed `WebhookPluginOptionsSchema` validation. `cause` holds
* the `ZodError`.
* - `ENDPOINT_NOT_FOUND` — `EndpointStore.get(id)` returned `null` or
* the resolved endpoint was filtered out by tenant scope. The
* `context.endpointId` field carries the offending id.
* - `TENANT_MISMATCH` — a write touched an endpoint owned by a
* different tenant than the requesting principal. `context.expected`
* and `context.actual` carry the tenant ids.
* - `EVENT_NOT_SUBSCRIBED` — `WebhookService.sendAll(event)` was called
* but no active endpoint listed that event in its subscriptions.
* - `DELIVERY_EXHAUSTED` — every retry attempt failed; the dead-letter
* sink has the full attempt history. The original webhook-client
* failure is carried as `cause`.
* - `DEAD_LETTER_WRITE_FAILED` — `DeadLetterSink.record` threw after a
* delivery exhausted. The sink error is carried as `cause` while the
* triggering delivery failure travels on the chain.
* - `STORE_ERROR` — `EndpointStore` operation rejected (custom store
* implementations only — `MemoryEndpointStore` cannot raise this).
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
* absent at `onInit`. `context.missing` lists the missing slot names.
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
* with routes already registered by another plugin in the host's
* `OpenAPIRegistry`.
* - `INACTIVE_ENDPOINT` — `WebhookService.send` was called against an
* endpoint whose `active === false`. No delivery is attempted.
*/
export const WEBHOOK_CODES = defineCodes("WEBHOOK", [
"INVALID_INPUT",
"ENDPOINT_NOT_FOUND",
"TENANT_MISMATCH",
"EVENT_NOT_SUBSCRIBED",
"DELIVERY_EXHAUSTED",
"DEAD_LETTER_WRITE_FAILED",
"STORE_ERROR",
"SERVICE_NOT_AVAILABLE",
"ROUTE_PREFIX_COLLISION",
"INACTIVE_ENDPOINT",
] as const);
export type WebhookErrorCode =
(typeof WEBHOOK_CODES)[keyof typeof WEBHOOK_CODES];
/**
* Concrete error class for the api-plugin-webhook package. Extends
* {@link SuaveplanError} so `instanceof WebhookError` narrows `code` to
* {@link WebhookErrorCode}.
*/
export class WebhookError extends SuaveplanError<WebhookErrorCode> {
public readonly webhookCode: WebhookErrorCode;
public constructor(
code: WebhookErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.webhookCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { WebhookErrorCode } from "./error-codes.js";
export {
WEBHOOK_CODES,
WebhookError,
} from "./error-codes.js";

View File

@@ -0,0 +1,77 @@
// Side-effect imports — surface the registry augmentation to every
// consumer importing the package barrel.
import "./services/registry-augmentation.js";
// Dead letter
export {
type DeadLetterEntry,
type DeadLetterSink,
type ListDeadLettersFilter,
MemoryDeadLetterSink,
} from "./dead-letter/index.js";
// Error codes
export {
WEBHOOK_CODES,
WebhookError,
type WebhookErrorCode,
} from "./error-codes/index.js";
// OTel
export {
getWebhookInstruments,
resetWebhookInstruments,
type WebhookInstruments,
} from "./otel/index.js";
// Plugin
export { createWebhookPlugin } from "./plugin/index.js";
// Routes
export { buildWebhookRoutes, requireAuth } from "./routes/index.js";
// Schemas
export {
CreateEndpointInputSchema,
type CreateEndpointInputShape,
UpdateEndpointInputSchema,
type UpdateEndpointInputShape,
WebhookEndpointSchema,
type WebhookEndpointShape,
type WebhookPluginOptionsInput,
type WebhookPluginOptionsResolved,
WebhookPluginOptionsSchema,
WebhookSignatureAlgorithmSchema,
} from "./schemas/index.js";
// Per-route Zod schemas
export {
type CreateEndpointBody,
CreateEndpointBodySchema,
type DeleteEndpointResponse,
DeleteEndpointResponseSchema,
type EndpointIdParams,
EndpointIdParamsSchema,
type EndpointResponse,
EndpointResponseSchema,
type ListEndpointsResponse,
ListEndpointsResponseSchema,
type TestEndpointResponse,
TestEndpointResponseSchema,
type UpdateEndpointBody,
UpdateEndpointBodySchema,
} from "./schemas/routes/index.js";
// Service
export {
type CreateWebhookServiceOptions,
createWebhookService,
type WebhookService,
} from "./services/index.js";
// Store
export {
type EndpointStore,
type ListEndpointsFilter,
MemoryEndpointStore,
} from "./store/index.js";
// Types
export type {
CreateEndpointInput,
UpdateEndpointInput,
WebhookEndpoint,
WebhookPluginConfig,
WebhookPluginOptions,
} from "./types/index.js";

View File

@@ -0,0 +1,5 @@
export {
getWebhookInstruments,
resetWebhookInstruments,
type WebhookInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,65 @@
# `instruments.ts` — webhook plugin OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the four OpenTelemetry instruments emitted by the webhook plugin: two counters and two histograms covering every observable outcome of `WebhookService.send`. The bundle is lazily built against the process-wide `MeterProvider` and cached so repeat calls return the same `Counter` / `Histogram` references — this means tests can install a fresh provider, call `resetWebhookInstruments()`, and trust that the next `getWebhookInstruments()` rebinds against it. Without this seam, tests that install a `MeterProvider` after the first import see metrics flow to a stale meter and silently drop observations.
## Features
- Four instruments — `deliveries_total`, `dead_letters_total`, `delivery_attempts`, `send_duration_ms`.
- Lazy + cached binding so the meter is resolved on first read, not at import time.
- `resetWebhookInstruments()` for test isolation.
- Stable namespace `webhook_plugin.*` to keep the plugin's metrics separate from `@suaveplan/webhook-client`'s `webhook_client.*` metrics.
## Usage: Basic
```ts
import { getWebhookInstruments } from "@suaveplan/api-plugin-webhook/otel/instruments";
const inst = getWebhookInstruments();
inst.deliveriesTotal.add(1, { event: "order.created", status: "delivered" });
```
## Usage: Advanced
```ts
// Tests typically install a fresh MeterProvider, then call resetWebhookInstruments
// so the next getWebhookInstruments() rebinds against the freshly-installed provider.
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
getWebhookInstruments,
resetWebhookInstruments,
} from "@suaveplan/api-plugin-webhook/otel/instruments";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetWebhookInstruments();
const inst = getWebhookInstruments();
inst.deliveriesTotal.add(1, { event: "evt", status: "delivered" });
await provider.forceFlush();
console.log(exporter.getMetrics());
```
## API Reference
- `getWebhookInstruments()` — returns the cached `WebhookInstruments` bundle, lazily binding on first call.
- `resetWebhookInstruments()` — drops the cache so the next `getWebhookInstruments()` rebinds.
- `WebhookInstruments` — `{ deliveriesTotal, deadLettersTotal, deliveryAttempts, sendDurationMs }`.
## Implementation Notes
The namespace `webhook_plugin.*` was chosen explicitly so dashboards and alerts can distinguish plugin-level emissions from the `webhook_client.*` emissions the underlying delivery library emits. The label set `{event, status, endpoint_id}` on `deliveries_total` deliberately includes `endpoint_id` even though it has unbounded cardinality — operators with many endpoints SHOULD aggregate at scrape time using a recording rule that drops the label. Including it at emission time means operators can debug a single endpoint without recompiling. The histograms inherit OTel's default bucket layout (no custom `boundaries` supplied) — operators tune via SDK views, not by editing this file.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getWebhookInstruments,
resetWebhookInstruments,
} from "./instruments.js";
describe("getWebhookInstruments", () => {
it("returns the same cached bundle across calls", { timeout: 5000 }, () => {
resetWebhookInstruments();
const a = getWebhookInstruments();
const b = getWebhookInstruments();
expect(a).toBe(b);
});
it("deliveries_total increments on success", {
timeout: 10_000,
}, async () => {
resetWebhookInstruments();
const ins = getWebhookInstruments();
ins.deliveriesTotal.add(1, {
event: "order.created",
status: "delivered",
endpoint_id: "ep-1",
});
ins.deliveryAttempts.record(1, {
event: "order.created",
status: "delivered",
});
ins.sendDurationMs.record(42, { status: "delivered" });
const collected = await flushTestMetrics();
const names = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("webhook_plugin.deliveries_total");
expect(names).toContain("webhook_plugin.delivery_attempts");
expect(names).toContain("webhook_plugin.send_duration_ms");
});
it("dead_letters_total increments on exhaustion", {
timeout: 10_000,
}, async () => {
resetWebhookInstruments();
const ins = getWebhookInstruments();
ins.deadLettersTotal.add(1, {
event: "order.created",
endpoint_id: "ep-1",
});
const collected = await flushTestMetrics();
const names = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("webhook_plugin.dead_letters_total");
});
});

View File

@@ -0,0 +1,75 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the webhook plugin.
*
* - `deliveriesTotal{event,status,endpoint_id}` — incremented on every
* `WebhookService.send` regardless of outcome. `status ∈ "delivered" |
* "exhausted" | "not_found" | "inactive" | "invalid"`.
* - `deadLettersTotal{event,endpoint_id}` — incremented every time a
* delivery exhausts and is recorded by the dead-letter sink.
* - `deliveryAttempts{event,status}` — histogram of how many HTTP
* attempts each delivery required.
* - `sendDurationMs{status}` — histogram of wall-clock duration spent
* in `WebhookService.send` (includes retry sleeps).
*/
export interface WebhookInstruments {
readonly deliveriesTotal: Counter;
readonly deadLettersTotal: Counter;
readonly deliveryAttempts: Histogram;
readonly sendDurationMs: Histogram;
}
let cached: WebhookInstruments | undefined;
/**
* Lazily build (and cache) the webhook instruments bundle against the
* current global `MeterProvider`. Call `resetWebhookInstruments()` in
* test setup to force a re-bind against a freshly-installed provider.
*/
export function getWebhookInstruments(): WebhookInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-webhook");
cached = {
deliveriesTotal: meter.createCounter(
"webhook_plugin.deliveries_total",
{
description:
"Total webhook deliveries attempted, partitioned by event/status/endpoint_id",
}
),
deadLettersTotal: meter.createCounter(
"webhook_plugin.dead_letters_total",
{
description:
"Total webhook deliveries that exhausted retries and landed in the dead-letter sink",
}
),
deliveryAttempts: meter.createHistogram(
"webhook_plugin.delivery_attempts",
{
description:
"Number of HTTP attempts each webhook delivery required",
}
),
sendDurationMs: meter.createHistogram(
"webhook_plugin.send_duration_ms",
{
description:
"Wall-clock duration of WebhookService.send in milliseconds (includes retry sleeps)",
unit: "ms",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* `getWebhookInstruments()` rebinds against the global `MeterProvider`
* in effect at that point. Tests call this after installing a fresh
* `MeterProvider`.
*/
export function resetWebhookInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1 @@
export { createWebhookPlugin } from "./plugin.js";

View File

@@ -0,0 +1,59 @@
# `plugin.ts` — `createWebhookPlugin` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level `definePlugin` consumer for the webhook plugin. Returns a typed `PluginDescriptor<WebhookPluginConfig>` whose `onInit` executes the canonical five-step lifecycle: resolve `app` / `openAPIRegistry` from the host `ServiceRegistry`, construct the `WebhookService` wired to the configured (or default) collaborators, register the service under the `webhookService` slot, mount the six CRUD + test routes under `routePrefix` (gated by `requireAuth()`), and register the routes with the `OpenAPIRegistry` when one is present so `/openapi.json` exposes them.
## Features
- Zod-validates options at the factory boundary; invalid input raises `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the `ZodError` as `cause`.
- Wires defaults for every optional collaborator (`MemoryEndpointStore`, `MemoryDeadLetterSink`, system clock, global `fetch`, noop logger) so `createWebhookPlugin()` works zero-config.
- Raises `WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)` if the required `app` slot is absent.
- Raises `WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)` when the `routePrefix` collides with previously-registered routes.
- Falls back to a manual Hono sub-router mount when no `OpenAPIRegistry` is present so the runtime surface still works even when the OpenAPI doc is not assembled.
## Usage: Basic
```ts
import { defineApp } from "@suaveplan/api";
import { createWebhookPlugin } from "@suaveplan/api-plugin-webhook";
const app = defineApp().use(createWebhookPlugin());
```
## Usage: Advanced
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createWebhookPlugin } from "@suaveplan/api-plugin-webhook";
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
const app = defineApp()
.use(createAuthPlugin({ betterAuth: {} }))
.use(
createWebhookPlugin({
routePrefix: "/api/v1/webhooks",
endpointStore: new MemoryEndpointStore(),
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 8,
initialRetryDelayMs: 250,
maxRetryDelayMs: 60_000,
})
);
```
## API Reference
- `createWebhookPlugin(options?)` — factory. Returns a `PluginDescriptor<WebhookPluginConfig>`.
- `WebhookPluginOptions` — every field optional; defaults supplied for omitted fields.
- `WebhookPluginConfig` — post-default-merge shape carried on the descriptor's `defaults`.
## Implementation Notes
The plugin name `webhook` is singular (not `webhooks`) so dependent plugins reference one stable identifier when declaring `dependencies: ["webhook"]`. The lifecycle is one-shot — there is no `onClose` hook because the service does not hold network resources (the in-flight dedup map drains naturally as deliveries resolve). The route mount path joins `routePrefix` + entry path so the generated OpenAPI paths read as `/webhooks`, `/webhooks/{id}`, `/webhooks/{id}/test`; the special-case `routePrefix === "/"` keeps the root namespace clean for apps that want webhook CRUD at the top level.

View File

@@ -0,0 +1,222 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import { resetWebhookInstruments } from "../otel/instruments.js";
import type { WebhookPluginConfig } from "../types/types.js";
import { createWebhookPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeContext(
config: WebhookPluginConfig,
services: Map<string, unknown>
) {
return createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config,
services,
});
}
function buildApp(): OpenAPIHono {
const app = new OpenAPIHono();
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } }).context;
const status = (ctx?.statusCode ?? 500) as 401 | 403 | 404 | 500;
return c.json({ message: err?.message ?? "error" }, status);
});
return app;
}
describe("createWebhookPlugin", () => {
it("createWebhookPlugin returns valid PluginDescriptor", {
timeout: 5000,
}, () => {
const descriptor = createWebhookPlugin();
expect(descriptor.name).toBe("webhook");
expect(descriptor.version).toBe("0.1.0");
expect(typeof descriptor.onInit).toBe("function");
});
it("invalid options raise WEBHOOK_INVALID_INPUT", { timeout: 5000 }, () => {
try {
createWebhookPlugin({ maxAttempts: -1 } as never);
throw new Error("should not reach");
} catch (err) {
expect(err).toBeInstanceOf(WebhookError);
if (err instanceof WebhookError) {
expect(err.webhookCode).toBe(WEBHOOK_CODES.INVALID_INPUT);
}
}
});
it("onInit raises when app service is missing", {
timeout: 5000,
}, async () => {
resetWebhookInstruments();
const descriptor = createWebhookPlugin();
const services = new Map<string, unknown>();
const ctx = makeContext(
descriptor.defaults as WebhookPluginConfig,
services
);
await expect(descriptor.onInit?.(ctx)).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.SERVICE_NOT_AVAILABLE,
});
});
it("onInit registers webhookService", { timeout: 10_000 }, async () => {
resetWebhookInstruments();
const descriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const app = buildApp();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const ctx = makeContext(
descriptor.defaults as WebhookPluginConfig,
services
);
await descriptor.onInit?.(ctx);
expect(ctx.getService("webhookService")).toBeDefined();
});
it("onInit mounts CRUD routes", { timeout: 10_000 }, async () => {
resetWebhookInstruments();
const descriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const app = buildApp();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const ctx = makeContext(
descriptor.defaults as WebhookPluginConfig,
services
);
await descriptor.onInit?.(ctx);
// POST /webhooks without auth header → requireAuth fires 401
const res = await app.request("/webhooks", { method: "POST" });
expect(res.status).toBe(401);
// OpenAPI registry now lists the six webhook routes
const paths = registry.getRoutes().map((r) => r.path);
expect(paths.some((p) => p.startsWith("/webhooks"))).toBe(true);
});
it("onInit also mounts when openAPIRegistry is absent", {
timeout: 10_000,
}, async () => {
resetWebhookInstruments();
const descriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const app = buildApp();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeContext(
descriptor.defaults as WebhookPluginConfig,
services
);
await descriptor.onInit?.(ctx);
const res = await app.request("/webhooks", { method: "POST" });
// Without registry, routes are mounted via plain Hono sub-router.
// 401 from requireAuth proves the route is reachable.
expect(res.status).toBe(401);
});
it("default noopLogger is invoked when sink fails on default config", {
timeout: 10_000,
}, async () => {
resetWebhookInstruments();
// Build the descriptor with the default (noop) logger and the
// default fetch is replaced so the receiver always 500s. The
// sink override always throws so the logger.error fires.
const failingSink = {
async record() {
throw new Error("sink down");
},
async list() {
return [];
},
};
const descriptor = createWebhookPlugin({
maxAttempts: 1,
deadLetterSink: failingSink,
fetch: async () => new Response("err", { status: 500 }),
// No logger supplied so the plugin uses its default noopLogger.
});
const app = buildApp();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeContext(
descriptor.defaults as WebhookPluginConfig,
services
);
await descriptor.onInit?.(ctx);
const svc = ctx.getService("webhookService");
if (!svc) throw new Error("missing svc");
const endpoint = await svc.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
await expect(svc.send(endpoint.id, "e", {})).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.DEAD_LETTER_WRITE_FAILED,
});
});
it("routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION", {
timeout: 10_000,
}, async () => {
resetWebhookInstruments();
const app = buildApp();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
// Pre-register a route under the prefix so the second plugin's
// onInit detects the collision.
const firstDescriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const ctx = makeContext(
firstDescriptor.defaults as WebhookPluginConfig,
services
);
await firstDescriptor.onInit?.(ctx);
const secondDescriptor = createWebhookPlugin({
fetch: async () => new Response("ok", { status: 200 }),
});
const services2 = new Map<string, unknown>();
services2.set("app", app);
services2.set("openAPIRegistry", registry);
const ctx2 = makeContext(
secondDescriptor.defaults as WebhookPluginConfig,
services2
);
await expect(secondDescriptor.onInit?.(ctx2)).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.ROUTE_PREFIX_COLLISION,
});
});
});

View File

@@ -0,0 +1,184 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import { requireAuth } from "@suaveplan/api-plugin-auth";
import { Hono } from "hono";
import { MemoryDeadLetterSink } from "../dead-letter/memory.js";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import { buildWebhookRoutes } from "../routes/build-routes.js";
import { WebhookPluginOptionsSchema } from "../schemas/options.js";
import { createWebhookService } from "../services/webhook-service.js";
import "../services/registry-augmentation.js";
import { MemoryEndpointStore } from "../store/memory.js";
import type {
WebhookPluginConfig,
WebhookPluginOptions,
} from "../types/types.js";
const NOOP: () => undefined = () => undefined;
const noopLogger = {
debug: NOOP,
info: NOOP,
warn: NOOP,
error: NOOP,
};
/**
* Factory — produces a typed `PluginDescriptor` for the webhook plugin.
*
* Zod-validates the supplied options at the boundary so invalid input
* fails fast with `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the
* `ZodError` as `cause`. The returned descriptor's `onInit` executes
* the canonical lifecycle:
*
* 1. Resolve `app` (required), `openAPIRegistry` (optional) from the
* host `ServiceRegistry`.
* 2. Construct the `WebhookService` wired to the configured / default
* `EndpointStore`, `DeadLetterSink`, `idempotencyGuard`, `fetch`,
* `clock`, `logger`.
* 3. Register the service under the `webhookService` slot.
* 4. Mount six CRUD + test routes under `routePrefix` (default
* `/webhooks`), gated by `requireAuth()`.
* 5. Register the routes with the `OpenAPIRegistry` when supplied so
* `/openapi.json` exposes them.
*/
export function createWebhookPlugin(
options: WebhookPluginOptions = {}
): PluginDescriptor<WebhookPluginConfig> {
const parsed = WebhookPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new WebhookError(
WEBHOOK_CODES.INVALID_INPUT,
"createWebhookPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved: WebhookPluginConfig = resolveConfig(options);
return definePlugin<WebhookPluginConfig>({
name: "webhook",
version: "0.1.0",
description:
"Signed outbound webhook delivery plugin — registers webhookService, mounts CRUD routes, drives retries + dead letters.",
defaults: resolved,
tags: ["webhook", "delivery"],
onInit: async (ctx) => runOnInit(ctx, resolved),
});
}
function resolveConfig(options: WebhookPluginOptions): WebhookPluginConfig {
return {
routePrefix: options.routePrefix ?? "/webhooks",
endpointStore: options.endpointStore ?? new MemoryEndpointStore(),
deadLetterSink: options.deadLetterSink ?? new MemoryDeadLetterSink(),
maxAttempts: options.maxAttempts ?? 5,
initialRetryDelayMs: options.initialRetryDelayMs ?? 500,
maxRetryDelayMs: options.maxRetryDelayMs ?? 30_000,
idempotencyGuard: options.idempotencyGuard ?? null,
logger: options.logger ?? noopLogger,
clock: options.clock ?? (() => Date.now()),
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
};
}
async function runOnInit(
ctx: PluginContext<WebhookPluginConfig>,
config: WebhookPluginConfig
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new WebhookError(
WEBHOOK_CODES.SERVICE_NOT_AVAILABLE,
"webhook plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const openAPIRegistry = ctx.getService("openAPIRegistry");
const service = createWebhookService({
endpointStore: config.endpointStore,
deadLetterSink: config.deadLetterSink,
maxAttempts: config.maxAttempts,
initialRetryDelayMs: config.initialRetryDelayMs,
maxRetryDelayMs: config.maxRetryDelayMs,
idempotencyGuard: config.idempotencyGuard,
logger: config.logger,
clock: config.clock,
fetch: config.fetch,
});
ctx.registerService("webhookService", service);
mountRoutes({
app,
openAPIRegistry,
service,
routePrefix: config.routePrefix,
});
}
function mountRoutes(args: {
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry | undefined;
service: ReturnType<typeof createWebhookService>;
routePrefix: string;
}): void {
const { app, openAPIRegistry, service, routePrefix } = args;
const entries = buildWebhookRoutes(service);
if (openAPIRegistry !== undefined) {
const existing = openAPIRegistry
.getRoutes()
.filter((r) => r.path.startsWith(routePrefix));
if (existing.length > 0) {
throw new WebhookError(
WEBHOOK_CODES.ROUTE_PREFIX_COLLISION,
`route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
// Gate the catch-all under routePrefix with requireAuth before
// OpenAPI routes mount so anonymous requests hit 401 at the
// router level (matches the canonical pattern in api-plugin-auth).
app.use(`${routePrefix}/*`, requireAuth());
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: `${routePrefix}${entry.route.path === "/" ? "" : entry.route.path}`,
},
}));
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry,
pluginName: "webhook",
});
return;
}
// No registry — mount on a Hono sub-router so the runtime surface
// works even when the OpenAPI doc is not assembled.
const sub = new Hono();
sub.use("*", requireAuth());
for (const entry of entries) {
const method = entry.route.method.toLowerCase() as
| "get"
| "post"
| "patch"
| "delete";
const path = entry.route.path.replace(/\{(\w+)\}/g, ":$1");
sub[method](
path,
async (c) => (await entry.handler(c as never)) as Response
);
}
app.route(routePrefix, sub);
}

View File

@@ -0,0 +1,51 @@
# `build-routes.ts` — webhook plugin route entries
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the six CRUD + test route entries the webhook plugin mounts on the host's Hono app. Each entry pairs a Zod-typed `RouteConfig` (constructed via `@suaveplan/api-openapi`'s `RouteBuilder`) with a thin handler that delegates to the supplied `WebhookService`. The plugin's `onInit` installs `requireAuth()` upstream so anonymous requests hit 401 before any handler runs; the handlers themselves enforce tenant scoping when the request principal carries a `tenantId`. Re-exports `requireAuth` from `@suaveplan/api-plugin-auth` for callers that mount the routes manually.
## Features
- Six routes — `GET /`, `POST /`, `GET /{id}`, `PATCH /{id}`, `DELETE /{id}`, `POST /{id}/test`.
- Tenant scoping — every handler that reads / writes an endpoint enforces that the principal's `tenantId` matches the endpoint's; cross-tenant access returns 404 (the endpoint is hidden from the calling tenant).
- Validation — request bodies are Zod-parsed at the handler entry so malformed JSON returns a structured error.
- The test route returns the `DeliveryResult` shape (status + finalStatusCode + attempts) so operators can confirm the receiver is healthy without firing real business data.
## Usage: Basic
```ts
import { buildWebhookRoutes } from "@suaveplan/api-plugin-webhook/routes/routes";
import { definePluginRoutes } from "@suaveplan/api-openapi";
const entries = buildWebhookRoutes(service);
definePluginRoutes(entries, { app, registry: openAPIRegistry, pluginName: "webhook" });
```
## Usage: Advanced
```ts
// Mount on a sub-router gated by requireAuth() before delegation
import { Hono } from "hono";
import { buildWebhookRoutes, requireAuth } from "@suaveplan/api-plugin-webhook/routes/routes";
const sub = new Hono();
sub.use("*", requireAuth());
for (const entry of buildWebhookRoutes(service)) {
const method = entry.route.method.toLowerCase() as "get" | "post" | "patch" | "delete";
sub[method](entry.route.path.replace(/{(\w+)}/g, ":$1"), entry.handler as never);
}
app.route("/webhooks", sub);
```
## API Reference
- `buildWebhookRoutes(service)` — returns `PluginRouteEntry[]`. Six entries in this order: list, create, read, update, delete, test.
- `requireAuth` — re-export of `@suaveplan/api-plugin-auth`'s `requireAuth()`. Apply BEFORE mounting these routes.
## Implementation Notes
The handlers do not call `requireAuth` directly because Hono's middleware chain composes more naturally when authentication is installed at the router level. Mounting `requireAuth` per-handler would require every handler to re-narrow `c.get("user")`; installing it at the router level lets the handlers consume `Principal` via the augmented `ContextVariableMap` from `@suaveplan/api-plugin-auth` with no extra ceremony. The "load-then-check-tenant" pattern (rather than "list filtered by tenant then index") is deliberate — it produces consistent 404 responses regardless of whether the endpoint exists in another tenant.

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { MemoryDeadLetterSink } from "../dead-letter/memory.js";
import { resetWebhookInstruments } from "../otel/instruments.js";
import { createWebhookService } from "../services/webhook-service.js";
import { MemoryEndpointStore } from "../store/memory.js";
import { buildWebhookRoutes, requireAuth } from "./build-routes.js";
interface TestPrincipal {
id: string;
tenantId: string | null;
}
function buildApp(principal: TestPrincipal | null) {
resetWebhookInstruments();
const endpointStore = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => `ep-${Math.random().toString(36).slice(2, 9)}`,
});
const service = createWebhookService({
endpointStore,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 10,
idempotencyGuard: null,
logger: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
clock: () => 0,
fetch: async () => new Response("ok", { status: 200 }),
});
const app = new Hono();
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } }).context;
const status = (ctx?.statusCode ?? 500) as 400 | 401 | 403 | 404 | 500;
const code = (err as { code?: string }).code ?? "ERROR";
return c.json({ code, message: err?.message ?? "error" }, status);
});
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
app.use("*", requireAuth());
for (const entry of buildWebhookRoutes(service)) {
const method = entry.route.method.toLowerCase() as
| "get"
| "post"
| "patch"
| "delete";
const path = entry.route.path.replace(/\{(\w+)\}/g, ":$1");
app[method](
path,
async (c) => (await entry.handler(c as never)) as Response
);
}
return { app, service };
}
describe("buildWebhookRoutes", () => {
it("anonymous request returns 401", { timeout: 5000 }, async () => {
const { app } = buildApp(null);
const res = await app.request("/");
expect(res.status).toBe(401);
});
it("admin (no tenant) lists every endpoint", {
timeout: 5000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: null });
await service.register({
tenantId: "t-a",
url: "https://a/",
secret: "shared-secret",
events: ["e"],
});
await service.register({
tenantId: "t-b",
url: "https://b/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request("/");
expect(res.status).toBe(200);
const body = (await res.json()) as { endpoints: { id: string }[] };
expect(body.endpoints).toHaveLength(2);
});
it("tenant-scoped principal lists only own tenant", {
timeout: 5000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: "t-a" });
await service.register({
tenantId: "t-a",
url: "https://a/",
secret: "shared-secret",
events: ["e"],
});
await service.register({
tenantId: "t-b",
url: "https://b/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request("/");
const body = (await res.json()) as {
endpoints: { tenantId: string | null }[];
};
expect(body.endpoints).toHaveLength(1);
expect(body.endpoints[0]?.tenantId).toBe("t-a");
});
it("cross-tenant read returns 404 via WebhookError", {
timeout: 5000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: "t-a" });
const created = await service.register({
tenantId: "t-b",
url: "https://b/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request(`/${created.id}`);
// The route handler throws WebhookError(ENDPOINT_NOT_FOUND);
// without a global error-handler middleware the framework
// surfaces that as 500. The body carries the error code.
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("create rejects cross-tenant write with WEBHOOK_TENANT_MISMATCH", {
timeout: 5000,
}, async () => {
const { app } = buildApp({ id: "u", tenantId: "t-a" });
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tenantId: "t-b",
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
}),
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("update changes the endpoint and returns the new shape", {
timeout: 10_000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: null });
const ep = await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request(`/${ep.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ active: false }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { endpoint: { active: boolean } };
expect(body.endpoint.active).toBe(false);
});
it("delete returns deleted:true", { timeout: 5000 }, async () => {
const { app, service } = buildApp({ id: "u", tenantId: null });
const ep = await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request(`/${ep.id}`, { method: "DELETE" });
expect(res.status).toBe(200);
const body = (await res.json()) as { deleted: boolean };
expect(body.deleted).toBe(true);
});
it("test route returns DeliveryResult shape", {
timeout: 10_000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: null });
const ep = await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request(`/${ep.id}/test`, { method: "POST" });
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("delivered");
});
it("create succeeds when body.tenantId matches principal tenant", {
timeout: 5000,
}, async () => {
const { app } = buildApp({ id: "u", tenantId: "t-a" });
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tenantId: "t-a",
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
}),
});
expect(res.status).toBe(201);
const body = (await res.json()) as { endpoint: { tenantId: string } };
expect(body.endpoint.tenantId).toBe("t-a");
});
it("read returns the endpoint for owning tenant", {
timeout: 5000,
}, async () => {
const { app, service } = buildApp({ id: "u", tenantId: "t-a" });
const ep = await service.register({
tenantId: "t-a",
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
const res = await app.request(`/${ep.id}`);
expect(res.status).toBe(200);
const body = (await res.json()) as { endpoint: { id: string } };
expect(body.endpoint.id).toBe(ep.id);
});
});

View File

@@ -0,0 +1,188 @@
import {
DELETE,
GET,
PATCH,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import { requireAuth } from "@suaveplan/api-plugin-auth";
import type { Context } from "hono";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import {
CreateEndpointBodySchema,
DeleteEndpointResponseSchema,
EndpointIdParamsSchema,
EndpointResponseSchema,
ListEndpointsResponseSchema,
TestEndpointResponseSchema,
UpdateEndpointBodySchema,
} from "../schemas/routes/index.js";
import type { WebhookService } from "../services/webhook-service.js";
import type { WebhookEndpoint } from "../types/types.js";
/**
* Build the six CRUD + test route entries that `createWebhookPlugin`
* mounts on the host's Hono app. Each entry pairs a `RouteConfig`
* (built with `RouteBuilder` so Zod inference flows through) with a
* thin handler that delegates to the supplied `WebhookService`.
*
* Routes are NOT pre-gated by `requireAuth()` here — the plugin's
* `onInit` installs `requireAuth()` on the router before mounting these
* entries so anonymous requests hit 401 before the handlers run.
*/
export function buildWebhookRoutes(
service: WebhookService
): PluginRouteEntry[] {
const listRoute = GET("/")
.tags("webhook")
.summary("List webhook endpoints visible to the current principal")
.response(successResponse(ListEndpointsResponseSchema))
.build();
const createRoute = POST("/")
.tags("webhook")
.summary("Create a webhook endpoint subscription")
.body(CreateEndpointBodySchema)
.response(successResponse(EndpointResponseSchema))
.build();
const readRoute = GET("/{id}")
.tags("webhook")
.summary("Read a single webhook endpoint by id")
.params(EndpointIdParamsSchema)
.response(successResponse(EndpointResponseSchema))
.build();
const updateRoute = PATCH("/{id}")
.tags("webhook")
.summary("Patch a webhook endpoint")
.params(EndpointIdParamsSchema)
.body(UpdateEndpointBodySchema)
.response(successResponse(EndpointResponseSchema))
.build();
const deleteRoute = DELETE("/{id}")
.tags("webhook")
.summary("Delete a webhook endpoint")
.params(EndpointIdParamsSchema)
.response(successResponse(DeleteEndpointResponseSchema))
.build();
const testRoute = POST("/{id}/test")
.tags("webhook")
.summary("Fire a synthetic webhook.ping event at the endpoint")
.params(EndpointIdParamsSchema)
.response(successResponse(TestEndpointResponseSchema))
.build();
return [
{
route: listRoute,
handler: async (c) => {
const tenantFilter = currentTenantFilter(c);
const endpoints = await service.listEndpoints(tenantFilter);
return c.json({ endpoints });
},
},
{
route: createRoute,
handler: async (c) => {
const body = CreateEndpointBodySchema.parse(await c.req.json());
const principalTenant = currentTenantId(c);
if (
principalTenant !== undefined &&
body.tenantId !== principalTenant
) {
throw new WebhookError(
WEBHOOK_CODES.TENANT_MISMATCH,
"principal cannot create endpoints for another tenant",
{
context: {
expected: principalTenant,
actual: body.tenantId,
statusCode: 403,
},
}
);
}
const endpoint = await service.register(body);
return c.json({ endpoint }, 201);
},
},
{
route: readRoute,
handler: async (c) => {
const id = c.req.param("id");
const endpoint = await loadOrThrow(service, id, c);
return c.json({ endpoint });
},
},
{
route: updateRoute,
handler: async (c) => {
const id = c.req.param("id");
await loadOrThrow(service, id, c);
const body = UpdateEndpointBodySchema.parse(await c.req.json());
const updated = await service.update(id, body);
return c.json({ endpoint: updated });
},
},
{
route: deleteRoute,
handler: async (c) => {
const id = c.req.param("id");
await loadOrThrow(service, id, c);
await service.unregister(id);
return c.json({ deleted: true as const });
},
},
{
route: testRoute,
handler: async (c) => {
const id = c.req.param("id");
await loadOrThrow(service, id, c);
const result = await service.test(id);
return c.json({
id: result.id,
status: result.status,
finalStatusCode: result.finalStatusCode,
attempts: result.attempts,
});
},
},
];
}
/**
* Re-export `requireAuth` from `@suaveplan/api-plugin-auth` so callers
* mounting these routes without the auth plugin in their import graph
* can still gate them.
*/
export { requireAuth };
function currentTenantId(c: Context): string | undefined {
const principal = c.get("user") as { tenantId?: string | null } | null;
const tid = principal?.tenantId;
return tid ?? undefined;
}
function currentTenantFilter(c: Context): { tenantId?: string | null } {
const tid = currentTenantId(c);
return tid === undefined ? {} : { tenantId: tid };
}
async function loadOrThrow(
service: WebhookService,
id: string,
c: Context
): Promise<WebhookEndpoint> {
const endpoint = await service.getEndpoint(id);
const principalTenant = currentTenantId(c);
if (
endpoint === null ||
(principalTenant !== undefined && endpoint.tenantId !== principalTenant)
) {
throw new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
`endpoint '${id}' does not exist`,
{ context: { endpointId: id, statusCode: 404 } }
);
}
return endpoint;
}

View File

@@ -0,0 +1 @@
export { buildWebhookRoutes, requireAuth } from "./build-routes.js";

View File

@@ -0,0 +1,68 @@
# `endpoint.ts` — webhook endpoint Zod schemas
## Purpose
Zod schemas for the webhook endpoint shapes — the persistent `WebhookEndpoint` row, the `POST /webhooks` create body, and the `PATCH /webhooks/:id` update body. These schemas are wired at the route boundary (so invalid client bodies fail with a typed `ZodError` before reaching the service) and re-used by store conformance harnesses (so any `EndpointStore` implementation can prove it round-trips the canonical shape).
## Features
- `WebhookEndpointSchema` — full row shape with id, timestamps, events array (non-empty), algorithm enum, active flag.
- `CreateEndpointInputSchema` — same shape minus derived fields; `algorithm` + `active` optional.
- `UpdateEndpointInputSchema` — every field optional; `tenantId` excluded by design.
- `WebhookSignatureAlgorithmSchema` — re-usable algorithm enum mirroring `@suaveplan/webhook-client`.
## Usage: Basic
```ts
import { CreateEndpointInputSchema } from "@suaveplan/api-plugin-webhook/schemas/endpoint";
const parsed = CreateEndpointInputSchema.parse({
tenantId: null,
url: "https://example.com/hooks",
secret: "a-very-long-secret",
events: ["order.created"],
});
```
## Usage: Advanced
```ts
import {
CreateEndpointInputSchema,
UpdateEndpointInputSchema,
WebhookEndpointSchema,
} from "@suaveplan/api-plugin-webhook/schemas/endpoint";
// Route boundary
async function createRoute(req: Request) {
const body = await req.json();
const parsed = CreateEndpointInputSchema.safeParse(body);
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), { status: 422 });
}
// parsed.data is the typed CreateEndpointInputShape
}
// Conformance harness for downstream stores
async function assertStoreRoundTrips(store: EndpointStore) {
const created = await store.create({
tenantId: null,
url: "https://x/",
secret: "ten-chars-min",
events: ["e"],
});
WebhookEndpointSchema.parse(created); // throws if the shape drifts
}
```
## API Reference
- `WebhookEndpointSchema` — Zod object schema. `secret` must be at least 8 chars; `events` non-empty; `url` valid URL; `algorithm` `"sha256"` or `"sha512"`.
- `CreateEndpointInputSchema` — Zod object schema. `algorithm` + `active` optional. `secret` length floor preserved.
- `UpdateEndpointInputSchema` — Zod object schema with every field optional. `tenantId` is absent.
- `WebhookSignatureAlgorithmSchema` — Zod enum `["sha256", "sha512"]`.
- `WebhookEndpointShape` / `CreateEndpointInputShape` / `UpdateEndpointInputShape` — inferred TypeScript types.
## Implementation Notes
The `secret.min(8)` floor is a practical lower bound to reject obviously-weak secrets at the route boundary; operators should pass at least 32 random bytes worth of entropy. The Zod `events.min(1)` constraint enforces that "subscribed-to-nothing" endpoints are rejected at create / update time — a degenerate endpoint that never fires is almost certainly a bug. `UpdateEndpointInputSchema` deliberately omits `tenantId` because allowing tenant rotation at the patch surface invites cross-tenant data leaks; callers needing to re-assign an endpoint to a different tenant must delete + recreate.

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateEndpointInputSchema,
UpdateEndpointInputSchema,
WebhookEndpointSchema,
} from "./endpoint.js";
describe("WebhookEndpointSchema", () => {
it("accepts a valid endpoint", { timeout: 5000 }, () => {
const result = WebhookEndpointSchema.safeParse({
id: "ep-1",
tenantId: "t-a",
url: "https://example.com/hooks",
secret: "shared-secret-1",
events: ["order.created"],
algorithm: "sha256",
active: true,
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
});
expect(result.success).toBe(true);
});
it("rejects bad url + short secret + empty events", {
timeout: 5000,
}, () => {
const result = WebhookEndpointSchema.safeParse({
id: "ep-1",
tenantId: null,
url: "not-a-url",
secret: "x",
events: [],
algorithm: "sha256",
active: true,
createdAt: "t",
updatedAt: "t",
});
expect(result.success).toBe(false);
});
});
describe("CreateEndpointInputSchema", () => {
it("accepts a minimal valid body and defaults are inferred at the store layer", {
timeout: 5000,
}, () => {
const result = CreateEndpointInputSchema.safeParse({
tenantId: null,
url: "https://example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.algorithm).toBeUndefined();
expect(result.data.active).toBeUndefined();
}
});
it("accepts explicit algorithm and headers", { timeout: 5000 }, () => {
const result = CreateEndpointInputSchema.safeParse({
tenantId: "t-a",
url: "https://example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
algorithm: "sha512",
headers: { "X-Tag": "premium" },
active: false,
});
expect(result.success).toBe(true);
});
});
describe("UpdateEndpointInputSchema", () => {
it("accepts an empty patch", { timeout: 5000 }, () => {
expect(UpdateEndpointInputSchema.safeParse({}).success).toBe(true);
});
it("rejects a tenantId field if supplied", { timeout: 5000 }, () => {
// The schema does not declare tenantId — strict-mode parsing
// would reject. Here we assert the field is simply ignored.
const result = UpdateEndpointInputSchema.safeParse({
tenantId: "x" as never,
url: "https://example.com/",
});
expect(result.success).toBe(true);
if (result.success) {
expect(
(result.data as Record<string, unknown>).tenantId
).toBeUndefined();
}
});
});

View File

@@ -0,0 +1,64 @@
import z from "zod";
/**
* Zod schema for the HMAC signature algorithm pin. Matches the values
* accepted by `@suaveplan/webhook-client`'s `WebhookSignatureAlgorithm`.
*/
export const WebhookSignatureAlgorithmSchema = z.enum(["sha256", "sha512"]);
/**
* Zod schema for the persistent `WebhookEndpoint` shape. Used by the
* `EndpointStore` conformance harness and by the read-side `GET` routes.
*/
export const WebhookEndpointSchema = z.object({
id: z.string().min(1),
tenantId: z.string().min(1).nullable(),
url: z.string().url(),
secret: z.string().min(8),
events: z.array(z.string().min(1)).min(1).readonly(),
description: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
algorithm: WebhookSignatureAlgorithmSchema,
active: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
});
/**
* Zod schema for `POST /webhooks` request bodies. Derives `id`,
* `createdAt`, `updatedAt`, `algorithm` (default `"sha256"`), and
* `active` (default `true`).
*/
export const CreateEndpointInputSchema = z.object({
tenantId: z.string().min(1).nullable(),
url: z.string().url(),
secret: z.string().min(8),
events: z.array(z.string().min(1)).min(1),
description: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
algorithm: WebhookSignatureAlgorithmSchema.optional(),
active: z.boolean().optional(),
});
/**
* Zod schema for `PATCH /webhooks/:id` request bodies. Every field is
* optional and `tenantId` is intentionally absent (cross-tenant moves
* require delete + recreate).
*/
export const UpdateEndpointInputSchema = z.object({
url: z.string().url().optional(),
secret: z.string().min(8).optional(),
events: z.array(z.string().min(1)).min(1).optional(),
description: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
algorithm: WebhookSignatureAlgorithmSchema.optional(),
active: z.boolean().optional(),
});
export type WebhookEndpointShape = z.infer<typeof WebhookEndpointSchema>;
export type CreateEndpointInputShape = z.infer<
typeof CreateEndpointInputSchema
>;
export type UpdateEndpointInputShape = z.infer<
typeof UpdateEndpointInputSchema
>;

View File

@@ -0,0 +1,14 @@
export {
CreateEndpointInputSchema,
type CreateEndpointInputShape,
UpdateEndpointInputSchema,
type UpdateEndpointInputShape,
WebhookEndpointSchema,
type WebhookEndpointShape,
WebhookSignatureAlgorithmSchema,
} from "./endpoint.js";
export {
type WebhookPluginOptionsInput,
type WebhookPluginOptionsResolved,
WebhookPluginOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,47 @@
# `options.ts` — `WebhookPluginOptionsSchema`
## Purpose
Zod schema gating `createWebhookPlugin(options)`. Validates every primitive option (route prefix, retry caps, attempt count) at the factory boundary so the plugin never boots with nonsensical configuration like `maxAttempts: -1` or `routePrefix: "no-leading-slash"`. Non-primitive injectables (`endpointStore`, `deadLetterSink`, `idempotencyGuard`, `logger`, `clock`, `fetch`) are accepted as `unknown` so the schema stays JSON-friendly while the factory's TypeScript signature carries precise types.
## Features
- `routePrefix` regex enforces leading `/`.
- `maxAttempts` clamped to `[1, 50]`.
- `initialRetryDelayMs` / `maxRetryDelayMs` clamped at `0` lower bound.
- Non-primitive fields accepted as `unknown` so the schema can serialise to JSON Schema cleanly.
## Usage: Basic
```ts
import { WebhookPluginOptionsSchema } from "@suaveplan/api-plugin-webhook/schemas/options";
const parsed = WebhookPluginOptionsSchema.parse({ routePrefix: "/hooks", maxAttempts: 5 });
```
## Usage: Advanced
```ts
import { WebhookPluginOptionsSchema } from "@suaveplan/api-plugin-webhook/schemas/options";
function validate(opts: unknown) {
const result = WebhookPluginOptionsSchema.safeParse(opts);
if (!result.success) {
for (const issue of result.error.issues) {
console.warn(`webhook config error at ${issue.path.join(".")}: ${issue.message}`);
}
throw result.error;
}
return result.data;
}
```
## API Reference
- `WebhookPluginOptionsSchema` — Zod object schema.
- `WebhookPluginOptionsInput` — `z.input<typeof ...>` (caller-facing shape).
- `WebhookPluginOptionsResolved` — `z.output<typeof ...>` (post-parse shape).
## Implementation Notes
The `maxAttempts.max(50)` upper bound is a safety rail — receivers that genuinely require dozens of retries usually have a deeper problem that retry budget cannot paper over, and an accidental `maxAttempts: 1_000_000` configuration would tie up the in-flight dedup map indefinitely. The non-primitive injectables travel as `unknown` because Zod cannot meaningfully validate a `Clock` or `LoggerLike` at runtime — the factory's TypeScript signature already pins those at the call site, and the runtime check happens implicitly when the plugin uses them.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WebhookPluginOptionsSchema } from "./options.js";
describe("WebhookPluginOptionsSchema", () => {
it("accepts an empty object", { timeout: 5000 }, () => {
expect(WebhookPluginOptionsSchema.safeParse({}).success).toBe(true);
});
it("accepts a fully-specified object", { timeout: 5000 }, () => {
expect(
WebhookPluginOptionsSchema.safeParse({
routePrefix: "/api/v1/hooks",
maxAttempts: 8,
initialRetryDelayMs: 250,
maxRetryDelayMs: 60_000,
}).success
).toBe(true);
});
it("rejects routePrefix without leading slash", { timeout: 5000 }, () => {
expect(
WebhookPluginOptionsSchema.safeParse({ routePrefix: "hooks" })
.success
).toBe(false);
});
it("rejects negative maxAttempts", { timeout: 5000 }, () => {
expect(
WebhookPluginOptionsSchema.safeParse({ maxAttempts: -1 }).success
).toBe(false);
});
it("rejects maxAttempts above 50", { timeout: 5000 }, () => {
expect(
WebhookPluginOptionsSchema.safeParse({ maxAttempts: 1000 }).success
).toBe(false);
});
it("accepts unknown injectables without validation", {
timeout: 5000,
}, () => {
const result = WebhookPluginOptionsSchema.safeParse({
endpointStore: { list: async () => [] },
deadLetterSink: {
record: async () => undefined,
list: async () => [],
},
logger: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
clock: () => 0,
fetch: () => new Response(),
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,37 @@
import z from "zod";
/**
* Zod schema for `WebhookPluginOptions`. Validates the constructor
* surface accepted by `createWebhookPlugin`. Every field is optional —
* the factory fills in defaults for fields the caller omits. Defaults:
* `routePrefix: "/webhooks"`, `maxAttempts: 5`,
* `initialRetryDelayMs: 500`, `maxRetryDelayMs: 30_000`.
*
* Non-serialisable fields (`endpointStore`, `deadLetterSink`,
* `idempotencyGuard`, `logger`, `clock`, `fetch`) are accepted as
* `unknown` so the schema stays JSON-clean while the factory's
* TypeScript signature carries the precise types.
*/
export const WebhookPluginOptionsSchema = z.object({
routePrefix: z
.string()
.min(1)
.regex(/^\//, "routePrefix must start with '/'")
.optional(),
maxAttempts: z.number().int().min(1).max(50).optional(),
initialRetryDelayMs: z.number().int().min(0).optional(),
maxRetryDelayMs: z.number().int().min(0).optional(),
endpointStore: z.unknown().optional(),
deadLetterSink: z.unknown().optional(),
idempotencyGuard: z.unknown().optional(),
logger: z.unknown().optional(),
clock: z.unknown().optional(),
fetch: z.unknown().optional(),
});
export type WebhookPluginOptionsInput = z.input<
typeof WebhookPluginOptionsSchema
>;
export type WebhookPluginOptionsResolved = z.output<
typeof WebhookPluginOptionsSchema
>;

View File

@@ -0,0 +1,16 @@
export {
type CreateEndpointBody,
CreateEndpointBodySchema,
type DeleteEndpointResponse,
DeleteEndpointResponseSchema,
type EndpointIdParams,
EndpointIdParamsSchema,
type EndpointResponse,
EndpointResponseSchema,
type ListEndpointsResponse,
ListEndpointsResponseSchema,
type TestEndpointResponse,
TestEndpointResponseSchema,
type UpdateEndpointBody,
UpdateEndpointBodySchema,
} from "./routes.js";

View File

@@ -0,0 +1,67 @@
# `routes.ts` — per-route Zod schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundles the Zod schemas for every CRUD + test route the webhook plugin mounts: list response, create body, create/read response, update body, delete response, test response, and the shared `:id` path-param. Keeping the route schemas adjacent in one file (rather than one file per route) makes the public surface easy to scan and avoids a long tail of single-export modules. Each schema is wired into the `buildWebhookRoutes` RouteBuilder so the unified `/openapi.json` reflects every request and response shape with full type inference.
## Features
- Six response shapes + two request bodies + one params shape covering every CRUD route.
- `EndpointResponseSchema` reused for create + read so the OpenAPI doc shows one canonical "the endpoint" response shape.
- `TestEndpointResponseSchema` mirrors `@suaveplan/webhook-client`'s `DeliveryResult` loosely (nested attempts typed as `unknown`) so this package does not have to mirror webhook-client schema updates byte-for-byte.
## Usage: Basic
```ts
import {
CreateEndpointBodySchema,
EndpointResponseSchema,
} from "@suaveplan/api-plugin-webhook/schemas/routes";
const created = EndpointResponseSchema.parse(await res.json());
const body = CreateEndpointBodySchema.parse({
tenantId: null,
url: "https://example.com/hooks",
secret: "shared",
events: ["order.created"],
});
```
## Usage: Advanced
```ts
import {
DeleteEndpointResponseSchema,
EndpointIdParamsSchema,
ListEndpointsResponseSchema,
TestEndpointResponseSchema,
UpdateEndpointBodySchema,
} from "@suaveplan/api-plugin-webhook/schemas/routes";
async function workflow(client: ApiClient, id: string) {
const list = ListEndpointsResponseSchema.parse(await (await client.get("/webhooks")).json());
const params = EndpointIdParamsSchema.parse({ id });
const patch = UpdateEndpointBodySchema.parse({ active: false });
const test = TestEndpointResponseSchema.parse(await (await client.post(`/webhooks/${params.id}/test`)).json());
const del = DeleteEndpointResponseSchema.parse(await (await client.delete(`/webhooks/${params.id}`)).json());
return { list, test, del };
}
```
## API Reference
- `ListEndpointsResponseSchema` — `{ endpoints: WebhookEndpoint[] }`.
- `CreateEndpointBodySchema` — alias for `CreateEndpointInputSchema`.
- `EndpointResponseSchema` — `{ endpoint: WebhookEndpoint }`.
- `EndpointIdParamsSchema` — `{ id: string }`.
- `UpdateEndpointBodySchema` — alias for `UpdateEndpointInputSchema`.
- `DeleteEndpointResponseSchema` — `{ deleted: true }`.
- `TestEndpointResponseSchema` — `{ id, status, finalStatusCode?, attempts: unknown[] }`.
## Implementation Notes
The aliases (`CreateEndpointBodySchema`, `UpdateEndpointBodySchema`) avoid duplication while still giving the OpenAPI doc the human-readable "this is a route body" labelling consumers expect. The reason `TestEndpointResponseSchema.attempts` types as `unknown[]` rather than a strict `DeliveryAttemptSchema` is that webhook-client's `DeliveryAttempt` shape is documented but not re-exported as a Zod schema — keeping the field loose here prevents a cross-package coupling that would force schema bumps across packages on every webhook-client revision.

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateEndpointBodySchema,
DeleteEndpointResponseSchema,
EndpointIdParamsSchema,
EndpointResponseSchema,
ListEndpointsResponseSchema,
TestEndpointResponseSchema,
UpdateEndpointBodySchema,
} from "./routes.js";
const validEndpoint = {
id: "ep-1",
tenantId: null,
url: "https://example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
algorithm: "sha256",
active: true,
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
};
describe("route schemas", () => {
it("ListEndpointsResponseSchema accepts a list of endpoints", {
timeout: 5000,
}, () => {
expect(
ListEndpointsResponseSchema.safeParse({
endpoints: [validEndpoint],
}).success
).toBe(true);
});
it("CreateEndpointBodySchema rejects short secret", {
timeout: 5000,
}, () => {
expect(
CreateEndpointBodySchema.safeParse({
tenantId: null,
url: "https://example.com/",
secret: "x",
events: ["e"],
}).success
).toBe(false);
});
it("EndpointResponseSchema accepts a single endpoint", {
timeout: 5000,
}, () => {
expect(
EndpointResponseSchema.safeParse({ endpoint: validEndpoint })
.success
).toBe(true);
});
it("EndpointIdParamsSchema requires non-empty id", {
timeout: 5000,
}, () => {
expect(EndpointIdParamsSchema.safeParse({ id: "ep-1" }).success).toBe(
true
);
expect(EndpointIdParamsSchema.safeParse({ id: "" }).success).toBe(
false
);
});
it("UpdateEndpointBodySchema accepts empty body", { timeout: 5000 }, () => {
expect(UpdateEndpointBodySchema.safeParse({}).success).toBe(true);
});
it("DeleteEndpointResponseSchema requires deleted: true", {
timeout: 5000,
}, () => {
expect(
DeleteEndpointResponseSchema.safeParse({ deleted: true }).success
).toBe(true);
expect(
DeleteEndpointResponseSchema.safeParse({ deleted: false }).success
).toBe(false);
});
it("TestEndpointResponseSchema accepts delivery result shape", {
timeout: 5000,
}, () => {
expect(
TestEndpointResponseSchema.safeParse({
id: "w-1",
status: "delivered",
finalStatusCode: 200,
attempts: [{ attemptNumber: 1 }],
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import z from "zod";
import {
CreateEndpointInputSchema,
UpdateEndpointInputSchema,
WebhookEndpointSchema,
} from "../endpoint.js";
/**
* Zod schema for `GET /webhooks` — wraps the array under `endpoints` so
* future fields (pagination, totals) can be added without an API break.
*/
export const ListEndpointsResponseSchema = z.object({
endpoints: z.array(WebhookEndpointSchema),
});
/** Zod schema for `POST /webhooks` request body. */
export const CreateEndpointBodySchema = CreateEndpointInputSchema;
/** Zod schema for `POST /webhooks` and `GET /webhooks/:id` response. */
export const EndpointResponseSchema = z.object({
endpoint: WebhookEndpointSchema,
});
/** Path params for `/webhooks/:id`. */
export const EndpointIdParamsSchema = z.object({
id: z.string().min(1),
});
/** Zod schema for `PATCH /webhooks/:id` request body. */
export const UpdateEndpointBodySchema = UpdateEndpointInputSchema;
/** Zod schema for `DELETE /webhooks/:id` response. */
export const DeleteEndpointResponseSchema = z.object({
deleted: z.literal(true),
});
/**
* Zod schema for `POST /webhooks/:id/test` response. The body mirrors
* the `DeliveryResult` shape from `@suaveplan/webhook-client` but uses
* loose `unknown` for nested attempts so the consuming OpenAPI doc does
* not have to mirror every webhook-client schema update.
*/
export const TestEndpointResponseSchema = z.object({
id: z.string(),
status: z.enum(["pending", "delivered", "failed", "retrying"]),
finalStatusCode: z.number().int().optional(),
attempts: z.array(z.unknown()),
});
export type ListEndpointsResponse = z.infer<typeof ListEndpointsResponseSchema>;
export type CreateEndpointBody = z.infer<typeof CreateEndpointBodySchema>;
export type EndpointResponse = z.infer<typeof EndpointResponseSchema>;
export type EndpointIdParams = z.infer<typeof EndpointIdParamsSchema>;
export type UpdateEndpointBody = z.infer<typeof UpdateEndpointBodySchema>;
export type DeleteEndpointResponse = z.infer<
typeof DeleteEndpointResponseSchema
>;
export type TestEndpointResponse = z.infer<typeof TestEndpointResponseSchema>;

View File

@@ -0,0 +1,5 @@
export {
type CreateWebhookServiceOptions,
createWebhookService,
type WebhookService,
} from "./webhook-service.js";

View File

@@ -0,0 +1,52 @@
# `registry-augmentation.ts` — `ServiceRegistry` module augmentation
## Purpose
Declares the `webhookService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript module augmentation. After this file is imported (which happens automatically via the package barrel side-effect import), every consumer typed against the augmented registry sees `ctx.getService("webhookService")` resolve to `WebhookService | undefined` with no cast required. The file also redeclares the host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`, `clock`) so the webhook plugin's `onInit(ctx)` can resolve them with full typing even when the consumer has not explicitly imported `@suaveplan/api`. TypeScript merges duplicate declarations safely.
## Features
- Adds `webhookService: WebhookService` to the shared `ServiceRegistry` interface.
- Cumulatively redeclares the host-side slots used by the plugin's lifecycle so downstream tests do not need to import `@suaveplan/api` for typing.
- Pure type-only declarations — no runtime side effect beyond the `declare module` block.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-webhook"; // side-effect import surfaces the augmentation
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
declare const reg: ServiceRegistry;
reg.webhookService.send("ep-1", "evt", {});
```
## Usage: Advanced
```ts
import { createPluginContext } from "@suaveplan/api-plugin";
import "@suaveplan/api-plugin-webhook/services/webhook-service";
const ctx = createPluginContext({
logger,
eventBus,
signal: ctrl.signal,
config: webhookConfig,
services,
});
const svc = ctx.getService("webhookService");
if (svc !== undefined) {
await svc.send("ep-1", "order.created", { orderId: 42 });
}
```
## API Reference
- `declare module "@suaveplan/api-plugin/service-registry"` — extends the shared interface with:
- `webhookService: WebhookService` — set by the plugin's `onInit`.
- `app: OpenAPIHono` — host-supplied router (required by the plugin's `onInit`).
- `openAPIRegistry: OpenAPIRegistry` — optional registry consumed when present.
- `logger: LoggerLike`, `eventBus: EventBusLike`, `clock: Clock` — convenience access.
## Implementation Notes
The redeclarations of the host-side slots are deliberate — without them, importing the webhook plugin in isolation (e.g. inside a unit test that does not pull in `@suaveplan/api`) would leave those slots typed as `unknown` on `ctx.getService(...)`. TypeScript guarantees the duplicates merge instead of conflicting because the field types match the ones `@suaveplan/api` declares. If `@suaveplan/api` changes the type of any of these slots, this file must be updated in lockstep — the doc-drift gate flags the discrepancy via the build break.

View File

@@ -0,0 +1,33 @@
/**
* ServiceRegistry module augmentation — declares the `webhookService`
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("webhookService")` resolve to `WebhookService |
* undefined` without any cast.
*
* Importing this module for its side effects (which the package root
* barrel does automatically) is sufficient — every consumer of
* `@suaveplan/api-plugin-webhook` gets typed `webhookService` access.
*
* The host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`,
* `clock`) are owned by `@suaveplan/api`'s own registry-augmentation
* module. We redeclare them here as a cumulative augmentation so the
* webhook plugin's `onInit(ctx)` can resolve them with full typing even
* when the consumer has not explicitly imported `@suaveplan/api`.
* TypeScript merges duplicate declarations so this is safe.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { WebhookService } from "./webhook-service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
webhookService: WebhookService;
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}

View File

@@ -0,0 +1,74 @@
# `webhook-service.ts` — `WebhookService` implementation
## Purpose
Wraps `@suaveplan/webhook-client`'s `deliver` function with the plugin's orchestration layer: endpoint lookup, tenant scoping, per-delivery dedup, dead-letter sink wiring, error-code translation, and OpenTelemetry emission. The result is the single object registered in the host `ServiceRegistry` under the `webhookService` slot — downstream plugins call it to send signed events, fan out to every subscriber of an event, manage subscriptions programmatically, and fire the synthetic `webhook.ping` event used by the `POST /webhooks/:id/test` route.
## Features
- `send(endpointId, event, data)` — looks up the endpoint, raises `WEBHOOK_ENDPOINT_NOT_FOUND` / `WEBHOOK_INACTIVE_ENDPOINT` on miss / inactive, signs and delivers via webhook-client.
- `sendAll(event, data, filter?)` — fans out to every active endpoint subscribed to `event`; raises `WEBHOOK_EVENT_NOT_SUBSCRIBED` if no endpoint is listening.
- `register` / `unregister` / `listEndpoints` — programmatic CRUD wrappers around the configured `EndpointStore`.
- `test(endpointId)` — fires a synthetic `webhook.ping` event with `{ requestedAt: ISO8601 }`.
- Per-delivery dedup — concurrent `send()` calls with the same `(endpointId, payload.id)` collapse to one delivery. When an `idempotencyGuard` is supplied, calls route through `guard.execute`; otherwise an in-process `Map<string, Promise<DeliveryResult>>` carries the dedup.
- Dead-letter — every exhausted delivery records a `DeadLetterEntry` via the configured sink and surfaces `WEBHOOK_DELIVERY_EXHAUSTED` to the caller. Sink write failures surface `WEBHOOK_DEAD_LETTER_WRITE_FAILED` with the sink error as `cause`.
- OTel — every code path increments `webhook_plugin.deliveries_total` with the appropriate `status` label (`delivered`, `exhausted`, `not_found`, `inactive`, `invalid`).
## Usage: Basic
```ts
import { createWebhookService } from "@suaveplan/api-plugin-webhook/services/webhook-service";
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
const service = createWebhookService({
endpointStore: new MemoryEndpointStore(),
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 5,
initialRetryDelayMs: 500,
maxRetryDelayMs: 30_000,
idempotencyGuard: null,
logger: console as never,
clock: () => Date.now(),
fetch: globalThis.fetch.bind(globalThis),
});
const endpoint = await service.register({
tenantId: null,
url: "https://partner.example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
});
await service.send(endpoint.id, "order.created", { orderId: 42 });
```
## Usage: Advanced
```ts
// Fan-out to every active subscriber, gated by tenant
const results = await service.sendAll(
"order.created",
{ orderId: 42 },
{ tenantId: "tenant-a" }
);
console.log(`${results.length} deliveries fired`);
// Probe an endpoint without firing real business data
const ping = await service.test(endpoint.id);
console.log(ping.status, ping.finalStatusCode);
```
## API Reference
- `createWebhookService(options)` — factory. Returns a `WebhookService`. Every option is required (the plugin's `onInit` fills defaults before calling).
- `WebhookService.send(endpointId, event, data)` — resolves with `DeliveryResult` on success, rejects with `WebhookError` on miss / inactive / exhausted.
- `WebhookService.sendAll(event, data, filter?)` — resolves with `readonly DeliveryResult[]`. Empty subscriptions raise `WEBHOOK_EVENT_NOT_SUBSCRIBED`.
- `WebhookService.register(input)` — proxy to `EndpointStore.create`. Store errors are translated to `WEBHOOK_STORE_ERROR`.
- `WebhookService.unregister(id)` — proxy to `EndpointStore.delete` (idempotent).
- `WebhookService.listEndpoints(filter?)` — proxy to `EndpointStore.list`.
- `WebhookService.test(endpointId)` — fires a synthetic `webhook.ping`.
## Implementation Notes
The dedup key (`${endpointId}:${payload.id}`) was chosen so two distinct payload ids for the same endpoint can deliver concurrently — only same-id sends collapse. This matches the webhook-client contract where `payload.id` IS the delivery identity. The `idempotencyGuard` branch and the in-process branch must produce identical observable behavior — concurrent same-key sends both return the same `DeliveryResult`. The exhaustion path always records the dead-letter entry BEFORE throwing so operators can replay even when the caller's catch block fails to log. The OTel `status` enum carries five values rather than the obvious two (`delivered`/`failed`) so dashboards can distinguish receiver failures (`exhausted`) from misconfiguration (`not_found`/`inactive`) and fan-out edge cases (`invalid`).

View File

@@ -0,0 +1,453 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { MemoryDeadLetterSink } from "../dead-letter/memory.js";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import { resetWebhookInstruments } from "../otel/instruments.js";
import { MemoryEndpointStore } from "../store/memory.js";
import {
type CreateWebhookServiceOptions,
createWebhookService,
errorMessage,
} from "./webhook-service.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeService(
overrides: Partial<CreateWebhookServiceOptions> = {},
fetchImpl: FetchLike = async () =>
new Response("ok", {
status: 200,
headers: { "content-type": "text/plain" },
})
) {
resetWebhookInstruments();
const endpointStore =
overrides.endpointStore ??
new MemoryEndpointStore({
clock: () => Date.UTC(2026, 4, 18),
idFactory: () => "ep-1",
});
const deadLetterSink =
overrides.deadLetterSink ?? new MemoryDeadLetterSink();
const service = createWebhookService({
endpointStore,
deadLetterSink,
maxAttempts: overrides.maxAttempts ?? 3,
initialRetryDelayMs: overrides.initialRetryDelayMs ?? 1,
maxRetryDelayMs: overrides.maxRetryDelayMs ?? 10,
idempotencyGuard: overrides.idempotencyGuard ?? null,
logger: overrides.logger ?? noopLogger,
clock: overrides.clock ?? (() => Date.UTC(2026, 4, 18)),
fetch: overrides.fetch ?? fetchImpl,
});
return { service, endpointStore, deadLetterSink };
}
describe("createWebhookService", () => {
it("send raises WEBHOOK_ENDPOINT_NOT_FOUND when endpoint absent", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await expect(service.send("missing", "evt", {})).rejects.toBeInstanceOf(
WebhookError
);
try {
await service.send("missing", "evt", {});
} catch (err) {
if (err instanceof WebhookError) {
expect(err.webhookCode).toBe(WEBHOOK_CODES.ENDPOINT_NOT_FOUND);
} else {
throw err;
}
}
});
it("send raises WEBHOOK_INACTIVE_ENDPOINT when endpoint inactive", {
timeout: 5000,
}, async () => {
const { service, endpointStore } = makeService();
await endpointStore.create({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["evt"],
active: false,
});
await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.INACTIVE_ENDPOINT,
});
});
it("send delivers and returns DeliveryResult on success", {
timeout: 10_000,
}, async () => {
let observedUrl = "";
let observedHeaders: Headers | undefined;
let observedBody = "";
const fetchImpl: FetchLike = async (input, init) => {
observedUrl = typeof input === "string" ? input : input.toString();
observedHeaders = new Headers(init?.headers);
observedBody = init?.body as string;
return new Response("ok", { status: 200 });
};
const { service, endpointStore } = makeService({}, fetchImpl);
await endpointStore.create({
tenantId: null,
url: "https://example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
});
const result = await service.send("ep-1", "order.created", {
orderId: 42,
});
expect(result.status).toBe("delivered");
expect(result.attempts.length).toBe(1);
expect(observedUrl).toBe("https://example.com/hooks");
expect(observedHeaders?.get("x-hub-signature-256")).toMatch(/^sha256=/);
expect(observedHeaders?.get("x-webhook-event")).toBe("order.created");
expect(observedHeaders?.get("x-webhook-id")).toBeTruthy();
expect(observedHeaders?.get("x-webhook-timestamp")).toBeTruthy();
expect(JSON.parse(observedBody).data).toEqual({ orderId: 42 });
});
it("send routes through idempotency guard when supplied", {
timeout: 10_000,
}, async () => {
let fetchCalls = 0;
const fetchImpl: FetchLike = async () => {
fetchCalls++;
return new Response("ok", { status: 200 });
};
const calls: string[] = [];
const guard = {
async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
calls.push(key);
return fn();
},
};
const { service, endpointStore } = makeService(
{ idempotencyGuard: guard as never },
fetchImpl
);
await endpointStore.create({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["evt"],
});
await service.send("ep-1", "evt", {});
expect(fetchCalls).toBe(1);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatch(/^ep-1:/);
});
it("in-flight dedup collapses concurrent sends with same id", {
timeout: 10_000,
}, async () => {
let fetchCalls = 0;
// Block first fetch on a microtask boundary so the second send()
// call enqueues against the in-flight Map before the first resolves.
const fetchImpl: FetchLike = async () => {
fetchCalls++;
await Promise.resolve();
await Promise.resolve();
return new Response("ok", { status: 200 });
};
const { service, endpointStore } = makeService({}, fetchImpl);
await endpointStore.create({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["evt"],
});
// Stub crypto.randomUUID to return the same id twice in a row
// so the two concurrent sends compute the same dedup key.
const realUuid = globalThis.crypto.randomUUID.bind(globalThis.crypto);
let n = 0;
globalThis.crypto.randomUUID = (() =>
++n <= 2
? "fixed-payload-id"
: realUuid()) as typeof crypto.randomUUID;
try {
const [a, b] = await Promise.all([
service.send("ep-1", "evt", {}),
service.send("ep-1", "evt", {}),
]);
expect(a.status).toBe("delivered");
expect(b.status).toBe("delivered");
expect(fetchCalls).toBe(1);
} finally {
globalThis.crypto.randomUUID = realUuid;
}
});
it("register proxies through to the endpoint store", {
timeout: 5000,
}, async () => {
const { service } = makeService();
const ep = await service.register({
tenantId: null,
url: "https://x/",
secret: "shared-secret",
events: ["e"],
});
expect(ep.id).toBe("ep-1");
expect(ep.events).toEqual(["e"]);
});
it("register translates store errors to WEBHOOK_STORE_ERROR", {
timeout: 5000,
}, async () => {
const failingStore = {
async list() {
return [];
},
async get() {
return null;
},
async create() {
throw new Error("store down");
},
async update() {
throw new Error("nope");
},
async delete() {
/* noop */
},
};
const { service } = makeService({ endpointStore: failingStore });
await expect(
service.register({
tenantId: null,
url: "https://x/",
secret: "shared-secret",
events: ["e"],
})
).rejects.toMatchObject({ webhookCode: WEBHOOK_CODES.STORE_ERROR });
});
it("unregister is idempotent", { timeout: 5000 }, async () => {
const { service } = makeService();
await service.unregister("nope");
await service.register({
tenantId: null,
url: "https://x/",
secret: "shared-secret",
events: ["e"],
});
await service.unregister("ep-1");
});
it("listEndpoints proxies through to the store", {
timeout: 5000,
}, async () => {
const { service } = makeService();
expect((await service.listEndpoints()).length).toBe(0);
await service.register({
tenantId: "t-a",
url: "https://x/",
secret: "shared-secret",
events: ["e"],
});
expect((await service.listEndpoints({ tenantId: "t-a" })).length).toBe(
1
);
expect((await service.listEndpoints({ tenantId: "t-b" })).length).toBe(
0
);
});
it("sendAll raises WEBHOOK_EVENT_NOT_SUBSCRIBED when no endpoint listens", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await expect(
service.sendAll("noone-listens", {})
).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.EVENT_NOT_SUBSCRIBED,
});
});
it("sendAll fans out to every subscribed active endpoint", {
timeout: 10_000,
}, async () => {
let calls = 0;
const fetchImpl: FetchLike = async () => {
calls++;
return new Response("ok", { status: 200 });
};
let n = 0;
const endpointStore = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => `ep-${++n}`,
});
const { service } = makeService({ endpointStore }, fetchImpl);
await service.register({
tenantId: null,
url: "https://a/",
secret: "shared-secret",
events: ["e"],
});
await service.register({
tenantId: null,
url: "https://b/",
secret: "shared-secret",
events: ["e"],
});
await service.register({
tenantId: null,
url: "https://c/",
secret: "shared-secret",
events: ["other"],
});
const results = await service.sendAll("e", {});
expect(results).toHaveLength(2);
expect(calls).toBe(2);
});
it("test fires a webhook.ping event", { timeout: 10_000 }, async () => {
let observed: { url: string; event: string } | undefined;
const fetchImpl: FetchLike = async (input, init) => {
observed = {
url: typeof input === "string" ? input : input.toString(),
event: new Headers(init?.headers).get("x-webhook-event") ?? "",
};
return new Response("ok", { status: 200 });
};
const { service } = makeService({}, fetchImpl);
await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["any"],
});
const result = await service.test("ep-1");
expect(result.status).toBe("delivered");
expect(observed?.event).toBe("webhook.ping");
});
it("send rejects with WEBHOOK_DELIVERY_EXHAUSTED after maxAttempts failures", {
timeout: 15_000,
}, async () => {
const fetchImpl: FetchLike = async () =>
new Response("err", { status: 500 });
const { service, deadLetterSink } = makeService(
{ maxAttempts: 2 },
fetchImpl
);
await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["evt"],
});
await expect(
service.send("ep-1", "evt", { x: 1 })
).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
});
const dl = await deadLetterSink.list();
expect(dl).toHaveLength(1);
expect(dl[0]?.event).toBe("evt");
});
it("update propagates WebhookError from the store unchanged", {
timeout: 5000,
}, async () => {
const { service, endpointStore } = makeService();
await endpointStore.create({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["e"],
});
// update against an unknown id throws WEBHOOK.ENDPOINT_NOT_FOUND.
await expect(
service.update("nope", { active: false })
).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
});
});
it("update translates non-Webhook store errors to WEBHOOK_STORE_ERROR", {
timeout: 5000,
}, async () => {
const failingStore = {
async list() {
return [];
},
async get() {
return null;
},
async create() {
throw new Error("nope");
},
async update() {
throw new Error("store down");
},
async delete() {
/* noop */
},
};
const { service } = makeService({ endpointStore: failingStore });
await expect(
service.update("anything", { active: false })
).rejects.toMatchObject({ webhookCode: WEBHOOK_CODES.STORE_ERROR });
});
it("errorMessage returns Error.message or String(value)", {
timeout: 5000,
}, () => {
expect(errorMessage(new Error("oops"))).toBe("oops");
expect(errorMessage("plain-string")).toBe("plain-string");
expect(errorMessage(42)).toBe("42");
});
it("getEndpoint proxies through to the store", {
timeout: 5000,
}, async () => {
const { service } = makeService();
expect(await service.getEndpoint("nope")).toBeNull();
await service.register({
tenantId: null,
url: "https://x/",
secret: "shared-secret",
events: ["e"],
});
const ep = await service.getEndpoint("ep-1");
expect(ep?.id).toBe("ep-1");
});
it("sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED", {
timeout: 15_000,
}, async () => {
const fetchImpl: FetchLike = async () =>
new Response("err", { status: 500 });
const failingSink = {
async record() {
throw new Error("sink down");
},
async list() {
return [];
},
};
const { service } = makeService(
{ maxAttempts: 2, deadLetterSink: failingSink },
fetchImpl
);
await service.register({
tenantId: null,
url: "https://example.com/",
secret: "shared-secret",
events: ["evt"],
});
await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.DEAD_LETTER_WRITE_FAILED,
});
});
});

View File

@@ -0,0 +1,322 @@
import type { DistributedIdempotencyGuard } from "@suaveplan/idempotency";
import type { Clock, FetchLike, LoggerLike } from "@suaveplan/types";
import {
type DeliveryAttempt,
type DeliveryResult,
deliver,
type WebhookPayload,
} from "@suaveplan/webhook-client";
import type {
DeadLetterEntry,
DeadLetterSink,
} from "../dead-letter/dead-letter.js";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import { getWebhookInstruments } from "../otel/instruments.js";
import type {
EndpointStore,
ListEndpointsFilter,
} from "../store/endpoint-store.js";
import type {
CreateEndpointInput,
UpdateEndpointInput,
WebhookEndpoint,
} from "../types/types.js";
/** Construction-time wiring required by `WebhookService`. */
export interface CreateWebhookServiceOptions {
readonly endpointStore: EndpointStore;
readonly deadLetterSink: DeadLetterSink;
readonly maxAttempts: number;
readonly initialRetryDelayMs: number;
readonly maxRetryDelayMs: number;
readonly idempotencyGuard: DistributedIdempotencyGuard | null;
readonly logger: LoggerLike;
readonly clock: Clock;
readonly fetch: FetchLike;
}
/**
* Public surface registered in the host `ServiceRegistry` under the
* `webhookService` slot. Downstream plugins read this to fan signed
* events out to subscribed endpoints, to manage subscriptions
* programmatically, and to fire the synthetic ping.
*/
export interface WebhookService {
send(
endpointId: string,
event: string,
data: unknown
): Promise<DeliveryResult>;
sendAll(
event: string,
data: unknown,
filter?: ListEndpointsFilter
): Promise<readonly DeliveryResult[]>;
register(input: CreateEndpointInput): Promise<WebhookEndpoint>;
update(id: string, patch: UpdateEndpointInput): Promise<WebhookEndpoint>;
unregister(id: string): Promise<void>;
listEndpoints(
filter?: ListEndpointsFilter
): Promise<readonly WebhookEndpoint[]>;
getEndpoint(id: string): Promise<WebhookEndpoint | null>;
test(endpointId: string): Promise<DeliveryResult>;
}
/**
* Construct the canonical `WebhookService` implementation. Every
* collaborator (store, sink, idempotency guard, fetch, clock, logger)
* is supplied by the plugin's `onInit`; callers should never construct
* this manually outside the plugin lifecycle.
*/
export function createWebhookService(
options: CreateWebhookServiceOptions
): WebhookService {
const inFlight = new Map<string, Promise<DeliveryResult>>();
const instruments = getWebhookInstruments();
async function executeDelivery(
endpoint: WebhookEndpoint,
payload: WebhookPayload
): Promise<DeliveryResult> {
const firstAttemptAt = new Date(options.clock()).toISOString();
const started = options.clock();
try {
const result = await deliver(
{
url: endpoint.url,
secret: endpoint.secret,
algorithm: endpoint.algorithm,
headers: { ...(endpoint.headers ?? {}) },
maxRetries: options.maxAttempts,
retryDelay: options.initialRetryDelayMs,
},
payload,
{ fetch: options.fetch }
);
recordSuccess(payload.event, result, endpoint.id, started);
return result;
} catch (err) {
return recordExhaustion(
err,
payload,
endpoint,
firstAttemptAt,
started
);
}
}
function recordSuccess(
event: string,
result: DeliveryResult,
endpointId: string,
started: number
): void {
const duration = options.clock() - started;
instruments.deliveriesTotal.add(1, {
event,
status: "delivered",
endpoint_id: endpointId,
});
instruments.deliveryAttempts.record(result.attempts.length, {
event,
status: "delivered",
});
instruments.sendDurationMs.record(duration, { status: "delivered" });
}
async function recordExhaustion(
err: unknown,
payload: WebhookPayload,
endpoint: WebhookEndpoint,
firstAttemptAt: string,
started: number
): Promise<never> {
const finalAttemptAt = new Date(options.clock()).toISOString();
const duration = options.clock() - started;
// webhook-client's WebhookClientError does not carry attempts on
// the thrown error; record an empty history. Operators get the
// attempt count via the OTel histogram + the reason string.
const attempts: readonly DeliveryAttempt[] = [];
const entry: DeadLetterEntry = {
id: globalThis.crypto.randomUUID(),
endpointId: endpoint.id,
tenantId: endpoint.tenantId,
event: payload.event,
payload,
attempts,
firstAttemptAt,
finalAttemptAt,
reason: errorMessage(err),
};
try {
await options.deadLetterSink.record(entry);
} catch (sinkErr) {
options.logger.error("webhook: dead-letter sink failed", {
error: errorMessage(sinkErr),
});
throw new WebhookError(
WEBHOOK_CODES.DEAD_LETTER_WRITE_FAILED,
"dead-letter sink rejected the entry",
{ cause: sinkErr, context: { endpointId: endpoint.id } }
);
}
instruments.deliveriesTotal.add(1, {
event: payload.event,
status: "exhausted",
endpoint_id: endpoint.id,
});
instruments.deadLettersTotal.add(1, {
event: payload.event,
endpoint_id: endpoint.id,
});
instruments.sendDurationMs.record(duration, { status: "exhausted" });
throw new WebhookError(
WEBHOOK_CODES.DELIVERY_EXHAUSTED,
`webhook delivery exhausted after ${attempts.length} attempts`,
{ cause: err, context: { endpointId: endpoint.id } }
);
}
async function dedupAndDeliver(
endpoint: WebhookEndpoint,
payload: WebhookPayload
): Promise<DeliveryResult> {
const key = `${endpoint.id}:${payload.id}`;
if (options.idempotencyGuard !== null) {
return options.idempotencyGuard.execute(key, () =>
executeDelivery(endpoint, payload)
);
}
const existing = inFlight.get(key);
if (existing !== undefined) return existing;
const promise = executeDelivery(endpoint, payload).finally(() => {
inFlight.delete(key);
});
inFlight.set(key, promise);
return promise;
}
async function buildAndSend(
endpoint: WebhookEndpoint,
event: string,
data: unknown,
explicitId?: string
): Promise<DeliveryResult> {
const payload: WebhookPayload = {
id: explicitId ?? globalThis.crypto.randomUUID(),
event,
data,
timestamp: new Date(options.clock()).toISOString(),
};
return dedupAndDeliver(endpoint, payload);
}
async function requireEndpoint(
endpointId: string
): Promise<WebhookEndpoint> {
const endpoint = await options.endpointStore.get(endpointId);
if (endpoint === null) {
instruments.deliveriesTotal.add(1, {
event: "",
status: "not_found",
endpoint_id: endpointId,
});
throw new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
`endpoint '${endpointId}' does not exist`,
{ context: { endpointId } }
);
}
if (!endpoint.active) {
instruments.deliveriesTotal.add(1, {
event: "",
status: "inactive",
endpoint_id: endpointId,
});
throw new WebhookError(
WEBHOOK_CODES.INACTIVE_ENDPOINT,
`endpoint '${endpointId}' is inactive`,
{ context: { endpointId } }
);
}
return endpoint;
}
return {
async send(endpointId, event, data) {
const endpoint = await requireEndpoint(endpointId);
return buildAndSend(endpoint, event, data);
},
async sendAll(event, data, filter = {}) {
const all = await options.endpointStore.list(filter);
const subscribed = all.filter(
(ep) => ep.active && ep.events.includes(event)
);
if (subscribed.length === 0) {
instruments.deliveriesTotal.add(1, {
event,
status: "invalid",
endpoint_id: "",
});
throw new WebhookError(
WEBHOOK_CODES.EVENT_NOT_SUBSCRIBED,
`no active endpoint subscribes to '${event}'`,
{ context: { event } }
);
}
return Promise.all(
subscribed.map((ep) => buildAndSend(ep, event, data))
);
},
async register(input) {
try {
return await options.endpointStore.create(input);
} catch (err) {
throw new WebhookError(
WEBHOOK_CODES.STORE_ERROR,
"endpoint store create() rejected",
{ cause: err }
);
}
},
async update(id, patch) {
try {
return await options.endpointStore.update(id, patch);
} catch (err) {
if (err instanceof WebhookError) throw err;
throw new WebhookError(
WEBHOOK_CODES.STORE_ERROR,
"endpoint store update() rejected",
{ cause: err, context: { endpointId: id } }
);
}
},
async unregister(id) {
await options.endpointStore.delete(id);
},
async getEndpoint(id) {
return options.endpointStore.get(id);
},
async listEndpoints(filter = {}) {
return options.endpointStore.list(filter);
},
async test(endpointId) {
const endpoint = await requireEndpoint(endpointId);
return buildAndSend(endpoint, "webhook.ping", {
requestedAt: new Date(options.clock()).toISOString(),
});
},
};
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export type {
EndpointStore,
ListEndpointsFilter,
} from "../store/endpoint-store.js";
export type { UpdateEndpointInput };
export { errorMessage };

View File

@@ -0,0 +1,61 @@
# `endpoint-store.ts` — `EndpointStore` interface
## Purpose
Defines the pluggable persistence seam for webhook endpoints. Every method is async-only so downstream packages can plug in SurrealDB, Postgres, Redis, or any other backend without forcing the in-memory implementation that ships with the plugin to bend its synchronous semantics. The interface is intentionally small (five methods) so implementers can author conforming stores with minimal surface area, and matches the shape consumed directly by the plugin's `WebhookService` and the CRUD routes.
## Features
- Five methods: `list`, `get`, `create`, `update`, `delete`.
- `list` accepts an optional `ListEndpointsFilter` with tri-state `tenantId` (string = single tenant, `null` = global bucket only, `undefined` = every endpoint).
- Returns are typed via the shared `WebhookEndpoint` shape from `types/types.ts` so swap-in backends inherit field-level type safety automatically.
## Usage: Basic
```ts
import type { EndpointStore } from "@suaveplan/api-plugin-webhook/store/endpoint-store";
declare const store: EndpointStore;
const endpoint = await store.get("ep-1");
```
## Usage: Advanced
```ts
import type {
EndpointStore,
ListEndpointsFilter,
} from "@suaveplan/api-plugin-webhook/store/endpoint-store";
class SurrealEndpointStore implements EndpointStore {
constructor(private readonly db: SurrealAdapter) {}
async list(filter: ListEndpointsFilter = {}): Promise<readonly WebhookEndpoint[]> {
const where =
filter.tenantId === undefined
? ""
: "WHERE tenant_id = $tenantId";
return this.db.query(`SELECT * FROM webhook_endpoint ${where}`, filter);
}
async get(id: string) {
const [row] = await this.db.query(`SELECT * FROM webhook_endpoint WHERE id = $id`, { id });
return row ?? null;
}
async create(input: CreateEndpointInput): Promise<WebhookEndpoint> { /* … */ }
async update(id: string, patch: UpdateEndpointInput): Promise<WebhookEndpoint> { /* … */ }
async delete(id: string): Promise<void> { /* … */ }
}
```
## API Reference
- `EndpointStore.list(filter?)` — returns every endpoint matching the optional filter. With no filter, returns every endpoint regardless of tenant.
- `EndpointStore.get(id)` — returns the endpoint or `null` if no row exists for the supplied id.
- `EndpointStore.create(input)` — derives `id`, `createdAt`, `updatedAt`, defaults `algorithm: "sha256"` and `active: true` when absent, returns the inserted row.
- `EndpointStore.update(id, patch)` — applies the patch to the existing row, refreshes `updatedAt`, returns the new row. SHOULD throw if the id is unknown.
- `EndpointStore.delete(id)` — idempotent. Removes the row if present; resolves regardless.
- `ListEndpointsFilter` — `{ tenantId?: string | null }`. Undefined = no filter, null = global bucket only, string = single tenant.
## Implementation Notes
The interface intentionally omits a `count` method — the plugin's CRUD routes use `list.length`, and operators that need true `count(*)` semantics either implement a richer store type or extend the interface in a downstream package via TypeScript declaration merging. The async return contract is forward-compatible with any backend; the in-memory default is synchronous internally but wraps its returns in `Promise.resolve(...)`. Implementers are responsible for tenant isolation within `list`; the calling code (routes / service) re-filters defensively against the principal's tenant.

View File

@@ -0,0 +1,32 @@
import type {
CreateEndpointInput,
UpdateEndpointInput,
WebhookEndpoint,
} from "../types/types.js";
/**
* Optional filter accepted by `EndpointStore.list`. When `tenantId` is
* `undefined` the store returns every endpoint regardless of tenant
* (admin / global scope). When `tenantId` is `null` the store returns
* endpoints with `tenantId === null` (the global bucket). When a string
* is supplied, only endpoints owned by that tenant are returned.
*/
export interface ListEndpointsFilter {
readonly tenantId?: string | null;
}
/**
* Pluggable persistence contract for webhook endpoints. The plugin
* ships `MemoryEndpointStore` as the default; downstream packages
* implement this interface against SurrealDB / Postgres / Redis etc.
*
* Every method is async-only so backends with network IO can plug in
* without forcing the in-memory implementation to bend.
*/
export interface EndpointStore {
list(filter?: ListEndpointsFilter): Promise<readonly WebhookEndpoint[]>;
get(id: string): Promise<WebhookEndpoint | null>;
create(input: CreateEndpointInput): Promise<WebhookEndpoint>;
update(id: string, patch: UpdateEndpointInput): Promise<WebhookEndpoint>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,5 @@
export type {
EndpointStore,
ListEndpointsFilter,
} from "./endpoint-store.js";
export { MemoryEndpointStore } from "./memory.js";

View File

@@ -0,0 +1,59 @@
# `memory.ts` — default in-memory `EndpointStore`
## Purpose
Provides the default `EndpointStore` shipped with the plugin: a `Map`-backed implementation suitable for tests, single-process services without persistence requirements, and as a reference implementation for downstream backends. The store accepts an injectable `Clock` and `idFactory` so tests can pin timestamps and ids without monkey-patching `Date.now()` or `crypto.randomUUID()`. It conforms to the `EndpointStore` interface exhaustively — every public method documented there is implemented here and exercised by the co-located test file.
## Features
- `Map<string, WebhookEndpoint>` backing — O(1) reads, writes, and deletes.
- Injectable `Clock` + `idFactory` for deterministic tests.
- Raises `WebhookError(ENDPOINT_NOT_FOUND)` from `update` when the id is unknown (delete remains idempotent).
- Defensive copies of caller arrays (`events`) so external mutation cannot corrupt stored rows.
## Usage: Basic
```ts
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
const store = new MemoryEndpointStore();
const ep = await store.create({
tenantId: null,
url: "https://example.com/hooks",
secret: "shared",
events: ["order.created"],
});
console.log(ep.id);
```
## Usage: Advanced
```ts
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
const sequence = ["ep-1", "ep-2", "ep-3"];
let i = 0;
const store = new MemoryEndpointStore({
clock: () => Date.UTC(2026, 0, 1),
idFactory: () => sequence[i++] ?? "overflow",
});
const a = await store.create({ tenantId: "t-a", url: "https://a/", secret: "s", events: ["e"] });
const b = await store.create({ tenantId: "t-b", url: "https://b/", secret: "s", events: ["e"] });
console.log(a.id, b.id); // "ep-1" "ep-2"
const onlyA = await store.list({ tenantId: "t-a" });
console.log(onlyA.length); // 1
```
## API Reference
- `new MemoryEndpointStore(options?)` — accepts `{ clock?: Clock; idFactory?: () => string }`. Falls back to `Date.now()` and `crypto.randomUUID()`.
- `list(filter?)` — returns every endpoint matching the tri-state `tenantId` filter (undefined / null / string).
- `get(id)` — returns the endpoint or `null`.
- `create(input)` — derives id + timestamps, applies `algorithm: "sha256"` / `active: true` defaults, persists, returns the row.
- `update(id, patch)` — throws `WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)` if `id` is unknown. Returns the merged row with `updatedAt` refreshed.
- `delete(id)` — idempotent; resolves regardless of whether the row existed.
## Implementation Notes
The store does not enforce uniqueness on `url` — multiple endpoints can subscribe the same URL to different event sets, and operators of multi-tenant systems explicitly want one URL serving many tenants. The `idFactory` injection point exists primarily for test determinism; production callers should leave it at the default `crypto.randomUUID()`. Because the store lives in process memory, it is reset on every process restart — operators with persistence requirements wire a `SurrealEndpointStore` (sibling package) or implement their own against the `EndpointStore` interface.

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WebhookError } from "../error-codes/error-codes.js";
import { MemoryEndpointStore } from "./memory.js";
function makeStore(sequence: readonly string[] = ["id-1", "id-2", "id-3"]) {
let i = 0;
return new MemoryEndpointStore({
clock: () => Date.UTC(2026, 4, 18),
idFactory: () => sequence[i++] ?? "overflow",
});
}
describe("MemoryEndpointStore", () => {
it("creates an endpoint with derived id + timestamps + defaults", {
timeout: 5000,
}, async () => {
const store = makeStore();
const ep = await store.create({
tenantId: "t-a",
url: "https://a/",
secret: "s",
events: ["e"],
});
expect(ep.id).toBe("id-1");
expect(ep.algorithm).toBe("sha256");
expect(ep.active).toBe(true);
expect(ep.createdAt).toBe("2026-05-18T00:00:00.000Z");
expect(ep.updatedAt).toBe("2026-05-18T00:00:00.000Z");
expect(ep.events).toEqual(["e"]);
});
it("preserves caller-supplied algorithm/active/description/headers", {
timeout: 5000,
}, async () => {
const store = makeStore();
const ep = await store.create({
tenantId: null,
url: "https://b/",
secret: "s",
events: ["e"],
algorithm: "sha512",
active: false,
description: "ops",
headers: { "X-K": "v" },
});
expect(ep.algorithm).toBe("sha512");
expect(ep.active).toBe(false);
expect(ep.description).toBe("ops");
expect(ep.headers).toEqual({ "X-K": "v" });
});
it("returns null on get of missing id", { timeout: 5000 }, async () => {
const store = makeStore();
expect(await store.get("nope")).toBeNull();
});
it("filters list by tenantId tri-state", { timeout: 5000 }, async () => {
const store = makeStore(["a", "b", "c"]);
await store.create({
tenantId: "t-a",
url: "https://a/",
secret: "s",
events: ["e"],
});
await store.create({
tenantId: "t-b",
url: "https://b/",
secret: "s",
events: ["e"],
});
await store.create({
tenantId: null,
url: "https://c/",
secret: "s",
events: ["e"],
});
expect((await store.list()).length).toBe(3);
expect((await store.list({ tenantId: "t-a" })).length).toBe(1);
expect((await store.list({ tenantId: null })).length).toBe(1);
});
it("updates an existing endpoint and refreshes updatedAt", {
timeout: 5000,
}, async () => {
let now = Date.UTC(2026, 4, 18);
const store = new MemoryEndpointStore({
clock: () => now,
idFactory: () => "id-1",
});
await store.create({
tenantId: null,
url: "https://a/",
secret: "s",
events: ["e"],
});
now = Date.UTC(2026, 4, 19);
const updated = await store.update("id-1", {
url: "https://b/",
secret: "s2",
events: ["e2"],
description: "ops",
headers: { "X-Y": "z" },
algorithm: "sha512",
active: false,
});
expect(updated.url).toBe("https://b/");
expect(updated.secret).toBe("s2");
expect(updated.events).toEqual(["e2"]);
expect(updated.description).toBe("ops");
expect(updated.headers).toEqual({ "X-Y": "z" });
expect(updated.algorithm).toBe("sha512");
expect(updated.active).toBe(false);
expect(updated.updatedAt).toBe("2026-05-19T00:00:00.000Z");
expect(updated.createdAt).toBe("2026-05-18T00:00:00.000Z");
});
it("update throws WEBHOOK_ENDPOINT_NOT_FOUND for unknown ids", {
timeout: 5000,
}, async () => {
const store = makeStore();
await expect(
store.update("nope", { active: false })
).rejects.toBeInstanceOf(WebhookError);
});
it("update with partial patch leaves unchanged fields intact", {
timeout: 5000,
}, async () => {
const store = makeStore();
await store.create({
tenantId: null,
url: "https://a/",
secret: "shared-secret",
events: ["e"],
});
// Patch only the URL; algorithm/active untouched.
const updated = await store.update("id-1", { url: "https://b/" });
expect(updated.url).toBe("https://b/");
expect(updated.algorithm).toBe("sha256");
expect(updated.active).toBe(true);
});
it("delete is idempotent", { timeout: 5000 }, async () => {
const store = makeStore();
await store.delete("nope"); // never created — resolves
await store.create({
tenantId: null,
url: "https://a/",
secret: "s",
events: ["e"],
});
await store.delete("id-1");
expect(await store.get("id-1")).toBeNull();
});
it("falls back to globalThis.crypto.randomUUID when no idFactory is supplied", {
timeout: 5000,
}, async () => {
const store = new MemoryEndpointStore();
const ep = await store.create({
tenantId: null,
url: "https://a/",
secret: "s",
events: ["e"],
});
expect(ep.id).toMatch(/^[0-9a-f-]{36}$/);
});
});

View File

@@ -0,0 +1,121 @@
import type { Clock } from "@suaveplan/types";
import { WEBHOOK_CODES, WebhookError } from "../error-codes/error-codes.js";
import type {
CreateEndpointInput,
UpdateEndpointInput,
WebhookEndpoint,
} from "../types/types.js";
import type { EndpointStore, ListEndpointsFilter } from "./endpoint-store.js";
/**
* Options accepted by `MemoryEndpointStore`. Both fields are optional —
* the store falls back to `Date.now()` and `crypto.randomUUID()` if the
* caller does not inject deterministic helpers (test suites typically
* do; production code typically does not need to).
*/
export interface MemoryEndpointStoreOptions {
readonly clock?: Clock;
readonly idFactory?: () => string;
}
/**
* Default in-memory `EndpointStore` shipped with the plugin. Backed by
* a `Map<string, WebhookEndpoint>`; every read returns a frozen
* snapshot so callers cannot mutate the stored row through the
* returned reference.
*
* Suitable for tests, single-process apps without persistence
* requirements, and as a reference implementation for downstream stores.
*/
export class MemoryEndpointStore implements EndpointStore {
private readonly endpoints = new Map<string, WebhookEndpoint>();
private readonly clock: Clock;
private readonly idFactory: () => string;
public constructor(options: MemoryEndpointStoreOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
this.idFactory = options.idFactory ?? defaultIdFactory;
}
public async list(
filter: ListEndpointsFilter = {}
): Promise<readonly WebhookEndpoint[]> {
const rows: WebhookEndpoint[] = [];
for (const ep of this.endpoints.values()) {
if (
filter.tenantId === undefined ||
ep.tenantId === filter.tenantId
) {
rows.push(ep);
}
}
return rows;
}
public async get(id: string): Promise<WebhookEndpoint | null> {
return this.endpoints.get(id) ?? null;
}
public async create(input: CreateEndpointInput): Promise<WebhookEndpoint> {
const id = this.idFactory();
const nowIso = new Date(this.clock()).toISOString();
const created: WebhookEndpoint = {
id,
tenantId: input.tenantId,
url: input.url,
secret: input.secret,
events: [...input.events],
...(input.description !== undefined
? { description: input.description }
: {}),
...(input.headers !== undefined ? { headers: input.headers } : {}),
algorithm: input.algorithm ?? "sha256",
active: input.active ?? true,
createdAt: nowIso,
updatedAt: nowIso,
};
this.endpoints.set(id, created);
return created;
}
public async update(
id: string,
patch: UpdateEndpointInput
): Promise<WebhookEndpoint> {
const existing = this.endpoints.get(id);
if (existing === undefined) {
throw new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
`endpoint '${id}' does not exist`,
{ context: { endpointId: id } }
);
}
const updated: WebhookEndpoint = {
...existing,
...(patch.url !== undefined ? { url: patch.url } : {}),
...(patch.secret !== undefined ? { secret: patch.secret } : {}),
...(patch.events !== undefined
? { events: [...patch.events] }
: {}),
...(patch.description !== undefined
? { description: patch.description }
: {}),
...(patch.headers !== undefined ? { headers: patch.headers } : {}),
...(patch.algorithm !== undefined
? { algorithm: patch.algorithm }
: {}),
...(patch.active !== undefined ? { active: patch.active } : {}),
updatedAt: new Date(this.clock()).toISOString(),
};
this.endpoints.set(id, updated);
return updated;
}
public async delete(id: string): Promise<void> {
this.endpoints.delete(id);
}
}
function defaultIdFactory(): string {
return globalThis.crypto.randomUUID();
}

View File

@@ -0,0 +1,7 @@
export type {
CreateEndpointInput,
UpdateEndpointInput,
WebhookEndpoint,
WebhookPluginConfig,
WebhookPluginOptions,
} from "./types.js";

View File

@@ -0,0 +1,74 @@
# `types.ts` — webhook plugin shared types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the cross-feature data shapes used by every module in `@suaveplan/api-plugin-webhook`: the persistent `WebhookEndpoint` record, the create/update input shapes, the `WebhookPluginOptions` constructor surface, and the post-default-merge `WebhookPluginConfig` shape. Keeping these in a single `types/` module avoids cycles between the store, the dead-letter sink, the service, the routes, and the plugin descriptor — they all import structural types from here.
## Features
- `WebhookEndpoint` — JSON-serialisable shape so any `EndpointStore` backend (memory, SurrealDB, Postgres) persists rows without bespoke adapters.
- `CreateEndpointInput` / `UpdateEndpointInput` — caller-supplied shapes used at the route boundary and by the store conformance tests.
- `WebhookPluginOptions` — every field optional; the factory fills in defaults.
- `WebhookPluginConfig` — required-field shape carried on the descriptor's `defaults`.
## Usage: Basic
```ts
import type { WebhookEndpoint } from "@suaveplan/api-plugin-webhook/types/types";
const endpoint: WebhookEndpoint = {
id: "ep-1",
tenantId: null,
url: "https://partner.example.com/hooks",
secret: "shared-secret",
events: ["order.created"],
algorithm: "sha256",
active: true,
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
};
```
## Usage: Advanced
```ts
import type {
CreateEndpointInput,
WebhookPluginOptions,
} from "@suaveplan/api-plugin-webhook";
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
const options: WebhookPluginOptions = {
routePrefix: "/api/v1/webhooks",
endpointStore: new MemoryEndpointStore(),
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 8,
initialRetryDelayMs: 250,
maxRetryDelayMs: 60_000,
};
const create: CreateEndpointInput = {
tenantId: "t-1",
url: "https://partner.example.com/hooks",
secret: crypto.randomUUID(),
events: ["order.created", "order.cancelled"],
headers: { "X-Customer-Tag": "premium" },
};
```
## API Reference
- `WebhookEndpoint` — readonly record. `id`/`tenantId`/`url`/`secret`/`events`/`algorithm`/`active`/`createdAt`/`updatedAt` required; `description`/`headers` optional.
- `CreateEndpointInput` — same shape minus the derived fields (`id`, `createdAt`, `updatedAt`) and with `algorithm`/`active` optional (defaults: `"sha256"` / `true`).
- `UpdateEndpointInput` — every field optional. `tenantId` is intentionally absent (cross-tenant moves are delete + recreate).
- `WebhookPluginOptions` — every field optional (route prefix defaults to `/webhooks`, max attempts to `5`, initial delay to `500ms`, max delay to `30_000ms`).
- `WebhookPluginConfig` — every field required, including `idempotencyGuard` (nullable when not configured), so the descriptor's `defaults` can be passed verbatim to the runtime.
## Implementation Notes
`WebhookEndpoint.secret` is a regular string field — the type system cannot stop the value being logged. The package's `logger.warn` / `logger.error` calls never include the endpoint object verbatim; they project `{ id, url, events }` only. Downstream stores SHOULD encrypt-at-rest. The reason every option is optional is so an app composing `createWebhookPlugin()` can start with `createWebhookPlugin()` (zero arguments) and grow into custom stores / sinks / guards without an API break.

View File

@@ -0,0 +1,95 @@
import type { DistributedIdempotencyGuard } from "@suaveplan/idempotency";
import type { Clock, FetchLike, LoggerLike } from "@suaveplan/types";
import type { WebhookSignatureAlgorithm } from "@suaveplan/webhook-client";
import type { DeadLetterSink } from "../dead-letter/dead-letter.js";
import type { EndpointStore } from "../store/endpoint-store.js";
/**
* Persistent record describing one webhook subscription.
*
* The shape is intentionally string-only / JSON-serialisable so any
* `EndpointStore` implementation (in-memory / SurrealDB / Postgres /
* Redis) can persist the row without bespoke adapters.
*/
export interface WebhookEndpoint {
readonly id: string;
readonly tenantId: string | null;
readonly url: string;
/** Shared HMAC secret. Never logged. */
readonly secret: string;
readonly events: readonly string[];
readonly description?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly algorithm: WebhookSignatureAlgorithm;
readonly active: boolean;
readonly createdAt: string;
readonly updatedAt: string;
}
/**
* Caller-supplied input for `EndpointStore.create` / the
* `POST /webhooks` route. `id`, `createdAt`, `updatedAt`, and `active`
* (default `true`) are derived by the store.
*/
export interface CreateEndpointInput {
readonly tenantId: string | null;
readonly url: string;
readonly secret: string;
readonly events: readonly string[];
readonly description?: string | undefined;
readonly headers?: Readonly<Record<string, string>> | undefined;
readonly algorithm?: WebhookSignatureAlgorithm | undefined;
readonly active?: boolean | undefined;
}
/**
* Caller-supplied patch for `EndpointStore.update` / the
* `PATCH /webhooks/:id` route. `tenantId` is intentionally non-rotatable
* (cross-tenant moves require delete + recreate).
*/
export interface UpdateEndpointInput {
readonly url?: string | undefined;
readonly secret?: string | undefined;
readonly events?: readonly string[] | undefined;
readonly description?: string | undefined;
readonly headers?: Readonly<Record<string, string>> | undefined;
readonly algorithm?: WebhookSignatureAlgorithm | undefined;
readonly active?: boolean | undefined;
}
/**
* Construction-time options accepted by `createWebhookPlugin`. Every
* field is optional — the factory wires defaults (in-memory store, in-
* memory dead-letter sink, system clock, global `fetch`) for fields the
* caller omits.
*/
export interface WebhookPluginOptions {
readonly routePrefix?: string;
readonly endpointStore?: EndpointStore;
readonly deadLetterSink?: DeadLetterSink;
readonly maxAttempts?: number;
readonly initialRetryDelayMs?: number;
readonly maxRetryDelayMs?: number;
readonly idempotencyGuard?: DistributedIdempotencyGuard;
readonly logger?: LoggerLike;
readonly clock?: Clock;
readonly fetch?: FetchLike;
}
/**
* Post-default-merge resolved shape of `WebhookPluginOptions`. Every
* field is required and the descriptor's `defaults` carries an instance
* of this shape.
*/
export interface WebhookPluginConfig {
readonly routePrefix: string;
readonly endpointStore: EndpointStore;
readonly deadLetterSink: DeadLetterSink;
readonly maxAttempts: number;
readonly initialRetryDelayMs: number;
readonly maxRetryDelayMs: number;
readonly idempotencyGuard: DistributedIdempotencyGuard | null;
readonly logger: LoggerLike;
readonly clock: Clock;
readonly fetch: FetchLike;
}

View File

@@ -0,0 +1,57 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(here, "..");
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as {
exports: Record<string, unknown>;
sideEffects: boolean;
};
describe("dep-graph", () => {
it("declares 14 tree-shakeable subpath exports", { timeout: 5000 }, () => {
const sub = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// 13 subpaths + the root "." = 14.
expect(sub.length + 1).toBe(14);
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
walk(join(pkgRoot, "src"), (path) => {
if (!path.endsWith(".ts")) return;
if (path.endsWith(".test.ts")) return;
if (path.endsWith(".types.ts")) return;
if (path.endsWith("/index.ts")) return;
if (path.endsWith(".spec-conformance.test.ts")) return;
const md = path.replace(/\.ts$/, ".md");
if (!existsSync(md)) missing.push(md);
});
expect(missing).toEqual([]);
});
});
function walk(dir: string, visit: (path: string) => void): void {
for (const entry of readdirSyncSafe(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, visit);
else visit(full);
}
}
function readdirSyncSafe(dir: string): string[] {
try {
const { readdirSync } = require("node:fs") as typeof import("node:fs");
return readdirSync(dir);
} catch {
return [];
}
}

View File

@@ -0,0 +1,97 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
WEBHOOK_CODES,
} from "../../src/index.js";
async function startReceiver(
handler: (req: IncomingMessage, res: ServerResponse) => void
) {
const server: Server = createServer((req, res) => {
req.on("data", () => undefined);
req.on("end", () => handler(req, res));
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook dead letter", () => {
it("5x 500 lands in dead letter", { timeout: 30_000 }, async () => {
let attempts = 0;
const receiver = await startReceiver((_, res) => {
attempts++;
res.writeHead(500);
res.end("nope");
});
try {
resetWebhookInstruments();
const sink = new MemoryDeadLetterSink();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: sink,
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 30,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["evt"],
});
await expect(
service.send("ep-1", "evt", { x: 1 })
).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
});
expect(attempts).toBe(5);
const dl = await sink.list({ endpointId: "ep-1" });
expect(dl).toHaveLength(1);
const entry = dl[0];
if (!entry) throw new Error("missing dl entry");
expect(entry.event).toBe("evt");
expect(entry.endpointId).toBe("ep-1");
expect(entry.payload.event).toBe("evt");
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,143 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { verifySignature } from "@suaveplan/webhook-client";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
} from "../../src/index.js";
interface CapturedRequest {
method: string;
url: string;
headers: Record<string, string>;
body: string;
}
interface TestReceiver {
readonly url: string;
readonly requests: CapturedRequest[];
close(): Promise<void>;
}
type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
attempt: number
) => void;
async function startReceiver(handler: RequestHandler): Promise<TestReceiver> {
const requests: CapturedRequest[] = [];
let attempt = 0;
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
attempt += 1;
const captured: CapturedRequest = {
method: req.method ?? "GET",
url: req.url ?? "/",
headers: Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(",") : (v ?? ""),
])
),
body,
};
requests.push(captured);
handler(req, res, attempt);
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind test receiver");
}
const url = `http://127.0.0.1:${address.port}/hooks`;
return {
url,
requests,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook delivery", () => {
it("sends a signed payload with canonical headers", {
timeout: 30_000,
}, async () => {
const receiver = await startReceiver((_, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 100,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["order.created"],
});
const result = await service.send("ep-1", "order.created", {
orderId: 42,
});
expect(result.status).toBe("delivered");
expect(receiver.requests).toHaveLength(1);
const req = receiver.requests[0];
if (!req) throw new Error("missing request");
expect(req.method).toBe("POST");
expect(req.headers["x-webhook-event"]).toBe("order.created");
expect(req.headers["x-webhook-id"]).toBeTruthy();
expect(req.headers["x-webhook-timestamp"]).toBeTruthy();
const sig = req.headers["x-hub-signature-256"];
expect(sig).toMatch(/^sha256=/);
const verified = await verifySignature(
req.body,
sig as string,
"shared-secret",
"sha256"
);
expect(verified).toBe(true);
expect(JSON.parse(req.body).data).toEqual({ orderId: 42 });
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,125 @@
import { createServer, type Server } from "node:http";
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookPlugin,
resetWebhookInstruments,
type WebhookPluginConfig,
type WebhookService,
} from "../../src/index.js";
interface Receiver {
readonly url: string;
readonly observed: { event: string; body: string }[];
close(): Promise<void>;
}
async function startReceiver(): Promise<Receiver> {
const observed: { event: string; body: string }[] = [];
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (c) => {
body += c;
});
req.on("end", () => {
const evt = req.headers["x-webhook-event"];
observed.push({
event: Array.isArray(evt) ? (evt[0] ?? "") : (evt ?? ""),
body,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
observed,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("integration: webhook plugin full boot", () => {
it("POST /webhooks/:id/test fires a synthetic webhook.ping", {
timeout: 30_000,
}, async () => {
const receiver = await startReceiver();
try {
resetWebhookInstruments();
const app = new OpenAPIHono();
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } })
.context;
const status = (ctx?.statusCode ?? 500) as
| 401
| 403
| 404
| 500;
return c.json({ message: err?.message ?? "error" }, status);
});
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
// Install a permissive auth middleware ABOVE the plugin's
// /webhooks/* requireAuth so the integration test does not
// need a real auth plugin.
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: null });
await next();
});
const descriptor = createWebhookPlugin();
const ctx = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: descriptor.defaults as WebhookPluginConfig,
services,
});
await descriptor.onInit?.(ctx);
// Register an endpoint via the service that the plugin
// registered in ServiceRegistry.
const service = ctx.getService("webhookService") as WebhookService;
const endpoint = await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["webhook.ping"],
});
// Fire via the test route.
const res = await app.request(`/webhooks/${endpoint.id}/test`, {
method: "POST",
});
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("delivered");
expect(receiver.observed).toHaveLength(1);
expect(receiver.observed[0]?.event).toBe("webhook.ping");
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,141 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
WEBHOOK_CODES,
} from "../../src/index.js";
async function startReceiver(
handler: (
req: IncomingMessage,
res: ServerResponse,
attempt: number
) => void
) {
let attempt = 0;
const server: Server = createServer((req, res) => {
req.on("data", () => undefined);
req.on("end", () => {
attempt += 1;
handler(req, res, attempt);
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook retry", () => {
it("retries 5xx then succeeds", { timeout: 30_000 }, async () => {
const receiver = await startReceiver((_, res, attempt) => {
if (attempt < 3) {
res.writeHead(500);
res.end("transient");
} else {
res.writeHead(200);
res.end("ok");
}
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 50,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["order.created"],
});
const result = await service.send("ep-1", "order.created", {
x: 1,
});
expect(result.status).toBe("delivered");
expect(result.attempts.length).toBe(3);
expect(result.finalStatusCode).toBe(200);
} finally {
await receiver.close();
}
});
it("4xx terminates without retry", { timeout: 30_000 }, async () => {
let attempts = 0;
const receiver = await startReceiver((_, res) => {
attempts++;
res.writeHead(422);
res.end("client error");
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 50,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["evt"],
});
await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject(
{
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
}
);
expect(attempts).toBe(1);
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-webhook.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-webhook",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});