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

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> with name === "webhook", version === "0.1.0", and an onInit hook

Scenario: invalid options raise WEBHOOK_INVALID_INPUT

  • WHEN createWebhookPlugin({ maxAttempts: -1 } as never) is called
  • THEN WebhookError(WEBHOOK_INVALID_INPUT) is thrown with cause 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 onInit is invoked with a ServiceRegistry containing app
  • THEN ctx.getService("webhookService") returns a WebhookService instance

Scenario: missing app raises WEBHOOK_SERVICE_NOT_AVAILABLE

  • WHEN the plugin's onInit is invoked without app in the ServiceRegistry
  • THEN WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE) is thrown with context.missing containing "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 onInit completes
  • THEN the Hono app responds (with a 401 from requireAuth) to POST /webhooks rather than 404

Scenario: routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION

  • WHEN the OpenAPIRegistry already contains a route at the configured prefix and onInit runs
  • THEN WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION) is thrown with context.prefix set 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 with result.status === "delivered", result.attempts.length === 1, AND the receiver observed X-Webhook-Id, X-Webhook-Event, X-Webhook-Timestamp, and a valid X-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 with context.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 with result.status === "delivered", result.attempts.length === 3, AND result.finalStatusCode === 200

Scenario: 4xx terminates without retry

  • WHEN the receiver returns HTTP 422 on attempt 1
  • THEN send() rejects with WebhookError(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 with WebhookError(WEBHOOK_DELIVERY_EXHAUSTED) AND the configured DeadLetterSink.list({ endpointId }) contains a single entry with attempts.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 with cause set 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" hits GET /webhooks/:id for 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/test is called against a healthy endpoint backed by a 200-OK receiver
  • THEN the response body has status === "delivered", finalStatusCode === 200, AND the receiver observed event === "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_total with status: "delivered" value >= 1 AND webhook_plugin.send_duration_ms recorded 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_total with 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.ts suite 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.json exports
  • THEN every subpath maps to a dist/<path>.js entry and a sibling .d.ts

Verified by: tests/dep-graph.test.ts::"declares 14 tree-shakeable subpath exports"