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
|
@@ -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
|