146 lines
9.5 KiB
Markdown
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 -->
|