Files
2026-05-27 23:29:53 +01:00

145 lines
14 KiB
Markdown

# Add @suaveplan/api-plugin-webhook — Signed outbound webhook delivery as a definePlugin
**Package**: `@suaveplan/api-plugin-webhook` (NEW, server-tier)
**Status**: PROPOSED
**Type**: new-package
**Position**: server plugin layered on `@suaveplan/api` + `@suaveplan/webhook-client`; depends only on already-shipped foundations (api-plugin, api-openapi, webhook-client, idempotency, types, error)
- **Spec delta**: ./specs/api-plugin-webhook/spec.md
## Why
Genesis ships `@suaveplan/webhook-client` (HMAC-signed delivery + retry, 0.1.0) and the `@suaveplan/api-plugin` / `@suaveplan/api-openapi` foundation. Every API that needs to fan signed events out to subscriber endpoints (order events, account events, partner notifications) currently has to wire endpoint storage, route validation, retry queue orchestration, dead-letter sink, and signature header conventions by hand. That defeats the point of having a webhook client and an OpenAPI plugin foundation.
This package collapses that scaffolding into a single `createWebhookPlugin(options)` factory that:
1. Registers a typed `webhookService` in the host `ServiceRegistry` exposing `send(endpoint, event)` / `register(endpoint)` / `unregister(id)` / `listEndpoints(tenantId?)`.
2. Mounts a small CRUD surface for subscription management (`GET/POST /webhooks`, `GET/PATCH/DELETE /webhooks/:id`, plus `POST /webhooks/:id/test`) gated by `requireAuth()` and tenant-scoped if the request carries a tenant.
3. Drives delivery through `@suaveplan/webhook-client` (HMAC-SHA256 by default, exponential backoff with jitter, 5 attempt cap, dead-letter sink) and uses `@suaveplan/idempotency` for per-delivery dedup so callers can safely retry `send()`.
4. Persists endpoints behind an `EndpointStore` interface with an in-memory default — a SurrealDB-backed sibling plugin lands separately.
It is the canonical exemplar of "wrap a `@suaveplan/<lib>` library as a `@suaveplan/api-plugin-*`" for downstream plugins that follow the same shape (`api-plugin-rate-limit`, `api-plugin-audit`).
## What changes
Net-new package at `0.1.0`. ADDED:
### Plugin factory + descriptor
- `createWebhookPlugin(options: WebhookPluginOptions): PluginDescriptor<WebhookPluginConfig>` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. Invalid input raises `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the `ZodError` as `cause`.
- `WebhookPluginOptions` (validated via `WebhookPluginOptionsSchema`): `{ routePrefix?: string (default "/webhooks"); endpointStore?: EndpointStore; deadLetterSink?: DeadLetterSink; maxAttempts?: number (default 5); initialRetryDelayMs?: number (default 500); maxRetryDelayMs?: number (default 30_000); idempotencyGuard?: DistributedIdempotencyGuard; logger?: LoggerLike; clock?: Clock; fetch?: FetchLike }`. Every optional dep that isn't supplied defaults to an injectable in-memory implementation owned by the package.
- `WebhookPluginConfig` — the resolved (post-default-merge) options shape.
### Endpoint store contract (the persistence seam)
- `EndpointStore` interface: `list({ tenantId? }): Promise<readonly WebhookEndpoint[]>` / `get(id): Promise<WebhookEndpoint | null>` / `create(input: CreateEndpointInput): Promise<WebhookEndpoint>` / `update(id, patch: UpdateEndpointInput): Promise<WebhookEndpoint>` / `delete(id): Promise<void>`.
- `MemoryEndpointStore` — default in-memory `Map`-backed implementation. Thread-safe under cooperative async (no concurrent JS execution).
- `WebhookEndpoint`: `{ id: string; tenantId: string | null; url: string; secret: string; events: readonly string[]; description?: string; headers?: Record<string, string>; algorithm: "sha256" | "sha512"; active: boolean; createdAt: string; updatedAt: string }`.
- `WebhookEndpointSchema` / `CreateEndpointInputSchema` / `UpdateEndpointInputSchema` Zod schemas — used at the route boundary and by the store conformance harness.
### Dead-letter sink
- `DeadLetterSink` interface: `record(entry: DeadLetterEntry): Promise<void>` / `list({ endpointId?, limit? }): Promise<readonly DeadLetterEntry[]>`.
- `MemoryDeadLetterSink` — default in-memory implementation. Records every delivery that exhausts `maxAttempts` retries with the full `DeliveryResult.attempts` history, the originating event, and the `firstAttemptAt` / `finalAttemptAt` timestamps.
- `DeadLetterEntry`: `{ id: string; endpointId: string; tenantId: string | null; event: string; payload: WebhookPayload; attempts: readonly DeliveryAttempt[]; firstAttemptAt: string; finalAttemptAt: string; reason: string }`.
### Webhook service (registered in the host ServiceRegistry)
- `WebhookService` interface: `send(endpointId, event, data): Promise<DeliveryResult>` / `sendAll(event, data, { tenantId? }): Promise<readonly DeliveryResult[]>` (fans out to every active endpoint subscribed to `event`) / `register(input): Promise<WebhookEndpoint>` / `unregister(id): Promise<void>` / `listEndpoints(filter): Promise<readonly WebhookEndpoint[]>` / `test(endpointId): Promise<DeliveryResult>` (sends a synthetic `webhook.ping` payload).
- ServiceRegistry module augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { webhookService: WebhookService } }`.
### Routes (the OpenAPI surface)
All routes mount at `options.routePrefix` (default `/webhooks`), are gated by `requireAuth()`, and filter by tenant when the request principal carries a `tenantId`:
- `GET /webhooks` — list active endpoints for the current tenant (admin sees all).
- `POST /webhooks` — create an endpoint. Body: `CreateEndpointInputSchema`. Caller supplies the HMAC secret.
- `GET /webhooks/:id` — read one endpoint. Returns 404 on miss or tenant mismatch.
- `PATCH /webhooks/:id` — partial update (url, events, headers, active flag, description). Cannot rotate `tenantId`. Secret rotation is opt-in via the body.
- `DELETE /webhooks/:id` — hard delete. Idempotent on miss.
- `POST /webhooks/:id/test` — fire a synthetic `webhook.ping` event at the endpoint and return the `DeliveryResult`. Honours rate limit + idempotency.
Every route is built with `RouteBuilder` (`POST`/`GET`/`PATCH`/`DELETE` from `@suaveplan/api-openapi`) so the unified `/openapi.json` includes Zod-typed request/response shapes for every method.
### Delivery semantics
- Signing — HMAC-SHA256 by default via `generateSignature` from `@suaveplan/webhook-client/signature`. Algorithm pinned per endpoint. Header naming follows webhook-client convention (`X-Hub-Signature-256` / `X-Hub-Signature-512`).
- Standard outbound headers — `X-Webhook-Id` (delivery id), `X-Webhook-Event` (event name), `X-Webhook-Timestamp` (ISO 8601 UTC), plus signature header above. Caller-supplied `endpoint.headers` are merged with these (caller wins on `Content-Type` only, never on the four canonical headers).
- Retry — exponential backoff with full jitter, starting at `initialRetryDelayMs`, doubling each attempt, capped at `maxRetryDelayMs`, exhausted after `maxAttempts` (default 5). Receivers returning 4xx (except 429) terminate immediately; 429 and 5xx retry; network errors and timeouts retry.
- Dead-letter — once `maxAttempts` is exhausted, the package records a `DeadLetterEntry` via the configured sink and surfaces a `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` to the caller of `send()`. The entry preserves the full attempt history so operators can replay manually.
- Idempotency — every `send()` call computes a delivery key from `(endpointId, payload.id)`. When an `idempotencyGuard` is supplied, the call routes through `guard.execute(key, () => delivery)` so duplicate sends within the dedup window return the cached result. Without a guard, deliveries are dedup'd per process via an in-flight `Map`.
### Errors + observability
- `WebhookError extends SuaveplanError<WebhookErrorCode>` via `defineCodes("WEBHOOK", [...])`. Codes: `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`.
- OTel: counters `webhook_plugin.deliveries_total{event,status,endpoint_id}`, `webhook_plugin.dead_letters_total{event,endpoint_id}`; histogram `webhook_plugin.delivery_attempts{event,status}`; histogram `webhook_plugin.send_duration_ms{status}`. Registered via the package's own `getWebhookInstruments()` factory pattern (mirrors `api-plugin-auth`).
### Subpath exports
`.`, `./plugin/plugin`, `./services/webhook-service`, `./store/endpoint-store`, `./store/memory-store`, `./dead-letter/dead-letter`, `./dead-letter/memory-sink`, `./routes/routes`, `./schemas/options`, `./schemas/endpoint`, `./schemas/routes`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
## Out of scope
- **SurrealDB-backed endpoint store.** Lands as a sibling `@suaveplan/api-plugin-webhook-surreal` plugin — keeps this package zero-IO so it can land in any storage tier.
- **Inbound webhook receivers.** This package is delivery-only; consuming webhooks from third parties belongs to a future `@suaveplan/api-plugin-webhook-receiver`.
- **Replay UI / admin dashboard.** Dead-letter entries are exposed via the `DeadLetterSink` interface; surfacing them in an admin app is per-application.
- **Cron-based redelivery from the dead-letter sink.** The sink records entries; redelivery orchestration lives in the operator's scheduled jobs.
- **Multi-tenant tenant-id auto-injection.** Tenants are owned by `@suaveplan/api-tenant`; this plugin reads `c.get("user")?.tenantId` if the auth plugin is wired upstream and falls back to global scope otherwise.
- **Filter / transform DSL.** Endpoints subscribe to literal event names today. A glob / topic DSL is deferred until two consumers need it.
## Source mapping
This package is **genesis-original** — there is no upstream `packages/server/api-plugin-webhook/` in `../suaveplan/` to port (only the enterprise audit doc `../suaveplan/enterprise/api-plugin-webhook.md`, which describes intent but not implementation). Design intent is drawn from:
- `../suaveplan/enterprise/api-plugin-webhook.md` — the enterprise audit (118 lines) that scopes purpose, required improvements, and acceptance criteria.
- Genesis `@suaveplan/api-plugin` `PluginContext` + `ServiceRegistry` shape (commit 0f338653).
- Genesis `@suaveplan/api-openapi` `definePluginRoutes` + `RouteBuilder` (commit 99a213ed).
- Genesis `@suaveplan/webhook-client` (HMAC signing, retry, attempt tracking).
- Genesis `@suaveplan/api-plugin-auth` (canonical `definePlugin` consumer shape — service registration, route mounting, ServiceRegistry augmentation, OTel pattern, error-code shape).
- Genesis `@suaveplan/idempotency` `DistributedIdempotencyGuard`.
| Requirement | Source | Notes |
|---|---|---|
| Plugin wiring via `definePlugin` | enterprise/api-plugin-webhook.md L37 + `@suaveplan/api-plugin` | "Plugin wiring — built with definePlugin() from @suaveplan/api, full onInit/onReady/onClose lifecycle" |
| Zod request/response schemas at route boundary | enterprise/api-plugin-webhook.md L39 | "Zod request/response schemas via @suaveplan/api-schemas — published OpenAPI 3.1 + JSON Schema" |
| Auth gating + tenant isolation | enterprise/api-plugin-webhook.md L40 | "Authentication & authorization — JWT, RBAC, tenant isolation enforced in every query" — adapted to genesis `requireAuth()` |
| Idempotency on mutations | enterprise/api-plugin-webhook.md L42 | "Idempotency — `Idempotency-Key` header, @suaveplan/idempotency guard on all mutations" |
| Observability — counters / histograms / spans | enterprise/api-plugin-webhook.md L44 | "OpenTelemetry spans, @suaveplan/logger structured logs with trace correlation, Prometheus metrics" |
| Retries + dead-letter sink on external calls | enterprise/api-plugin-webhook.md L45 | "Retries & circuit breakers on external calls via @suaveplan/retry" — extended with dead-letter sink |
| HMAC-signed delivery | enterprise/api-plugin-webhook.md L47 | "Webhooks — signed via @suaveplan/webhook-client, delivery tracked via @suaveplan/delivery-tracking" |
| No mocks — real services in tests | enterprise/api-plugin-webhook.md L48 + L55 | "Integration tests against real services (no mocks)" — genesis enforces via real Hono receiver |
| 100% coverage / docs / no banned patterns | enterprise/api-plugin-webhook.md L52-L65 | "Cross-cutting (every package)" block |
| File-size + biome + no `any` budget | enterprise/api-plugin-webhook.md L54 + L60-L61 | inherited from CLAUDE.md |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Pluggable `EndpointStore` interface | Decouples persistence so SurrealDB / Redis / Postgres siblings can plug in without forking. The audit calls for SurrealDB integration but explicitly invites pluggable stores via L37 "Plugin / extension interface (where it makes sense) so downstream packages can contribute behaviour without forking" (L71). |
| Pluggable `DeadLetterSink` interface | Same rationale — operators may wire dead letters into Mailpit / Slack / S3. |
| Per-delivery in-flight dedup without an external guard | Default safety net so callers don't have to wire `@suaveplan/idempotency` to get re-entrancy safety. Audit L42 calls for the guard on mutations; this extends the safety net to single-process delivery. |
## Verification approach
Every requirement in `specs/api-plugin-webhook/spec.md` carries a `**Verified by:**` line pointing at:
- `src/<feature>/<feature>.test.ts` — unit scenarios (no IO, injectable defaults).
- `src/<feature>/<feature>.spec-conformance.test.ts` — type-level + observability + lifecycle scenarios.
- `tests/integration/delivery.test.ts` — real listening Hono receiver on a random port; verifies signed payload, headers, retry-then-success, dead-letter after 5x 500.
- `tests/integration/full-boot.test.ts` — real `PluginRegistry` + real Hono app; full register → send → receive → assert round-trip.
- `tests/dep-graph.test.ts` — pins dependency invariants (14 subpaths, sibling `.md` per impl file).
## Migration impact
None — additive, net-new package. No existing consumer in the monorepo (`bunx turbo ls --filter=...@suaveplan/api-plugin-webhook` returns empty).
## Impact
### Affected dependents
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-webhook` returning the empty set on the genesis branch — net-new package).
### Catalog additions required
None. All deps are already pinned: `hono` (`catalog:server`), `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api` (`catalog:observability`).