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,84 @@
# Webhook receiver — parser, verifier class, receiver, hooks, error taxonomy
**Package**: `@suaveplan/webhook-client`
**Status**: PROPOSED
**Type**: feature + spec-conformance
## Why
`@suaveplan/webhook-client` (`packages/platform/webhook-client/`) currently ships only the outbound delivery surface: `WebhookClient`, `deliver`, signing helpers (`generateSignature`, `parseSignatureHeader`, `verifySignature`), and the delivery types. The legacy package shipped both delivery AND receive surfaces, and downstream consumers (admin webhooks endpoint, billing webhook ingest, vendor-event mux) need the inbound side: a parser, a verifier class abstraction, a `WebhookReceiver` orchestrator, a typed hook bus, and a structured error taxonomy with retry-hint metadata. Without these, every consumer hand-rolls the verify → parse → dispatch pipeline with inconsistent error semantics.
All five legacy modules already exist (`packages/universal/webhook-client/src/parser/parser.ts` 130 L; `verifier/verifier.ts` 127 L; `receiver/receiver.ts` 135 L; `hooks.ts` 58 L; `errors/error-codes.ts` 164 L) and pass the genesis quality bar — porting them closes the parity gap before consumers fork.
The package name is kept as `@suaveplan/webhook-client` (not renamed to `@suaveplan/webhook`) to avoid a breaking rename. The receiver lives at the subpath `@suaveplan/webhook-client/receiver/receiver` alongside the existing `core/client/webhook-client` subpath.
## What changes
### `@suaveplan/webhook-client`
- ADDED: `WebhookParser` class — `parseJsonBody(request)`, `parseFormUrlencodedBody(request)`, `parseRequest(request)` (auto-detects content-type); returns a typed `ParsedWebhookEvent<T>`. Validates input through Zod schemas; throws `WebhookClientError` with code `PARSER_INVALID_BODY` / `PARSER_UNSUPPORTED_CONTENT_TYPE` on failure.
- ADDED: `WebhookVerifier` class — wraps the existing `verifySignature` helper with a class abstraction. Pluggable `signingScheme` parameter selects vendor-specific schemes: `stripe` (HMAC-SHA256 of `<timestamp>.<body>`), `github` (HMAC-SHA256 of body, prefix `sha256=`), `slack` (HMAC-SHA256 of `v0:<timestamp>:<body>`), `generic` (default — raw HMAC-SHA256 of body). Throws `WebhookClientError` with codes `VERIFIER_BAD_SIGNATURE` / `VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE`.
- ADDED: `WebhookReceiver` class — orchestrator: verify → parse → dispatch via typed handler registry (`registerHandler<T>(eventType, handler)`); emits `webhookHooks` events at every stage; structured errors per failure mode. `handle(request)` returns `Promise<HandleResult>` carrying `{ status, parsedEvent, dispatchResult }`.
- ADDED: `webhookHooks` typed event bus — five lifecycle events: `onWebhookDelivering(handler)`, `onWebhookReceived(handler)`, `onWebhookVerified(handler)`, `onWebhookHandled(handler)`, `onWebhookFailed(handler)`. Backed by `@suaveplan/events`.
- ADDED: `WEBHOOK_CLIENT_CODES` extensions — `PARSER_INVALID_BODY`, `PARSER_UNSUPPORTED_CONTENT_TYPE`, `VERIFIER_BAD_SIGNATURE`, `VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE`, `RECEIVER_HANDLER_NOT_FOUND`, `RECEIVER_HANDLER_FAILED`. Each code SHALL carry `metadata.retryHint: 'transient' | 'permanent'` so callers can tell "transient — retry" from "bad signature — drop".
- ADDED: `ParsedWebhookEvent<T>` interface — `{ id, type, payload: T, receivedAt, signature?, headers }`.
- ADDED: `WebhookSchema<T>` Zod-builder helper — `WebhookSchema(payloadSchema)` returns a Zod object schema for `ParsedWebhookEvent<T>`.
- ADDED: `WebhookHandler<T>`, `WebhookHandlerRegistry`, `WebhookHookMap`, `SigningScheme`, `HandleResult` types.
- ADDED: subpath exports `./parser/parser`, `./verifier/verifier`, `./receiver/receiver`, `./hooks/hooks`.
- MODIFIED: root `src/index.ts` re-exports the parser, verifier, receiver, hooks, and the new error codes.
- MODIFIED: `WEBHOOK_CLIENT_CODES` block — extended in place, no breaking renames.
## Out of scope
- **Renaming the package to `@suaveplan/webhook`** — out of scope. The package is published at `@suaveplan/webhook-client` and renaming would force every consumer to update `package.json` and re-publish. Document the receiver as a sibling subpath; keep the name.
- **Replay-protection idempotency store** — out of scope; tracked separately. The receiver records the event ID via OTel attributes but does not persist it. Consumers wire `@suaveplan/idempotency` if they need replay protection.
- **Vendor-specific event-type registries** (Stripe `customer.created`, GitHub `pull_request.opened`, etc.) — out of scope. The receiver registry is generic; vendor-specific event-type taxonomies are downstream concerns.
- **Receiver as an HTTP framework adapter** (express / hono / fastify wrappers) — out of scope. `WebhookReceiver.handle(request)` accepts a `Request` (Web Fetch API); framework adapters are tiny and downstream.
## Source mapping
This change derives from `../suaveplan/enterprise/webhook-client.md` and the upstream package source.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `WebhookParser` class + JSON / form-urlencoded parsers | `packages/universal/webhook-client/src/parser/parser.ts` | L1-L130 | Verbatim port |
| `WebhookVerifier` class | `packages/universal/webhook-client/src/verifier/verifier.ts` | L1-L127 | Verbatim port; reuse existing `generateSignature` / `verifySignature` from `utils/signature.js` |
| `WebhookReceiver` class + handler registry | `packages/universal/webhook-client/src/receiver/receiver.ts` | L1-L135 | Verbatim port |
| `webhookHooks` typed event bus | `packages/universal/webhook-client/src/hooks.ts` | L1-L58 | Verbatim port; back with `@suaveplan/events` instead of upstream's bespoke `Hooks` class |
| Error code catalog with retry-hint metadata | `packages/universal/webhook-client/src/errors/error-codes.ts` | L1-L164 | Verbatim port |
| `ParsedWebhookEvent<T>`, `SigningScheme`, `WebhookHandler<T>`, `WebhookHookMap`, `HandleResult` types | `packages/universal/webhook-client/src/types/webhook-client.types.ts` | L1-L64 | Verbatim port; co-locate with existing `webhook-client.types.ts` |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `metadata.retryHint` on every new error code | Genesis-wide convention: structured error metadata so callers can branch without parsing message strings. Upstream encodes retry-hint in prose, not metadata. |
| `webhookHooks` backed by `@suaveplan/events` | AGENTS.md §0 ("If an existing implementation covers 80%+ of the requirement, port or wrap it — do not start from scratch"). Upstream uses a bespoke `Hooks` class; genesis already has `@suaveplan/events`. |
| `WebhookSchema<T>` Zod-builder helper | AGENTS.md §13 row 2 ("Zod schemas wired at every declared boundary"). Upstream uses bare TS interfaces. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta.
- `packages/platform/webhook-client/src/parser/parser/parser.test.ts` — JSON / form-urlencoded round-trip, content-type rejection, malformed-body rejection
- `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts` — every signing scheme verified; bad signature rejected; timestamp tolerance enforced
- `packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts` — full pipeline (verify → parse → dispatch); handler-not-found path; handler-failure path
- `packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts` — `webhookHooks` events fire in order; OTel spans emitted; error codes carry retry-hint metadata
- `packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts` — `webhookHooks` registration / unregistration / event dispatch order
- `packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts` — every new code carries `metadata.retryHint`; codes registered via `defineCodes`
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/webhook-client` to enumerate.
- No dependents (verified by `grep -rl "@suaveplan/webhook-client" /home/ubuntu/web/genesis/packages/ --include="package.json"` → only `packages/platform/webhook-client/package.json` itself).
Pure additive change. Existing `WebhookClient` / `deliver` / signing helpers unchanged.
## Migration impact
None — additive only. Existing inbound hand-rolls in downstream consumers (none yet in genesis) can migrate to `WebhookReceiver` at their own pace. The receiver is opt-in: callers either continue calling `verifySignature` directly (pre-existing surface) or wire the full `WebhookReceiver` pipeline.
The package name stays as `@suaveplan/webhook-client`; the receiver is documented as a sibling capability under the `./receiver/*` subpath. A renamed package (`@suaveplan/webhook`) is out of scope to avoid breaking the publish identity.

View File

@@ -0,0 +1,145 @@
<!-- 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 -->

View File

@@ -0,0 +1,121 @@
# Tasks — Webhook receiver
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## Audit Note (2026-05-02)
**Minimal progress** — DEFERRED, do not archive.
- `packages/platform/webhook-client/src/` has only the outbound surface: `core/`, `errors/`, `types/`, `utils/`. No `parser/`, `verifier/`, `receiver/`, `hooks/`, or `schemas/` directories.
- `grep -rln "WebhookParser\|WebhookVerifier\|WebhookReceiver\|webhookHooks\|ParsedWebhookEvent" packages/platform/webhook-client/src/` returns zero hits.
- `bunx turbo lint typecheck test --filter=@suaveplan/webhook-client` is GREEN — but the entire inbound surface is unimplemented.
- All 64 tasks remain: port `WebhookParser` (130 L), `WebhookVerifier` (127 L) with stripe/github/slack/generic schemes, `WebhookReceiver` (135 L) orchestrator, `webhookHooks` event bus over `@suaveplan/events`, six new error codes with `metadata.retryHint`, `WebhookSchema<T>` Zod builder, all types (`ParsedWebhookEvent<T>`, `SigningScheme`, `WebhookHandler<T>`, etc.), four subpath exports, OTel histogram + counter, sibling `.md`s.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/enterprise/webhook-client.md` end-to-end
- [ ] 1.2 Read `openspec/specs/webhook/spec.md` (canonical capability spec; this change introduces it if absent)
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.5 Fill `## Source mapping` table with upstream line ranges
- [ ] 1.6 Draft `specs/webhook/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.7 Every requirement carries a `**Verified by:**` line
- [ ] 1.8 `openspec change validate webhook-receiver` exits with no errors
## 2. Implementation — `WebhookParser`
- [ ] 2.1 Port `WebhookParser` from `packages/universal/webhook-client/src/parser/parser.ts` (L1-L130) into `packages/platform/webhook-client/src/parser/parser/parser.ts`
- [ ] 2.2 Implement `parseJsonBody(request)`, `parseFormUrlencodedBody(request)`, `parseRequest(request)` (auto-detects from `content-type` header)
- [ ] 2.3 Validate parsed payload through caller-supplied Zod schema (or default `z.unknown()`); reject with `WebhookClientError` code `PARSER_INVALID_BODY` / `PARSER_UNSUPPORTED_CONTENT_TYPE`
- [ ] 2.4 Return `ParsedWebhookEvent<T>` carrying `{ id, type, payload, receivedAt, signature?, headers }`
- [ ] 2.5 Sibling `.md` (≥200 words, basic + advanced examples)
## 3. Implementation — `WebhookVerifier`
- [ ] 3.1 Port `WebhookVerifier` from `packages/universal/webhook-client/src/verifier/verifier.ts` (L1-L127) into `packages/platform/webhook-client/src/verifier/verifier/verifier.ts`
- [ ] 3.2 Reuse existing `generateSignature` / `verifySignature` from `packages/platform/webhook-client/src/utils/signature.ts` for the `generic` scheme
- [ ] 3.3 Implement `signingScheme: 'stripe' | 'github' | 'slack' | 'generic'` — Stripe (HMAC-SHA256 of `<timestamp>.<body>`), GitHub (HMAC-SHA256 of body, prefix `sha256=`), Slack (HMAC-SHA256 of `v0:<timestamp>:<body>`), generic (raw HMAC-SHA256 of body)
- [ ] 3.4 Enforce `timestampToleranceMs` (default 5 minutes) for schemes that include a timestamp; reject with `VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE`
- [ ] 3.5 Reject bad signatures with `VERIFIER_BAD_SIGNATURE`
- [ ] 3.6 Sibling `.md`
## 4. Implementation — `WebhookReceiver`
- [ ] 4.1 Port `WebhookReceiver` from `packages/universal/webhook-client/src/receiver/receiver.ts` (L1-L135) into `packages/platform/webhook-client/src/receiver/receiver/receiver.ts`
- [ ] 4.2 Implement `registerHandler<T>(eventType, handler)`, `unregisterHandler(eventType)`, `handle(request)` orchestrator
- [ ] 4.3 Pipeline: emit `onWebhookReceived` → verify (emit `onWebhookVerified`) → parse → dispatch to handler (emit `onWebhookHandled` or `onWebhookFailed`)
- [ ] 4.4 Throw `RECEIVER_HANDLER_NOT_FOUND` when no handler is registered for the event type
- [ ] 4.5 Throw `RECEIVER_HANDLER_FAILED` when the handler itself throws; preserve `cause`
- [ ] 4.6 Return `HandleResult` carrying `{ status, parsedEvent, dispatchResult }`
- [ ] 4.7 Sibling `.md`
## 5. Implementation — `webhookHooks` event bus
- [ ] 5.1 Port `webhookHooks` from `packages/universal/webhook-client/src/hooks.ts` (L1-L58) into `packages/platform/webhook-client/src/hooks/hooks/hooks.ts`
- [ ] 5.2 Back the bus with `@suaveplan/events` (not upstream's bespoke `Hooks` class) — `webhookHooks` is an `EventBus<WebhookHookMap>`
- [ ] 5.3 Export `onWebhookDelivering`, `onWebhookReceived`, `onWebhookVerified`, `onWebhookHandled`, `onWebhookFailed` typed-handler registration helpers
- [ ] 5.4 Sibling `.md`
## 6. Error codes
- [ ] 6.1 Extend `WEBHOOK_CLIENT_CODES` in `packages/platform/webhook-client/src/errors/webhook-client-error.ts` with: `PARSER_INVALID_BODY`, `PARSER_UNSUPPORTED_CONTENT_TYPE`, `VERIFIER_BAD_SIGNATURE`, `VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE`, `RECEIVER_HANDLER_NOT_FOUND`, `RECEIVER_HANDLER_FAILED`
- [ ] 6.2 Each new code carries `metadata.retryHint: 'transient' | 'permanent'` — `transient` for handler-failure, `permanent` for bad-signature / unsupported-content-type / parser-invalid-body / handler-not-found / timestamp-out-of-tolerance
- [ ] 6.3 Update `error-codes.md` doc to document each code's `retryHint`
## 7. Types and schemas
- [ ] 7.1 Add `ParsedWebhookEvent<T>`, `SigningScheme`, `WebhookHandler<T>`, `WebhookHandlerRegistry`, `WebhookHookMap`, `HandleResult`, `WebhookReceiverOptions`, `WebhookVerifierOptions`, `WebhookParserOptions` to `packages/platform/webhook-client/src/types/webhook-client.types.ts`
- [ ] 7.2 Add `WebhookSchema<T>(payloadSchema)` Zod-builder helper to `packages/platform/webhook-client/src/schemas/webhook.schema.ts`
- [ ] 7.3 Sibling `.md` for the schema module
## 8. Wiring
- [ ] 8.1 Update `packages/platform/webhook-client/src/index.ts` to re-export the parser, verifier, receiver, hooks, schema-builder, and new types
- [ ] 8.2 Add subpath exports `./parser/parser/parser`, `./verifier/verifier/verifier`, `./receiver/receiver/receiver`, `./hooks/hooks/hooks`, `./schemas/webhook.schema` to `packages/platform/webhook-client/package.json`
- [ ] 8.3 Update root `README.md` `## Modules` section to link the new co-located `.md`s
## 9. Tests
- [ ] 9.1 `packages/platform/webhook-client/src/parser/parser/parser.test.ts` — JSON round-trip, form-urlencoded round-trip, unsupported content-type rejection, malformed-body rejection
- [ ] 9.2 `packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts` — each scheme (stripe, github, slack, generic) round-trip; tampered signature rejected; out-of-tolerance timestamp rejected
- [ ] 9.3 `packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts` — full pipeline with handler dispatch; handler-not-found path; handler-throws path
- [ ] 9.4 `packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts` — `webhookHooks` events fire in order (`onWebhookReceived` before `onWebhookVerified` before `onWebhookHandled`); OTel `webhook.receiver.handle.duration_ms` histogram; error codes carry `metadata.retryHint`
- [ ] 9.5 `packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts` — `webhookHooks.on/off/emit` round-trip
- [ ] 9.6 `packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts` — every new code is registered; every new code carries `metadata.retryHint`
- [ ] 9.7 Every `it(...)` carries `{ timeout: 5000 }`
## 10. Documentation
- [ ] 10.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 10.2 README links the new `.md`s under `## Modules`
- [ ] 10.3 Update `error-codes.md` to enumerate each new code with its `retryHint` and a one-line description
## 11. Pre-archive verification (the 5-gate triple)
- [ ] 11.1 `bun run check` — Biome 0/0/0 across the monorepo
- [ ] 11.2 `bunx turbo lint typecheck test --filter=@suaveplan/webhook-client` — green
- [ ] 11.3 `bunx turbo test:coverage --filter=@suaveplan/webhook-client` — 100/100/100/100
- [ ] 11.4 `bun scripts/openspec-conformance.ts @suaveplan/webhook-client` — exit 0, no `error` findings
- [ ] 11.5 `bunx turbo build --filter=@suaveplan/webhook-client` — green
## 12. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 12.1 Generics threaded; `expectTypeOf` assertions on `ParsedWebhookEvent<T>` and `WebhookHandler<T>`
- [ ] 12.2 Zod schemas wired at every public boundary (`WebhookSchema`, `WebhookReceiverOptionsSchema`, `WebhookVerifierOptionsSchema`)
- [ ] 12.3 Every new error code thrown from at least one production path
- [ ] 12.4 Strict-mode tested: schemes with vs without timestamp tolerance
- [ ] 12.5 OTel histogram `webhook.receiver.handle.duration_ms` and counter `webhook.receiver.handle_total` (tagged by status) emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 12.6 Lifecycle dedup: handler registry rejects duplicate registrations for the same event type unless `replace: true` is set
- [ ] 12.7 No retroactive tick — every box reflects work done in this change
## 13. Archive
- [ ] 13.1 Fold spec delta into `openspec/specs/webhook/spec.md` (creating it if absent)
- [ ] 13.2 Move `openspec/changes/webhook-receiver/` → `openspec/archive/<yyyy-mm-dd>-webhook-receiver/`
- [ ] 13.3 Refresh `MANIFEST.md`