14 KiB
Capability Spec Delta: api-plugin-webhook (add-api-plugin-webhook)
Package: @suaveplan/api-plugin-webhook
Status: ACCEPTED
Every requirement below carries a
**Verified by:**line at authoring time.
ADDED Requirements
Requirement: createWebhookPlugin returns a Zod-validated PluginDescriptor
The package SHALL export createWebhookPlugin(options): PluginDescriptor<WebhookPluginConfig> that validates options against WebhookPluginOptionsSchema (Zod) at the factory boundary. Invalid input SHALL raise WebhookError(WEBHOOK_INVALID_INPUT) carrying the ZodError as cause. The returned descriptor SHALL declare name: "webhook", the resolved defaults, and onInit / onClose lifecycle hooks.
Scenario: factory produces a valid descriptor
- WHEN
createWebhookPlugin({})is called with no overrides - THEN the returned descriptor satisfies
PluginDescriptor<WebhookPluginConfig>withname === "webhook",version === "0.1.0", and anonInithook
Scenario: invalid options raise WEBHOOK_INVALID_INPUT
- WHEN
createWebhookPlugin({ maxAttempts: -1 } as never)is called - THEN
WebhookError(WEBHOOK_INVALID_INPUT)is thrown withcause instanceof ZodError
Verified by: src/plugin/plugin.test.ts::"createWebhookPlugin returns valid PluginDescriptor" AND src/plugin/plugin.test.ts::"invalid options raise WEBHOOK_INVALID_INPUT"
Requirement: onInit registers webhookService in ServiceRegistry
The descriptor's onInit(ctx) hook SHALL resolve app from ctx's ServiceRegistry. If app is absent it SHALL throw WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE) listing ["app"] in context.missing. When app is present, onInit SHALL construct the WebhookService (wiring the configured or default EndpointStore, DeadLetterSink, idempotencyGuard, fetch, clock, logger), register it under the webhookService slot via ctx.registerService("webhookService", service), and mount the CRUD + test routes via definePluginRoutes (or directly on the Hono app if openAPIRegistry is absent).
Scenario: webhookService registered after onInit completes
- WHEN the plugin's
onInitis invoked with aServiceRegistrycontainingapp - THEN
ctx.getService("webhookService")returns aWebhookServiceinstance
Scenario: missing app raises WEBHOOK_SERVICE_NOT_AVAILABLE
- WHEN the plugin's
onInitis invoked withoutappin theServiceRegistry - THEN
WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)is thrown withcontext.missingcontaining"app"
Verified by: src/plugin/plugin.test.ts::"onInit registers webhookService" AND src/plugin/plugin.test.ts::"onInit raises when app service is missing"
Requirement: onInit mounts CRUD + test routes under routePrefix
onInit SHALL mount six routes under options.routePrefix (default /webhooks) on the resolved Hono app, gated by requireAuth(): GET /webhooks, POST /webhooks, GET /webhooks/:id, PATCH /webhooks/:id, DELETE /webhooks/:id, POST /webhooks/:id/test. When openAPIRegistry is registered, the routes SHALL also be registered there via definePluginRoutes so the unified /openapi.json exposes them with Zod-typed request/response shapes. If another plugin already owns routes under routePrefix, onInit SHALL throw WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION).
Scenario: routes mounted on Hono app
- WHEN the plugin's
onInitcompletes - THEN the Hono app responds (with a 401 from
requireAuth) toPOST /webhooksrather than 404
Scenario: routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION
- WHEN the
OpenAPIRegistryalready contains a route at the configured prefix andonInitruns - THEN
WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)is thrown withcontext.prefixset to the offending value
Verified by: src/plugin/plugin.test.ts::"onInit mounts CRUD routes" AND src/plugin/plugin.test.ts::"routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION"
Requirement: WebhookService.send delivers a signed payload via webhook-client
WebhookService.send(endpointId, event, data) SHALL look up the endpoint via EndpointStore.get, raise WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND) if absent, raise WebhookError(WEBHOOK_INACTIVE_ENDPOINT) if endpoint.active === false, and otherwise build a WebhookPayload ({ id, event, data, timestamp }) and deliver it via @suaveplan/webhook-client's deliver() using the endpoint's secret, algorithm, merged headers, and the configured fetch. The outbound request SHALL carry Content-Type: application/json, X-Webhook-Id, X-Webhook-Event, X-Webhook-Timestamp, and the HMAC signature header (X-Hub-Signature-256 or -512). The successful DeliveryResult SHALL be returned to the caller.
Scenario: successful single-attempt delivery
- WHEN an endpoint with secret
"shared"is registered and the receiver returns 200 OK - THEN
send()resolves withresult.status === "delivered",result.attempts.length === 1, AND the receiver observedX-Webhook-Id,X-Webhook-Event,X-Webhook-Timestamp, and a validX-Hub-Signature-256: sha256=<hex>header
Scenario: missing endpoint raises WEBHOOK_ENDPOINT_NOT_FOUND
- WHEN
send("missing", "evt", {})is called against an empty store - THEN
WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)is thrown withcontext.endpointId === "missing"
Scenario: inactive endpoint raises WEBHOOK_INACTIVE_ENDPOINT
- WHEN the resolved endpoint has
active === false - THEN
WebhookError(WEBHOOK_INACTIVE_ENDPOINT)is thrown without contacting the receiver
Verified by: tests/integration/delivery.test.ts::"sends a signed payload with canonical headers" AND src/services/webhook-service.test.ts::"send raises WEBHOOK_ENDPOINT_NOT_FOUND when endpoint absent" AND src/services/webhook-service.test.ts::"send raises WEBHOOK_INACTIVE_ENDPOINT when endpoint inactive"
Requirement: Retry queue with exponential backoff and jitter
When the receiver returns a retryable response (HTTP 429 or 5xx, or a network error / timeout), WebhookService.send SHALL retry up to maxAttempts - 1 additional times with exponential backoff: delay = initial * 2^(attempt-1), capped at maxRetryDelayMs, with full jitter applied. 4xx responses other than 429 SHALL terminate immediately (no retry).
Scenario: receiver returns 500 twice then 200
- WHEN the receiver returns HTTP 500 on attempts 1 and 2 and HTTP 200 on attempt 3
- THEN
send()resolves withresult.status === "delivered",result.attempts.length === 3, ANDresult.finalStatusCode === 200
Scenario: 4xx terminates without retry
- WHEN the receiver returns HTTP 422 on attempt 1
- THEN
send()rejects withWebhookError(WEBHOOK_DELIVERY_EXHAUSTED)AND only one attempt was made (no retries)
Verified by: tests/integration/retry.test.ts::"retries 5xx then succeeds" AND tests/integration/retry.test.ts::"4xx terminates without retry"
Requirement: Dead-letter sink records exhausted deliveries
When WebhookService.send exhausts all maxAttempts retries, the service SHALL record a DeadLetterEntry ({ id, endpointId, tenantId, event, payload, attempts, firstAttemptAt, finalAttemptAt, reason }) via the configured DeadLetterSink.record() and SHALL raise WebhookError(WEBHOOK_DELIVERY_EXHAUSTED) to the caller. If the sink itself throws, the service SHALL raise WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED) with the sink error as cause while preserving the original delivery failure on the chain.
Scenario: 5x 500 lands in the dead-letter sink
- WHEN the receiver returns HTTP 500 on all five attempts (
maxAttempts: 5) - THEN
send()rejects withWebhookError(WEBHOOK_DELIVERY_EXHAUSTED)AND the configuredDeadLetterSink.list({ endpointId })contains a single entry withattempts.length === 5
Scenario: sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED
- WHEN the configured sink's
record()rejects after a delivery is exhausted - THEN
WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED)is thrown withcauseset to the sink error
Verified by: tests/integration/dead-letter.test.ts::"5x 500 lands in dead letter" AND src/services/webhook-service.test.ts::"sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED"
Requirement: Per-delivery in-flight dedup
WebhookService.send SHALL dedup concurrent calls that share the same delivery key (${endpointId}:${payload.id}). When an idempotencyGuard is supplied, the call SHALL be routed through guard.execute(key, () => delivery) so cached results are returned for repeat calls within the dedup window. When no guard is supplied, the service SHALL maintain an in-process Map<string, Promise<DeliveryResult>> so concurrent calls with the same key resolve to the same delivery and the receiver only sees one request.
Scenario: concurrent sends with the same id resolve to one delivery
- WHEN two concurrent
send()calls supply the same explicit payload id and the receiver counts requests - THEN both promises resolve with
status === "delivered"AND the receiver observed exactly one POST
Verified by: src/services/webhook-service.test.ts::"in-flight dedup collapses concurrent sends with same id"
Requirement: Subscription CRUD routes are tenant-scoped and auth-gated
The six CRUD routes mounted under routePrefix SHALL be gated by requireAuth() so anonymous requests receive HTTP 401. When the request principal carries a tenantId, the routes SHALL only see endpoints whose tenantId matches the principal's; cross-tenant reads/writes SHALL be rejected with WebhookError(WEBHOOK_TENANT_MISMATCH). When the principal carries no tenantId (admin / global scope), the routes SHALL operate on every endpoint regardless of tenant.
Scenario: anonymous request hits 401
- WHEN an unauthenticated request hits
GET /webhooks - THEN the response status is 401 AND the body carries
code === "WEBHOOK_UNAUTHENTICATED" | "AUTH_UNAUTHENTICATED"(whichever auth gate is wired)
Scenario: cross-tenant read returns 404
- WHEN an authenticated request from tenant
"t-a"hitsGET /webhooks/:idfor an endpoint owned by tenant"t-b" - THEN the response status is 404 (the endpoint is hidden from the calling tenant)
Verified by: src/routes/build-routes.test.ts::"anonymous request returns 401" AND src/routes/build-routes.test.ts::"cross-tenant read returns 404"
Requirement: POST /webhooks/:id/test fires a synthetic ping
POST /webhooks/:id/test SHALL look up the endpoint, raise WEBHOOK_ENDPOINT_NOT_FOUND if absent or tenant-mismatched, and otherwise call service.test(endpointId) which delivers a synthetic event webhook.ping with payload { requestedAt: ISO8601 }. The response body SHALL be the DeliveryResult (status, finalStatusCode, attempts).
Scenario: ping is delivered and reported
- WHEN
POST /webhooks/:id/testis called against a healthy endpoint backed by a 200-OK receiver - THEN the response body has
status === "delivered",finalStatusCode === 200, AND the receiver observedevent === "webhook.ping"
Verified by: tests/integration/full-boot.test.ts::"POST /webhooks/:id/test fires a synthetic webhook.ping"
Requirement: OTel instruments emitted from every delivery path
The package SHALL register four OTel instruments via getWebhookInstruments(): counters webhook_plugin.deliveries_total{event,status,endpoint_id} and webhook_plugin.dead_letters_total{event,endpoint_id}; histograms webhook_plugin.delivery_attempts{event,status} and webhook_plugin.send_duration_ms{status}. Every send() call SHALL emit both counters / histograms regardless of outcome, with the status attribute taking one of delivered | exhausted | not_found | inactive | invalid.
Scenario: successful delivery records the counter
- WHEN a single successful delivery completes against a real in-memory
MeterProvider - THEN the collected metrics contain
webhook_plugin.deliveries_totalwithstatus: "delivered"value>= 1ANDwebhook_plugin.send_duration_msrecorded at least one observation
Scenario: exhausted delivery records the dead-letter counter
- WHEN a delivery exhausts all retries
- THEN the collected metrics contain
webhook_plugin.dead_letters_totalwith value>= 1
Verified by: src/otel/instruments.test.ts::"deliveries_total increments on success" AND src/otel/instruments.test.ts::"dead_letters_total increments on exhaustion"
Requirement: Every WEBHOOK_* error code is reachable from a production path
The package SHALL define exactly ten error codes via defineCodes("WEBHOOK", [...]): INVALID_INPUT, ENDPOINT_NOT_FOUND, TENANT_MISMATCH, EVENT_NOT_SUBSCRIBED, DELIVERY_EXHAUSTED, DEAD_LETTER_WRITE_FAILED, STORE_ERROR, SERVICE_NOT_AVAILABLE, ROUTE_PREFIX_COLLISION, INACTIVE_ENDPOINT. Every code SHALL be thrown from at least one production code path in src/ (asserted by the spec-conformance test).
Scenario: spec-conformance proves every code is thrown
- WHEN the
api-plugin-webhook.spec-conformance.test.tssuite runs - THEN the observed set of thrown
WEBHOOK_*codes equals the declared set (10 of 10)
Verified by: src/api-plugin-webhook.spec-conformance.test.ts::"every WEBHOOK_* code is thrown from a production path"
Requirement: Subpath exports are tree-shakeable
The package SHALL declare 14 subpath exports in package.json (., ./plugin/plugin, ./services/webhook-service, ./store/endpoint-store, ./store/memory, ./dead-letter/dead-letter, ./dead-letter/memory, ./routes/routes, ./schemas/options, ./schemas/endpoint, ./schemas/routes, ./error-codes/error-codes, ./otel/instruments, ./types/types). The package SHALL declare "sideEffects": false.
Scenario: every subpath resolves to a built file
- WHEN the dep-graph test enumerates
package.jsonexports - THEN every subpath maps to a
dist/<path>.jsentry and a sibling.d.ts
Verified by: tests/dep-graph.test.ts::"declares 14 tree-shakeable subpath exports"