diff --git a/.agent/packages/webhooks.md b/.agent/packages/webhooks.md index 45baa0f1..b6f3ab7a 100644 --- a/.agent/packages/webhooks.md +++ b/.agent/packages/webhooks.md @@ -37,14 +37,16 @@ - `WebhookDispatcher` _(final)_ - `WebhookHandler` _(final)_ - `WebhookMessage` _(final)_ -- `WebhookReplayCommand` _(final)_ — implements `SignalableCommandInterface` -- `WebhookShowFailedCommand` _(final)_ — implements `SignalableCommandInterface` +- `WebhookReplayCommand` _(final)_ +- `WebhookShowFailedCommand` _(final)_ - `WebhookVerifyMiddleware` _(final)_ — implements `MiddlewareInterface` +- `WebhooksConfiguration` _(final)_ — implements `ConfigurationInterface` ## Tests as documentation - `tests/Webhooks/Cli/WebhookReplayCommandTest.php` - `tests/Webhooks/Cli/WebhookShowFailedCommandTest.php` +- `tests/Webhooks/Configuration/WebhooksConfigurationTest.php` - `tests/Webhooks/Dispatcher/RetryPolicyTest.php` - `tests/Webhooks/Dispatcher/WebhookDispatcherTest.php` - `tests/Webhooks/Dispatcher/WebhookHandlerTest.php` diff --git a/benchmarks/tokens-to-ship/fixtures/posts-webhook-in.openapi.yaml b/benchmarks/tokens-to-ship/fixtures/posts-webhook-in.openapi.yaml new file mode 100644 index 00000000..f61fd93c --- /dev/null +++ b/benchmarks/tokens-to-ship/fixtures/posts-webhook-in.openapi.yaml @@ -0,0 +1,56 @@ +openapi: 3.1.0 +info: + title: Stripe webhook receiver (inbound) + version: 1.0.0 +paths: + /webhooks/stripe: + post: + operationId: receiveStripe + summary: Receive Stripe webhook events + # Inbound: verify HMAC-SHA256 over the raw body, enforce a 5m + # timestamp window, and dedupe by event id for 24h. Field order + # mirrors OpenApiEmitter::renderWebhook so openapi:roundtrip is clean. + x-altair-webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + header: Stripe-Signature + dedupe_ttl: 24h + x-altair-domain: + class: App\Webhook\ReceiveStripe + invocation: __invoke + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, type] + properties: + id: { type: string } + type: { type: string } + responses: + '200': + description: Accepted (or idempotently absorbed on replay) + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + '400': + description: Missing or expired timestamp (replay window) + content: + application/json: + schema: + type: object + properties: + error: { type: string } + '401': + description: Signature verification failed + content: + application/json: + schema: + type: object + properties: + error: { type: string } diff --git a/benchmarks/tokens-to-ship/fixtures/posts-webhook-out.openapi.yaml b/benchmarks/tokens-to-ship/fixtures/posts-webhook-out.openapi.yaml new file mode 100644 index 00000000..12dc4f66 --- /dev/null +++ b/benchmarks/tokens-to-ship/fixtures/posts-webhook-out.openapi.yaml @@ -0,0 +1,60 @@ +openapi: 3.1.0 +info: + title: Posts API (outbound webhook) + version: 1.0.0 +paths: + /posts: + post: + operationId: createPost + summary: Create a post and emit a signed post.created webhook + # Outbound: after a successful create, sign + dispatch a + # post.created webhook with retry + dead-letter. Field order mirrors + # OpenApiEmitter::renderWebhook so openapi:roundtrip is clean (only + # non-default retry values are carried, otherwise they would be + # dropped on re-emit and the gate would flag drift). + x-altair-webhook: + direction: out + signing: hmac-sha256 + retry: + max_attempts: 8 + backoff: linear + base_delay: 60s + dead_letter: webhook.deadletter + x-altair-domain: + class: App\Post\CreatePost + invocation: __invoke + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title, body] + properties: + title: { type: string } + body: { type: string } + responses: + '201': + description: Created (a post.created webhook is dispatched) + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '422': + description: Validation failed + content: + application/json: + schema: + type: object + properties: + errors: + type: object +components: + schemas: + Post: + type: object + required: [id, title, body] + properties: + id: { type: string } + title: { type: string } + body: { type: string } diff --git a/benchmarks/tokens-to-ship/task-webhooks.md b/benchmarks/tokens-to-ship/task-webhooks.md new file mode 100644 index 00000000..01d37e0e --- /dev/null +++ b/benchmarks/tokens-to-ship/task-webhooks.md @@ -0,0 +1,125 @@ +# Frozen task variant — webhooks (inbound + outbound) + +> Companion to [`task.md`](./task.md), [`task-import.md`](./task-import.md) +> and [`task-idempotency.md`](./task-idempotency.md). Measures what it +> costs each arm to deliver a *correctly signed and idempotent inbound +> webhook receiver* and a *retried and dead-letterable outbound +> dispatcher* — the canonical realistic third-party-integration task in +> 2026. + +Inbound and outbound are **two separate fixtures and two separate +acceptance sections**. They measure different things; lumping them into +one number would average a misleading aggregate. Two rows in the +published table is the honest framing. + +## Prompt given to the agent (verbatim) + +### Inbound + +> Add a **Stripe webhook receiver** at `POST /webhooks/stripe` that: +> +> - Verifies an HMAC-SHA256 signature carried in the `Stripe-Signature` +> header against a shared secret. A tampered or absent signature is +> rejected with `401`. +> - Reads a signed timestamp and rejects requests outside a 5-minute +> window with `400` (replay protection). +> - Dedupes by event id for 24 hours: a redelivery of an already-seen +> event returns `200` without re-processing. +> +> Stop when the acceptance suite passes. + +### Outbound + +> After a successful `POST /posts`, emit a signed `post.created` +> webhook to a configured subscriber. Sign with HMAC-SHA256, retry on +> `5xx` / network failure with backoff, and dead-letter after the +> configured maximum attempts. Provide a way to re-send a +> dead-lettered delivery. +> +> Stop when the acceptance suite passes. + +## What changes between arms + +- **Arm A — Altair.** Agent adds a `webhook:` block to the relevant + spec (`direction: in` on the receiver, `direction: out` on + `POST /posts`), re-runs `bin/altair spec:scaffold`. Inbound wires + `ActionAwareWebhookVerifyMiddleware` from the generated Action's + `webhook()` accessor; outbound wires a `WebhookDispatcher` binding. + Storage adapters come from `WebhooksConfiguration` (`InMemory*` for + the test run; Redis in a production deploy); + `bin/altair webhook:show-failed` / `webhook:replay` cover the + dead-letter loop. Done. +- **Arm B — Baseline.** Agent picks (or hand-rolls) a signing + middleware, wires the timestamp window, writes the dedupe storage, + builds the outbound dispatcher with retry + DLQ, wires replay + tooling, and writes the tests. Reasonable baselines exist for the + signing part (`paragonie/halite`, hand-rolled HMAC); the + *integration cost* — wiring verification, dedupe, retry curves, + dead-letter and replay into one coherent surface — is what is being + measured. + +## Fixtures + +- [`fixtures/posts-webhook-in.openapi.yaml`](./fixtures/posts-webhook-in.openapi.yaml) + — inbound: `POST /webhooks/stripe` carrying + `x-altair-webhook: { direction: in, signing: hmac-sha256, + secret_name: stripe, header: Stripe-Signature, dedupe_ttl: 24h }`. +- [`fixtures/posts-webhook-out.openapi.yaml`](./fixtures/posts-webhook-out.openapi.yaml) + — outbound: `POST /posts` carrying + `x-altair-webhook: { direction: out, signing: hmac-sha256, + retry: { max_attempts: 8, backoff: linear, base_delay: 60s }, + dead_letter: webhook.deadletter }`. + +Both fixtures round-trip clean through `bin/altair openapi:roundtrip` +and scaffold a working project through +`bin/altair openapi:import --scaffold`. The frozen documents carry only +non-default field values, matching the v1 wire contract: `direction` +and `signing` always travel; other fields round-trip only when they +differ from their default (see +[docs/openapi/extensions.md](../../docs/openapi/extensions.md)). + +## Acceptance criteria + +### Inbound (delta on the receiver) + +- [ ] `POST /webhooks/stripe` with a valid HMAC + fresh event id → + `200`. +- [ ] Replay with the same event id → `200` with + `Webhook-Replayed: true`. +- [ ] Tampered signature → `401`. +- [ ] Timestamp outside the 5-minute window → `400`. + +### Outbound (delta on `POST /posts`) + +- [ ] Creating a post dispatches a signed POST to the configured + subscriber (verified by a test fixture endpoint), carrying + `X-Signature` / `X-Timestamp` / `X-Event-Id` / `X-Delivery-Id`. +- [ ] A failing subscriber (`5xx`) is retried with the configured + backoff and eventually dead-lettered after `max_attempts`. +- [ ] `bin/altair webhook:replay ` re-dispatches a + dead-lettered delivery. + +## Reporting + +Report this variant **separately** — and as **two rows**, inbound and +outbound. The forward task answers "given a prose spec, what does CRUD +cost?"; the import variant answers "what does translating an OpenAPI +contract cost?"; the idempotency variant answers "what does correct +idempotency cost?"; this variant answers **"what does a correct, +production-grade webhook integration cost — in each direction?"** + +| Variant | Median tokens (Arm A / Arm B) | Median wallclock | pass@1 | +|---|---|---|---| +| [task.md](./task.md) | … / … | … / … | … | +| [task-import.md](./task-import.md) | … / … | … / … | … | +| [task-idempotency.md](./task-idempotency.md) | … / … | … / … | … | +| **task-webhooks.md — inbound** *(this)* | … / … | … / … | … | +| **task-webhooks.md — outbound** *(this)* | … / … | … / … | … | + +The honest framing: "integrate with a third party's webhooks" is the +canonical realistic API task in 2026 — every payments / SaaS / commerce +platform ships both directions, and no PHP framework ships a native +primitive for either. The Univeros bet is that a spec block + built-in +signing / dedupe / dispatch + a round-trip-safe OpenAPI extension beats +hand-rolled-from-scratch and beats find-and-wire-three-packages, *and* +keeps beating them on the next change. diff --git a/bin/altair b/bin/altair index 1aefc9c1..1688b885 100755 --- a/bin/altair +++ b/bin/altair @@ -50,6 +50,7 @@ $builtIn = [ __DIR__ . '/../src/Altair/Scaffold/Cli', __DIR__ . '/../src/Altair/Suggest/Cli', __DIR__ . '/../src/Altair/Tinker/Cli', + __DIR__ . '/../src/Altair/Webhooks/Cli', ]; foreach ($builtIn as $entry) { if (is_dir($entry)) { diff --git a/docs/README.md b/docs/README.md index f74c7926..c33fb35b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ The request/response lifecycle and everything that runs inside it. - [Session](./packages/session.md) — server-side session storage with File / Mongo / PDO / Predis handlers, paired with the cookie envelope and HTTP cache limiters. - [Sanitation](./packages/sanitation.md) — sixteen input filters (Alpha, Boolean, Integer, Regex, …) that normalise raw values into safe canonical forms before validation runs. - [Validation](./packages/validation.md) — eighteen rule-based input validators (Email, IBAN, ZipCode, …) composable into rule collections and runnable through a `Validator`. +- [Idempotency](./packages/idempotency.md) — Stripe-style `Idempotency-Key` primitive: a PSR-15 middleware that hashes the body, claims the key in a pluggable store (InMemory / APCu / Redis), replays the captured response on retry, and refuses 409 on payload drift. Spec-driven via an `idempotency:` block and an `x-altair-idempotency` OpenAPI extension that round-trips. +- [Webhooks](./packages/webhooks.md) — first-class webhook primitive, both directions: inbound signature verification (HMAC-SHA256/512, Ed25519) + timestamp window + event-id dedupe as PSR-15 middleware, and an outbound signed dispatcher over Symfony Messenger with retry, dead-letter, and `bin/altair webhook:replay`. Spec-driven via a `webhook:` block and an `x-altair-webhook` OpenAPI extension that round-trips. ### Application core diff --git a/docs/packages/webhooks.md b/docs/packages/webhooks.md new file mode 100644 index 00000000..95f5c6df --- /dev/null +++ b/docs/packages/webhooks.md @@ -0,0 +1,304 @@ +# Webhooks + +> First-class webhook primitive — both directions. **Inbound:** a spec block wires a PSR-15 middleware that verifies an HMAC / Ed25519 signature, enforces a timestamp replay window, and dedupes by event id in a pluggable store. **Outbound:** a `WebhookDispatcher` signs the payload, dispatches asynchronously over Symfony Messenger, retries failed deliveries with exponential / linear backoff, dead-letters after `max_attempts`, and exposes `bin/altair webhook:replay ` to re-send. Round-trips through OpenAPI 3.1 via `x-altair-webhook` so the policy survives `spec:emit-openapi` → `openapi:import` byte-for-byte. + +**Composer:** `univeros/webhooks` +**Namespace:** `Altair\Webhooks` + +## Introduction + +PHP frameworks ship no native primitive for webhooks. Laravel has none. Symfony has none. Slim has none. Yet every API that talks to another system eventually needs them — Stripe, GitHub, Slack, Twilio, Shopify and Square all ship both inbound *and* outbound webhook contracts. Teams either roll their own (and re-invent HMAC verification, timestamp-window protection, idempotent delivery, retry curves and dead-letter semantics) or stitch together three uncoordinated community packages. + +The agent-era cost is sharper. An agent asked to integrate Stripe's webhooks has to discover the signing scheme, find a verification library, wire the timestamp check, dedupe deliveries and write replay handling — all from spec-less prose. An agent asked to *emit* webhooks for a third-party integration has to invent retry curves and dead-letter behaviour from scratch. + +This package ships the contract for both directions: + +```yaml +# inbound — verify what arrives +webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + dedupe_ttl: 24h + timestamp_window: 5m +``` + +```yaml +# outbound — sign and dispatch what you emit +webhook: + direction: out + signing: hmac-sha256 + retry: + max_attempts: 5 + backoff: exponential + dead_letter: webhook.deadletter +``` + +That YAML is the source of truth. Run `bin/altair spec:scaffold` and the scaffolder wires the right artifacts for the declared direction. No hand-rolled signing middleware, no invented retry curve. + +Four pieces make the design honest: + +1. **Multi-scheme signing.** `SignerInterface` has three operations — `name`, `sign`, `verify` — and the package ships `HmacSha256Signer`, `HmacSha512Signer` and `Ed25519Signer`. HMAC signatures are hex-encoded to match Stripe / GitHub; `verify()` is constant-time (`hash_equals` / libsodium) and tolerantly parses the Stripe `t=,v1=` header format as well as a bare hex digest. +2. **Pluggable storage.** Inbound dedupe (`InboundDeduplicatorInterface`) and outbound delivery state (`DeliveryStoreInterface`) each ship an `InMemory` adapter for tests and a `Redis` adapter for production. The inbound dedupe primitive is `SET key value NX EX ttl` — concurrent identical deliveries see exactly one handler invocation. +3. **No hand-rolled dispatcher.** `WebhookDispatcher` records a `Delivery`, dispatches a `WebhookMessage` over Symfony Messenger, and `WebhookHandler` performs the signed POST with retry + dead-letter. Delivery state (id, attempts, last response, status) is persisted so `webhook:replay` works. +4. **Round-trips through OpenAPI.** The `x-altair-webhook` extension carries the policy through OpenAPI 3.1 (see [docs/openapi/extensions.md](../openapi/extensions.md)); the round-trip drift gate (`bin/altair openapi:roundtrip`) refuses to merge a regression that drops the block. + +What this package deliberately does **not** do: + +- **Webhook subscription management.** Listing / adding / removing subscribers is a host-application concern; the framework provides the dispatch primitive, not the admin surface. +- **Cross-region replication of the dedupe / delivery store.** Adapters target single-region clusters; multi-region is the host's call. +- **WebSocket / SSE signing.** Different transport, different semantics. +- **Signature scheme negotiation.** The spec block declares a single fixed scheme; mixed-scheme support waits until a real consumer asks for it. + +## Installation + +Standalone: + +```bash +composer require univeros/webhooks +``` + +The package requires PHP 8.3+ and depends only on the PSR HTTP interfaces (`psr/http-message`, `psr/http-factory`, `psr/http-server-middleware`), `symfony/messenger` (outbound dispatch) and `univeros/configuration` + `univeros/container` for DI wiring. The Redis adapters need `ext-redis` plus a reachable Redis instance; Ed25519 signing needs `ext-sodium` (the `Ed25519Signer` throws at construction when it is absent, and `SignerRegistry::default()` simply omits it). + +Secrets resolve from the environment by default: + +```bash +# EnvSecretResolver maps secret_name 'stripe' → WEBHOOK_SECRET_STRIPE +WEBHOOK_SECRET_STRIPE=whsec_xxx +WEBHOOK_SECRET_PARTNER_X=... # secret_name 'partner-x' folds non-alphanumerics to '_' +``` + +## Quick start — inbound + +### 1. Add the block to a spec + +```yaml +endpoint: + method: POST + path: /webhooks/stripe + summary: Receive Stripe events + tags: [webhooks] +domain: + class: App\Webhook\ReceiveStripe +webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + header: Stripe-Signature + dedupe_ttl: 24h + timestamp_window: 5m +``` + +### 2. Scaffold + +```bash +bin/altair spec:scaffold api/webhooks/stripe.yaml +``` + +The generated Action exposes the policy via a static `webhook()` accessor, which the `ActionAwareWebhookVerifyMiddleware` reads per request. + +### 3. Wire the middleware (host) + +Register `WebhooksConfiguration` so the signer registry, secret resolver and dedupe store resolve, then add the auto-wiring middleware **after** `DispatcherMiddleware` (which publishes the resolved Action) and **before** `ActionMiddleware` (which invokes it): + +```php +// config/configurations.php +return [ + // ... + new \Altair\Webhooks\Configuration\WebhooksConfiguration(), +]; +``` + +```php +$middleware->add(new \Altair\Webhooks\Middleware\ActionAwareWebhookVerifyMiddleware( + signers: $container->get(\Altair\Webhooks\Signing\SignerRegistry::class), + secrets: $container->get(\Altair\Webhooks\Contracts\SecretResolverInterface::class), + deduplicator: $container->get(\Altair\Webhooks\Contracts\InboundDeduplicatorInterface::class), + responseFactory: $container->get(\Psr\Http\Message\ResponseFactoryInterface::class), + streamFactory: $container->get(\Psr\Http\Message\StreamFactoryInterface::class), +)); +``` + +Endpoints without a `webhook: { direction: in }` block are passed straight through. + +### 4. Use it + +```bash +# Valid signature, fresh event id → handler runs (201). +curl -X POST http://localhost:8080/webhooks/stripe \ + -H 'Stripe-Signature: ' \ + -H 'X-Timestamp: 1700000000' \ + -H 'X-Event-Id: evt_123' \ + -d '{"type":"payment_intent.succeeded"}' +# HTTP/1.1 201 Created + +# Same event id again → absorbed without re-processing. +# HTTP/1.1 200 OK +# Webhook-Replayed: true + +# Tampered signature → rejected. +# HTTP/1.1 401 Unauthorized +# {"error":"webhook signature verification failed"} + +# Timestamp outside the 5m window → rejected. +# HTTP/1.1 400 Bad Request +``` + +When the `X-Event-Id` header is absent the middleware synthesises a stable id from `sha256(body + timestamp)`, so dedupe still works for senders that don't supply one. + +## Quick start — outbound + +### 1. Declare it, or dispatch directly + +A `webhook: { direction: out }` block on a creating endpoint wires a `WebhookDispatcher` binding; application code emits through it: + +```php +$dispatcher = $container->get(\Altair\Webhooks\Dispatcher\WebhookDispatcher::class); + +$dispatcher->dispatch( + eventName: 'post.created', + payload: ['id' => $post->id, 'title' => $post->title], + subscriberUrl: 'https://subscriber.example/hooks', + secretName: 'partner-x', + signerName: 'hmac-sha256', // optional; defaults to hmac-sha256 +); +``` + +`dispatch()` records a `Pending` `Delivery` and puts a `WebhookMessage` on the bus. Outbound dispatch needs `Symfony\Component\Messenger\MessageBusInterface` bound (apply `MessengerConfiguration` alongside `WebhooksConfiguration`). + +### 2. Consume + +```bash +bin/altair worker # the WebhookHandler signs + POSTs each delivery +``` + +`WebhookHandler` adds these headers to the outbound POST: + +| Header | Value | +|---|---| +| `Content-Type` | `application/json` | +| `X-Signature` | signature of the payload under the chosen scheme + secret | +| `X-Timestamp` | unix timestamp at send time | +| `X-Event-Id` | the delivery id (ULID) | +| `X-Delivery-Id` | the delivery id (ULID) | + +### 3. Inspect + replay failures + +```bash +bin/altair webhook:show-failed # list dead-lettered deliveries, oldest first +bin/altair webhook:replay # re-dispatch one (accepts an unambiguous id prefix) +``` + +`webhook:replay` resets the delivery to `Pending` (attempts 0) and puts a fresh `WebhookMessage` back on the bus. + +## Signing primitives + +| Scheme | Class | Output | Notes | +|---|---|---|---| +| `hmac-sha256` | `HmacSha256Signer` | hex HMAC-SHA256 | Default. Matches Stripe / GitHub. | +| `hmac-sha512` | `HmacSha512Signer` | hex HMAC-SHA512 | Stronger digest, same wire shape. | +| `ed25519` | `Ed25519Signer` | hex detached signature | Asymmetric: `sign()` takes the hex 64-byte secret key, `verify()` the hex 32-byte public key. Needs `ext-sodium`. | + +All implement `SignerInterface`: + +```php +interface SignerInterface +{ + public function name(): string; // 'hmac-sha256', ... + public function sign(string $payload, string $secret): string; // hex + public function verify(string $payload, string $signature, string $secret): bool; // constant-time +} +``` + +`verify()` returns `false` rather than throwing on mismatch, and the HMAC signers accept either a bare hex digest or a Stripe-style `t=,v1=` header (the `v1=` component is extracted). Resolve a scheme by name through the registry: + +```php +$registry = \Altair\Webhooks\Signing\SignerRegistry::default(); // HMAC always; Ed25519 when ext-sodium is loaded +$signer = $registry->get('hmac-sha256'); +``` + +### Secret resolution + +`SecretResolverInterface::resolve(string $name): string` turns a `secret_name` into the actual secret. `EnvSecretResolver` reads `WEBHOOK_SECRET_` (configurable prefix; non-alphanumerics in the name fold to `_`) and throws `WebhookException::missingSecret()` when unset. Bind your own implementation to back secrets with a KMS / secret manager — the secret value never travels through OpenAPI, only the `secret_name` lookup key does. + +## Storage adapters + +| Concern | InMemory | Redis | +|---|---|---| +| Inbound dedupe | `InMemoryDeduplicator` (tests, single-worker dev) | `RedisDeduplicator` — atomic `SET … NX EX ttl`, key prefix `webhook:dedupe:` | +| Outbound delivery state | `InMemoryDeliveryStore` (tests) | `RedisDeliveryStore` — serialized at `webhook:delivery:`, dead-letter index as a sorted set scored by `createdAt` | + +The Redis adapters take a pre-configured `\Redis` client so connection lifecycle stays the host's responsibility. `WebhooksConfiguration` binds the InMemory adapters by default; swap to Redis by re-binding in your own Configuration: + +```php +$container->factory( + \Altair\Webhooks\Contracts\DeliveryStoreInterface::class, + static function (): \Altair\Webhooks\Storage\RedisDeliveryStore { + $redis = new \Redis(); + $redis->connect((string) (getenv('REDIS_HOST') ?: '127.0.0.1'), (int) (getenv('REDIS_PORT') ?: 6379)); + return new \Altair\Webhooks\Storage\RedisDeliveryStore($redis); + }, +); +``` + +## Behaviour matrix — inbound + +`WebhookVerifyMiddleware` handles every meaningful state in one place. Defaults: `dedupe_ttl` 1h, `timestamp_window` 5m. + +| Situation | Response | +|---|---| +| Signature header absent | `401 Unauthorized` (opaque `{error}` envelope) | +| Signature mismatch / secret missing | `401 Unauthorized` (opaque — never leak which check failed) | +| Timestamp header absent (`requireTimestamp=true`) | `400 Bad Request` | +| Timestamp non-numeric | `400 Bad Request` | +| Timestamp outside the window (past or future) | `400 Bad Request` | +| Event id already claimed within TTL | `200 OK`, empty body, `Webhook-Replayed: true` | +| Fresh event, handler succeeds | Handler's response (e.g. `201`) | +| Fresh event, handler throws | Claim released; exception re-thrown so retry is re-processed | +| Fresh event, handler returns `5xx` | `5xx`; claim released so the sender's retry is re-processed | + +The request body is read for verification and then re-streamed from position 0 so the downstream handler sees the full payload. Dedupe is claim-once: the first caller wins, later identical deliveries within the TTL are absorbed with `200 OK`. + +## Behaviour matrix — outbound + +`WebhookHandler` drives delivery state through the `RetryPolicy` (defaults: `max_attempts` 5, `exponential` backoff, `base_delay` 30s). + +| Situation | Delivery status | Messenger action | +|---|---|---| +| `2xx` response | `Delivered` (`nextAttemptAt` cleared) | message acknowledged | +| `4xx` response | `DeadLettered` immediately | `UnrecoverableMessageHandlingException` → failure transport | +| `5xx` / network error, attempt < `max_attempts` | `Failed` (`nextAttemptAt` scheduled) | `RecoverableMessageHandlingException` → redelivered after the backoff delay | +| `5xx` / network error, attempt ≥ `max_attempts` | `DeadLettered` | `UnrecoverableMessageHandlingException` → failure transport | +| delivery row missing | — | `UnrecoverableMessageHandlingException` (not retried) | + +Backoff delay before the *n*-th attempt: exponential = `base_delay × 2^(n-1)` (30s, 60s, 120s, 240s…), linear = `base_delay × n` (30s, 60s, 90s…). A `4xx` is treated as a permanent rejection and dead-letters without burning the retry budget; only `5xx` and transport-level failures are retried. + +## Auto-wiring + +`ActionAwareWebhookVerifyMiddleware` reads the resolved Action from the request attribute (`altair:http:action`). When that Action exposes a static `webhook()` accessor with `direction: in`, the middleware builds a per-request `WebhookVerifyMiddleware` from the policy (signer, secret name, dedupe TTL, timestamp window, header names — durations parsed by `DurationParser`). It passes through when there is no Action, no `webhook()` accessor, or the policy is outbound. This is the inbound equivalent of `ActionAwareIdempotencyMiddleware` (see [idempotency.md](./idempotency.md)). + +## Round-trip via OpenAPI + +When a spec carries `webhook:`, the forward emitter (`spec:emit-openapi`) writes an `x-altair-webhook` block on the operation; the reverse importer (`openapi:import`) reconstructs the `webhook:` block. `direction` and `signing` always travel; every other field is written only when it differs from its default, and the importer re-applies those defaults — so the block is byte-stable across the round-trip. The shared secret itself never appears in OpenAPI; only `secret_name` carries through. + +The drift gate (`openapi:roundtrip`) compares `x-altair-webhook` on both sides; a regression that drops or changes the block produces a `kind: extension_drift` entry and fails CI in `--check` mode. + +See [docs/openapi/extensions.md](../openapi/extensions.md) for the extension contract and [docs/openapi/roundtrip.md](../openapi/roundtrip.md) for the gate. + +## What is not yet supported + +- **Subscription management UI.** Listing / adding / removing subscribers is a host concern. +- **Cross-region replication** of the dedupe / delivery store. Adapters target single-region clusters. +- **WebSocket / SSE signing.** Different transport, different semantics. +- **Signature scheme negotiation.** The spec block declares one fixed scheme. + +## See also + +- [#184](https://github.com/univeros/framework/issues/184) — epic +- [#185](https://github.com/univeros/framework/issues/185) — storage contracts + signers + adapters +- [#186](https://github.com/univeros/framework/issues/186) — inbound verify middleware +- [#187](https://github.com/univeros/framework/issues/187) — outbound dispatcher + retry / dead-letter / replay +- [#188](https://github.com/univeros/framework/issues/188) — `webhook:` spec block + scaffolder +- [#189](https://github.com/univeros/framework/issues/189) — `x-altair-webhook` round-trip activation +- [docs/openapi/extensions.md](../openapi/extensions.md) — the OpenAPI extension family +- [docs/openapi/roundtrip.md](../openapi/roundtrip.md) — the drift gate diff --git a/src/Altair/Webhooks/Cli/WebhookReplayCommand.php b/src/Altair/Webhooks/Cli/WebhookReplayCommand.php index a068ff9c..3da7e471 100644 --- a/src/Altair/Webhooks/Cli/WebhookReplayCommand.php +++ b/src/Altair/Webhooks/Cli/WebhookReplayCommand.php @@ -11,52 +11,45 @@ namespace Altair\Webhooks\Cli; +use Altair\Cli\Attribute\Argument; +use Altair\Cli\Attribute\Command; use Altair\Webhooks\Contracts\DeliveryStoreInterface; use Altair\Webhooks\Dispatcher\WebhookDispatcher; use Altair\Webhooks\Storage\Delivery; -use Override; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand(name: 'webhook:replay', description: 'Re-dispatch a failed / dead-lettered webhook delivery')] -final class WebhookReplayCommand extends Command +/** + * `bin/altair webhook:replay ` — re-dispatch a dead-lettered + * delivery. Resets the delivery to pending (attempts 0) and puts a fresh + * {@see \Altair\Webhooks\Dispatcher\WebhookMessage} back on the bus. + * + * Exit code is `1` when no delivery matches the id (or prefix), otherwise `0`. + */ +#[Command( + name: 'webhook:replay', + description: 'Re-dispatch a failed / dead-lettered webhook delivery.', +)] +final readonly class WebhookReplayCommand { public function __construct( - private readonly DeliveryStoreInterface $deliveries, - private readonly WebhookDispatcher $dispatcher, - ) { - parent::__construct(); - } - - #[Override] - protected function configure(): void - { - $this->addArgument('delivery-id', InputArgument::REQUIRED, 'Delivery id (or an unambiguous prefix)'); - } - - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int - { - $style = new SymfonyStyle($input, $output); - - $argument = $input->getArgument('delivery-id'); - $id = \is_string($argument) ? $argument : ''; - - $delivery = $this->resolve($id); + private DeliveryStoreInterface $deliveries, + private WebhookDispatcher $dispatcher, + ) {} + + public function __invoke( + #[Argument(description: 'Delivery id (or an unambiguous prefix).')] + string $deliveryId = '', + ): int { + $delivery = $this->resolve($deliveryId); if (!$delivery instanceof Delivery) { - $style->error(\sprintf('No delivery matching "%s".', $id)); + echo \sprintf("No delivery matching \"%s\".\n", $deliveryId); - return Command::FAILURE; + return 1; } $reset = $this->dispatcher->redispatch($delivery); - $style->success(\sprintf('Re-dispatched delivery %s (reset to pending).', $reset->id)); + echo \sprintf("Re-dispatched delivery %s (reset to pending).\n", $reset->id); - return Command::SUCCESS; + return 0; } private function resolve(string $id): ?Delivery diff --git a/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php b/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php index 2273d4ea..e6bf72fa 100644 --- a/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php +++ b/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php @@ -11,60 +11,81 @@ namespace Altair\Webhooks\Cli; +use Altair\Cli\Attribute\Command; +use Altair\Cli\Attribute\Option; use Altair\Webhooks\Contracts\DeliveryStoreInterface; use Altair\Webhooks\Storage\Delivery; -use Override; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand(name: 'webhook:show-failed', description: 'List dead-lettered webhook deliveries')] -final class WebhookShowFailedCommand extends Command +/** + * `bin/altair webhook:show-failed` — list dead-lettered webhook deliveries, + * oldest first, so an operator (or agent) can pick a `delivery-id` to feed + * `webhook:replay`. Read-only; always exits `0`. + */ +#[Command( + name: 'webhook:show-failed', + description: 'List dead-lettered webhook deliveries.', +)] +final readonly class WebhookShowFailedCommand { public function __construct( - private readonly DeliveryStoreInterface $deliveries, - ) { - parent::__construct(); - } + private DeliveryStoreInterface $deliveries, + ) {} - #[Override] - protected function configure(): void - { - $this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of deliveries to list', '100'); + public function __invoke( + #[Option(description: 'Maximum number of deliveries to list.')] + int $limit = 100, + ): int { + $failed = $this->deliveries->findFailed(max(1, $limit)); + if ($failed === []) { + echo "No dead-lettered deliveries.\n"; + + return 0; + } + + $rows = array_map( + static fn(Delivery $delivery): array => [ + $delivery->id, + $delivery->eventName, + $delivery->subscriberUrl, + (string) $delivery->attempts, + $delivery->lastResponse ?? '', + ], + $failed, + ); + + echo $this->renderTable(['Delivery', 'Event', 'Subscriber', 'Attempts', 'Last response'], $rows); + + return 0; } - #[Override] - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * @param list $headers + * @param list> $rows + */ + private function renderTable(array $headers, array $rows): string { - $style = new SymfonyStyle($input, $output); + $widths = array_map(strlen(...), $headers); + foreach ($rows as $row) { + foreach ($row as $i => $cell) { + $widths[$i] = max($widths[$i], \strlen($cell)); + } + } - $limitOption = $input->getOption('limit'); - $limit = is_numeric($limitOption) ? max(1, (int) $limitOption) : 100; + $line = static function (array $cells) use ($widths): string { + $padded = []; + foreach ($cells as $i => $cell) { + $padded[] = str_pad((string) $cell, $widths[$i]); + } - $failed = $this->deliveries->findFailed($limit); - if ($failed === []) { - $style->success('No dead-lettered deliveries.'); + return rtrim(implode(' ', $padded)) . "\n"; + }; - return Command::SUCCESS; + $out = $line($headers); + $out .= $line(array_map(static fn(int $w): string => str_repeat('-', $w), $widths)); + foreach ($rows as $row) { + $out .= $line($row); } - $style->table( - ['Delivery', 'Event', 'Subscriber', 'Attempts', 'Last response'], - array_map( - static fn(Delivery $delivery): array => [ - $delivery->id, - $delivery->eventName, - $delivery->subscriberUrl, - (string) $delivery->attempts, - $delivery->lastResponse ?? '', - ], - $failed, - ), - ); - - return Command::SUCCESS; + return $out; } } diff --git a/src/Altair/Webhooks/Configuration/WebhooksConfiguration.php b/src/Altair/Webhooks/Configuration/WebhooksConfiguration.php new file mode 100644 index 00000000..35ef795b --- /dev/null +++ b/src/Altair/Webhooks/Configuration/WebhooksConfiguration.php @@ -0,0 +1,56 @@ +` via {@see EnvSecretResolver}; + * the {@see SignerRegistry} ships the always-available HMAC signers plus + * Ed25519 when ext-sodium is loaded. + * + * The per-endpoint verification policy (signing / dedupe_ttl / timestamp + * window) is sourced from the generated Action's `webhook()` accessor (#188) + * and consumed by `ActionAwareWebhookVerifyMiddleware`; the outbound + * dispatcher additionally needs `Symfony\Component\Messenger\MessageBusInterface` + * bound by `MessengerConfiguration`. This Configuration owns only the + * adapter / signer / secret bindings, not the middleware lifecycle. + */ +final readonly class WebhooksConfiguration implements ConfigurationInterface +{ + #[Override] + public function apply(Container $container): void + { + $container->alias(InboundDeduplicatorInterface::class, InMemoryDeduplicator::class); + $container->alias(DeliveryStoreInterface::class, InMemoryDeliveryStore::class); + $container->alias(SecretResolverInterface::class, EnvSecretResolver::class); + $container->singleton(SignerRegistry::class, static fn(): SignerRegistry => SignerRegistry::default()); + } +} diff --git a/src/Altair/Webhooks/README.md b/src/Altair/Webhooks/README.md new file mode 100644 index 00000000..8976386a --- /dev/null +++ b/src/Altair/Webhooks/README.md @@ -0,0 +1,39 @@ +# univeros/webhooks + +First-class webhook primitive for Univeros — both directions. **Inbound:** signature verification (HMAC-SHA256 / HMAC-SHA512 / Ed25519) + timestamp replay window + event-id dedupe, as a PSR-15 middleware. **Outbound:** a signed dispatcher over Symfony Messenger with retry, dead-letter and replay. Driven by a `webhook:` spec block and an `x-altair-webhook` OpenAPI 3.1 extension that round-trips the policy. + +```yaml +# inbound +webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + dedupe_ttl: 24h + timestamp_window: 5m +``` + +```yaml +# outbound +webhook: + direction: out + signing: hmac-sha256 + retry: { max_attempts: 5, backoff: exponential } + dead_letter: webhook.deadletter +``` + +Add the block to a spec → `bin/altair spec:scaffold` → the scaffolder wires the inbound `ActionAwareWebhookVerifyMiddleware` or the outbound `WebhookDispatcher` binding for the declared direction. + +```bash +bin/altair webhook:show-failed # list dead-lettered deliveries +bin/altair webhook:replay # re-dispatch one +``` + +See **[docs/packages/webhooks.md](../../../docs/packages/webhooks.md)** for the full reference: signing primitives, storage adapters, both behaviour matrices, round-trip semantics, and host wiring. + +## Composer + +```bash +composer require univeros/webhooks +``` + +PHP 8.3+; depends on the PSR HTTP interfaces, `symfony/messenger` (outbound), and `univeros/configuration` + `univeros/container`. The Redis adapters need `ext-redis`; Ed25519 signing needs `ext-sodium` (omitted from `SignerRegistry::default()` when absent). diff --git a/tests/Webhooks/Cli/WebhookReplayCommandTest.php b/tests/Webhooks/Cli/WebhookReplayCommandTest.php index 624da1bc..23ac73ee 100644 --- a/tests/Webhooks/Cli/WebhookReplayCommandTest.php +++ b/tests/Webhooks/Cli/WebhookReplayCommandTest.php @@ -12,8 +12,6 @@ use Altair\Webhooks\Storage\InMemoryDeliveryStore; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; #[CoversClass(WebhookReplayCommand::class)] final class WebhookReplayCommandTest extends TestCase @@ -24,11 +22,11 @@ public function testReplaysADeadLetteredDeliveryByFullId(): void $store->record($this->deadLettered('01HZZZAAAA0000000000000001')); $bus = new RecordingMessageBus(); - $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); + $command = new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store)); - $exit = $tester->execute(['delivery-id' => '01HZZZAAAA0000000000000001']); + [$exit] = $this->invoke($command, '01HZZZAAAA0000000000000001'); - self::assertSame(Command::SUCCESS, $exit); + self::assertSame(0, $exit); self::assertSame(DeliveryStatus::Pending, $store->findById('01HZZZAAAA0000000000000001')?->status); self::assertSame('01HZZZAAAA0000000000000001', $bus->lastWebhookMessage()?->deliveryId); } @@ -39,23 +37,34 @@ public function testReplaysByUnambiguousPrefix(): void $store->record($this->deadLettered('01HZZZAAAA0000000000000001')); $bus = new RecordingMessageBus(); - $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); + $command = new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store)); - $exit = $tester->execute(['delivery-id' => '01HZZZAAAA']); + [$exit] = $this->invoke($command, '01HZZZAAAA'); - self::assertSame(Command::SUCCESS, $exit); + self::assertSame(0, $exit); self::assertSame('01HZZZAAAA0000000000000001', $bus->lastWebhookMessage()?->deliveryId); } public function testFailsForUnknownDelivery(): void { $store = new InMemoryDeliveryStore(); - $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher(new RecordingMessageBus(), $store))); + $command = new WebhookReplayCommand($store, new WebhookDispatcher(new RecordingMessageBus(), $store)); - $exit = $tester->execute(['delivery-id' => 'nope']); + [$exit, $output] = $this->invoke($command, 'nope'); - self::assertSame(Command::FAILURE, $exit); - self::assertStringContainsString('No delivery matching', $tester->getDisplay()); + self::assertSame(1, $exit); + self::assertStringContainsString('No delivery matching', $output); + } + + /** + * @return array{int, string} + */ + private function invoke(WebhookReplayCommand $command, string $deliveryId): array + { + ob_start(); + $exit = $command($deliveryId); + + return [$exit, (string) ob_get_clean()]; } private function deadLettered(string $id): Delivery diff --git a/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php index afc92c3b..8b8fcde9 100644 --- a/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php +++ b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php @@ -10,20 +10,16 @@ use Altair\Webhooks\Storage\InMemoryDeliveryStore; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; #[CoversClass(WebhookShowFailedCommand::class)] final class WebhookShowFailedCommandTest extends TestCase { public function testReportsWhenNoDeadLetteredDeliveries(): void { - $tester = new CommandTester(new WebhookShowFailedCommand(new InMemoryDeliveryStore())); + [$exit, $output] = $this->invoke(new WebhookShowFailedCommand(new InMemoryDeliveryStore())); - $exit = $tester->execute([]); - - self::assertSame(Command::SUCCESS, $exit); - self::assertStringContainsString('No dead-lettered deliveries.', $tester->getDisplay()); + self::assertSame(0, $exit); + self::assertStringContainsString('No dead-lettered deliveries.', $output); } public function testListsDeadLetteredDeliveries(): void @@ -32,14 +28,23 @@ public function testListsDeadLetteredDeliveries(): void $store->record($this->deadLettered('dlv_old', 1_000)); $store->record($this->deadLettered('dlv_new', 2_000)); - $tester = new CommandTester(new WebhookShowFailedCommand($store)); + [$exit, $output] = $this->invoke(new WebhookShowFailedCommand($store)); + + self::assertSame(0, $exit); + self::assertStringContainsString('dlv_old', $output); + self::assertStringContainsString('dlv_new', $output); + self::assertStringContainsString('HTTP 500', $output); + } - $exit = $tester->execute([]); - $display = $tester->getDisplay(); + /** + * @return array{int, string} + */ + private function invoke(WebhookShowFailedCommand $command): array + { + ob_start(); + $exit = $command(); - self::assertSame(Command::SUCCESS, $exit); - self::assertStringContainsString('dlv_old', $display); - self::assertStringContainsString('dlv_new', $display); + return [$exit, (string) ob_get_clean()]; } private function deadLettered(string $id, int $createdAt): Delivery diff --git a/tests/Webhooks/Configuration/WebhooksConfigurationTest.php b/tests/Webhooks/Configuration/WebhooksConfigurationTest.php new file mode 100644 index 00000000..7db68271 --- /dev/null +++ b/tests/Webhooks/Configuration/WebhooksConfigurationTest.php @@ -0,0 +1,56 @@ +apply($container); + + self::assertInstanceOf(InMemoryDeduplicator::class, $container->make(InboundDeduplicatorInterface::class)); + self::assertInstanceOf(InMemoryDeliveryStore::class, $container->make(DeliveryStoreInterface::class)); + self::assertInstanceOf(EnvSecretResolver::class, $container->make(SecretResolverInterface::class)); + } + + public function testSignerRegistryShipsHmacSchemes(): void + { + $container = new Container(); + (new WebhooksConfiguration())->apply($container); + + $registry = $container->make(SignerRegistry::class); + self::assertInstanceOf(SignerRegistry::class, $registry); + self::assertTrue($registry->has('hmac-sha256')); + self::assertTrue($registry->has('hmac-sha512')); + self::assertSame('hmac-sha256', $registry->get('hmac-sha256')->name()); + } + + public function testSignerRegistryIsShared(): void + { + $container = new Container(); + (new WebhooksConfiguration())->apply($container); + + // get() returns the shared instance; make() always builds fresh. + self::assertSame( + $container->get(SignerRegistry::class), + $container->get(SignerRegistry::class), + 'the signer registry should resolve as a singleton', + ); + } +}