Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->