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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # A registered inbound webhook endpoint. Verifies a request's signature, dispatches
7
+ # the (verified, parsed) event to a handler Axn, and maps the pipeline's outcome to an
8
+ # HTTP Response. Challenge (GET) and Rack mount arrive in a later phase.
9
+ class Endpoint
10
+ def initialize(name:, verifier:, dispatch: nil, respond: nil, static_respond: nil, challenge: nil,
11
+ unauthorized_headers: nil, challenge_required: nil)
12
+ if dispatch && dispatch[:mode] == :async && respond
13
+ raise Axn::Webhooks::Error,
14
+ "inbound endpoint `#{name}` declares a custom `respond` but explicit `dispatch mode: :async` " \
15
+ "can't produce a handler_result for it to read — use `mode: :sync` (or omit mode) or drop the respond block"
16
+ end
17
+
18
+ if respond && static_respond
19
+ raise Axn::Webhooks::Error,
20
+ "inbound endpoint `#{name}` declares both `respond` and `static_respond` — declare only one; " \
21
+ "`respond` reads the handler's result, `static_respond` doesn't and renders on every non-error outcome"
22
+ end
23
+
24
+ @name = name.to_sym
25
+ @verifier = verifier
26
+ @dispatch = dispatch
27
+ @respond = respond
28
+ @static_respond = static_respond
29
+ @challenge = challenge
30
+ @unauthorized_headers = unauthorized_headers
31
+ @challenge_required = challenge_required
32
+
33
+ validate_challenge!
34
+ end
35
+
36
+ attr_reader :name
37
+
38
+ # Headers attached to the 401 a verify failure produces. Empty for the signature
39
+ # strategies — there is nothing for a signing client to be challenged *with* — but
40
+ # mandatory for HTTP Basic auth (RFC 7617), where a client that doesn't authenticate
41
+ # preemptively sends its first request bare and repeats it with credentials only after a
42
+ # 401 carrying `WWW-Authenticate`. Without this the second leg never comes and every
43
+ # request from such a client is dropped, uniformly and silently.
44
+ #
45
+ # An explicit `unauthorized_headers` declaration wins, so a custom `verify` block can
46
+ # supply its own challenge; otherwise the verifier speaks for itself.
47
+ def unauthorized_headers
48
+ return @unauthorized_headers if @unauthorized_headers
49
+ return @verifier.unauthorized_headers if @verifier.respond_to?(:unauthorized_headers)
50
+
51
+ {}
52
+ end
53
+
54
+ # Is this request an authentication attempt at all? When it isn't, there is nothing to
55
+ # verify — it's a protocol precondition, not a failed verification — and #to_response
56
+ # answers with the challenge without invoking Verify (PRO-3148). Under a two-legged scheme
57
+ # like RFC 7617 Basic auth a reactive client sends one such request per *successful*
58
+ # webhook, so recording them as verify failures made the highest-volume outcome on a healthy
59
+ # endpoint a recorded failure, and a cross-vendor verify-failure monitor unusable without
60
+ # knowing which vendors happen to use Basic auth.
61
+ #
62
+ # False unless something says otherwise, so the signature strategies — which have no
63
+ # challenge to offer and no second leg to wait for — are untouched: no predicate means no
64
+ # ChallengeRequired call either, not merely a false answer from one.
65
+ #
66
+ # Note this is NOT the `challenge` declaration (that's the vendor's GET handshake, see
67
+ # #challenge_response). Same word, different protocol: this one is the 401 kind.
68
+ def challenge_required?(request)
69
+ predicate = challenge_predicate
70
+ return false unless predicate
71
+
72
+ # Inside an Axn boundary: the predicate is request-dependent code the gem doesn't own, and
73
+ # it runs ahead of every other boundary on the POST path. A crash settles not-ok and is read
74
+ # as "can't tell" -> verify normally (see ChallengeRequired for why that's the safe answer).
75
+ checked = ChallengeRequired.call(request:, predicate:, vendor: @name)
76
+ checked.ok? && checked.required
77
+ end
78
+
79
+ # Verify the request's signature. Returns an Axn::Result: ok? when verified,
80
+ # a failure on mismatch, an exception if the verifier raises.
81
+ def verify(request)
82
+ Verify.call(request:, verifier: @verifier, vendor: @name)
83
+ end
84
+
85
+ # Full pipeline: verify, then (if a dispatch is declared and verification passed)
86
+ # parse + route to the handler. Returns the final Axn::Result.
87
+ def handle(request)
88
+ verified = verify(request)
89
+ return verified unless verified.ok? && @dispatch
90
+
91
+ Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
92
+ mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
93
+ end
94
+
95
+ # The staged HTTP outcome mapping (spec: "Respond + staged outcome model"). Verify and
96
+ # dispatch are mapped in separate branches — deliberately NOT a single outcome->status
97
+ # rule, because a verify failure (401) and a handler business fail! (2xx) are both
98
+ # `outcome.failure?` but mean opposite things at the HTTP layer.
99
+ def to_response(request)
100
+ # Ahead of verify, deliberately: a request that isn't an authentication attempt gets the
101
+ # challenge rather than a recorded verify failure (see #challenge_required?). Same 401 on
102
+ # the wire, and it still can't reach a handler — strictly safer than the `done!` that
103
+ # would settle this leg as a *success*.
104
+ return Response.new(status: 401, headers: unauthorized_headers) if challenge_required?(request)
105
+
106
+ verified = verify(request)
107
+ return Response.new(status: 401, headers: unauthorized_headers) unless verified.ok?
108
+ return default_ack unless @dispatch
109
+
110
+ dispatched = Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
111
+ mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
112
+ response_for(dispatched)
113
+ end
114
+
115
+ # The GET branch (spec: the mount owns the whole path, every verb). Testable without a Rack
116
+ # env, mirroring #verify/#handle/#to_response.
117
+ def challenge_response(request)
118
+ return Response.new(status: 405) unless @challenge
119
+
120
+ # The Challenge axn computes the exact Response (200 echo / 403 guard-fail / 400 nil).
121
+ # Only a raising resolver/guard makes it not-ok -> a reported 500.
122
+ result = Challenge.call(request:, resolver: @challenge[:resolver], guard: @challenge[:guard], vendor: @name)
123
+ result.ok? ? result.response : Response.new(status: 500)
124
+ end
125
+
126
+ # The Rack app entry point (spec: mount-first packaging). `Inbound[:vendor]` (this object)
127
+ # is directly `mount`-able in Rails routes.rb or `run`-able in a bare Rack::Builder — the
128
+ # mount owns the whole path and every verb: POST -> #to_response, GET -> #challenge_response,
129
+ # anything else -> 405. Named `call`, deliberately reserved since Phase 3 (see #handle).
130
+ def call(env)
131
+ built = BuildRequest.call(env:, vendor: @name)
132
+ return Response.new(status: 500).to_rack unless built.ok?
133
+
134
+ request = built.request
135
+ response =
136
+ case request.http_method
137
+ when "POST" then to_response(request)
138
+ when "GET" then challenge_response(request)
139
+ else Response.new(status: 405)
140
+ end
141
+ response.to_rack
142
+ end
143
+
144
+ private
145
+
146
+ # The declared block, else the verifier's own bound predicate, else nil for "nobody claims a
147
+ # challenge" — the signature strategies and every plain `verify` lambda. Same precedence as
148
+ # #unauthorized_headers: a declaration wins, so a custom block can speak for a verifier the
149
+ # gem can't see through.
150
+ def challenge_predicate
151
+ return @challenge_required if @challenge_required
152
+ return @verifier.method(:challenge_required?) if @verifier.respond_to?(:challenge_required?)
153
+
154
+ nil
155
+ end
156
+
157
+ # A challenge with nothing in it is the PRO-3146 silent drop: the client is told to retry and
158
+ # never told how, so every request is dropped forever — and now without even a verify failure
159
+ # recorded, since answering the challenge skips Verify. Fails the boot rather than shipping an
160
+ # endpoint that is both broken and invisible.
161
+ #
162
+ # Both halves read the *effective* value, not the declaration: a predicate can arrive from a
163
+ # registered verifier (`Verifiers.register` is public) as easily as from a `challenge_required`
164
+ # block, and either can be paired with a challenge from the other side. So `verify :basic_auth`
165
+ # plus a declared predicate is fine, a declared pair is fine, and only an endpoint that claims
166
+ # a challenge is required without saying what to challenge with lands here.
167
+ def validate_challenge!
168
+ return unless challenge_predicate && unauthorized_headers.empty?
169
+
170
+ raise Axn::Webhooks::Error,
171
+ "inbound endpoint `#{@name}` requires a challenge (`challenge_required`, or a verifier that " \
172
+ "answers `#challenge_required?`) but has no challenge to send — declare `unauthorized_headers`, " \
173
+ "or the challenged client is never told how to retry"
174
+ end
175
+
176
+ def response_for(dispatched)
177
+ return Response.service_unavailable(retry_after: dispatched.retry_after) if dispatched.retry_later
178
+ return default_ack(status: unparseable_status) if unparseable?(dispatched)
179
+ return Response.new(status: 500) if dispatched.outcome.exception?
180
+ return default_ack if dispatched.outcome.failure? # handler fail! -> quiet ack (or static body)
181
+ return default_ack if dispatched.handler_result.nil? # otherwise: :ack / async enqueue -> ack (or static body)
182
+ return default_ack unless @respond
183
+
184
+ # Run the user's respond block inside the Respond axn so a raise in it (e.g. reading a
185
+ # missing exposure) becomes a reported 500, not an exception escaping the HTTP mapper.
186
+ responded = Respond.call(handler_result: dispatched.handler_result, responder: @respond, vendor: @name)
187
+ responded.ok? ? responded.response : Response.new(status: 500)
188
+ end
189
+
190
+ # A verified request whose body doesn't parse (PRO-3143). Checked ahead of the generic
191
+ # exception -> 500 branch: it IS an exception outcome (already reported via on_exception, which
192
+ # is how you learn a vendor is sending garbage), but a retry can never fix malformed bytes, so
193
+ # the HTTP answer is terminal instead of an invitation to redeliver forever.
194
+ def unparseable?(dispatched) = dispatched.exception.is_a?(Axn::Webhooks::UnparseableBody)
195
+
196
+ # This endpoint's declaration wins over the global setting; see the setting's own comment for
197
+ # why the default is a 2xx rather than the semantically-tidier 400.
198
+ def unparseable_status = @dispatch[:unparseable_status] || Axn::Webhooks.config.unparseable_status
199
+
200
+ # The bare-ack default, or the declared static_respond body in its place. Every branch
201
+ # above that used to hardcode `Response.ack` (dispatch.failure?, nil handler_result, no
202
+ # respond declared, no dispatch at all) now goes through here — static_respond, unlike
203
+ # respond, has no handler_result to read, so it renders on all of them uniformly.
204
+ #
205
+ # `status:` restamps the rendered response, for the one caller (the unparseable-body row) whose
206
+ # status the gem decides rather than the block. Left nil by every other caller, so a block that
207
+ # picked its own status — `text("queued", status: 202)` — still keeps it on the success rows.
208
+ # A raising/non-Response static_respond block is still a 500 here: an internal error in the
209
+ # endpoint's own body-rendering isn't the vendor's malformed body, and shouldn't be acked as one.
210
+ def default_ack(status: nil)
211
+ return Response.ack(status: status || 200) unless @static_respond
212
+
213
+ responded = StaticRespond.call(responder: @static_respond, vendor: @name)
214
+ return Response.new(status: 500) unless responded.ok?
215
+
216
+ status ? responded.response.with_status(status) : responded.response
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ # Builds the callable that turns a Request into the parsed `event` a dispatcher routes on.
8
+ module Parsers
9
+ module_function
10
+
11
+ def build(option)
12
+ case option
13
+ when nil, :json then ->(request) { JSON.parse(request.raw_body) }
14
+ when Proc then option
15
+ else raise Axn::Webhooks::Error, "unknown parse option #{option.inspect} (use :json or a proc)"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # instance_exec context for a `respond` block: exposes `ack`/`text`/`xml`/`json` as bare calls,
7
+ # so a respond proc reads `text("...")` rather than `Axn::Webhooks::Response.text("...")` —
8
+ # mirrors how the `verify` custom block gets `header`/`params`/etc. as bare calls from DSL.
9
+ class RespondContext
10
+ def ack(**) = Response.ack(**)
11
+ def text(body, **) = Response.text(body, **)
12
+ def xml(body, **) = Response.xml(body, **)
13
+ def json(body, **) = Response.json(body, **)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # Resolves a parsed webhook event to the handler to invoke. Pure logic (no Axn) —
7
+ # a missing constant or an unmatched key with no `otherwise:` raises, and the
8
+ # Dispatch Axn turns that raise into a reported exception + formatted result.
9
+ class Router
10
+ # rubocop:disable Naming/MethodParameterName
11
+ def initialize(to:, on: nil, otherwise: nil, via: nil)
12
+ # rubocop:enable Naming/MethodParameterName
13
+ raise Axn::Webhooks::Error, "dispatch needs a `to:` target" if to.nil?
14
+
15
+ @to = to
16
+ @on = on
17
+ @otherwise = otherwise
18
+ @via = via
19
+ end
20
+
21
+ # → [handler_class, kwargs, route_async] for a matched handler, or :ack.
22
+ def resolve(event)
23
+ return handler_for(@to, event) if @on.nil?
24
+
25
+ key = @on.call(event)
26
+ @to.is_a?(Hash) ? resolve_mapped(key, event) : resolve_by_convention(key, event)
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_mapped(key, event)
32
+ entry = @to.fetch(key) { return unmatched(key, event) }
33
+ handler_for(entry, event)
34
+ end
35
+
36
+ def resolve_by_convention(key, event)
37
+ transform = @via || method(:default_transform)
38
+ [constantize("#{@to}::#{transform.call(key)}"), { event: }, nil]
39
+ end
40
+
41
+ def handler_for(entry, event)
42
+ case entry
43
+ when String, Module then [constantize(entry), { event: }, nil]
44
+ when Hash
45
+ [constantize(entry.fetch(:call)), args_for(entry, event), route_async(entry)]
46
+ else
47
+ raise Axn::Webhooks::Error, "invalid dispatch target: #{entry.inspect}"
48
+ end
49
+ end
50
+
51
+ # Handler kwargs from a map entry: no `with:` passes the whole event as `event:`, a Symbol
52
+ # passes it under that name instead (for endpoints whose parsed object isn't naturally an
53
+ # "event" — e.g. a Slack interaction `payload`), and a callable projects it to whatever it
54
+ # returns. Non-callables raise here rather than NoMethodError-ing on `.call`.
55
+ def args_for(entry, event)
56
+ return { event: } unless entry.key?(:with)
57
+
58
+ extractor = entry.fetch(:with)
59
+ return { extractor => event } if extractor.is_a?(Symbol)
60
+
61
+ raise Axn::Webhooks::Error, "dispatch entry `with:` must be a Symbol or a callable (got #{extractor.inspect})" unless extractor.respond_to?(:call)
62
+
63
+ extractor.call(event)
64
+ end
65
+
66
+ def unmatched(key, event)
67
+ case @otherwise
68
+ when :ack then :ack
69
+ when nil then raise Axn::Webhooks::Error, "no handler for webhook event #{key.inspect} (and no `otherwise:`)"
70
+ else
71
+ @otherwise.call(event) # user callable (e.g. alerting); return value ignored
72
+ :ack
73
+ end
74
+ end
75
+
76
+ # Optional per-route sync/async opt-out from a map entry: true=async, false=sync,
77
+ # absent=nil (no opinion — Dispatch falls through to endpoint mode / respond default).
78
+ def route_async(entry)
79
+ return nil unless entry.key?(:async)
80
+
81
+ value = entry.fetch(:async)
82
+ return value if [true, false, nil].include?(value)
83
+
84
+ raise Axn::Webhooks::Error, "dispatch entry `async:` must be true or false (got #{value.inspect})"
85
+ end
86
+
87
+ # Accepts a class-name String (resolved late) or a Class/Module target. A named Module is
88
+ # re-resolved via const_get on EVERY call, so a class object passed at declaration time stays
89
+ # reload-safe under Rails/Zeitwerk (a captured object would go stale when the constant is
90
+ # reassigned on reload). A target whose name doesn't actually resolve to a constant — a truly
91
+ # anonymous class (name.nil?), or an Axn::Factory product (whose debug name is a String but was
92
+ # never assigned to a constant) — has nothing to re-resolve by, so it's used as-is.
93
+ def constantize(target)
94
+ return Object.const_get(target) unless target.is_a?(Module)
95
+
96
+ name = target.name
97
+ name && Object.const_defined?(name) ? Object.const_get(name) : target
98
+ end
99
+
100
+ def default_transform(key) = key.to_s.split(/[._]/).reject(&:empty?).map(&:capitalize).join
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inbound/dsl"
4
+ require_relative "inbound/endpoint"
5
+ require_relative "inbound/router"
6
+
7
+ module Axn
8
+ module Webhooks
9
+ # Process-global registry of inbound webhook endpoints, populated by
10
+ # `Axn::Webhooks.inbound(:vendor) { ... }` and looked up as `Inbound[:vendor]`.
11
+ module Inbound
12
+ @registry = {}
13
+ # declaration name => the registry keys it owns. Once one `inbound :slack` can produce N
14
+ # endpoints, re-declaring it has to be able to REMOVE keys, not just overwrite them.
15
+ @declared = {}
16
+ # registry key => the declaration that CURRENTLY owns it. Needed because two declarations can
17
+ # generate the same endpoint name, and a stale claim must never delete a live route.
18
+ @owners = {}
19
+
20
+ class << self
21
+ def register(name, endpoint) = @registry[name.to_sym] = endpoint
22
+ def [](name) = @registry.fetch(name.to_sym) { raise KeyError, "no inbound webhook registered for #{name.inspect}" }
23
+ def registered = @registry.keys
24
+
25
+ # Publish everything one `inbound <name>` declaration defines, dropping whatever that same
26
+ # declaration registered last time. Covers every transition — fewer children, more children,
27
+ # nested becoming plain, plain becoming nested — each of which previously left a route
28
+ # mounted with its old verifier and handler (Codex review).
29
+ #
30
+ # Reclaims only the keys this declaration STILL owns. Two declarations can generate the same
31
+ # endpoint name (`inbound :slack` with `endpoint(:events)` vs. a plain `inbound
32
+ # :slack_events`); last writer wins, which predates nesting — but without the ownership
33
+ # check, re-declaring the FIRST one would then delete the second one's live route and not
34
+ # restore it. That deletion is not pre-existing: before this bookkeeping, registration only
35
+ # ever overwrote (Codex review). A takeover warns rather than raising: the collision is
36
+ # recoverable, and the endpoint that loses is named so it can be found.
37
+ def replace_declaration(name, endpoints)
38
+ key = name.to_sym
39
+
40
+ (@declared[key] || []).each do |registered_name|
41
+ next unless @owners[registered_name] == key
42
+
43
+ @registry.delete(registered_name)
44
+ @owners.delete(registered_name)
45
+ end
46
+
47
+ endpoints.each do |endpoint_name, endpoint|
48
+ warn_takeover(endpoint_name, key) if @owners.key?(endpoint_name) && @owners[endpoint_name] != key
49
+ @registry[endpoint_name] = endpoint
50
+ @owners[endpoint_name] = key
51
+ end
52
+
53
+ @declared[key] = endpoints.keys
54
+ end
55
+
56
+ def reset!
57
+ @registry.clear
58
+ @declared.clear
59
+ @owners.clear
60
+ end
61
+
62
+ private
63
+
64
+ def warn_takeover(endpoint_name, new_owner)
65
+ Axn.config.logger.warn(
66
+ "[axn-webhooks] inbound endpoint #{endpoint_name.inspect} is now registered by " \
67
+ "`inbound #{new_owner.inspect}`, replacing the one from `inbound #{@owners[endpoint_name].inspect}` — " \
68
+ "two declarations generate the same endpoint name",
69
+ )
70
+ end
71
+ end
72
+ end
73
+
74
+ # Declare an inbound webhook endpoint. Evaluated at boot (e.g. a Rails initializer)
75
+ # so registration is deterministic, in or out of Rails.
76
+ #
77
+ # With one or more nested `endpoint` blocks, this registers one endpoint per child, named
78
+ # :"#{name}_#{child}", and does NOT register `name` itself — see Inbound::DSL#endpoint.
79
+ def self.inbound(name, &block)
80
+ raise ArgumentError, "Axn::Webhooks.inbound requires a block" unless block
81
+
82
+ dsl = Inbound::DSL.new
83
+ dsl.instance_exec(&block)
84
+ children = dsl.__children__
85
+ return Inbound.replace_declaration(name, { name.to_sym => build_endpoint(name, dsl) }) if children.empty?
86
+
87
+ # A parent with children is a container, not an endpoint. A top-level `dispatch` is what
88
+ # would make it look like one, and registering both it and the children would leave an extra
89
+ # endpoint nobody mounted, silently — so that combination is a declaration mistake, caught at
90
+ # boot. A parent `respond`/`static_respond` is NOT: it renders nothing on its own, and
91
+ # sharing one renderer across a vendor's endpoints is precisely what nesting is for, so it
92
+ # inherits like every other declaration (Codex review).
93
+ if dsl.__dispatch_declared?
94
+ raise ArgumentError,
95
+ "inbound #{name.inspect} declares `endpoint` blocks AND its own `dispatch` — a parent " \
96
+ "with endpoints registers nothing itself; move the dispatch into an endpoint"
97
+ end
98
+
99
+ # Build and validate EVERY child before publishing any: the registry is process-global, so
100
+ # registering as we go left earlier children live when a later one raised — a rescued
101
+ # declaration failure or a reload would mix endpoints from different declarations
102
+ # (Codex review).
103
+ built = children.map { |child, child_block| [:"#{name}_#{child}", build_endpoint(:"#{name}_#{child}", dsl.__child_dsl__(child_block))] }
104
+ Inbound.replace_declaration(name, built.to_h)
105
+ end
106
+
107
+ # Each child is a complete, independently valid endpoint by the time it gets here, so the
108
+ # existing per-endpoint validation (__verifier__'s "declared no `verify`" check, Endpoint's
109
+ # respond/static_respond exclusivity) runs per child, unchanged.
110
+ def self.build_endpoint(name, dsl)
111
+ Inbound::Endpoint.new(
112
+ name:,
113
+ verifier: dsl.__verifier__,
114
+ dispatch: dsl.__dispatch__,
115
+ respond: dsl.__respond__,
116
+ static_respond: dsl.__static_respond__,
117
+ challenge: dsl.__challenge__,
118
+ unauthorized_headers: dsl.__unauthorized_headers__,
119
+ challenge_required: dsl.__challenge_required__,
120
+ )
121
+ end
122
+ private_class_method :build_endpoint
123
+ end
124
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Outbound
6
+ # Whether `callable.call(*args)` actually works for exactly `count` positional arguments — via
7
+ # `#parameters`, which correctly distinguishes required/optional/rest/keyword params uniformly
8
+ # across a lambda, a non-strict Proc, and a plain object's `#call` Method. `#arity` alone can't
9
+ # tell "one required positional" apart from "one required KEYWORD" (same arity, one raises) or
10
+ # "needs 2+ positional, has a splat" (negative arity, but still too few args at `count == 1`) —
11
+ # both boot-time-valid under a bare arity check, both raising `ArgumentError` on the very first
12
+ # real invocation (Codex P2 findings, on `backoff`, `user_agent`, and the signing `secret`).
13
+ module CallableArity
14
+ module_function
15
+
16
+ def accepts?(callable, count)
17
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
18
+ return false if params.any? { |(type, _)| type == :keyreq }
19
+
20
+ required = params.count { |(type, _)| type == :req }
21
+ return false if required > count
22
+
23
+ optional = params.count { |(type, _)| type == :opt }
24
+ rest = params.any? { |(type, _)| type == :rest }
25
+ required + optional + (rest ? 1 : 0) >= count
26
+ end
27
+
28
+ # Which keyword names `callable.call(**kwargs)` actually accepts: `:all` for a callable that
29
+ # double-splats (accepts anything), else the Array of Symbol names it declares (required and
30
+ # optional alike). Used to filter a fixed kwarg set down to what a caller-supplied signing
31
+ # block declares (`CustomSigner`), so a block written against today's `(id:, timestamp:,
32
+ # body:)` contract keeps working byte-for-byte when a widened caller starts also offering
33
+ # `url:`/`subscriber:` — those become a plain ArgumentError from a *filtered* call, not a
34
+ # silent widening the block didn't ask for.
35
+ def accepted_keywords(callable)
36
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
37
+ return :all if params.any? { |(type, _)| type == :keyrest }
38
+
39
+ params.select { |(type, _)| %i[key keyreq].include?(type) }.map { |(_, name)| name }
40
+ end
41
+
42
+ # Which keyword names `callable.call(**kwargs)` REQUIRES — a strict subset of
43
+ # `accepted_keywords` (excludes optional `:key` params). Used to catch a callable that
44
+ # needs a keyword outside a fixed supplied set (e.g. `sign { |id:, vendor:| … }`, where
45
+ # `vendor:` is never one of the kwargs this gem passes a signer) — the one shape that
46
+ # genuinely fails on every call, as opposed to a callable that simply ignores some/all of
47
+ # what it's offered (which Ruby's own Proc/block semantics already tolerate fine).
48
+ def required_keywords(callable)
49
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
50
+ params.select { |(type, _)| type == :keyreq }.map { |(_, name)| name }
51
+ end
52
+
53
+ # The ORIGINAL `subscribers`/`to:` resolver dispatch rule, preserved byte-for-byte (Codex P2
54
+ # finding): "pass the event unless the callable's raw arity is EXACTLY zero." Deliberately
55
+ # raw #arity, not #parameters-based: a Proc (non-lambda) with a single OPTIONAL/default
56
+ # param reports arity `0` (a Ruby quirk -- lambda-with-default reports NEGATIVE instead),
57
+ # and that quirk is exactly what a pre-existing `proc { |event = :all| … }` resolver already
58
+ # relied on to keep using its own default. Only made callable-object-safe here (falls back
59
+ # to `Method#arity` via `#call`) -- the dispatch RULE itself is unchanged.
60
+ def zero_arity?(callable)
61
+ raw_arity(callable).zero?
62
+ end
63
+
64
+ # For a newly-introduced 0-OR-1-arity callable (PRO-3214's per-subscriber `secret`/
65
+ # `headers`): prefer a zero-arg call whenever genuinely possible. Raw arity, not
66
+ # `#parameters`-based `accepts?`: a plain `proc { |subscriber| … }` (NO default) reports its
67
+ # param as `:opt` via `#parameters` -- indistinguishable from a genuine default by that
68
+ # API -- but its raw arity is still the correct POSITIVE `1`, so this is the one signal that
69
+ # tells "has a real default/rest" apart from "merely tolerates a missing arg, Proc-style,
70
+ # but was never given one to default from" (Codex P1 finding: passing `nil` in place of the
71
+ # subscriber for exactly this shape).
72
+ #
73
+ # ONLY arity `0` (a truly empty/all-defaulted signature) or `-1` (Ruby's `-(required + 1)`
74
+ # encoding with `required == 0` -- zero REQUIRED params, any number of optional/rest ones)
75
+ # genuinely means "callable with zero args". A more negative arity still has a required
76
+ # LEADING param: `->(subscriber, cache = nil)` is arity `-2` (required == 1) and raises if
77
+ # actually called with zero args -- `arity <= 0` wrongly matched it too (Codex P1 finding,
78
+ # round 5: passed nothing, so a resolver shaped exactly like this raised on every attempt).
79
+ def prefers_zero_args?(callable)
80
+ [0, -1].include?(raw_arity(callable))
81
+ end
82
+
83
+ # Shared by `zero_arity?`/`prefers_zero_args?`: raw `#arity`, falling back to
84
+ # `Method#arity` via `#call` for a plain callable object with none of its own.
85
+ def raw_arity(callable)
86
+ callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
87
+ end
88
+
89
+ # Whether `callable` declares at least one POSITIONAL parameter (required, optional, or
90
+ # rest) -- used by `Signer::CustomSigner` to detect the historical "single options-hash
91
+ # positional" custom-signer shape (`sign { |options| … }`, no keyword params at all).
92
+ def accepts_positional?(callable)
93
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
94
+ params.any? { |(type, _)| %i[req opt rest].include?(type) }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end