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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # Routes a verified request to its handler Axn. Built as an Axn so every loud failure
6
+ # (missing handler, unmatched key, parse error, handler crash, or an async enqueue with no
7
+ # adapter configured) lands in axn's exception bucket — reported once via on_exception,
8
+ # returned as a formatted result — and a handler business `fail!` stays a quiet failure.
9
+ class Dispatch
10
+ include Axn
11
+ include Axn::Webhooks::VendorFacet
12
+
13
+ expects :request, type: Axn::Webhooks::Request, sensitive: true
14
+ expects :router
15
+ expects :parse
16
+ expects :mode, default: :auto
17
+ expects :respond_declared, type: :boolean, default: false
18
+ exposes :handler_result, allow_nil: true
19
+ exposes :retry_later, type: :boolean, default: false
20
+ exposes :retry_after, allow_nil: true
21
+ error "Webhook dispatch failed"
22
+
23
+ def call
24
+ event = parse_event
25
+ resolution = router.resolve(event)
26
+ return done!("acknowledged") if resolution == :ack
27
+
28
+ handler_class, args, route_async = resolution
29
+ return dispatch_async(handler_class, args) if async?(handler_class, route_async)
30
+
31
+ expose handler_result: nil
32
+ expose handler_result: handler_class.call!(**args)
33
+ rescue Axn::Webhooks::RetryLater => e
34
+ # Rescued for the whole method, not just the handler call: a `parse:` proc that does I/O needs
35
+ # the same "come back later" escape hatch, now that everything else it raises is terminal (see
36
+ # #parse_event) — and a RetryLater from a `with:` extractor or an `otherwise:` callable means
37
+ # the same thing wherever it's raised. handler_result is left unexposed (allow_nil) whenever
38
+ # the deferral happens before the handler ran.
39
+ expose retry_later: true
40
+ expose retry_after: e.retry_after
41
+ end
42
+
43
+ private
44
+
45
+ # The parse step is terminal by contract: a body that can't become an event won't parse on a
46
+ # redelivery either, so anything raised here is wrapped as UnparseableBody (PRO-3143) — still a
47
+ # reported exception outcome, but one Inbound::Endpoint maps to `unparseable_status` rather than
48
+ # a 500 that invites the vendor to send it again forever. The original error is preserved as the
49
+ # wrapper's `cause` (`raise` inside a rescue sets it). Wrapping the whole step, rather than
50
+ # allowlisting JSON::ParserError, is what makes this format-agnostic: a custom XML/form/protobuf
51
+ # `parse:` proc gets the terminal outcome without having to know about this gem's error classes.
52
+ #
53
+ # Two pass-throughs: an UnparseableBody the proc raised itself (already the right answer — don't
54
+ # double-wrap and bury its message), and a RetryLater (handled by #call's rescue above).
55
+ def parse_event
56
+ reads_before = request.params_reads
57
+ event = parse.call(request)
58
+
59
+ # `Request#params` fails SOFT (returns {}) because it is reachable before verification,
60
+ # where a raise would let an unauthenticated sender turn a 401 into a reported 500. That
61
+ # softness must not survive into here: this runs only after verification, and a verified
62
+ # request whose form body did not parse has to be reported as UnparseableBody and mapped to
63
+ # `unparseable_status` — not silently dispatched with an empty event (Codex review).
64
+ #
65
+ # Gated on reads made BY THIS PARSE CALL — not on whether params were ever read — so the
66
+ # failure surfaces only for a parse that actually depended on them. A JSON `parse:` never
67
+ # reads params, so a hostile query string appended to a validly-signed request (the
68
+ # signature covers the body, not the query) cannot downgrade it. Scoping matters: a custom
69
+ # verifier or a `challenge_required` predicate may have read params first, and a lifetime
70
+ # flag would have counted that and reopened exactly this hole (Codex review).
71
+ raise request.params_error if request.params_error && request.params_reads > reads_before
72
+
73
+ event
74
+ rescue Axn::Webhooks::RetryLater, Axn::Webhooks::UnparseableBody
75
+ raise
76
+ rescue StandardError => e
77
+ raise Axn::Webhooks::UnparseableBody, "#{e.class}: #{e.message}"
78
+ end
79
+
80
+ # Resolve sync vs async per resolved route (Decision D extended, PRO-2952),
81
+ # most-specific wins: (1) the route's own async: flag; (2) an explicit endpoint
82
+ # mode:; (3) a declared respond forces sync (Decision D default); (4) :auto —
83
+ # async when an adapter is configured for THIS handler, else sync.
84
+ def async?(handler_class, route_async)
85
+ return route_async unless route_async.nil?
86
+ return true if mode == :async
87
+ return false if mode == :sync
88
+ return false if respond_declared
89
+
90
+ async_adapter_configured?(handler_class)
91
+ end
92
+
93
+ # Presence check ONLY — decides async-vs-sync, never asks which adapter.
94
+ # A handler's own explicit setting always wins over the global default — mirrors axn's own
95
+ # call_async semantics, where _async_adapter only falls back to the global default when nil;
96
+ # an explicit `false` (opted out) is sticky and never falls back. So an explicitly-disabled
97
+ # handler (_async_adapter == false) is correctly treated as "not configured" even when a
98
+ # truthy global default is set, not silently overridden by it.
99
+ def async_adapter_configured?(handler_class)
100
+ if handler_class.respond_to?(:_async_adapter) && !handler_class._async_adapter.nil?
101
+ return !!handler_class._async_adapter # explicit per-handler setting (incl. `async false`) always wins
102
+ end
103
+
104
+ Axn.config.default_async?
105
+ end
106
+
107
+ # Delegates entirely to axn's own async interface; no handler_result (nothing ran
108
+ # synchronously). Guarded so an unconfigured OR explicitly-disabled handler never reaches
109
+ # call_async, which would raise a ScriptError (NotImplementedError) that escapes the Dispatch
110
+ # axn boundary entirely (the boundary only rescues StandardError). Raising our own StandardError
111
+ # here instead keeps the failure inside the boundary as a clean, reported exception outcome.
112
+ #
113
+ # Only handlers that expose _async_adapter (real Axn handlers) are second-guessed here — that's
114
+ # the only case where axn's own call_async can raise the escaping NotImplementedError. A handler
115
+ # class that doesn't respond to _async_adapter isn't going through axn's async machinery at all
116
+ # (e.g. a plain object providing its own call_async), so there's nothing to guard against.
117
+ def dispatch_async(handler_class, args)
118
+ if handler_class.respond_to?(:_async_adapter) && !async_adapter_configured?(handler_class)
119
+ raise Axn::Webhooks::Error,
120
+ "dispatch mode: :async requires an axn async adapter, but none is configured for " \
121
+ "#{handler_class} (add `async :sidekiq`/`async :active_job` to the handler, or set a global default)"
122
+ end
123
+
124
+ handler_class.call_async(**args)
125
+ done!("enqueued")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # Rooted in core's public-error boundary (PRO-2997). `Axn::Error` is a marker MODULE, not a base
6
+ # class — `rescue` matches it by `is_a?` — so the tag costs this hierarchy no ancestry: `Error`
7
+ # stays a plain `StandardError`, and a consuming app's `rescue Axn::Error` catches webhook errors
8
+ # alongside core's. The tag is inherited, so `RetryLater` below is covered automatically.
9
+ class Error < StandardError
10
+ include Axn::Error
11
+ end
12
+
13
+ # Raised by a handler (via Axn::Webhooks.retry_later!) to ask the sender to redeliver later —
14
+ # mapped to 503 + Retry-After by the inbound endpoint. Distinct from a crash (a reported 500):
15
+ # a deliberate, un-paged "come back later".
16
+ class RetryLater < Error
17
+ attr_reader :retry_after
18
+
19
+ def initialize(message = "retry later", retry_after: nil)
20
+ @retry_after = retry_after
21
+ super(message)
22
+ end
23
+ end
24
+
25
+ # Raised by `Dispatch` when the parse step can't turn a verified request's body into an event —
26
+ # wrapping whatever the parser raised (the original stays reachable as `cause`), or raised directly
27
+ # by a custom `parse:` proc that knows its own format is malformed. Terminal by construction: a
28
+ # redelivery of the same bytes will never parse either, so `Inbound::Endpoint` maps it to the
29
+ # configured `unparseable_status` (a 2xx by default) instead of a retry-inviting 500 (PRO-3143).
30
+ # Deliberately NOT in any `fails_on` — it stays an axn exception outcome, so `on_exception` still
31
+ # reports that a vendor is sending garbage. Report, then ack.
32
+ class UnparseableBody < Error; end
33
+
34
+ # Raised by Outbound::TargetPolicy when a resolved subscriber row (a static `to:` entry OR a
35
+ # runtime `subscribers`/`to:` lambda's return value) fails shape/host validation — a non-String
36
+ # URL, a non-http(s) scheme, a missing host, an unknown Hash key, or a host the declared
37
+ # `allowed_hosts`/`allow_url` policy rejects. A RUNTIME condition (a DB-backed store can produce
38
+ # a bad row at any time, not just at boot), so it's `Axn::Webhooks::Error`-rooted rather than the
39
+ # plain `ArgumentError` a pure `outbound` block declaration mistake raises — see the error-class
40
+ # split documented above `Outbound::Config#validate_event!`. `Config#resolve_targets` rescues
41
+ # this per-row so one malformed subscriber can't discard the rest of a fan-out.
42
+ class InvalidTarget < Error; end
43
+
44
+ def self.retry_later!(after: nil)
45
+ raise RetryLater.new(retry_after: after)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # Include in a webhook handler to get `Axn` plus the retry_later! contract: a RetryLater
6
+ # raised by the handler is treated as a FAILURE (not a reported exception), so asking the
7
+ # sender to redeliver never pages. Dispatch still maps it to 503 (+ Retry-After).
8
+ module Handler
9
+ def self.included(base)
10
+ base.include(Axn)
11
+ base.fails_on(Axn::Webhooks::RetryLater)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # THE rule for what may appear in an HTTP header value, shared by the outbound request path
6
+ # (Outbound::Deliver#add_custom_header) and the inbound response path (Response#initialize).
7
+ #
8
+ # Deliberately one shared home rather than a copy per side: the outbound half already enforced
9
+ # this while the inbound half did not, which is the same "fixed only where it was noticed"
10
+ # pattern that let a secret-handling bug recur four times in this gem.
11
+ module HeaderValue
12
+ # RFC 7230's `field-value` grammar forbids every control byte except HTAB (0x09). CR/LF
13
+ # (0x0D/0x0A) are the response-splitting pair, but any other control byte (NUL, BEL, ...) is
14
+ # equally invalid on the wire and can get a message rejected by a proxy in between.
15
+ FORBIDDEN_BYTES = /[\x00-\x08\x0A-\x1F\x7F]/
16
+
17
+ module_function
18
+
19
+ # False for a value carrying a forbidden byte, AND for one whose encoding makes the question
20
+ # unanswerable — an invalid/incompatible encoding raises from String#match?, and a value we
21
+ # cannot inspect must not be trusted onto the wire.
22
+ def safe?(value)
23
+ !value.to_s.match?(FORBIDDEN_BYTES)
24
+ rescue Encoding::CompatibilityError, ArgumentError
25
+ false
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # Wraps Request.from_rack in an Axn boundary so a malformed/adversarial env (missing
7
+ # rack.input, etc.) is reported via Axn.config.on_exception and mapped to a clean 500 by
8
+ # Endpoint#call, never an unhandled exception escaping the Rack app.
9
+ class BuildRequest
10
+ include Axn
11
+ include Axn::Webhooks::VendorFacet
12
+
13
+ expects :env, sensitive: true
14
+ exposes :request, type: Axn::Webhooks::Request, sensitive: true
15
+ error "Webhook Rack request parsing failed"
16
+
17
+ def call
18
+ expose request: Axn::Webhooks::Request.from_rack(env)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # The GET-echo handshake (spec: "### 3. Challenge"). Computes the exact Response: 200 echo,
7
+ # 403 when a guard (e.g. Meta hub.verify_token) rejects, 400 when there's no challenge value
8
+ # — all quiet (no page). A resolver or guard that RAISES is a loud exception (reported, mapped
9
+ # to 500 by Endpoint#challenge_response) — never an unhandled crash. Exposes a typed Response.
10
+ class Challenge
11
+ include Axn
12
+ include Axn::Webhooks::VendorFacet
13
+
14
+ expects :request, type: Axn::Webhooks::Request, sensitive: true
15
+ expects :resolver
16
+ expects :guard, allow_blank: true, default: nil
17
+ exposes :response, type: Axn::Webhooks::Response
18
+ error "Webhook challenge failed"
19
+
20
+ def call
21
+ expose response: build_response
22
+ end
23
+
24
+ private
25
+
26
+ def build_response
27
+ return Response.new(status: 403) if guard && !guard.call(request) # e.g. Meta hub.verify_token mismatch
28
+
29
+ value = resolver.call(request)
30
+ return Response.new(status: 400) if value.nil?
31
+
32
+ Response.text(value.to_s)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # The challenge-required precondition, as an Axn — for the same reason the verifier, the
7
+ # `parse:` step and the GET challenge resolver each have one: it is request-dependent code the
8
+ # gem does not own, reading adversarial input. It runs FIRST on the POST path, ahead of every
9
+ # other boundary, so a raise here would otherwise leave Endpoint#call as an unhandled Rack
10
+ # exception rather than a reported one and a controlled response.
11
+ #
12
+ # A crash settles not-ok, which Endpoint reads as "can't tell" and answers by verifying
13
+ # normally — the behaviour from before the precondition existed. Safe by construction: Verify
14
+ # still decides, so a broken predicate can neither dispatch an unauthenticated request nor drop
15
+ # an authenticated one. It costs the telemetry saving until it's fixed, and says so once via
16
+ # on_exception rather than failing silently.
17
+ class ChallengeRequired
18
+ include Axn
19
+ include Axn::Webhooks::VendorFacet
20
+
21
+ expects :request, type: Axn::Webhooks::Request, sensitive: true
22
+ # The verifier's bound `#challenge_required?` method, or a declared block. Sensitive for the
23
+ # same reason Verify's `verifier:` is: a bound method renders its receiver, which for
24
+ # `verify :basic_auth` is the object holding the vendor's credentials.
25
+ expects :predicate, sensitive: true
26
+ exposes :required, allow_blank: true, default: false
27
+ error "Webhook challenge-required check failed"
28
+
29
+ def call
30
+ expose required: !!predicate.call(request)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Inbound
6
+ # Receiver for an `inbound` block: captures declarations (Phase 2: `verify`) and
7
+ # exposes request resolvers. Later phases add dispatch/challenge/respond here.
8
+ class DSL
9
+ # Every declaration a child `endpoint` inherits. Deliberately excludes @child_endpoints
10
+ # (nesting is one level deep) and @nested.
11
+ #
12
+ # Also excludes @dispatch_spec, which cannot be inherited because a parent declaring
13
+ # `dispatch` alongside `endpoint` blocks is rejected at registration (see
14
+ # Axn::Webhooks.inbound). Listing it would be inert, and it would advertise a shared-dispatch
15
+ # feature that does not work anyway: `dispatch` captures ONE spec hash, so a child
16
+ # re-declaring it replaces the parent's wholesale — there is no partial override, and a
17
+ # parent dispatch every child copies verbatim leaves the children differing by nothing
18
+ # (Codex review asked for this; declined for that reason plus the migration hazard the guard
19
+ # exists to catch — silently losing Inbound[:vendor] out from under a mounted route).
20
+ INHERITED_IVARS = %i[
21
+ @verify_spec @unauthorized_headers @challenge_required
22
+ @respond_block @static_respond_block @challenge_spec
23
+ ].freeze
24
+
25
+ # verify :hmac, **opts | verify :standard_webhooks, **opts | verify { |req| ... }
26
+ def verify(strategy = nil, **opts, &block)
27
+ @verify_spec = { strategy:, opts:, block: }
28
+ end
29
+
30
+ # unauthorized_headers "WWW-Authenticate" => %(Basic realm="Webhook")
31
+ #
32
+ # Headers to attach to the 401 a verify failure produces. `verify :basic_auth` supplies
33
+ # this itself; declare it only for a custom `verify` block that has to challenge a client
34
+ # into retrying with credentials (see Endpoint#unauthorized_headers).
35
+ def unauthorized_headers(headers)
36
+ @unauthorized_headers = headers
37
+ end
38
+
39
+ # challenge_required { |req| req.header("Authorization").to_s.empty? }
40
+ #
41
+ # Declares which requests are not authentication attempts at all, and so get the challenge
42
+ # (401 + `unauthorized_headers`) instead of being run through `verify` and recorded as
43
+ # verify failures. `verify :basic_auth` answers this itself; declare it only for a custom
44
+ # `verify` block that wraps a two-legged scheme the gem can't see through — the shape
45
+ # buyout's Twilio routes use, where the BasicAuth verifier sits inside a block:
46
+ #
47
+ # challenge_required { |req| my_basic_auth.challenge_required?(req) }
48
+ def challenge_required(&block)
49
+ @challenge_required = block
50
+ end
51
+
52
+ # dispatch to: "Handler" | dispatch on: ->(e){…}, to: {map}, otherwise:, via: | parse: | mode:
53
+ # `unparseable_status:` overrides Axn::Webhooks.config.unparseable_status for THIS endpoint —
54
+ # it belongs here, next to `parse:`, because it only describes what happens when that parse
55
+ # fails, and because the right value is a fact about one vendor's retry policy (PRO-3143).
56
+ # rubocop:disable-next Naming/MethodParameterName
57
+ def dispatch(to: nil, on: nil, otherwise: nil, via: nil, parse: :json, mode: :auto, unparseable_status: nil)
58
+ @dispatch_spec = { to:, on:, otherwise:, via:, parse:, mode:, unparseable_status: }
59
+ end
60
+
61
+ # respond { |handler_result| text("...") } — maps a genuine handler success to a
62
+ # Response. Every other outcome (ack, business fail!, verify failure/exception, or a
63
+ # no-dispatch endpoint) always gets the default bare ack (or a declared `static_respond`
64
+ # body — see below), regardless of this declaration — see Endpoint#to_response.
65
+ def respond(&block)
66
+ # Checked BEFORE the discard below: without a block this stored nil after clearing the
67
+ # inherited alternative, booting an endpoint with no renderer at all — an undocumented way
68
+ # to un-declare a parent's renderer, and a typo that silently downgraded responses to bare
69
+ # acks (Codex review).
70
+ raise Axn::Webhooks::Error, "inbound endpoint's `respond` requires a block" unless block
71
+
72
+ # Endpoint rejects having both renderers set, and a child inherits BOTH ivars — so
73
+ # overriding an inherited `static_respond` with a `respond` has to clear it, or the child
74
+ # raises. Clears only an INHERITED one: declaring both in the same block stays an error
75
+ # rather than silently becoming last-one-wins (Codex review).
76
+ discard_inherited(:@static_respond_block)
77
+ @respond_block = block
78
+ claim_ownership(:@respond_block)
79
+ end
80
+
81
+ # static_respond { text("...") } — a body that does NOT read the handler result (block
82
+ # takes zero args, unlike respond's `|handler_result|`), so it renders on every non-error
83
+ # outcome: sync success, async enqueue, otherwise: :ack, and business fail! — see
84
+ # Endpoint#default_ack. Mutually exclusive with `respond` (Endpoint#initialize raises if
85
+ # both are declared) and never forces sync dispatch (Dispatch#async? never reads it).
86
+ def static_respond(&block)
87
+ raise Axn::Webhooks::Error, "inbound endpoint's `static_respond` requires a block" unless block
88
+
89
+ if block.parameters.any?
90
+ raise Axn::Webhooks::Error,
91
+ "inbound endpoint's static_respond block must take no arguments (it never reads the " \
92
+ "handler's result, unlike respond) — got a parameter; use `respond` instead if you need " \
93
+ "to read the handler's result"
94
+ end
95
+
96
+ discard_inherited(:@respond_block) # see `respond` — cross-form override, inherited only
97
+ @static_respond_block = block
98
+ claim_ownership(:@static_respond_block)
99
+ end
100
+
101
+ # challenge ->(req){ req.params["challenge"] } — Nylas
102
+ # challenge ->(req){ req.params["hub.challenge"] }, if: ->(req){ ... } — Meta
103
+ def challenge(resolver, if: nil)
104
+ # `if:` shadows Ruby's `if` keyword inside this method body — must read it back via
105
+ # binding.local_variable_get, not a bare `if` reference (that's a syntax trap, not a var).
106
+ guard = binding.local_variable_get(:if)
107
+ @challenge_spec = { resolver:, guard: }
108
+ end
109
+
110
+ def header(name) = Resolvers.header(name)
111
+ def raw_body = Resolvers.raw_body
112
+ def params = Resolvers.params
113
+ def url = Resolvers.url
114
+
115
+ # Dispatch-map sugar: `async("H")` == `{ call: "H", async: true }`; `sync` forces sync.
116
+ # Callable inside a `dispatch to: { … }` map because the `inbound` block is instance_exec'd
117
+ # against this DSL. Extra kwargs (e.g. `with:`) pass through: `async("H", with: ->(e){ … })`.
118
+ # `**opts` is spread FIRST so the fixed mode and the positional handler always win — a
119
+ # splatted shared options hash carrying `:async`/`:call` can never silently flip the mode
120
+ # or retarget the handler (the helper's name is its contract).
121
+ def async(call, **opts) = { **opts, call:, async: true }
122
+ def sync(call, **opts) = { **opts, call:, async: false }
123
+
124
+ # endpoint(:events) { dispatch … } — declares a CHILD endpoint that inherits everything the
125
+ # parent block declared and may override any of it by re-declaring. One `inbound :slack`
126
+ # block with two `endpoint` blocks registers Inbound[:slack_interactivity] and
127
+ # Inbound[:slack_events]; the parent itself registers nothing.
128
+ def endpoint(name, &block)
129
+ raise ArgumentError, "`endpoint #{name.inspect}` requires a block" unless block
130
+ raise ArgumentError, "`endpoint #{name.inspect}` cannot be nested inside another `endpoint` — one level only" if @nested
131
+
132
+ @child_endpoints ||= {}
133
+ raise ArgumentError, "duplicate `endpoint #{name.inspect}` in the same inbound block" if @child_endpoints.key?(name.to_sym)
134
+
135
+ @child_endpoints[name.to_sym] = block
136
+ end
137
+
138
+ # Drops an ivar this DSL INHERITED from a parent `endpoint` container, leaving one declared
139
+ # in this very block untouched. Backs the mutually-exclusive renderer override above.
140
+ def discard_inherited(ivar)
141
+ return unless @inherited_ivars&.include?(ivar)
142
+
143
+ instance_variable_set(ivar, nil)
144
+ @inherited_ivars.delete(ivar)
145
+ end
146
+
147
+ # Marks an ivar as belonging to THIS block from here on. Without it, a child that
148
+ # re-declared `respond` left `@respond_block` still listed as inherited, so a following
149
+ # `static_respond` discarded the child's OWN block and the pair was accepted — exactly the
150
+ # same-block conflict the discard rule is supposed to keep raising (Codex review).
151
+ def claim_ownership(ivar) = @inherited_ivars&.delete(ivar)
152
+
153
+ # Internal: declared child endpoints, name => block. Empty for a plain `inbound` block.
154
+ def __children__ = @child_endpoints || {}
155
+
156
+ # Internal: whether `dispatch` was declared directly on THIS DSL. Read instead of
157
+ # `__dispatch__` so the parent-with-children check doesn't build a Router just to ask.
158
+ def __dispatch_declared? = !@dispatch_spec.nil?
159
+
160
+ # Internal: a fresh DSL seeded with this one's captured declarations, with `block` evaluated
161
+ # against it — so a child inherits everything and overrides by re-declaring.
162
+ #
163
+ # Copies the ivars rather than re-`instance_exec`ing the parent block per child (the obvious
164
+ # alternative): replaying the parent block would re-run any side effects in it, and would
165
+ # re-enter `endpoint` recursively, registering each child once per sibling.
166
+ def __child_dsl__(block)
167
+ child = self.class.new
168
+ inherited = INHERITED_IVARS.select { |ivar| instance_variable_defined?(ivar) }
169
+ inherited.each { |ivar| child.instance_variable_set(ivar, instance_variable_get(ivar)) }
170
+ # Recorded so `respond`/`static_respond` can tell an inherited value from one declared in
171
+ # the child's own block, and clear only the former.
172
+ child.instance_variable_set(:@inherited_ivars, inherited.dup)
173
+ child.instance_variable_set(:@nested, true)
174
+ child.instance_exec(&block)
175
+ child
176
+ end
177
+
178
+ # Internal: build the verifier callable from the captured declaration.
179
+ # For challenge-only endpoints (no dispatch, no verify declared), return a no-op verifier
180
+ # that always succeeds — a challenge-only endpoint just handshakes the GET and 200-acks any
181
+ # POST, so there's no unverified processing to guard against.
182
+ # `verify` is REQUIRED whenever `dispatch` is declared — dispatching an unverified webhook
183
+ # would run the handler on an unauthenticated request.
184
+ def __verifier__
185
+ unless @verify_spec
186
+ # Nothing declared at all: bare endpoint, always an error.
187
+ raise Axn::Webhooks::Error, "inbound endpoint declared no `verify`" if @dispatch_spec.nil? && @challenge_spec.nil?
188
+
189
+ # `dispatch` without `verify` is unsafe regardless of whether `challenge` is also present.
190
+ if @dispatch_spec
191
+ raise Axn::Webhooks::Error,
192
+ "inbound endpoint with `dispatch` must declare `verify` — dispatching an unverified webhook is unsafe"
193
+ end
194
+
195
+ # Challenge-only endpoint (no dispatch): return a no-op verifier.
196
+ return ->(_request) { true }
197
+ end
198
+
199
+ raise Axn::Webhooks::Error, "inbound endpoint `verify` needs a strategy or a block" if @verify_spec[:strategy].nil? && @verify_spec[:block].nil?
200
+
201
+ Verifiers.build(**@verify_spec)
202
+ end
203
+
204
+ # Internal: build the { router:, parse:, mode: } dispatch config, or nil if none declared.
205
+ def __dispatch__
206
+ return nil unless @dispatch_spec
207
+
208
+ spec = @dispatch_spec
209
+ unless %i[auto sync async].include?(spec[:mode])
210
+ raise Axn::Webhooks::Error, "dispatch mode: must be :sync, :async, or :auto (got #{spec[:mode].inspect})"
211
+ end
212
+
213
+ unless spec[:unparseable_status].nil? || Response.valid_status?(spec[:unparseable_status])
214
+ raise Axn::Webhooks::Error,
215
+ "dispatch unparseable_status: must be an Integer HTTP status between 200 and 599 " \
216
+ "(got #{spec[:unparseable_status].inspect})"
217
+ end
218
+
219
+ router = Router.new(to: spec[:to], on: spec[:on], otherwise: spec[:otherwise], via: spec[:via])
220
+ { router:, parse: Parsers.build(spec[:parse]), mode: spec[:mode], unparseable_status: spec[:unparseable_status] }
221
+ end
222
+
223
+ # Internal: the captured respond block, or nil if none declared.
224
+ def __respond__ = @respond_block
225
+
226
+ # Internal: the captured static_respond block, or nil if none declared.
227
+ def __static_respond__ = @static_respond_block
228
+
229
+ # Internal: the captured { resolver:, guard: } challenge declaration, or nil if none.
230
+ def __challenge__ = @challenge_spec
231
+
232
+ # Internal: the declared 401 headers, or nil to let the verifier speak for itself.
233
+ def __unauthorized_headers__ = @unauthorized_headers
234
+
235
+ # Internal: the declared challenge-required predicate, or nil to ask the verifier.
236
+ def __challenge_required__ = @challenge_required
237
+ end
238
+ end
239
+ end
240
+ end