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,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ module Outbound
8
+ # The resolved, immutable outbound declaration. One per process (a single `outbound` block).
9
+ class Config
10
+ # Validated settings via the upstream Axn::Configurable DSL (class flavor) — max_attempts/
11
+ # backoff/transport/vendor/user_agent/timeouts are simple, independently valid-or-not values.
12
+ # `events` (and its `to:`/URL structure) stays hand-written below: it's a Hash built and
13
+ # cross-validated as a whole from one DSL block, not a flat setting.
14
+ extend Axn::Configurable::Settings
15
+
16
+ DEFAULT_MAX_ATTEMPTS = 8
17
+ # Equal jitter (half fixed, half random): a fan-out event whose receiver is down would
18
+ # otherwise have every failing target retry in lockstep, converging on the same instant.
19
+ DEFAULT_BACKOFF = lambda do |attempt|
20
+ base = [30 * (3**(attempt - 1)), 6 * 3600].min
21
+ ((base / 2.0) + (rand * base / 2.0)).round
22
+ end
23
+ DEFAULT_OPEN_TIMEOUT = 5
24
+ DEFAULT_READ_TIMEOUT = 10
25
+
26
+ setting :max_attempts, default: DEFAULT_MAX_ATTEMPTS,
27
+ validate: ->(v) { (v.is_a?(Integer) && v.positive?) || "must be a positive Integer" }
28
+ # The default itself IS a callable (not a value computed BY one) — a bare Proc default would
29
+ # otherwise be read as "call this with no args to derive the default" (Configurable's dynamic-
30
+ # default convention) and blow up on DEFAULT_BACKOFF's required `attempt` arg. A zero-arity
31
+ # wrapper resolves to the lambda itself instead.
32
+ # `arity == 1 || arity.negative?` alone can't tell "one required positional" apart from "one
33
+ # required KEYWORD" (same arity, one raises on `.call(attempt)`) or "needs 2+ positional, has
34
+ # a splat" (negative arity, still too few args) — see CallableArity.
35
+ setting :backoff, default: -> { DEFAULT_BACKOFF },
36
+ validate: lambda { |v|
37
+ next "must be a callable accepting the attempt number" unless v.respond_to?(:call)
38
+
39
+ CallableArity.accepts?(v, 1) || "must be a callable accepting the attempt number"
40
+ }
41
+ # Same reasoning as `backoff`: `Transport` is a Module, and Configurable's non-dynamic default
42
+ # path calls `.dup` on it — silently swapping in an anonymous copy that fails every `== Transport`
43
+ # identity check downstream (e.g. Deliver's timeout-forwarding guard).
44
+ setting :transport, default: -> { Transport }
45
+ setting :vendor
46
+ # Deliver's `resolve_user_agent_suffix` calls a callable value with NO arguments (a zero-arg
47
+ # invocation is the documented contract) — one that requires an argument would otherwise boot
48
+ # successfully and raise ArgumentError on every real delivery (Codex P2 finding).
49
+ setting :user_agent,
50
+ validate: lambda { |v|
51
+ next true unless v.respond_to?(:call)
52
+
53
+ CallableArity.accepts?(v, 0) || "callable must accept zero arguments (resolved with no args per delivery attempt)"
54
+ }
55
+ # Forwarded straight to Net::HTTP (see Transport), which calls `.zero?`/compares on whatever
56
+ # it's given — an unvalidated non-Numeric (e.g. a String from `ENV.fetch("OPEN_TIMEOUT")`)
57
+ # would otherwise raise NoMethodError mid-delivery instead of failing at boot (Codex P2
58
+ # finding).
59
+ TIMEOUT_VALIDATE = ->(v) { (v.is_a?(Numeric) && v.positive?) || "must be a positive Numeric" }
60
+ setting :open_timeout, default: DEFAULT_OPEN_TIMEOUT, validate: TIMEOUT_VALIDATE
61
+ setting :read_timeout, default: DEFAULT_READ_TIMEOUT, validate: TIMEOUT_VALIDATE
62
+ # A host policy, not a network one: neither this nor `allow_url` resolves DNS, so neither is
63
+ # proof against DNS rebinding or a hostname that later resolves to a private IP. Both nil by
64
+ # default (any http(s) URL passes), so an existing `outbound` block is unaffected. See
65
+ # TargetPolicy for the actual matching semantics (case-insensitive, `*.suffix` wildcard).
66
+ setting :allowed_hosts,
67
+ validate: lambda { |v|
68
+ next true if v.is_a?(Array) && v.all? { |h| h.is_a?(String) && !h.strip.empty? }
69
+
70
+ "must be an Array of non-empty host Strings"
71
+ }
72
+ # Required arity 1 (the parsed URI) -- unlike `user_agent`/a signing `secret`, there is no
73
+ # useful zero-arg reading of a URL-allow predicate; matches `backoff`'s "the callable's whole
74
+ # purpose is examining its argument" precedent.
75
+ setting :allow_url,
76
+ validate: lambda { |v|
77
+ next "must be a callable accepting the parsed URL" unless v.respond_to?(:call)
78
+
79
+ CallableArity.accepts?(v, 1) || "must be a callable accepting the parsed URL"
80
+ }
81
+ # Per-destination extra headers (e.g. a subscriber's bearer token), resolved fresh per
82
+ # DELIVERY ATTEMPT from the Subscriber -- never stored, same convention `sign`'s `secret:`
83
+ # follows -- so nothing here ever sits in a Sidekiq job payload. 0-arity (ignores the
84
+ # subscriber) or 1-arity (receives it); nil by default (no extra headers).
85
+ setting :headers,
86
+ validate: lambda { |v|
87
+ next "must be a callable accepting zero or one arguments (the resolved Subscriber)" unless v.respond_to?(:call)
88
+
89
+ (CallableArity.accepts?(v, 0) || CallableArity.accepts?(v, 1)) ||
90
+ "must be a callable accepting zero or one arguments (the resolved Subscriber)"
91
+ }
92
+
93
+ # The problem with `url` as an outbound target, or nil when there is none. A predicate
94
+ # rather than a raiser, because its two callers disagree on the error class: a declaration
95
+ # mistake at boot is an ArgumentError (see validate_url!), while a bad one-off `emit(to:)`
96
+ # URL is an Axn::Webhooks::Error a caller may rescue at runtime (see Outbound::Emit). Shape
97
+ # only (no host policy) -- `Outbound::Emit#resolve_targets` applies `allowed_hosts`/
98
+ # `allow_url` itself via `TargetPolicy.check!` for the one-off `to:` override; this predicate
99
+ # stays a pure URL-shape check so `validate_url!`'s narrower boot-time contract doesn't drift.
100
+ def self.url_problem(url)
101
+ TargetPolicy.parse_url!(url)
102
+ nil
103
+ rescue Axn::Webhooks::InvalidTarget => e
104
+ e.message
105
+ end
106
+
107
+ # rubocop:disable Metrics/ParameterLists -- one kwarg per DSL setting, mirroring `DSL#__config__`'s
108
+ # call site 1:1; a Hash-options refactor would ripple through every caller for no real gain.
109
+ def initialize(signer:, events:, default_subscribers:, max_attempts:, backoff:, transport:,
110
+ vendor: nil, user_agent: nil, open_timeout: nil, read_timeout: nil,
111
+ allowed_hosts: nil, allow_url: nil, headers: nil)
112
+ # rubocop:enable Metrics/ParameterLists
113
+ @signer = signer
114
+ @events = events # { Symbol => { to:, type:, vendor: } }
115
+ @default_subscribers = default_subscribers
116
+
117
+ self.max_attempts = max_attempts unless max_attempts.nil?
118
+ self.backoff = backoff unless backoff.nil?
119
+ self.transport = transport unless transport.nil?
120
+ self.vendor = vendor unless vendor.nil?
121
+ self.user_agent = user_agent unless user_agent.nil?
122
+ self.open_timeout = open_timeout unless open_timeout.nil?
123
+ self.read_timeout = read_timeout unless read_timeout.nil?
124
+ self.allowed_hosts = allowed_hosts unless allowed_hosts.nil?
125
+ self.headers = headers unless headers.nil?
126
+ self.allow_url = allow_url unless allow_url.nil?
127
+
128
+ @events.each { |name, spec| validate_event!(name, spec) }
129
+
130
+ deep_freeze!
131
+ end
132
+
133
+ attr_reader :signer
134
+
135
+ def events = @events.keys
136
+
137
+ def wire_type(event)
138
+ (fetch(event)[:type] || event).to_s
139
+ end
140
+
141
+ # A per-event `vendor:` overrides the block-level default; same precedence as `type:`.
142
+ def vendor_for(event)
143
+ fetch(event)[:vendor] || vendor
144
+ end
145
+
146
+ # `resolve_subscribers`'s return value: `subscribers` is the Array of validated `Subscriber`s
147
+ # to actually fan out to; `rejections` is `{ target:, reason: }` for every row TargetPolicy
148
+ # refused. Rejection is PER ROW -- one malformed subscriber (or one your `allowed_hosts`/
149
+ # `allow_url` policy refuses) never discards the rest of a fan-out.
150
+ Resolution = Data.define(:subscribers, :rejections)
151
+
152
+ # A DECLARED per-event `to:` always wins, even when it resolves to zero targets — a static
153
+ # Array as-is (including `[]`), or a lambda `->(event){…}` invoked (arity-aware, matching
154
+ # Resolvers.resolve) and its result wrapped in Array (nil -> []). The block-level
155
+ # `subscribers` resolver is ONLY consulted when the event declared no `to:` at all
156
+ # (spec[:to].nil?) — never as a fallback for a declared resolver returning nil.
157
+ #
158
+ # A static `to:` Array is validated ONCE, at boot (`validate_event!`) -- so its entries are
159
+ # only ever re-COERCED into Subscribers here, never re-checked against `TargetPolicy`.
160
+ # Re-running it on every resolution would be wasted work for a target that can never
161
+ # change, and would actually BREAK a stateful/rate-limited `allow_url` predicate: it could
162
+ # reject an already-boot-validated static target later, contradicting the documented
163
+ # once-at-boot contract (Codex P2 finding). Every OTHER raw entry -- from a callable `to:`
164
+ # or the `subscribers` fallback, neither checkable at boot since they depend on runtime
165
+ # state -- goes through the SAME `TargetPolicy` a static Array was checked against, so a
166
+ # runtime resolver can never see a looser bar than a hand-written Array does.
167
+ def resolve_subscribers(event)
168
+ spec = fetch(event)
169
+ return static_resolution(spec) if spec[:to].is_a?(Array)
170
+
171
+ raw = spec[:to].nil? ? call_resolver(@default_subscribers, event) : resolve_to(spec[:to], event)
172
+ check_targets(raw)
173
+ end
174
+
175
+ # Back-compat convenience for callers that only want the resolved URLs and don't care about
176
+ # a malformed row -- e.g. today's `Emit` fan-out. Silently drops rejections; a caller that
177
+ # needs to know about (or report) them wants `resolve_subscribers` directly. Frozen: for a
178
+ # static `to:` Array, `resolve_subscribers` used to return the SAME frozen Array `deep_freeze!`
179
+ # already produced; going through `Subscriber`/`.map` builds a fresh one, which `.map` never
180
+ # freezes on its own -- `targets_for`'s own immutability contract (asserted in
181
+ # config_immutability_spec.rb) has to be re-established here explicitly.
182
+ def targets_for(event)
183
+ resolve_subscribers(event).subscribers.map(&:url).freeze
184
+ end
185
+
186
+ # Scalars a rejected target's `#inspect` is safe to show verbatim in `redact_target`
187
+ # below: a bare-value target (the common malformed cases -- nil, a wrong-type url, a
188
+ # stray Integer) carries no OTHER fields that could hide a credential. NOT String: a
189
+ # webhook URL commonly embeds a credential itself (HTTP Basic userinfo, a signed/token
190
+ # query param) -- see `redact_target`'s dedicated String handling.
191
+ SAFE_TO_INSPECT = [Numeric, Symbol, NilClass, TrueClass, FalseClass].freeze
192
+
193
+ # A plausible field-name typo (`:secret`, `:api_key` -- what `Subscriber.coerce`'s own
194
+ # "unknown key(s)" message exists to surface) is a short, simple identifier -- used by
195
+ # `redact_hash_key` below to decide whether a Hash row's key is safe to show as-is.
196
+ HASH_KEY_NAME = /\A[A-Za-z_][A-Za-z0-9_]{0,49}\z/
197
+
198
+ private
199
+
200
+ # Every element already passed `TargetPolicy.check!` (shape + host policy) at boot, via
201
+ # `validate_event!` -- constructing the `Config` at all is proof of that, so re-running it
202
+ # here could only ever re-confirm a fact already established, at the cost of the once-at-
203
+ # boot contract `resolve_subscribers`'s doc comment above describes. `Subscriber.coerce`
204
+ # alone (no TargetPolicy) does the Hash/String/Subscriber normalization without re-touching
205
+ # the host policy.
206
+ def static_resolution(spec)
207
+ Resolution.new(subscribers: spec[:to].map { |target| Subscriber.coerce(target) }, rejections: [])
208
+ end
209
+
210
+ def check_targets(raw)
211
+ subscribers = []
212
+ rejections = []
213
+ wrap_targets(raw).each do |target|
214
+ subscribers << TargetPolicy.check!(target, allowed_hosts:, allow_url:)
215
+ rescue Axn::Webhooks::InvalidTarget => e
216
+ rejections << { target: redact_target(target), reason: e.message }
217
+ end
218
+
219
+ Resolution.new(subscribers:, rejections:)
220
+ end
221
+
222
+ # `Kernel#Array` on a bare Hash converts it to `[[k, v], ...]` PAIRS (Hash responds to
223
+ # `#to_a`), not `[hash]` -- so a `subscribers`/`to:` resolver returning ONE row directly
224
+ # (an easy mistake: "return the row" is the natural instinct when there's exactly one
225
+ # match) would otherwise have both halves of that Hash treated as separate malformed
226
+ # targets, and the real subscriber delivered to NOBODY (Codex review). A `Subscriber`
227
+ # already coerced (or anything else array-like) still goes through plain `Array()`.
228
+ def wrap_targets(raw)
229
+ return [] if raw.nil?
230
+ return [raw] if raw.is_a?(Hash) || raw.is_a?(Subscriber)
231
+
232
+ Array(raw)
233
+ end
234
+
235
+ # A safe-to-log stand-in for a rejected row: `target.inspect` verbatim would otherwise
236
+ # copy a live credential straight into `emit`'s exposed `rejected` AND the `on_exception`
237
+ # report -- exactly the row shape `Subscriber.coerce` rejects for carrying an unknown key
238
+ # like `secret:` in the first place (Codex P1 finding). Only the two keys `Subscriber`
239
+ # actually recognizes are shown as-is; every other key's NAME survives (so the rejection
240
+ # reason -- "unknown key(s): [...]" -- and this stay legible together) but its value never
241
+ # does. A non-Hash target (nil, a URI, ...) has nothing to redact.
242
+ def redact_target(target)
243
+ case target
244
+ when Hash
245
+ # `k` is redacted too, not just `v` -- a resolver mistake using a COMPOUND object AS A
246
+ # KEY (e.g. a malformed `.to_h` transform keying by the record itself rather than its
247
+ # id) would otherwise survive into the reconstructed Hash unchanged, and the outer
248
+ # `Hash#inspect` renders that key's own #inspect regardless of what its value became
249
+ # (Codex P1 finding, round 14).
250
+ target.to_h { |k, v| [redact_hash_key(k), hash_target_value(k, v)] }.inspect
251
+ when String
252
+ # A webhook URL commonly carries a credential ITSELF -- HTTP Basic userinfo or a
253
+ # signed/token query param -- so a rejected URL String isn't safe to `#inspect`
254
+ # verbatim (Codex P1 finding). `TargetPolicy.redact_url` is the SAME sanitizer every
255
+ # InvalidTarget message routes through, so a rejection's :target and its own :reason
256
+ # can never disagree about what's safe to show.
257
+ TargetPolicy.redact_url(target).inspect
258
+ when *SAFE_TO_INSPECT
259
+ target.inspect
260
+ else
261
+ # Anything else -- an ActiveRecord model instance is the plausible real-world case --
262
+ # is a COMPOUND object whose own #inspect commonly renders every attribute, including
263
+ # a secret/token column. Only the Hash-row shape has a known-safe subset (:url/:id) to
264
+ # show; for everything else, only the class is safe to name (Codex P1 finding: the
265
+ # earlier Hash-only redaction still leaked a model's attributes verbatim here).
266
+ "#<#{target.class} (redacted)>"
267
+ end
268
+ end
269
+
270
+ # Only a Symbol/String key is ever legitimate here (the only shapes `Subscriber.coerce`
271
+ # accepts) -- anything else is already a rejected row on its OWN terms (an "unsupported
272
+ # key" InvalidTarget), so only its class need survive, matching the class-only convention
273
+ # `Subscriber.coerce`'s own message already uses for the identical shape (Codex P1 finding,
274
+ # round 14).
275
+ # A plausible field-name typo (`:secret`, `:api_key` -- what `Subscriber.coerce`'s own
276
+ # "unknown key(s)" message exists to surface) is a short, simple identifier. A resolver
277
+ # mistake keying its row by a URL String instead of `url:` (a plausible
278
+ # `.to_h { |row| [row.url, row.id] }` bug) is a Symbol/String too, so returning EVERY
279
+ # Symbol/String key as-is (as an earlier version did) rendered the whole URL into this
280
+ # rejection's `:target` -- credentials commonly embedded in it included. `Subscriber.coerce`
281
+ # already redacts the identical shape in its own `:reason` message (round 20); this is the
282
+ # SEPARATE `:target` representation `redact_target` builds, which had the same gap (Codex
283
+ # P1 finding, round 21).
284
+ def redact_hash_key(key)
285
+ return "#<#{key.class} (redacted)>" unless key.is_a?(Symbol) || key.is_a?(String)
286
+
287
+ key.to_s.match?(HASH_KEY_NAME) ? key : "<redacted>"
288
+ rescue Encoding::CompatibilityError, ArgumentError
289
+ # `#match?` itself can raise for a key in an unexpected encoding (Codex P2 finding, round
290
+ # 19's class of bug) -- treated as "not a safe name" here too.
291
+ "<redacted>"
292
+ end
293
+
294
+ # `:url`'s value gets the SAME URL sanitization as a bare String target -- a Hash row
295
+ # rejected for some unrelated reason (an unknown key, say) must not leak a credential
296
+ # embedded in its OWN `:url` value either (Codex P1 finding, round 7). `:id` is never
297
+ # sensitive PROVIDED it's the documented scalar shape (a String/Integer identifier) --
298
+ # `:id` is only ever stringified into that shape by `Subscriber.coerce`, which this
299
+ # ALREADY-rejected raw row never reached, so a plausible mistake (passing the whole record
300
+ # instead of `record.id`) leaves a COMPOUND object under `:id` here. Showing it as-is would
301
+ # have the outer `Hash#inspect` render that object's own #inspect verbatim -- an
302
+ # ActiveRecord-like model commonly defines that to include every attribute, secrets
303
+ # included (Codex P1 finding, round 12). Every other key's NAME survives (so the rejection
304
+ # reason -- "unknown key(s): [...]" -- and this stay legible together) but its value never
305
+ # does.
306
+ def hash_target_value(key, value)
307
+ return TargetPolicy.redact_url(value) if key.to_s == "url" && value.is_a?(String)
308
+ return value if key.to_s == "id" && (value.is_a?(String) || SAFE_TO_INSPECT.any? { |klass| value.is_a?(klass) })
309
+
310
+ "<redacted>"
311
+ end
312
+
313
+ # Freezes the CONTAINERS we own, never the caller's objects: a `to:` resolver, the signer,
314
+ # an injected transport and a `user_agent` callable all stay mutable — they belong to the
315
+ # app, and freezing them could break a memoizing resolver. A statically-declared `to:`
316
+ # Array is ours once validated, so it freezes.
317
+ def deep_freeze!
318
+ materialize_settings!
319
+ @events.each_value do |spec|
320
+ # COPY rather than freeze in place, and cover EVERY value, not just `to:`: `event to:
321
+ # SOME_CONSTANT` would otherwise leave the application holding a frozen object it never
322
+ # froze, while the Strings inside stayed mutable — so `url.replace("ftp://…")` rewrote
323
+ # the published config past the boot-time validation that already ran on it, and a
324
+ # mutable `type:`/`vendor:` rewrote `wire_type`/`vendor_for` the same way (Codex review).
325
+ spec.transform_values! { |value| config_owned(value) }
326
+ spec.freeze
327
+ end
328
+ @events.freeze
329
+ freeze
330
+ end
331
+
332
+ # Read every declared setting once (so Configurable's lazy memoization happens BEFORE the
333
+ # freeze) and replace any mutable value with a config-owned copy — `vendor`/`user_agent` are
334
+ # plain Strings the caller may still hold, and a later `replace()` would otherwise change the
335
+ # observability facet and the delivery User-Agent header despite the frozen-config contract
336
+ # (Codex review). Callables, Modules and Numerics come back from config_owned untouched, so
337
+ # only the copyable shapes are reassigned. Without this, a frozen Config raises FrozenError from the READER of any setting
338
+ # the `outbound` block never explicitly assigned — `backoff`/`transport` escape only
339
+ # because their defaults are dynamic (`-> { … }`) and recomputed rather than memoized.
340
+ #
341
+ # Derived from Configurable rather than a hand-maintained list: a constant listing the
342
+ # settings has to be updated in lockstep with every new `setting` declaration, and
343
+ # forgetting turns that setting's own reader into a FrozenError at runtime. Nothing to keep
344
+ # in sync now — adding a `setting` is enough.
345
+ def materialize_settings!
346
+ Axn::Configurable.declared_settings_for(self.class).each_key do |name|
347
+ value = public_send(name)
348
+ owned = config_owned(value)
349
+ public_send(:"#{name}=", owned) unless owned.equal?(value)
350
+ end
351
+ end
352
+
353
+ # An immutable copy the config owns, for the shapes an event spec can hold. A callable `to:`
354
+ # (or anything else the app supplied) is returned untouched — not ours to copy or freeze.
355
+ # Hash (PRO-3214's `{ url:, id: }` row, inside a static `to:` Array) copies key-for-key
356
+ # rather than freezing the caller's Hash in place — the identical hazard this whole method
357
+ # exists to close, for a shape `to:`'s own String handling doesn't reach: `row[:url] =
358
+ # "ftp://…"` after boot would otherwise rewrite a validated target through a Hash the app
359
+ # still holds a mutable reference to. Its own keys aren't recursed into (a caller's Hash key
360
+ # is a Symbol or String literal, never something a boot-time mutation-safety fix cares
361
+ # about); only values are.
362
+ def config_owned(value)
363
+ case value
364
+ when String then value.dup.freeze
365
+ when Array then value.map { |element| config_owned(element) }.freeze
366
+ when Hash then value.to_h { |k, v| [k, config_owned(v)] }.freeze
367
+ when Subscriber
368
+ # `Data` instances are always frozen, but that freezes only the WRAPPER -- a prebuilt
369
+ # `Subscriber.new(url: app_string, id: ...)` still holds the app's own mutable url/id
370
+ # Strings underneath. Worse than the String/Hash cases above: `static_resolution`
371
+ # deliberately skips re-validating an already-boot-checked static entry, so a post-
372
+ # boot mutation here would switch a boot-approved destination to an arbitrary host
373
+ # with NO re-validation at all (Codex P2 finding).
374
+ Subscriber.new(url: config_owned(value.url), id: config_owned(value.id))
375
+ else value
376
+ end
377
+ end
378
+
379
+ def fetch(event)
380
+ @events.fetch(event.to_sym) do
381
+ raise Axn::Webhooks::Error,
382
+ "unknown outbound event #{event.inspect} (known: #{events.map(&:inspect).join(', ')})"
383
+ end
384
+ end
385
+
386
+ # The ORIGINAL dispatch rule, preserved byte-for-byte: pass the event unless the callable's
387
+ # raw arity is EXACTLY zero. Deliberately raw arity (`CallableArity.zero_arity?`), not
388
+ # `#parameters`-based `accepts?`: a pre-existing `subscribers ->(event = :all) { … }` or
389
+ # `proc { |event = :all| … }` resolver relies on Ruby's own arity quirks (a Proc with a
390
+ # single optional/default param reports arity 0; a lambda with one reports -1) to decide
391
+ # whether it gets called with the event or falls back to its own default -- an
392
+ # `accepts?`-based check (which reads `#parameters`' `:opt` LABEL rather than raw arity)
393
+ # would flip that for the Proc case specifically, silently changing which subscribers get
394
+ # selected (Codex P2 finding). Only made callable-object-safe here (falls back to
395
+ # `Method#arity` via `#call`, fixing the original NoMethodError for a plain callable OBJECT
396
+ # such as `Subscription::Store.new`) -- the dispatch RULE itself is unchanged.
397
+ def call_resolver(callable, event)
398
+ return nil if callable.nil?
399
+
400
+ CallableArity.zero_arity?(callable) ? callable.call : callable.call(event)
401
+ end
402
+
403
+ # `to:` is "declared" whenever spec[:to] is non-nil — a static value (Array, including
404
+ # `[]`) is returned as-is; a callable is invoked (arity-aware, via call_resolver).
405
+ def resolve_to(raw, event)
406
+ raw.respond_to?(:call) ? call_resolver(raw, event) : raw
407
+ end
408
+
409
+ # Boot-time validation, so a malformed declaration fails loudly here rather than as an
410
+ # unexpected exception mid-delivery (which the async adapter would retry as if it were a
411
+ # network failure). A pure declaration mistake — never triggered by runtime/user data, and
412
+ # nothing a running app would want to rescue-and-continue past — so this raises plain
413
+ # ArgumentError, same as `max_attempts`/`backoff` above, rather than the gem's own
414
+ # `Axn::Webhooks::Error` (reserved for conditions a caller might legitimately rescue at
415
+ # runtime, e.g. `fetch`'s unknown-event error below, raised on every `emit`/`targets_for`
416
+ # call rather than once at boot).
417
+ def validate_event!(name, spec)
418
+ return if spec[:to].nil? || spec[:to].respond_to?(:call)
419
+
420
+ unless spec[:to].is_a?(Array)
421
+ raise ArgumentError,
422
+ "outbound event #{name.inspect} `to:` must be an Array of URLs or a callable (got #{spec[:to].class})"
423
+ end
424
+
425
+ spec[:to].each { |target| validate_target!(name, target) }
426
+ end
427
+
428
+ # Delegates to the SAME `TargetPolicy` a runtime-resolved row goes through
429
+ # (`resolve_subscribers`), including the declared `allowed_hosts`/`allow_url` -- so a static
430
+ # `to:` entry your own host policy would reject fails loudly at boot instead of silently
431
+ # (well, loudly, but confusingly late) at the first emit. A pure declaration mistake, so this
432
+ # raises plain ArgumentError like `max_attempts`/`backoff` above, not the runtime
433
+ # `InvalidTarget` `resolve_subscribers` collects into `rejections`.
434
+ def validate_target!(name, target)
435
+ TargetPolicy.check!(target, allowed_hosts:, allow_url:)
436
+ rescue Axn::Webhooks::InvalidTarget => e
437
+ raise ArgumentError, "outbound event #{name.inspect} `to:` #{e.message}"
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end