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:
- Registers a typed
webhookServicein the hostServiceRegistryexposingsend(endpoint, event)/register(endpoint)/unregister(id)/listEndpoints(tenantId?). - Mounts a small CRUD surface for subscription management (
GET/POST /webhooks,GET/PATCH/DELETE /webhooks/:id, plusPOST /webhooks/:id/test) gated byrequireAuth()and tenant-scoped if the request carries a tenant. - Drives delivery through
@suaveplan/webhook-client(HMAC-SHA256 by default, exponential backoff with jitter, 5 attempt cap, dead-letter sink) and uses@suaveplan/idempotencyfor per-delivery dedup so callers can safely retrysend(). - Persists endpoints behind an
EndpointStoreinterface 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 aPluginDescriptorfrom@suaveplan/api-plugin. Invalid input raisesWebhookError(WEBHOOK_INVALID_INPUT)carrying theZodErrorascause.WebhookPluginOptions(validated viaWebhookPluginOptionsSchema):{ 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)
EndpointStoreinterface: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-memoryMap-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/UpdateEndpointInputSchemaZod schemas — used at the route boundary and by the store conformance harness.
Dead-letter sink
DeadLetterSinkinterface:record(entry: DeadLetterEntry): Promise<void>/list({ endpointId?, limit? }): Promise<readonly DeadLetterEntry[]>.MemoryDeadLetterSink— default in-memory implementation. Records every delivery that exhaustsmaxAttemptsretries with the fullDeliveryResult.attemptshistory, the originating event, and thefirstAttemptAt/finalAttemptAttimestamps.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)
WebhookServiceinterface:send(endpointId, event, data): Promise<DeliveryResult>/sendAll(event, data, { tenantId? }): Promise<readonly DeliveryResult[]>(fans out to every active endpoint subscribed toevent) /register(input): Promise<WebhookEndpoint>/unregister(id): Promise<void>/listEndpoints(filter): Promise<readonly WebhookEndpoint[]>/test(endpointId): Promise<DeliveryResult>(sends a syntheticwebhook.pingpayload).- 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 rotatetenantId. Secret rotation is opt-in via the body.DELETE /webhooks/:id— hard delete. Idempotent on miss.POST /webhooks/:id/test— fire a syntheticwebhook.pingevent at the endpoint and return theDeliveryResult. 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
generateSignaturefrom@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-suppliedendpoint.headersare merged with these (caller wins onContent-Typeonly, never on the four canonical headers). - Retry — exponential backoff with full jitter, starting at
initialRetryDelayMs, doubling each attempt, capped atmaxRetryDelayMs, exhausted aftermaxAttempts(default 5). Receivers returning 4xx (except 429) terminate immediately; 429 and 5xx retry; network errors and timeouts retry. - Dead-letter — once
maxAttemptsis exhausted, the package records aDeadLetterEntryvia the configured sink and surfaces aWebhookError(WEBHOOK_DELIVERY_EXHAUSTED)to the caller ofsend(). 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 anidempotencyGuardis supplied, the call routes throughguard.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-flightMap.
Errors + observability
WebhookError extends SuaveplanError<WebhookErrorCode>viadefineCodes("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}; histogramwebhook_plugin.delivery_attempts{event,status}; histogramwebhook_plugin.send_duration_ms{status}. Registered via the package's owngetWebhookInstruments()factory pattern (mirrorsapi-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-surrealplugin — 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
DeadLetterSinkinterface; 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 readsc.get("user")?.tenantIdif 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-pluginPluginContext+ServiceRegistryshape (commit 0f338653). - Genesis
@suaveplan/api-openapidefinePluginRoutes+RouteBuilder(commit 99a213ed). - Genesis
@suaveplan/webhook-client(HMAC signing, retry, attempt tracking). - Genesis
@suaveplan/api-plugin-auth(canonicaldefinePluginconsumer shape — service registration, route mounting, ServiceRegistry augmentation, OTel pattern, error-code shape). - Genesis
@suaveplan/idempotencyDistributedIdempotencyGuard.
| 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— realPluginRegistry+ real Hono app; full register → send → receive → assert round-trip.tests/dep-graph.test.ts— pins dependency invariants (14 subpaths, sibling.mdper 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).