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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +176 -0
- data/DESIGN-NOTES.md +241 -0
- data/LICENSE.txt +21 -0
- data/README.md +1042 -0
- data/lib/axn/webhooks/dispatch.rb +129 -0
- data/lib/axn/webhooks/errors.rb +48 -0
- data/lib/axn/webhooks/handler.rb +15 -0
- data/lib/axn/webhooks/header_value.rb +29 -0
- data/lib/axn/webhooks/inbound/build_request.rb +23 -0
- data/lib/axn/webhooks/inbound/challenge.rb +37 -0
- data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
- data/lib/axn/webhooks/inbound/dsl.rb +240 -0
- data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
- data/lib/axn/webhooks/inbound/parsers.rb +20 -0
- data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
- data/lib/axn/webhooks/inbound/router.rb +104 -0
- data/lib/axn/webhooks/inbound.rb +124 -0
- data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
- data/lib/axn/webhooks/outbound/config.rb +442 -0
- data/lib/axn/webhooks/outbound/deliver.rb +425 -0
- data/lib/axn/webhooks/outbound/dsl.rb +121 -0
- data/lib/axn/webhooks/outbound/emit.rb +181 -0
- data/lib/axn/webhooks/outbound/envelope.rb +23 -0
- data/lib/axn/webhooks/outbound/signer.rb +376 -0
- data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
- data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
- data/lib/axn/webhooks/outbound/transport.rb +59 -0
- data/lib/axn/webhooks/outbound.rb +73 -0
- data/lib/axn/webhooks/request.rb +230 -0
- data/lib/axn/webhooks/resolvers.rb +43 -0
- data/lib/axn/webhooks/respond.rb +26 -0
- data/lib/axn/webhooks/response.rb +116 -0
- data/lib/axn/webhooks/signature.rb +268 -0
- data/lib/axn/webhooks/static_respond.rb +22 -0
- data/lib/axn/webhooks/vendor_facet.rb +25 -0
- data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
- data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
- data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
- data/lib/axn/webhooks/verifiers.rb +50 -0
- data/lib/axn/webhooks/verify.rb +106 -0
- data/lib/axn/webhooks/version.rb +7 -0
- data/lib/axn/webhooks.rb +61 -0
- data/lib/axn-webhooks.rb +3 -0
- 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.
|