Initial Commit
This commit is contained in:
144
openspec/archive/2026-05-18-add-api-plugin-webhook/proposal.md
Normal file
144
openspec/archive/2026-05-18-add-api-plugin-webhook/proposal.md
Normal 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`).
|
||||
@@ -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 -->
|
||||
74
openspec/archive/2026-05-18-add-api-plugin-webhook/tasks.md
Normal file
74
openspec/archive/2026-05-18-add-api-plugin-webhook/tasks.md
Normal 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)
|
||||
Reference in New Issue
Block a user