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
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Outbound
6
+ # Resolves an event's subscribers and enqueues one Deliver per target. Built as an Axn so an
7
+ # unknown event (a typo) is a loud, reported failure instead of today's silent no-op.
8
+ class Emit
9
+ include Axn
10
+ include Axn::Webhooks::VendorFacet
11
+
12
+ expects :event
13
+ # sensitive: this is the caller's own event payload — routinely PII (the whole point of a
14
+ # webhook is shipping domain data), and axn's auto-logging would otherwise render it into
15
+ # the application log on every emit. Matches `Deliver`'s `body`, which is this serialized.
16
+ expects :data, type: Hash, allow_blank: true, default: {}, sensitive: true
17
+
18
+ # Per-call overrides (both nil = declaration-time behavior). `to:` is a URL String or an
19
+ # Array of them; `async:` is a tri-state — nil (:auto), true (demand async), false (force
20
+ # sync). `allow_blank` on both: nil is the "not given" signal, and `false` is blank too.
21
+ expects :to, allow_blank: true, default: nil
22
+ # `type: :boolean` is the tri-state guard: without it a config-derived `async: "false"` is
23
+ # truthy and demands async — the exact opposite of what the caller asked for (Codex review).
24
+ expects :async, type: :boolean, allow_nil: true, default: nil
25
+
26
+ # Kept for back-compat -- now derived from `deliveries` rather than being its own tally,
27
+ # so ordering is documented rather than incidental (`webhook_ids == deliveries.map { |d|
28
+ # d[:webhook_id] }`).
29
+ exposes :webhook_ids, type: Array, allow_blank: true, default: []
30
+ # Rows actually ENQUEUED, not rows resolved -- a malformed row is now caught by
31
+ # `resolve_targets`/`TargetPolicy` before it ever reaches here (see `rejected_count` below),
32
+ # where before it got a webhook_id and was counted despite `Deliver` immediately failing its
33
+ # own `expects :url, type: String` validation.
34
+ exposes :target_count, type: Integer, default: 0
35
+
36
+ # Sync fallback only: how many of `target_count` deliveries came back failed. ALWAYS 0 on
37
+ # the async path — nothing has failed at emit time there; failures happen later, and
38
+ # `Deliver` reports them itself (exhaustion via on_exception, a permanent 4xx via its own
39
+ # result). `nil`-when-async would be more honest AND would turn every
40
+ # `result.failed_count > 0` into a NoMethodError, so the footgun costs more than the
41
+ # precision buys.
42
+ exposes :failed_count, type: Integer, default: 0
43
+
44
+ # `{ webhook_id:, url:, subscriber_id: }` per enqueued target -- the correlation a DB-backed
45
+ # sender wants to persist one delivery record per subscription, without re-resolving
46
+ # `subscribers`/`to:` and trusting undocumented ordering.
47
+ exposes :deliveries, type: Array, allow_blank: true, default: []
48
+ # Rows `TargetPolicy` refused (a malformed row, or one the declared `allowed_hosts`/
49
+ # `allow_url` policy rejects) -- collected rather than discarded, so a bad row never
50
+ # silently disappears from what `target_count` used to (over)count. `emit` still reports
51
+ # `ok?` (the good rows really were enqueued); a caller that cares checks `rejected_count`.
52
+ exposes :rejected_count, type: Integer, default: 0
53
+ exposes :rejected, type: Array, allow_blank: true, default: []
54
+
55
+ # Bounded to the events a sending app declares — same shape as inbound's unconditional
56
+ # `reason` dimension, not a per-request identity.
57
+ dimension :event, -> { event.to_s }
58
+
59
+ def call
60
+ config = Axn::Webhooks::Outbound.config
61
+ type = config.wire_type(event)
62
+ use_async = async?
63
+ # Only the :auto path is a DEGRADED mode worth warning about — an explicit `async: false`
64
+ # is the caller getting exactly what they asked for.
65
+ warn_sync_fallback(type) if async.nil? && !use_async
66
+
67
+ resolution = resolve_targets(config)
68
+ report_rejections(resolution.rejections) if resolution.rejections.any?
69
+
70
+ deliveries = []
71
+ failed = 0
72
+ resolution.subscribers.each do |subscriber|
73
+ id = Envelope.new_id
74
+ body = Envelope.build(id:, type:, data:)
75
+ delivered = enqueue(use_async, url: subscriber.url, webhook_id: id, body:, event: type, vendor:,
76
+ subscriber_id: subscriber.id)
77
+ failed += 1 if delivered && !delivered.ok?
78
+ deliveries << { webhook_id: id, url: subscriber.url, subscriber_id: subscriber.id }
79
+ end
80
+
81
+ expose(webhook_ids: deliveries.map { |d| d[:webhook_id] }, target_count: deliveries.size, failed_count: failed,
82
+ deliveries:, rejected_count: resolution.rejections.size, rejected: resolution.rejections)
83
+ end
84
+
85
+ private
86
+
87
+ # Overrides the plain `expects :vendor` reader VendorFacet declared above. Computed FRESH on
88
+ # every call (not memoized into an ivar set inside `#call`): axn resolves `dimension`/`tag`
89
+ # facets input-phase, i.e. eagerly BEFORE the body runs, so a value only set inside `#call`
90
+ # would still read as unset there — Emit's own `:vendor` dimension/tag would stamp nil even
91
+ # though the identical lookup, threaded down to `Deliver`, stamps correctly (Codex P2
92
+ # finding). Reading `config.vendor_for(event)` here still keeps `fetch`'s unknown-event raise
93
+ # inside axn's executor (whichever facet-resolution or body call reaches it first is already
94
+ # running under axn's own exception-reporting boundary) — the ordering `Axn::Webhooks.emit`'s
95
+ # comment cares about is never resolving this ahead of `Emit.call!` itself.
96
+ def vendor = Axn::Webhooks::Outbound.config.vendor_for(event)
97
+
98
+ # Async when an adapter is configured for Deliver, else a warned best-effort sync fallback
99
+ # (no cross-process retries). Presence check only — never branches on adapter type.
100
+ # Returns Deliver's own result on the sync path, or nil when the delivery was ENQUEUED —
101
+ # an enqueue can't know the eventual outcome, so there is no result to report and nothing
102
+ # to count as failed (see the `failed_count` exposure above).
103
+ def enqueue(use_async, **)
104
+ if use_async
105
+ Deliver.call_async(**)
106
+ nil
107
+ else
108
+ Deliver.call(**)
109
+ end
110
+ end
111
+
112
+ # A per-call `to:` REPLACES resolution entirely — the declared `to:`/`subscribers` is not
113
+ # consulted and not appended to, the same no-silent-merge stance Config#resolve_subscribers
114
+ # takes for a declared resolver that returns nil. Validated here rather than at boot (it
115
+ # can't be known earlier) and as an Axn::Webhooks::Error, not the ArgumentError a declaration
116
+ # mistake gets: this one is raised per call, on caller-supplied runtime data. Goes through
117
+ # the SAME TargetPolicy (including the declared `allowed_hosts`/`allow_url`) as a declared
118
+ # `to:`/`subscribers` resolver -- a one-off override is not a way around the host policy.
119
+ # Any rejection here raises immediately rather than being collected: the caller supplied
120
+ # exactly this URL, on purpose, this one call; a typo deserves an immediate raise; unlike a
121
+ # DB-backed resolver returning many rows where one bad one shouldn't sink the rest.
122
+ def resolve_targets(config)
123
+ return config.resolve_subscribers(event) if to.nil?
124
+
125
+ subscribers = Array(to).map do |target|
126
+ TargetPolicy.check!(target, allowed_hosts: config.allowed_hosts, allow_url: config.allow_url)
127
+ rescue Axn::Webhooks::InvalidTarget => e
128
+ raise Axn::Webhooks::Error, "emit(#{event.inspect}, to:) #{e.message}"
129
+ end
130
+ Config::Resolution.new(subscribers:, rejections: [])
131
+ end
132
+
133
+ # Reported ONCE per emit (a single fact: "N rows were rejected"), not once per rejected row
134
+ # -- the same once-per-emit discipline `warn_sync_fallback` already follows. Best-effort: a
135
+ # broken reporter must not turn "some rows were bad" into a crash of an otherwise-successful
136
+ # emit (the same reasoning Deliver's own `report_exhaustion` documents).
137
+ def report_rejections(rejections)
138
+ Axn::Extensions.best_effort("reporting rejected outbound targets", action: self) do
139
+ Axn.config.on_exception(
140
+ Axn::Webhooks::Error.new("#{rejections.size} outbound target(s) rejected for #{event}"),
141
+ action: self,
142
+ context: { event: event.to_s, rejections: },
143
+ )
144
+ end
145
+ end
146
+
147
+ # Tri-state. An explicit `true` with no adapter RAISES rather than degrading: a missing
148
+ # adapter falls back to sync only under `:auto`, never under an explicit request (the same
149
+ # rule inbound's Dispatch#dispatch_async enforces, for the same reason — something marked
150
+ # async usually is so because running it inline would blow someone's time budget).
151
+ def async?
152
+ return async_configured? if async.nil?
153
+ return false unless async
154
+
155
+ unless async_configured?
156
+ raise Axn::Webhooks::Error,
157
+ "emit(#{event.inspect}, async: true) requires an axn async adapter, but none is " \
158
+ "configured for #{Deliver} (add `async :sidekiq`/`async :active_job` to it, or set a global default)"
159
+ end
160
+
161
+ true
162
+ end
163
+
164
+ # Warned ONCE per emit (not once per target) — a high-fan-out event would otherwise spam
165
+ # one line per subscriber for what is a single configuration fact.
166
+ def warn_sync_fallback(type)
167
+ Axn.config.logger.warn(
168
+ "[axn-webhooks] delivering #{type} synchronously (no async adapter configured) — " \
169
+ "best-effort, no cross-process retries",
170
+ )
171
+ end
172
+
173
+ def async_configured?
174
+ return !!Deliver._async_adapter if Deliver.respond_to?(:_async_adapter) && !Deliver._async_adapter.nil?
175
+
176
+ Axn.config.default_async?
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Axn
7
+ module Webhooks
8
+ module Outbound
9
+ # Builds the Standard Webhooks message body and its idempotency id. The body is fixed at
10
+ # emit time (part of the dedup identity); the SIGNATURE is recomputed per delivery attempt
11
+ # (see Deliver), so this carries no signing concern.
12
+ module Envelope
13
+ module_function
14
+
15
+ def new_id = "msg_#{SecureRandom.uuid}"
16
+
17
+ def build(id:, type:, data:, now: Time.now)
18
+ JSON.generate(id:, timestamp: now.to_i, type: type.to_s, data:)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Outbound
6
+ # Builds a signer callable (#call(id:, timestamp:, body:) -> header Hash) from a `sign`
7
+ # declaration. The :standard_webhooks strategy is the outbound face of the inbound
8
+ # verify :standard_webhooks — same scheme, so a receiver using that verifier accepts it.
9
+ module Signer
10
+ module_function
11
+
12
+ # RFC 7230 field-name token. Net::HTTP stores whatever key it is handed and serializes it
13
+ # straight into the header line, so a space yields a malformed request and a newline
14
+ # appends attacker-shaped wire headers — neither caught until delivery (Codex review).
15
+ # Module-level (not nested under HmacSigner) so `Deliver`'s per-destination `headers`
16
+ # merge (PRO-3214) can hold a subscriber-supplied header name to the SAME grammar, rather
17
+ # than duplicating it — the identical injection risk, just from runtime data instead of a
18
+ # `sign :hmac` declaration (Codex P1 finding).
19
+ HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
20
+
21
+ def build(strategy:, opts:, block:)
22
+ return CustomSigner.new(block) if block
23
+
24
+ case strategy&.to_sym
25
+ when :hmac then HmacSigner.new(**opts)
26
+ when :standard_webhooks then StandardWebhooksSigner.new(**opts)
27
+ # A pure declaration mistake (decided once at boot, never at runtime) — ArgumentError, not
28
+ # Axn::Webhooks::Error, matching Config's own misconfiguration-vs-runtime split.
29
+ else raise ArgumentError, "unknown sign strategy #{strategy.inspect}"
30
+ end
31
+ end
32
+
33
+ # Wraps a user block; called with the same kwargs as the built-in signers, PLUS `subscriber:`
34
+ # (PRO-3214, a `Subscriber` or nil) -- filtered down to whatever the block actually declares
35
+ # (via CallableArity.accepted_keywords), so a block written against the original
36
+ # `(id:, timestamp:, body:)` contract keeps working byte-for-byte rather than raising an
37
+ # unexpected-keyword ArgumentError the moment a widened caller starts also offering
38
+ # `subscriber:`. A block declaring `**` receives everything unfiltered.
39
+ class CustomSigner
40
+ # The only kwargs a signer is ever called with. A block may ignore any/all of them --
41
+ # `sign { { "X-API-Key" => key } }` (zero params) is a legitimate, pre-existing pattern:
42
+ # Ruby blocks are always non-lambda Procs, which silently tolerate being called with
43
+ # kwargs they never declared (Codex P1 finding: an earlier version of this check
44
+ # REQUIRED every block to declare id:/timestamp:/body:, rejecting that working
45
+ # configuration at boot even though it was never actually broken).
46
+ SUPPLIED_KEYWORDS = %i[id timestamp body subscriber].freeze
47
+
48
+ def initialize(block)
49
+ @block = block
50
+ @accepted = CallableArity.accepted_keywords(block)
51
+ # A block declaring NO keywords at all but at least one POSITIONAL param (`sign { |options|
52
+ # … }`) is the historical "options Hash" shape: Ruby auto-converts trailing keyword
53
+ # arguments into a Hash for a lone positional parameter (true for both a Proc/block and a
54
+ # lambda alike), which is exactly what the ORIGINAL unconditional `.call(id:, timestamp:,
55
+ # body:)` relied on. Filtering down to `**{}` (zero keywords accepted) calls with ZERO
56
+ # arguments instead -- fine for a Proc (leaves `options` nil, tolerated) but a REQUIRED-arity
57
+ # lambda raises outright; either way `options` never gets the data (Codex P1 finding).
58
+ @wants_positional_hash = @accepted != :all && @accepted.empty? && CallableArity.accepts_positional?(block)
59
+ validate_required_keywords!
60
+ end
61
+
62
+ def call(id:, timestamp:, body:, subscriber: nil)
63
+ # The positional-Hash shape's CONTRACT is exactly these three keys -- it has no way to
64
+ # OPT IN to `subscriber:` the way a keyword-declaring block does, so it must never see
65
+ # it: a pre-existing signer deriving its signature from the WHOLE hash (e.g. hashing
66
+ # every key-value pair together) would compute a DIFFERENT signature the instant a 4th
67
+ # key appeared, silently breaking verification on the receiving end (Codex P1 finding).
68
+ return @block.call({ id:, timestamp:, body: }) if @wants_positional_hash
69
+
70
+ @block.call(**filtered({ id:, timestamp:, body:, subscriber: }))
71
+ end
72
+
73
+ private
74
+
75
+ def filtered(kwargs)
76
+ return kwargs if @accepted == :all
77
+
78
+ kwargs.slice(*@accepted)
79
+ end
80
+
81
+ # The one shape that genuinely fails on every call: a block REQUIRING a keyword outside
82
+ # `SUPPLIED_KEYWORDS` (e.g. `sign { |id:, vendor:| … }`) -- `vendor:` is never one of the
83
+ # kwargs a signer is called with, so every real signing attempt would raise "missing
84
+ # keyword: vendor". A block that merely ignores some/all of id:/timestamp:/body:/
85
+ # subscriber: -- including declaring NONE of them -- is fine; Ruby's own Proc/block
86
+ # semantics already tolerate that.
87
+ def validate_required_keywords!
88
+ # No `@accepted == :all` early-return: `**` only absorbs EXTRA/unknown keywords, it
89
+ # does nothing for a REQUIRED one this gem still never supplies -- `->(id:, vendor:,
90
+ # **) { }` reported :all (skipping this check entirely) but still raised "missing
91
+ # keyword: vendor" on the very first real call (Codex P2 finding, round 11).
92
+ # `required_keywords` inspects the block's OWN declared params directly and is
93
+ # unaffected by whether it ALSO double-splats.
94
+ unsupplied = CallableArity.required_keywords(@block) - SUPPLIED_KEYWORDS
95
+ return if unsupplied.empty?
96
+
97
+ raise ArgumentError,
98
+ "sign block requires #{unsupplied.map { |k| "#{k}:" }.join(', ')}, which this gem " \
99
+ "never supplies (only #{SUPPLIED_KEYWORDS.map { |k| "#{k}:" }.join(', ')} are ever passed)"
100
+ end
101
+ end
102
+
103
+ # Parametric HMAC, the outbound face of `verify :hmac`. Emits ONE signature header plus an
104
+ # optional timestamp header. `header:` is required: unlike Standard Webhooks there is no
105
+ # universal header name, which is exactly why the inbound verifier requires `signature:`.
106
+ class HmacSigner
107
+ PLACEHOLDERS = %w[timestamp body].freeze
108
+ DEFAULT_SIGNING_STRING = "{body}"
109
+
110
+ def initialize(secret:, header:, digest: :sha256, encoding: :hex, prefix: nil,
111
+ signing_string: DEFAULT_SIGNING_STRING, timestamp_header: nil)
112
+ validate_header_name!(:header, header)
113
+ unless timestamp_header.nil?
114
+ validate_header_name!(:timestamp_header, timestamp_header)
115
+ # The timestamp assignment in `call` lands SECOND and would overwrite the signature,
116
+ # shipping every delivery unverifiable — silently. HTTP header names are
117
+ # case-insensitive, so compare that way (Codex review).
118
+ if header.casecmp?(timestamp_header)
119
+ raise ArgumentError,
120
+ "sign :hmac `header:` and `timestamp_header:` are the same header name " \
121
+ "(#{header.inspect}) — the timestamp would overwrite the signature"
122
+ end
123
+ end
124
+
125
+ # Same reasoning as :standard_webhooks — `resolved_secret` calls with NO arguments, or
126
+ # with the PRO-3214 `Subscriber` for a 1-arity per-subscriber secret. A callable needing
127
+ # MORE than that boots fine and raises on every real signing attempt.
128
+ if secret.respond_to?(:call) && !(CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1))
129
+ raise ArgumentError,
130
+ "sign :hmac secret callable must accept zero or one arguments (resolved with no " \
131
+ "args, or the Subscriber, per signing attempt)"
132
+ end
133
+
134
+ # Both are finite sets in Signature; an unvalidated typo boots fine and then raises
135
+ # inside EVERY delivery attempt — on the async path, after the job is enqueued, so it
136
+ # retries the same broken config (Codex review).
137
+ raise ArgumentError, "sign :hmac unsupported digest: #{digest.inspect}" unless Signature::DIGESTS.key?(digest)
138
+ raise ArgumentError, "sign :hmac unsupported encoding: #{encoding.inspect}" unless Signature::ENCODINGS.include?(encoding)
139
+
140
+ validate_template!(signing_string, timestamp_header)
141
+
142
+ # Copy every String we validated or emit. Validation runs ONCE, here; retaining the
143
+ # caller's mutable object lets an app change what ships afterwards —
144
+ # `header.replace("Content-Type")` walks straight past both the field-name grammar and
145
+ # the MANAGED_HEADERS collision rule, and Deliver then overwrites the signature (Codex
146
+ # review). Same validate-then-alias shape as Config's static `to:` array.
147
+ @secret = secret # NOT copied: may be a callable, and a String secret is re-read per call anyway
148
+ @header = dup_frozen(header)
149
+ @digest = digest
150
+ @encoding = encoding
151
+ @prefix = dup_frozen(prefix)
152
+ @signing_string = dup_frozen(signing_string)
153
+ @timestamp_header = dup_frozen(timestamp_header)
154
+ end
155
+
156
+ # `id:` is part of the signer contract but unused here — an id-bearing signature is what
157
+ # :standard_webhooks is for, and this preset emits no id header for a receiver to read one
158
+ # back from. Absorbed by `**` rather than named, so it isn't an unused argument.
159
+ def call(timestamp:, body:, subscriber: nil, **)
160
+ sig = Signature.compute(
161
+ secret: resolved_secret(subscriber),
162
+ payload: render(timestamp:, body:),
163
+ digest: @digest,
164
+ encoding: @encoding,
165
+ )
166
+
167
+ headers = { @header => "#{@prefix}#{sig}" }
168
+ headers[@timestamp_header] = timestamp.to_s if @timestamp_header
169
+ headers
170
+ end
171
+
172
+ private
173
+
174
+ def dup_frozen(value) = value.is_a?(String) ? value.dup.freeze : value
175
+
176
+ # A `to_s`-based blank check is not enough: `false.to_s` is "false" and `123.to_s` is
177
+ # "123", so both pass it and publish a signer that emits `{ false => "<sig>" }` for the
178
+ # transport to choke on mid-delivery — a boot-time declaration mistake turned into a
179
+ # repeatedly-retried delivery exception. For `timestamp_header:` a `false` is worse than
180
+ # useless: it satisfies the {timestamp}-needs-a-header rule below while `call`'s
181
+ # `if @timestamp_header` skips emitting it, leaving the receiver a timestamp-bound
182
+ # signature it cannot reconstruct (Codex review).
183
+ def validate_header_name!(option, value)
184
+ unless value.is_a?(String) && !value.strip.empty?
185
+ raise ArgumentError,
186
+ "sign :hmac `#{option}:` must be a non-empty String header name (got #{value.inspect})"
187
+ end
188
+ unless value.match?(HEADER_NAME)
189
+ raise ArgumentError,
190
+ "sign :hmac `#{option}:` must be a valid HTTP header name (got #{value.inspect}) — " \
191
+ "letters, digits and !#$%&'*+-.^_`|~ only, with no spaces, colons or newlines"
192
+ end
193
+
194
+ # Everything set AFTER the signer runs: Deliver merges its own content-type/user-agent,
195
+ # and the transport regenerates content-length from the body at send time. Any of them
196
+ # emitted as the signature or timestamp header is silently replaced downstream.
197
+ # Resolved here rather than at load time — signer.rb is required before deliver.rb.
198
+ reserved = Deliver::MANAGED_HEADERS + Transport::RESERVED_HEADERS
199
+ return unless reserved.any? { |managed| managed.casecmp?(value) }
200
+
201
+ raise ArgumentError,
202
+ "sign :hmac `#{option}:` is #{value.inspect}, a header the delivery pipeline controls " \
203
+ "(#{reserved.join(', ')}) — it is set after signing and would replace this one"
204
+ end
205
+
206
+ def render(timestamp:, body:)
207
+ @signing_string.gsub(/\{(\w+)\}/) { Regexp.last_match(1) == "timestamp" ? timestamp.to_s : body }
208
+ end
209
+
210
+ # A template (not a callable) so an unknown placeholder is caught HERE, at declaration
211
+ # time — impossible with a lambda. Anyone needing real logic has the custom `sign { … }`
212
+ # block already; a callable option would be a worse-ergonomics duplicate of it.
213
+ def validate_template!(template, timestamp_header)
214
+ raise ArgumentError, "sign :hmac `signing_string:` must be a String template (got #{template.class})" unless template.is_a?(String)
215
+
216
+ # Strip the KNOWN placeholders, then treat any brace left behind as an error. Scanning
217
+ # for `\{(\w+)\}` alone only ever saw well-formed braces, so `{time-stamp}` (hyphen) and
218
+ # `{timestamp` (unmatched) matched nothing, passed validation, and got signed as literal
219
+ # text — a signature the receiver cannot reconstruct, from the very option whose selling
220
+ # point is declaration-time validation (Codex review). A literal brace in a signing
221
+ # string is therefore not supported; it is far likelier to be a typo.
222
+ leftover = template.gsub(/\{(?:#{PLACEHOLDERS.join('|')})\}/, "")
223
+ if leftover.match?(/[{}]/)
224
+ bad = leftover.scan(/\{[^{}]*\}|[{}]/).uniq
225
+ raise ArgumentError,
226
+ "sign :hmac `signing_string:` has unknown or malformed placeholder(s) " \
227
+ "#{bad.map(&:inspect).join(', ')} (known: {timestamp}, {body})"
228
+ end
229
+
230
+ found = template.scan(/\{(\w+)\}/).flatten.uniq
231
+
232
+ return unless found.include?("timestamp") && timestamp_header.nil?
233
+
234
+ raise ArgumentError,
235
+ "sign :hmac `signing_string:` references {timestamp} but no `timestamp_header:` is " \
236
+ "declared — the receiver would have no way to reconstruct the signed string"
237
+ end
238
+
239
+ # A blank or non-String secret would otherwise sign every delivery with an empty/garbage
240
+ # key, and the receiver's 401 is indistinguishable from any other misconfiguration. The
241
+ # message NEVER carries the secret's bytes: a callable secret is re-resolved per attempt,
242
+ # so this can raise on every delivery and would flow the live credential into whatever
243
+ # Axn.config.on_exception is wired to.
244
+ # Arity-aware, mirroring StandardWebhooksSigner's `resolve_secret`: a 1-arity secret
245
+ # callable gets the Subscriber (PRO-3214, a per-subscriber secret); a 0-arity one (or a
246
+ # plain value) resolves exactly as it did before subscriber-awareness existed.
247
+ def resolved_secret(subscriber)
248
+ secret = if @secret.respond_to?(:call)
249
+ # Prefer a ZERO-arg call whenever the callable can accept one: a PRE-EXISTING
250
+ # secret resolver with an unrelated optional arg (e.g. `->(app =
251
+ # Rails.application) { ... }`) must keep using ITS OWN default, not silently
252
+ # start receiving the Subscriber just because it COULD accept one arg. Only a
253
+ # callable that genuinely cannot be invoked with zero args gets the
254
+ # subscriber. Raw arity (`prefers_zero_args?`), not `#parameters`-based --
255
+ # a plain `proc { |subscriber| }` (no default) reports its param as `:opt`
256
+ # via `#parameters`, indistinguishable from a genuine default by that API,
257
+ # but its raw arity is still the correct positive `1` (Codex P1 finding: a
258
+ # `#parameters`-based check silently passed `nil` for exactly this shape).
259
+ CallableArity.prefers_zero_args?(@secret) ? @secret.call : @secret.call(subscriber)
260
+ else
261
+ @secret
262
+ end
263
+ return secret if secret.is_a?(String) && !secret.empty?
264
+
265
+ raise Axn::Webhooks::Error,
266
+ "sign :hmac secret must be a non-empty String (got #{secret.is_a?(String) ? 'an empty String' : secret.class})"
267
+ end
268
+ end
269
+
270
+ # Standard Webhooks: secret is `whsec_<base64>`; sign `id.timestamp.body` (sha256/base64);
271
+ # emit `v1,<sig>` alongside the id/timestamp headers the inbound verifier reads.
272
+ class StandardWebhooksSigner
273
+ def initialize(secret:)
274
+ # A pure declaration mistake, decided once at boot from the callable's own shape (not
275
+ # from what it resolves to) — ArgumentError, matching Config's misconfiguration split.
276
+ # `resolve_secret` below calls `@secret.call` with NO arguments, or with the PRO-3214
277
+ # `Subscriber` for a 1-arity per-subscriber secret; a callable needing MORE than that
278
+ # would otherwise boot successfully and raise ArgumentError on every real signing attempt
279
+ # (Codex P2 finding, widened for the subscriber-aware case).
280
+ if secret.respond_to?(:call)
281
+ unless CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1)
282
+ raise ArgumentError,
283
+ "sign :standard_webhooks secret callable must accept zero or one arguments " \
284
+ "(resolved with no args, or the Subscriber, per signing attempt)"
285
+ end
286
+ else
287
+ # A LITERAL secret is fully knowable now, so the whsec_ check that guards every signing
288
+ # attempt runs once here instead. Otherwise the natural mistake — pasting the raw key a
289
+ # vendor's dashboard shows you, without the `whsec_` prefix — declares cleanly and then
290
+ # raises inside EVERY delivery attempt, which is the worst place for it: an async
291
+ # adapter retries that as if it were a transient network failure. ArgumentError (not
292
+ # Axn::Webhooks::Error) to match the arity check above and Config's misconfiguration
293
+ # split: a declaration mistake, decided at boot.
294
+ #
295
+ # A CALLABLE secret is deliberately NOT resolved here — it may read a secret store or
296
+ # be per-subscriber, so its VALUE stays a per-attempt check (its arity is all that's
297
+ # knowable at boot). Documented in the README's "Boot-time validation" section.
298
+ raise ArgumentError, invalid_secret_message(secret) unless Verifiers::StandardWebhooks.secret_key(secret)
299
+ end
300
+
301
+ @secret = secret
302
+ end
303
+
304
+ # The raw HMAC key behind a Standard Webhooks secret, or nil if it isn't one: `whsec_` +
305
+ # a base64 body that decodes to something non-empty. Both the boot-time check above and
306
+ # the per-attempt `decoded_secret` below go through this, so validity and the decoded
307
+ # bytes can never disagree, and the decode happens exactly once per caller.
308
+ #
309
+ # An unprefixed or blank secret would otherwise decode "successfully" (both are valid
310
+ # base64) and sign every delivery with an empty or wrong key — silently, since the
311
+ # receiver's 401 is indistinguishable from any other misconfiguration (Codex P1 finding).
312
+ # The rescue is scoped to ONLY the decode: a callable secret's own resolver may raise its
313
+ # own ArgumentError for an unrelated reason (a secret-store wrapper rejecting a malformed
314
+ # response), and that diagnostic must reach Axn.config.on_exception intact rather than
315
+ # being rewritten as a generic invalid-secret message (Codex P2 finding) — which is why
316
+ # resolution happens in `decoded_secret`, outside this method.
317
+ def self.decode_secret(secret)
318
+ return nil unless secret.is_a?(String) && secret.start_with?("whsec_")
319
+
320
+ decoded = Verifiers::StandardWebhooks.decode_secret(secret)
321
+ decoded.empty? ? nil : decoded
322
+ rescue ArgumentError
323
+ nil
324
+ end
325
+
326
+ def call(id:, timestamp:, body:, subscriber: nil)
327
+ sig = Signature.compute(
328
+ secret: decoded_secret(subscriber),
329
+ payload: "#{id}.#{timestamp}.#{body}",
330
+ digest: :sha256,
331
+ encoding: :base64,
332
+ )
333
+ {
334
+ "webhook-id" => id.to_s,
335
+ "webhook-timestamp" => timestamp.to_s,
336
+ "webhook-signature" => "v1,#{sig}",
337
+ }
338
+ end
339
+
340
+ private
341
+
342
+ # A callable secret (the norm for every other webhook secret in this gem — see
343
+ # Resolvers.resolve) resolves per call rather than being frozen at `sign` time; a plain
344
+ # value is used as-is. Arity-aware (PRO-3214): a 1-arity callable gets the Subscriber (a
345
+ # per-subscriber secret); a 0-arity one resolves exactly as it did before subscriber-
346
+ # awareness existed.
347
+ def resolve_secret(subscriber)
348
+ return @secret unless @secret.respond_to?(:call)
349
+
350
+ # See the identical precedence rationale (and the Proc/#parameters quirk it works
351
+ # around) on HmacSigner#resolved_secret above (Codex P1 finding).
352
+ CallableArity.prefers_zero_args?(@secret) ? @secret.call : @secret.call(subscriber)
353
+ end
354
+
355
+ # `resolve_secret` runs OUTSIDE the guard on purpose — a secret store's own ArgumentError
356
+ # must reach the exception reporter intact, not be rewritten as an invalid-secret message.
357
+ def decoded_secret(subscriber)
358
+ secret = resolve_secret(subscriber)
359
+
360
+ Verifiers::StandardWebhooks.secret_key(secret) || raise(invalid_secret_error(secret))
361
+ end
362
+
363
+ def invalid_secret_error(secret)
364
+ Axn::Webhooks::Error.new(invalid_secret_message(secret))
365
+ end
366
+
367
+ # Shared with inbound `verify :standard_webhooks` so both halves reject the same values
368
+ # and describe them the same way — never interpolating the secret's own bytes.
369
+ def invalid_secret_message(secret)
370
+ Verifiers::StandardWebhooks.invalid_secret_message("sign :standard_webhooks", secret)
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end