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

146 lines
9.5 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: webhook (webhook-receiver)
**Package**: `@suaveplan/webhook-client`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: WebhookParser parses JSON and form-urlencoded bodies
The package SHALL expose a `WebhookParser` class with `parseJsonBody(request)`, `parseFormUrlencodedBody(request)`, and `parseRequest(request)` methods. `parseRequest` SHALL auto-detect the parser based on the request's `Content-Type` header. Each method SHALL return a typed `ParsedWebhookEvent<T>` carrying `{ id, type, payload, receivedAt, signature?, headers }`.
#### Scenario: JSON body parsed
- **WHEN** `parseRequest` is called with a `Request` whose `Content-Type` is `application/json` and body is `{"id":"evt_1","type":"customer.created","data":{}}`
- **THEN** the result is a `ParsedWebhookEvent` with `id === "evt_1"`, `type === "customer.created"`
#### Scenario: form-urlencoded body parsed
- **WHEN** `parseRequest` is called with a `Request` whose `Content-Type` is `application/x-www-form-urlencoded` and body is `id=evt_2&type=ping`
- **THEN** the result is a `ParsedWebhookEvent` with `id === "evt_2"`, `type === "ping"`
#### Scenario: unsupported content-type rejected
- **WHEN** `parseRequest` is called with `Content-Type: application/xml`
- **THEN** it throws `WebhookClientError` with code `WEBHOOK_CLIENT.PARSER_UNSUPPORTED_CONTENT_TYPE` and `metadata.retryHint === "permanent"`
#### Scenario: malformed JSON rejected
- **WHEN** `parseJsonBody` is called with body `not json`
- **THEN** it throws `WebhookClientError` with code `WEBHOOK_CLIENT.PARSER_INVALID_BODY` and `metadata.retryHint === "permanent"`
**Verified by:** `packages/platform/webhook-client/src/parser/parser/parser.test.ts::"parses JSON body"` AND `packages/platform/webhook-client/src/parser/parser/parser.test.ts::"parses form-urlencoded body"` AND `packages/platform/webhook-client/src/parser/parser/parser.test.ts::"rejects unsupported content-type as PARSER_UNSUPPORTED_CONTENT_TYPE"` AND `packages/platform/webhook-client/src/parser/parser/parser.test.ts::"rejects malformed JSON as PARSER_INVALID_BODY"`
---
### Requirement: WebhookVerifier supports vendor signing schemes
The package SHALL expose a `WebhookVerifier` class with a `signingScheme` parameter accepting `'stripe' | 'github' | 'slack' | 'generic'`. Each scheme SHALL match its vendor's signing convention: Stripe (HMAC-SHA256 of `<timestamp>.<body>`), GitHub (HMAC-SHA256 of body, header prefix `sha256=`), Slack (HMAC-SHA256 of `v0:<timestamp>:<body>`), generic (raw HMAC-SHA256 of body).
#### Scenario: Stripe scheme verifies signed body
- **WHEN** a body and Stripe-format signature header `t=<ts>,v1=<sig>` are presented and `signingScheme: 'stripe'`
- **THEN** `verify(request, secret)` resolves successfully
#### Scenario: GitHub scheme honours `sha256=` prefix
- **WHEN** the header is `X-Hub-Signature-256: sha256=<sig>` and `signingScheme: 'github'`
- **THEN** the verifier strips the prefix, computes HMAC-SHA256 of the body, and accepts the match
#### Scenario: tampered body rejected
- **WHEN** the body is mutated after signing
- **THEN** the verifier throws `WebhookClientError` with code `WEBHOOK_CLIENT.VERIFIER_BAD_SIGNATURE` and `metadata.retryHint === "permanent"`
#### Scenario: timestamp older than tolerance rejected
- **WHEN** Stripe's `t=` value is older than `timestampToleranceMs` (default 5 min) relative to the injected `Clock`
- **THEN** the verifier throws `WebhookClientError` with code `WEBHOOK_CLIENT.VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE` and `metadata.retryHint === "permanent"`
**Verified by:** `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"verifies Stripe-signed body"` AND `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"strips github sha256= prefix"` AND `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"rejects tampered body as VERIFIER_BAD_SIGNATURE"` AND `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"rejects expired timestamp as VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE"`
---
### Requirement: WebhookReceiver orchestrates verify → parse → dispatch
The package SHALL expose a `WebhookReceiver` class with `registerHandler<T>(eventType, handler)`, `unregisterHandler(eventType)`, and `handle(request)` methods. The `handle` orchestrator SHALL run the pipeline `verify → parse → dispatch` and return a `HandleResult` carrying `{ status, parsedEvent, dispatchResult }`. `webhookHooks` events SHALL fire at every stage in the documented order: `onWebhookReceived` → `onWebhookVerified` → `onWebhookHandled` (or `onWebhookFailed`).
#### Scenario: full pipeline dispatches to registered handler
- **WHEN** a handler is registered for `customer.created` and `handle(request)` is called with a valid Stripe request
- **THEN** the handler is invoked exactly once with the parsed event AND the result is `{ status: "handled", parsedEvent, dispatchResult }`
#### Scenario: handler-not-found surfaces as RECEIVER_HANDLER_NOT_FOUND
- **WHEN** no handler is registered for the event type
- **THEN** `handle` throws `WebhookClientError` with code `WEBHOOK_CLIENT.RECEIVER_HANDLER_NOT_FOUND` and `metadata.retryHint === "permanent"`
#### Scenario: handler-throws surfaces as RECEIVER_HANDLER_FAILED
- **WHEN** the registered handler itself throws
- **THEN** `handle` throws `WebhookClientError` with code `WEBHOOK_CLIENT.RECEIVER_HANDLER_FAILED`, `metadata.retryHint === "transient"`, AND `cause` carries the original error
#### Scenario: hooks fire in order
- **WHEN** `handle` runs successfully end-to-end
- **THEN** the recorded fire order is `["onWebhookReceived", "onWebhookVerified", "onWebhookHandled"]`
#### Scenario: failed pipeline emits onWebhookFailed instead of onWebhookHandled
- **WHEN** verification fails
- **THEN** the recorded fire order is `["onWebhookReceived", "onWebhookFailed"]` (no `onWebhookVerified`, no `onWebhookHandled`)
**Verified by:** `packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"dispatches to registered handler"` AND `packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"throws RECEIVER_HANDLER_NOT_FOUND when no handler registered"` AND `packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"throws RECEIVER_HANDLER_FAILED when handler throws"` AND `packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts::"hooks fire in success order"` AND `packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts::"emits onWebhookFailed when verification fails"`
---
### Requirement: webhookHooks typed event bus
The package SHALL expose a `webhookHooks` event bus typed by `WebhookHookMap`. Five lifecycle events SHALL be supported: `onWebhookDelivering`, `onWebhookReceived`, `onWebhookVerified`, `onWebhookHandled`, `onWebhookFailed`. The bus SHALL be backed by `@suaveplan/events`. Listener registration SHALL return an unsubscribe function.
#### Scenario: typed listener receives typed payload
- **WHEN** `onWebhookReceived(handler)` is registered and a handler signature claims `{ event: ParsedWebhookEvent<{ id: string }> }`
- **THEN** TypeScript infers the payload type without `any` (verified via `expectTypeOf`)
#### Scenario: unsubscribe stops further deliveries
- **WHEN** a listener is registered, then its returned unsubscribe is called
- **THEN** subsequent `webhookHooks.emit("onWebhookReceived", ...)` calls do not invoke the listener
#### Scenario: onWebhookDelivering fires from outbound delivery path
- **WHEN** the existing `WebhookClient.deliver` (outbound) is invoked
- **THEN** `onWebhookDelivering` is emitted with the outbound `WebhookPayload` (covers the legacy delivery path through the same hook bus)
**Verified by:** `packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"typed listener receives typed payload"` AND `packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"unsubscribe stops deliveries"` AND `packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"onWebhookDelivering fires from outbound deliver"`
---
### Requirement: Error codes carry retry-hint metadata
The package SHALL register six new error codes via `defineCodes`: `PARSER_INVALID_BODY`, `PARSER_UNSUPPORTED_CONTENT_TYPE`, `VERIFIER_BAD_SIGNATURE`, `VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE`, `RECEIVER_HANDLER_NOT_FOUND`, `RECEIVER_HANDLER_FAILED`. Each SHALL carry `metadata.retryHint: 'transient' | 'permanent'` so callers can branch without parsing message strings.
#### Scenario: every new code declares retryHint
- **WHEN** `WEBHOOK_CLIENT_CODES` is loaded
- **THEN** every new code's metadata object satisfies `Object.hasOwn(meta, "retryHint")` AND the value is `"transient"` or `"permanent"`
#### Scenario: handler-failure flagged transient
- **WHEN** `RECEIVER_HANDLER_FAILED` is emitted
- **THEN** `error.metadata.retryHint === "transient"`
#### Scenario: bad-signature flagged permanent
- **WHEN** `VERIFIER_BAD_SIGNATURE` is emitted
- **THEN** `error.metadata.retryHint === "permanent"`
**Verified by:** `packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"every new code declares retryHint"` AND `packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"handler-failure flagged transient"` AND `packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"bad-signature flagged permanent"`
<!-- markdownlint-enable MD024 -->