Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin-webhook/proposal.md
2026-05-27 23:29:53 +01:00

14 KiB

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).