axn-webhooks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +176 -0
  3. data/DESIGN-NOTES.md +241 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1042 -0
  6. data/lib/axn/webhooks/dispatch.rb +129 -0
  7. data/lib/axn/webhooks/errors.rb +48 -0
  8. data/lib/axn/webhooks/handler.rb +15 -0
  9. data/lib/axn/webhooks/header_value.rb +29 -0
  10. data/lib/axn/webhooks/inbound/build_request.rb +23 -0
  11. data/lib/axn/webhooks/inbound/challenge.rb +37 -0
  12. data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
  13. data/lib/axn/webhooks/inbound/dsl.rb +240 -0
  14. data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
  15. data/lib/axn/webhooks/inbound/parsers.rb +20 -0
  16. data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
  17. data/lib/axn/webhooks/inbound/router.rb +104 -0
  18. data/lib/axn/webhooks/inbound.rb +124 -0
  19. data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
  20. data/lib/axn/webhooks/outbound/config.rb +442 -0
  21. data/lib/axn/webhooks/outbound/deliver.rb +425 -0
  22. data/lib/axn/webhooks/outbound/dsl.rb +121 -0
  23. data/lib/axn/webhooks/outbound/emit.rb +181 -0
  24. data/lib/axn/webhooks/outbound/envelope.rb +23 -0
  25. data/lib/axn/webhooks/outbound/signer.rb +376 -0
  26. data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
  27. data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
  28. data/lib/axn/webhooks/outbound/transport.rb +59 -0
  29. data/lib/axn/webhooks/outbound.rb +73 -0
  30. data/lib/axn/webhooks/request.rb +230 -0
  31. data/lib/axn/webhooks/resolvers.rb +43 -0
  32. data/lib/axn/webhooks/respond.rb +26 -0
  33. data/lib/axn/webhooks/response.rb +116 -0
  34. data/lib/axn/webhooks/signature.rb +268 -0
  35. data/lib/axn/webhooks/static_respond.rb +22 -0
  36. data/lib/axn/webhooks/vendor_facet.rb +25 -0
  37. data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
  38. data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
  39. data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
  40. data/lib/axn/webhooks/verifiers.rb +50 -0
  41. data/lib/axn/webhooks/verify.rb +106 -0
  42. data/lib/axn/webhooks/version.rb +7 -0
  43. data/lib/axn/webhooks.rb +61 -0
  44. data/lib/axn-webhooks.rb +3 -0
  45. metadata +128 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6afafb259befddc2850cf02b18286b55948c9e061c1e07377203dd5430c75f77
