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,144 @@
# 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`).

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**: PROPOSED
> 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 -->

View File

@@ -0,0 +1,74 @@
# Tasks — add-api-plugin-webhook
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-webhook.md` end-to-end
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.4 Fill `## Source mapping` — every requirement cites enterprise audit line + justified additions
- [x] 1.5 Draft `specs/api-plugin-webhook/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.6 Every requirement carries a `**Verified by:**` line at authoring time
## 2. Implementation
- [x] 2.1 `src/error-codes/error-codes.{ts,test.ts,md,index.ts}` — 10 `WEBHOOK_*` codes + `WebhookError`
- [x] 2.2 `src/types/types.{ts,md,index.ts}` — `WebhookEndpoint`, `CreateEndpointInput`, `UpdateEndpointInput`, `WebhookPluginOptions`, `WebhookPluginConfig`
- [x] 2.3 `src/schemas/endpoint.{ts,test.ts,md,index.ts}` — `WebhookEndpointSchema`, `CreateEndpointInputSchema`, `UpdateEndpointInputSchema`
- [x] 2.4 `src/schemas/options.{ts,test.ts,md,index.ts}` — `WebhookPluginOptionsSchema`
- [x] 2.5 `src/schemas/routes/*.{ts,test.ts,md}` + `index.ts` — per-route schemas (`list`, `create`, `read`, `update`, `delete`, `test`)
- [x] 2.6 `src/otel/instruments.{ts,test.ts,md,index.ts}` — counters + histograms, reset for tests
- [x] 2.7 `src/store/endpoint-store.{ts,md,index.ts}` — `EndpointStore` interface
- [x] 2.8 `src/store/memory.{ts,test.ts,md}` — `MemoryEndpointStore`
- [x] 2.9 `src/dead-letter/dead-letter.{ts,md,index.ts}` — `DeadLetterSink` interface + `DeadLetterEntry`
- [x] 2.10 `src/dead-letter/memory.{ts,test.ts,md}` — `MemoryDeadLetterSink`
- [x] 2.11 `src/services/webhook-service.{ts,test.ts,md,index.ts}` — `WebhookService` impl wrapping `@suaveplan/webhook-client`
- [x] 2.12 `src/services/registry-augmentation.ts` — `ServiceRegistry` augmentation
- [x] 2.13 `src/routes/build-routes.{ts,test.ts,md,index.ts}` — `buildWebhookRoutes(service, opts)` returning `PluginRouteEntry[]`
- [x] 2.14 `src/plugin/plugin.{ts,test.ts,md,index.ts}` — `createWebhookPlugin(options)` factory + 5-step `onInit` lifecycle
- [x] 2.15 `src/api-plugin-webhook.spec-conformance.test.ts` — every `WEBHOOK_*` code thrown from a production path; `expectTypeOf` on `WebhookService`, registry augmentation
- [x] 2.16 `src/index.ts` — explicit named re-exports only
## 3. Tests
- [x] 3.1 Co-located `*.test.ts` per spec scenario via `@suaveplan/testing/runner`
- [x] 3.2 `tests/integration/delivery.test.ts` — real Hono receiver on random port: register → send → receive; assert HMAC sig + canonical headers + payload
- [x] 3.3 `tests/integration/retry.test.ts` — receiver returns 500 twice then 200; delivery succeeds, `attempts.length === 3`
- [x] 3.4 `tests/integration/dead-letter.test.ts` — receiver always returns 500; after `maxAttempts` send rejects with `DELIVERY_EXHAUSTED` and dead-letter sink contains the entry
- [x] 3.5 `tests/integration/full-boot.test.ts` — real `PluginRegistry` + real Hono app + real receiver; CRUD via routes then send via service
- [x] 3.6 `tests/dep-graph.test.ts` — pins 14 subpath exports + sibling `.md` per impl file
- [x] 3.7 All `it(...)` carry `{ timeout: 5000 }` (30000 for integration)
- [x] 3.8 Zero `vi.mock`, zero direct `from "vitest" | "bun:test"` imports
## 4. Documentation
- [x] 4.1 Sibling `.md` (≥200 words, basic + advanced examples, API reference) per impl file
- [x] 4.2 `README.md` — quick-start: `defineApp().use(createWebhookPlugin({ ... }))`; CRUD via routes; service-side `send()` recipes; `EndpointStore` / `DeadLetterSink` extension recipes; OTel + idempotency notes
## 5. Pre-archive verification (the 5 gates)
- [x] 5.1 `cd /home/paul/web/genesis && bun run check` — Biome 0/0/0 across the monorepo
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-webhook --force`
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-webhook` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-webhook` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-webhook --force`
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts`
## 6. Pre-archive 9-row checklist
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on `WebhookService` / `EndpointStore` / `ContextVariableMap`
- [x] 6.2 Zod schemas wired at every route + factory boundary
- [x] 6.3 Every `WEBHOOK_*` error code thrown by at least one production path (asserted by `api-plugin-webhook.spec-conformance.test.ts`)
- [x] 6.4 Strict-mode behaviors tested (invalid input rejected; ZodError surfaced as `cause`)
- [x] 6.5 OTel counters + histograms emitted from production paths and asserted via real in-memory `MeterProvider`
- [x] 6.6 Per-delivery in-flight dedup tested (concurrent `send()` with same key resolves to one delivery)
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
- [x] 6.8 No `export *` in any feature barrel
- [x] 6.9 No direct `vitest` / `bun:test` imports in `src/` or `tests/`
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-webhook/spec.md`
- [x] 7.2 Move `openspec/changes/add-api-plugin-webhook/` → `openspec/archive/2026-05-18-add-api-plugin-webhook/`
- [x] 7.3 DO NOT touch `MANIFEST.md` / `STATUS.md` (per orchestrator instruction)