first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.