4
+ data.tar.gz: 79bf28d109278e3c6a63a22093758e32f02496e9747edebcf33cbaf8c8ec0635
5
+ SHA512:
6
+ metadata.gz: ccea01ad6d3b8f9da8347466c715b843e87d3a638f6851d025dba9258e83256e8d51f00ad4b065cf3e1c6a5f8a12f8640c2b883f0dda77065ffa0c3f0699508f
7
+ data.tar.gz: a1460ca78f2eeb544e90728e4c559d2c1e473cd4ddd5ba197aa3cc8d2a92f93d42e325628baa3480dd012e848fb34a0ab53dfd3132351bd3f0aae0409aaff5aa
data/CHANGELOG.md ADDED
@@ -0,0 +1,176 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [Unreleased]
7
+
8
+ _Nothing yet._
9
+
10
+ ## [0.1.0] - 2026-08-24
11
+
12
+ _Prepared, not yet tagged — the version is cut immediately on merge. Update this date if that slips._
13
+
14
+ Initial release. Webhook handling for [axn](https://github.com/teamshares/axn) in both directions,
15
+ built on one shared signature primitive, and usable in or out of Rails.
16
+
17
+ Everything below is new in this release; the pre-release iteration that produced it is in the git
18
+ history rather than here.
19
+
20
+ ### Requirements
21
+
22
+ - Ruby >= 3.2.1, `axn` >= 0.1.0-alpha.5, `rack` >= 3.0 (< 4).
23
+ - Rack 3 is required (`Response` uses lowercased header names and Rack 3's native Array multi-value
24
+ headers). Under Rails, `axn`'s own `activesupport >= 7.2` floor makes the effective minimum
25
+ **Rails 7.2+**.
26
+
27
+ ### Signature primitive
28
+
29
+ - `Axn::Webhooks::Signature` — a standalone, Rails-agnostic HMAC verifier. `sha256`/`sha1`/`md5`
30
+ digests; `hex`/`base64`/`base64_urlsafe` encodings; prefix stripping; multi-candidate
31
+ (key-rotation) signature headers; always constant-time.
32
+ - Replay protection via `timestamp:`/`tolerance:` — a bidirectional window accepting an epoch
33
+ Integer/String or a `Time`.
34
+ - `unit:` (default `:auto`) infers a timestamp's scale from its magnitude, so vendors that send epoch
35
+ seconds, milliseconds, or microseconds — sometimes more than one, as Lob does — need no
36
+ configuration. Pin `:seconds`/`:ms`/`:microseconds` to make a change in what a vendor sends fail
37
+ loudly instead of being absorbed.
38
+ - `hmac` answers *whether* a request verified; `hmac_check` answers *why* it didn't, returning a
39
+ `Signature::Check` (`ok?`, `reason`, `skew`, `suggested_unit`). `Signature.mismatched_unit` answers
40
+ the "would another scale have fit?" question on its own, side-effect-free.
41
+ - Exported verdicts for custom verifiers: `OK`, `MISMATCH`, `SIGNATURE_MISSING`,
42
+ `CREDENTIALS_MISSING`, `CREDENTIALS_MISMATCH`.
43
+
44
+ ### Inbound
45
+
46
+ - `Axn::Webhooks.inbound(:vendor) { … }` declares an endpoint; `Axn::Webhooks::Inbound[:vendor]`
47
+ looks it up. Endpoints are declared once (e.g. a Rails initializer) and registered process-globally.
48
+ - **Verification strategies:** `verify :hmac` (parametric — digest, encoding, prefix, custom signing
49
+ string, replay window), `verify :standard_webhooks` (the Standard Webhooks / Svix scheme, removing
50
+ any need for the `svix` gem), `verify :basic_auth` (owning the full two-legged handshake, including
51
+ the `WWW-Authenticate` challenge that clients like Twilio require), or a custom `verify` block.
52
+ `verify` is mandatory whenever `dispatch` is declared.
53
+ - **Every secret and credential, in both directions, must be a non-empty String** — enforced through
54
+ one shared guard (`Verifiers.require_secret!`) rather than per-strategy. A blank secret is a *weak
55
+ key*, not a failure: `""` is a legal HMAC key, so `verify :hmac` with a secret that resolved to
56
+ blank authenticated anyone who signed with the empty key. `verify :basic_auth` additionally
57
+ coerced with `to_s` before its blank check, so a `false` credential pair became the guessable
58
+ `false:false`. `Signature.compute` guards the same at the primitive level.
59
+ - **Replay protection can no longer disable itself silently.** Omitting `tolerance:` still means
60
+ "no replay check" (the documented default), but explicitly passing a blank one — the shape
61
+ `ENV["TOLERANCE"]&.to_i` produces on an unset var — now raises. A declared `replay:` hash must
62
+ carry a positive `within:`, and `verify :standard_webhooks`'s `tolerance:` must be positive; both
63
+ are checked at declaration.
64
+ - **Response header values are validated** against RFC 7230's `field-value` grammar, via the same
65
+ shared rule the outbound path uses. A value carrying CR/LF (or any other forbidden control byte)
66
+ is dropped with a warning rather than rendered, so a `respond`/`unauthorized_headers` block that
67
+ echoes request data can't be used to inject headers or split the response.
68
+ - Inbound query/form parsing of an **unverified** body now fails soft (`{}`) like the multipart
69
+ branch already did, instead of letting a malformed body raise past `verify` — a ~600-byte hostile
70
+ body turned a 401 into a 500 and fired `on_exception` once per request. The failure is not
71
+ discarded: `Request#params_error` carries it, and the parse step (which runs only *after*
72
+ verification) re-raises it as `UnparseableBody` when the parse actually read `params`, so a
73
+ verified request with a malformed form body still gets the documented report and
74
+ `unparseable_status` rather than silently dispatching an empty event.
75
+ - Outbound `Deliver` marks `url`/`body` (and `Emit` its `data`) `sensitive:`, and redacts the URL to
76
+ its origin in failure messages and the exhaustion report. A Slack/Discord/Teams hook carries its
77
+ token in the URL path, which was being written to the application log on every delivery.
78
+ - Every inbound `verify :standard_webhooks` secret is validated as early as it can be: a literal at
79
+ declaration, a callable or resolver on **every request**. A secret that resolves to `nil` — an
80
+ unset env var, or a `header(…)` on an absent header — used to be coerced with `to_s` and
81
+ Base64-decoded into an **empty HMAC key**, so anyone who knew the credential was missing could
82
+ sign with that key and verify. It now fails loudly instead.
83
+ - A **custom `verify` block's return value is duck-typed on `#ok?`** — any object reporting its own
84
+ verdict (a `Signature::Check`, an `Axn::Result`) is asked for it; any other truthy value means
85
+ verified; `nil`/`false` mean rejected. A literal `whsec_` secret is validated at declaration.
86
+ - **Verification failures name their cause** on the result and as a bounded `reason` metrics
87
+ dimension: `:replay_window` (carrying `skew` and `suggested_unit`), `:replay_timestamp_invalid`,
88
+ `:signature_missing`, `:signature_mismatch`, `:credentials_missing`, `:credentials_mismatch`.
89
+ - **`dispatch`** routes a verified, parsed event to a handler Axn — a single `to:`, an explicit
90
+ `on:` + map, or name-from-key convention (with an optional `via:` transform). Targets may be a
91
+ class-name String or the class itself, both resolved lazily per request (reload-safe under
92
+ Zeitwerk). A map entry's `with:` renames or projects the handler's arguments; `otherwise:` takes
93
+ `:ack` or a callable for unmatched keys.
94
+ - **`respond`** renders a body from the handler's own result (TwiML, a JSON instruction body);
95
+ **`static_respond`** renders a fixed body that doesn't read the result, and so stays compatible
96
+ with async dispatch. The two are mutually exclusive.
97
+ - **Staged HTTP outcome mapping** (`#to_response`): verify rejection or verifier crash → 401;
98
+ missing/unmatched handler or handler crash → 500 (reported once); unparseable body →
99
+ `unparseable_status` (default **200**, since 2xx is the only answer every vendor reads as "stop
100
+ redelivering"); `otherwise: :ack` and a handler's business `fail!` → 2xx ack; success → the declared
101
+ response body.
102
+ - **Async dispatch** — `mode: :auto` (default) runs async when the handler has an axn async adapter
103
+ configured and sync otherwise; `:async`/`:sync` pin it. A dispatch-map entry's `async:` flag, with
104
+ `async(…)`/`sync(…)` sugar, overrides per route — the interaction-platform pattern (Slack, Discord)
105
+ where one URL needs both disciplines. This gem never branches on adapter type.
106
+ - **Nested endpoints** — an `inbound` block may contain `endpoint(name) { … }` blocks that inherit
107
+ the parent's `verify`/`challenge`/`respond`/`unauthorized_headers` and register as
108
+ `:"#{parent}_#{child}"`. One level deep.
109
+ - **`challenge`** — the GET-echo handshake (Nylas, Meta), with an optional `if:` guard, taught to the
110
+ same mount. `challenge_required` declares the bare-first-leg predicate for a custom verifier.
111
+ - **Rack mounting** — `Inbound[:vendor]` is itself a Rack app: `mount … at:` in Rails routes, or
112
+ `map(…) { run … }` in a `config.ru`. No controller needed.
113
+ - **`Axn::Webhooks::Request`** — a Rails-agnostic view of the request (`raw_body`, `header`, `params`,
114
+ `url`, `http_method`), built from a Rack env or constructed directly in tests. Captures pristine
115
+ body bytes, parses form-urlencoded and multipart bodies, and redacts `raw_body`/headers from
116
+ `inspect`/`pp` so payloads never reach logs or exception reports.
117
+ - **`retry_later!`** — `Axn::Webhooks.retry_later!(after:)` from a handler (or a `parse:` proc, or a
118
+ `with:` extractor) maps to a 503 with an optional `Retry-After`, asking the sender to redeliver.
119
+ `include Axn::Webhooks::Handler` in place of `include Axn` declares the `fails_on` that keeps a
120
+ deferral from paging on every occurrence.
121
+
122
+ ### Outbound
123
+
124
+ - `Axn::Webhooks.outbound { … }` declares events, subscribers, signing, and delivery policy once;
125
+ `Axn::Webhooks.emit(:event, data:)` fans out from wherever the triggering event happens. An unknown
126
+ event symbol raises immediately, listing the known ones.
127
+ - **Standard Webhooks envelope** — `{id, timestamp, type, data}` with `webhook-id`/`webhook-timestamp`
128
+ /`webhook-signature` headers, so a receiver's own `verify :standard_webhooks` accepts it. Signing
129
+ happens **per attempt** (fresh timestamp, stable `webhook-id`) so retries land inside the receiver's
130
+ replay window while staying dedupable.
131
+ - **`sign :hmac`** — a parametric outbound preset mirroring `verify :hmac`, for receivers that expect
132
+ a plain signature header. `digest:`/`encoding:`/`prefix:`/`signing_string:` (a `{timestamp}`/`{body}`
133
+ template) and both header names are validated at declaration.
134
+ - **Subscriber resolution** — a per-event `to:` (static Array or lambda) or a block-level
135
+ `subscribers` resolver, both re-resolved on every `emit`. A row may be a bare URL String or a
136
+ `{ url:, id: }` Hash carrying a subscriber identity that `sign`'s `secret:` and the `headers`
137
+ resolver can key off of.
138
+ - **Separately-resolved credentials stay out of the queue.** `Deliver` carries only a subscriber's
139
+ *identity*; the signing secret and per-destination `headers` are re-resolved from it on every
140
+ attempt, never serialized into the job payload for the life of the retry chain. This covers those
141
+ resolved values only — `Deliver` does declare `expects :url`, so a credential a receiver embeds in
142
+ its own webhook URL (a secret path segment or signed query token) *is* persisted in the queue
143
+ backend for that same lifetime.
144
+ - **Target policy** — `allowed_hosts` (exact or `*.` wildcard, case-insensitive) and `allow_url` (the
145
+ parsed `URI`, for real IP-range logic). A static `to:` is checked at boot; a resolver's rows are
146
+ checked at every `emit` and collected into `rejected`/`rejected_count` rather than failing the fan-out.
147
+ - **Self-managed, adapter-agnostic retries.** `Deliver` computes its own backoff and re-enqueues
148
+ itself via axn's delayed-enqueue seam, so retry behavior is identical across adapters. 2xx succeeds;
149
+ 408/425/429/5xx/timeouts reschedule (honoring `Retry-After`, including its HTTP-date form); other
150
+ 4xx is a permanent quiet `fail!` carrying a truncated copy of the response body; an unexpected
151
+ exception propagates so the adapter's at-least-once safety net applies. `max_attempts` defaults to
152
+ 8, and the default backoff curve applies equal jitter and caps at 6h. Exhaustion reports once and
153
+ then stops.
154
+ - **`emit`'s result** exposes `webhook_ids`, `target_count` (rows actually enqueued), `deliveries`
155
+ (one `{ webhook_id:, url:, subscriber_id: }` per target, for persisting delivery records without
156
+ re-resolving), `rejected`/`rejected_count`, and `failed_count` (sync path only).
157
+ - **Per-call overrides** — `emit(…, to:)` replaces the event's targets for one call (validated
158
+ identically, raising on a bad URL); `emit(…, async:)` pins the dispatch path.
159
+ - **Injectable transport** — `.post(url:, body:, headers:) -> Transport::Response`, defaulting to
160
+ stdlib `net/http` so the gem adds no HTTP dependency. `timeouts open:`/`read:` (5s/10s) configure
161
+ the built-in one.
162
+ - **Boot-time validation** of the whole `outbound` block: `max_attempts`, `backoff` arity, `to:` shape
163
+ and static entries, `allowed_hosts`, `allow_url`/`headers` arity, every `sign :hmac` option, and a
164
+ literal `sign :standard_webhooks` secret's `whsec_<base64>` format.
165
+
166
+ ### Shared
167
+
168
+ - `Axn::Webhooks.config.vendor_facet` (`false` by default; `:dimension` or `:tag`) stamps the
169
+ registered vendor name onto every verify/dispatch/respond/challenge call, and onto outbound
170
+ deliveries via `vendor`. A resolved `subscriber_id` is always stamped as a tag, never a dimension.
171
+ - `Axn::Webhooks::Error` includes `Axn::Error`, so a consuming app's existing axn error handling
172
+ covers this gem. `Axn::Webhooks.deprecator` is a dedicated `ActiveSupport::Deprecation` instance a
173
+ Rails app can register and govern.
174
+ - `Axn::Webhooks::Inbound.reset!` / `Outbound.reset!` clear the process-global registries for tests.
175
+ - The packaged gem ships an allowlist of paths (`lib/`, `README.md`, `DESIGN-NOTES.md`,
176
+ `CHANGELOG.md`, `LICENSE.txt`), so development artifacts never leak into the release.
data/DESIGN-NOTES.md ADDED
@@ -0,0 +1,241 @@
1
+ # axn-webhooks — design notes & gotchas
2
+
3
+ The [README](README.md) is the whole API. This document is the *why* behind the parts most likely to
4
+ surprise you, plus the traps worth naming. Nothing here is required reading to use the gem.
5
+
6
+ **Contents:** [Rails autoloading](#rails-autoloading-in-initializers) ·
7
+ [URL-signing verifiers](#url-signing-verifiers) ·
8
+ [Don't return an `Axn::Result` from `verify`](#dont-return-an-axnresult-from-a-verify-block) ·
9
+ [Basic auth is two-legged](#basic-auth-is-two-legged) ·
10
+ [Why unparseable bodies ack 200](#why-unparseable-bodies-ack-with-200) ·
11
+ [Async posture](#async-posture-auto-vs-explicit) ·
12
+ [Credentials and the queue](#credentials-never-enter-the-queue) ·
13
+ [Two timestamps](#two-timestamps-deliberately-different) ·
14
+ [Routing is sender-owned](#routing-is-sender-owned-config) ·
15
+ [Dimensions vs tags](#observability-dimensions-vs-tags)
16
+
17
+ ## Rails autoloading in initializers
18
+
19
+ <sub>README: [Declaring an endpoint](README.md#declaring-an-endpoint)</sub>
20
+
21
+ `inbound`/`outbound` blocks are evaluated where they're declared — at boot, if that's an initializer
22
+ — and Rails disallows autoloading during initialization. Naming a class from `app/` while the
23
+ initializer runs raises `NameError` and fails the boot.
24
+
25
+ Handler classes are already safe: `dispatch to:` accepts a String, resolved per request. A custom
26
+ `verify` block needs the same treatment — keep the constant *inside* the block, which runs per
27
+ request:
28
+
29
+ ```ruby
30
+ # NameError at boot — the constant is named while the initializer runs
31
+ checker = MyApp::SignatureChecker.new(secret: ENV.fetch("SECRET"))
32
+ Axn::Webhooks.inbound(:vendor) { verify { |req| checker.call(req) } }
33
+
34
+ # Fine — the constant is named when a request arrives
35
+ Axn::Webhooks.inbound(:vendor) do
36
+ verify { |req| MyApp::SignatureChecker.verify(req, secret: ENV.fetch("SECRET")) }
37
+ end
38
+ ```
39
+
40
+ ## URL-signing verifiers
41
+
42
+ <sub>README: [Verifying](README.md#verifying) · [The request object](README.md#the-request-object)</sub>
43
+
44
+ Some vendors — Twilio most notably — sign the **request URL** rather than the body, and compare
45
+ against the URL as registered in their dashboard. Two properties of `Request#url` will bite.
46
+
47
+ **A mount whose path is the whole route adds a trailing slash.** Rack puts the mount point in
48
+ `SCRIPT_NAME` and leaves `PATH_INFO` as `"/"` for a request matching it exactly:
49
+
50
+ ```ruby
51
+ mount Axn::Webhooks::Inbound[:twilio], at: "/webhooks/twilio"
52
+
53
+ # vendor POSTs to https://example.com/webhooks/twilio
54
+ req.url # => "https://example.com/webhooks/twilio/" <- note the slash
55
+ # ...and with a query string:
56
+ req.url # => "https://example.com/webhooks/twilio/?callId=42"
57
+ ```
58
+
59
+ The vendor signed the URL *without* that slash, so passing `req.url` straight to a validator rejects
60
+ every request — which reads in the logs exactly like a rotated secret. Split on the query, then chomp
61
+ the path:
62
+
63
+ ```ruby
64
+ path, query = req.url.split("?", 2)
65
+ signed_url = [path.chomp("/"), query].compact.join("?")
66
+ ```
67
+
68
+ Chomping the whole URL is **not** equivalent: it strips nothing when a query string is present, which
69
+ is precisely the case a status-callback URL (`…/update?callId=N`) exercises. Regex-matching `/` before
70
+ `?`-or-end isn't either — the leftmost match lands in the *query* for something like `?redirect=a/`.
71
+
72
+ A mount at a prefix (`at: "/webhooks"`, vendor posts `/webhooks/twilio`) leaves a non-`"/"`
73
+ `PATH_INFO` and so has no slash to strip, making the form above a no-op there — so it's safe to apply
74
+ unconditionally, **as long as the URL registered with the vendor doesn't itself end in `/`**. Both
75
+ cases produce a `req.url` ending in `/` and are indistinguishable at this layer, but one should be
76
+ chomped and the other must not be. Register the webhook URL without a trailing slash and it doesn't
77
+ come up.
78
+
79
+ **`url` reflects the scheme and host the proxy reported.** It comes from `Rack::Request#url`, so a CDN
80
+ or load balancer added in front, a change in `X-Forwarded-Proto` handling, or a new domain changes
81
+ what actually gets verified. The only symptom is `:signature_mismatch` on every request — again
82
+ indistinguishable from a rotated secret, so it's worth naming in whatever alerts on `reason`.
83
+
84
+ None of this applies to body-signing verifiers (`:hmac`, `:standard_webhooks`), which never read `url`.
85
+
86
+ ## Don't return an `Axn::Result` from a `verify` block
87
+
88
+ <sub>README: [Custom `verify` blocks](README.md#custom-verify-blocks)</sub>
89
+
90
+ In an axn-consuming app the instinct is to put the check in an action and return its result. This
91
+ used to be actively dangerous: the contract was read as
92
+ `check.is_a?(Signature::Check) ? check.ok? : !!check`, and an `Axn::Result` is neither a `Check` nor
93
+ a boolean — but it *is* **truthy even when `ok?` is false**. So a verifier returning one reported
94
+ every rejected request as verified and dispatched it, with no verify failure recorded anywhere.
95
+ Authentication silently off, and nothing in the logs to say so.
96
+
97
+ The contract is now duck-typed on `#ok?`, so any object that reports its own verdict is asked for it
98
+ rather than read for truthiness. That closes the silent-bypass hole: a failed `Axn::Result` now
99
+ rejects the request.
100
+
101
+ **One sharp edge survives, and it can't be fixed from this side.** `ok?` on an `Axn::Result` means
102
+ *the action succeeded*, which is not the same claim as *the signature was valid*. The two coincide
103
+ only when the action `fail!`s on a bad signature. An action that succeeds while carrying its verdict
104
+ in an exposure —
105
+
106
+ ```ruby
107
+ def call = expose(valid: signature_matches?) # ok? is TRUE even when valid: false
108
+ ```
109
+
110
+ — still reads as verified. `fail!` on rejection, or translate to a `Check` explicitly.
111
+
112
+ Usually it doesn't need to be an action at all: `Verify` is already the Axn boundary for this stage —
113
+ it owns the `expects`/`exposes` contract, the `sensitive:` redaction of the verifier, the `reason`
114
+ dimension, and the exception report. That's why both built-in strategies are a plain class and a
115
+ lambda rather than actions.
116
+
117
+ ## Basic auth is two-legged
118
+
119
+ <sub>README: [`verify :basic_auth`](README.md#verify-basic_auth)</sub>
120
+
121
+ A client that doesn't authenticate preemptively — **Twilio is one** — sends its first request with no
122
+ `Authorization` header, expects a `401` carrying `WWW-Authenticate: Basic realm="…"`, and only then
123
+ repeats the request with credentials. Return a bare 401 and that retry never comes: every webhook is
124
+ dropped, and it reads as an ordinary stream of auth failures rather than an outage.
125
+
126
+ `verify :basic_auth` owns that challenge, along with constant-time comparison and fail-closed behavior
127
+ on a missing or blank credential (comparing against `""` would authenticate `Authorization: Basic Og==`
128
+ for anyone, and CI and secret managers can both set an empty string).
129
+
130
+ That bare first leg is **not** a verification failure — there's nothing to verify. It's answered before
131
+ `verify` runs at all, so it records nothing; without that, the highest-volume outcome on a healthy
132
+ Basic-auth endpoint would be a recorded failure, and a cross-vendor monitor on verify failures couldn't
133
+ tell a stream of them from an outage.
134
+
135
+ A custom block doesn't know which requests those are, so declare both halves yourself:
136
+
137
+ ```ruby
138
+ Axn::Webhooks.inbound :vendor do
139
+ verify { |req| my_own_check(req) }
140
+ unauthorized_headers "WWW-Authenticate" => %(Basic realm="Webhook")
141
+ challenge_required { |req| req.header("Authorization").to_s.strip.empty? }
142
+ end
143
+ ```
144
+
145
+ The two go together: an endpoint that requires a challenge but has none to send raises at boot, since
146
+ challenging a client with nothing drops every request forever and records nothing about it.
147
+ `Endpoint#challenge_required?(request)` is public for callers driving `#verify`/`#handle` themselves —
148
+ those two stay honest about a bare request (it does not verify), so answer the challenge first.
149
+
150
+ Twilio [recommends signature verification over Basic auth](https://www.twilio.com/docs/usage/webhooks/webhooks-security);
151
+ so do we. It's one request rather than two, and it authenticates the *payload* rather than merely the
152
+ caller.
153
+
154
+ ## Why unparseable bodies ack with 200
155
+
156
+ <sub>README: [Unparseable bodies](README.md#unparseable-bodies)</sub>
157
+
158
+ 2xx is the only answer every vendor reads as "stop redelivering". Lob retries non-2xx for 5 days and
159
+ then disables the endpoint; Stripe, Slack and Shopify all retry non-2xx too, and the last two also
160
+ disable an endpoint after sustained failures. So a semantically tidy 400 buys a retry loop from most
161
+ senders for a body that can never parse.
162
+
163
+ Set `400` for a vendor that does honor 4xx as terminal (nicer status codes in their delivery
164
+ dashboard), or `500` to restore retry-inviting behavior. A declared `static_respond` still renders
165
+ here — Dropbox Sign and friends key the ack on the body text, not the status.
166
+
167
+ ## Async posture: `:auto` vs explicit
168
+
169
+ <sub>README: [Sync vs async](README.md#sync-vs-async) · [Per-call overrides](README.md#per-call-overrides)</sub>
170
+
171
+ Three behaviors that look inconsistent side by side:
172
+
173
+ | | No adapter configured |
174
+ | -- | -- |
175
+ | inbound `mode: :auto` | runs sync |
176
+ | inbound `mode: :async` (or a route marked `async`) | reported exception → 500 |
177
+ | `emit(…)` (defaults to `:auto`) | warns once per emit, runs sync |
178
+ | `emit(…, async: true)` | raises |
179
+
180
+ They line up once you read `emit`'s default as `:auto`: **a missing adapter degrades to sync only
181
+ under `:auto`, never under an explicit request.**
182
+
183
+ Downgrading an explicit `async` would be wrong twice over. `async` is usually declared *because* the
184
+ handler outlives the vendor's ack window (Slack's 3s), so running it inline trades a clean 500 for a
185
+ vendor timeout, a redelivery, and duplicate processing. And it changes the response the vendor sees:
186
+ the async path acks with no handler result, while the sync path renders one — a handler `fail!`
187
+ included.
188
+
189
+ It's the same no-silent-downgrade stance outbound's `to:` takes: a *declared* resolver that returns
190
+ nil delivers nowhere rather than falling back to `subscribers`.
191
+
192
+ ## Credentials never enter the queue
193
+
194
+ <sub>README: [Per-subscriber secrets and headers](README.md#per-subscriber-secrets-and-headers)</sub>
195
+
196
+ `Deliver` re-enqueues *itself* on a retry, so anything in its `expects` is persisted, plaintext, in the
197
+ queue backend (Redis for Sidekiq) for the life of the retry chain — `max_attempts` × the backoff curve,
198
+ hours by default.
199
+
200
+ So `Deliver` only ever carries a subscriber's **identity** (`subscriber_id`, a String). `sign`'s secret
201
+ and the `headers` resolver are called fresh **per delivery attempt** from that identity. This is also
202
+ why there is no per-emit `headers:` override: it's the obvious place to hang a bearer token, and it
203
+ would ride the payload for the whole retry lifetime.
204
+
205
+ > **This covers separately-resolved values only.** `Deliver` *does* carry `url:` — so a credential a
206
+ > receiver embeds in its own webhook URL (a Slack/Discord/Teams-style secret path segment, or a signed
207
+ > query token) is persisted for that same lifetime.
208
+
209
+ ## Two timestamps, deliberately different
210
+
211
+ <sub>README: [`sign :standard_webhooks`](README.md#sign-standard_webhooks)</sub>
212
+
213
+ **Signing happens per attempt.** Each retry recomputes the signature with a fresh `webhook-timestamp`
214
+ header, so it lands inside the receiver's replay-tolerance window, while reusing the same `webhook-id`
215
+ from the first attempt, so the receiver can still dedup.
216
+
217
+ The envelope body's own `timestamp` field, by contrast, is fixed at emit time — it's part of the dedup
218
+ identity. So a retried delivery's signed header and its body field deliberately diverge. Read the
219
+ header as "when this attempt was signed", not the body's "when this event happened".
220
+
221
+ ## Routing is sender-owned config
222
+
223
+ <sub>README: [Subscriber rows](README.md#subscriber-rows)</sub>
224
+
225
+ The event→targets map lives in each *sending* app's own `outbound` block, not in this gem. A
226
+ general-purpose DB-backed self-registration store — where receivers register their own endpoint URLs
227
+ at runtime, no deploy required — is a real future shape, but it's **intentionally deferred until a
228
+ real use-case justifies it**.
229
+
230
+ The `subscribers`/`to:` lambda is the seam it slots into with no API change: both are resolved fresh on
231
+ every `emit`, so swapping the lambda body for a DB lookup already picks up runtime changes, and the
232
+ `{ url:, id: }` row shape already carries the identity per-subscriber secrets and headers key off of.
233
+
234
+ ## Observability: dimensions vs tags
235
+
236
+ <sub>README: [Per-vendor observability](README.md#per-vendor-observability)</sub>
237
+
238
+ `event` and `vendor` are stamped as **dimensions** — axn's bounded metrics facet. A resolved
239
+ `subscriber_id` is stamped as a **tag** (the high-cardinality log/trace facet), never a dimension: a
240
+ subscriber id off a live table is unbounded, and stamping it as a dimension would quietly blow past a
241
+ metrics backend's cardinality limits the first time a real subscriber table is wired up.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Kali Donovan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.