Class: Axn::Webhooks::Inbound::Endpoint

Inherits:
Object
  • Object
show all
Defined in:
lib/axn/webhooks/inbound/endpoint.rb

Overview

A registered inbound webhook endpoint. Verifies a request's signature, dispatches the (verified, parsed) event to a handler Axn, and maps the pipeline's outcome to an HTTP Response. Challenge (GET) and Rack mount arrive in a later phase.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, verifier:, dispatch: nil, respond: nil, static_respond: nil, challenge: nil, unauthorized_headers: nil, challenge_required: nil) ⇒ Endpoint

Returns a new instance of Endpoint.



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 10

def initialize(name:, verifier:, dispatch: nil, respond: nil, static_respond: nil, challenge: nil,
               unauthorized_headers: nil, challenge_required: nil)
  if dispatch && dispatch[:mode] == :async && respond
    raise Axn::Webhooks::Error,
          "inbound endpoint `#{name}` declares a custom `respond` but explicit `dispatch mode: :async` " \
          "can't produce a handler_result for it to read — use `mode: :sync` (or omit mode) or drop the respond block"
  end

  if respond && static_respond
    raise Axn::Webhooks::Error,
          "inbound endpoint `#{name}` declares both `respond` and `static_respond` — declare only one; " \
          "`respond` reads the handler's result, `static_respond` doesn't and renders on every non-error outcome"
  end

  @name = name.to_sym
  @verifier = verifier
  @dispatch = dispatch
  @respond = respond
  @static_respond = static_respond
  @challenge = challenge
  @unauthorized_headers = unauthorized_headers
  @challenge_required = challenge_required

  validate_challenge!
end

Instance Attribute Details

#name ⇒ Object (readonly)

Returns the value of attribute name.



36
37
38
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 36

def name
  @name
end

Instance Method Details

#call(env) ⇒ Object

The Rack app entry point (spec: mount-first packaging). Inbound[:vendor] (this object) is directly mount-able in Rails routes.rb or run-able in a bare Rack::Builder — the mount owns the whole path and every verb: POST -> #to_response, GET -> #challenge_response, anything else -> 405. Named call, deliberately reserved since Phase 3 (see #handle).



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 152

def call(env)
  Axn::Extensions::InvokedVia.with(:webhooks) do
    built = BuildRequest.call(env:, vendor: @name)
    next Response.new(status: 500).to_rack unless built.ok?

    request = built.request
    response =
      case request.http_method
      when "POST" then to_response(request)
      when "GET" then challenge_response(request)
      else Response.new(status: 405)
      end
    response.to_rack
  end
end

#challenge_required?(request) ⇒ Boolean

Is this request an authentication attempt at all? When it isn't, there is nothing to verify — it's a protocol precondition, not a failed verification — and #to_response answers with the challenge without invoking Verify (PRO-3148). Under a two-legged scheme like RFC 7617 Basic auth a reactive client sends one such request per successful webhook, so recording them as verify failures made the highest-volume outcome on a healthy endpoint a recorded failure, and a cross-vendor verify-failure monitor unusable without knowing which vendors happen to use Basic auth.

False unless something says otherwise, so the signature strategies — which have no challenge to offer and no second leg to wait for — are untouched: no predicate means no ChallengeRequired call either, not merely a false answer from one.

Note this is NOT the challenge declaration (that's the vendor's GET handshake, see #challenge_response). Same word, different protocol: this one is the 401 kind.

DESIGN-NOTES.md documents this as public for controllers driving #verify/#handle themselves, so — like them — it carries its own InvokedVia wrap (Codex review, PR #31).

Returns:

  • (Boolean)


71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 71

def challenge_required?(request)
  predicate = challenge_predicate
  return false unless predicate

  Axn::Extensions::InvokedVia.with(:webhooks) do
    # Inside an Axn boundary: the predicate is request-dependent code the gem doesn't own, and
    # it runs ahead of every other boundary on the POST path. A crash settles not-ok and is
    # read as "can't tell" -> verify normally (see ChallengeRequired for why that's safe).
    checked = ChallengeRequired.call(request:, predicate:, vendor: @name)
    checked.ok? && checked.required
  end
end

#challenge_response(request) ⇒ Object

The GET branch (spec: the mount owns the whole path, every verb). Testable without a Rack env, mirroring #verify/#handle/#to_response — and, like them, a standalone entrypoint that carries its own InvokedVia wrap rather than relying on #call.



137
138
139
140
141
142
143
144
145
146
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 137

def challenge_response(request)
  Axn::Extensions::InvokedVia.with(:webhooks) do
    next Response.new(status: 405) unless @challenge

    # The Challenge axn computes the exact Response (200 echo / 403 guard-fail / 400 nil).
    # Only a raising resolver/guard makes it not-ok -> a reported 500.
    result = Challenge.call(request:, resolver: @challenge[:resolver], guard: @challenge[:guard], vendor: @name)
    result.ok? ? result.response : Response.new(status: 500)
  end
end

#handle(request) ⇒ Object

Full pipeline: verify, then (if a dispatch is declared and verification passed) parse + route to the handler. Returns the final Axn::Result.

A second, independent entrypoint alongside #call (the Rack app) — not called from it, so it carries its own Axn::Extensions::InvokedVia wrap rather than inheriting one.



99
100
101
102
103
104
105
106
107
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 99

def handle(request)
  Axn::Extensions::InvokedVia.with(:webhooks) do
    verified = verify(request)
    next verified unless verified.ok? && @dispatch

    Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
                  mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
  end
end

#to_response(request) ⇒ Object

The staged HTTP outcome mapping (spec: "Respond + staged outcome model"). Verify and dispatch are mapped in separate branches — deliberately NOT a single outcome->status rule, because a verify failure (401) and a handler business fail! (2xx) are both outcome.failure? but mean opposite things at the HTTP layer.

Also a standalone entrypoint (README: "drive it yourself from a controller") reachable without going through #call, so it carries its own InvokedVia wrap (Codex review, PR #31).



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 116

def to_response(request)
  Axn::Extensions::InvokedVia.with(:webhooks) do
    # Ahead of verify, deliberately: a request that isn't an authentication attempt gets the
    # challenge rather than a recorded verify failure (see #challenge_required?). Same 401 on
    # the wire, and it still can't reach a handler — strictly safer than the `done!` that
    # would settle this leg as a *success*.
    next Response.new(status: 401, headers: unauthorized_headers) if challenge_required?(request)

    verified = verify(request)
    next Response.new(status: 401, headers: unauthorized_headers) unless verified.ok?
    next default_ack unless @dispatch

    dispatched = Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
                               mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
    response_for(dispatched)
  end
end

#unauthorized_headers ⇒ Object

Headers attached to the 401 a verify failure produces. Empty for the signature strategies — there is nothing for a signing client to be challenged with — but mandatory for HTTP Basic auth (RFC 7617), where a client that doesn't authenticate preemptively sends its first request bare and repeats it with credentials only after a 401 carrying WWW-Authenticate. Without this the second leg never comes and every request from such a client is dropped, uniformly and silently.

An explicit unauthorized_headers declaration wins, so a custom verify block can supply its own challenge; otherwise the verifier speaks for itself.



47
48
49
50
51
52
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 47

def unauthorized_headers
  return @unauthorized_headers if @unauthorized_headers
  return @verifier.unauthorized_headers if @verifier.respond_to?(:unauthorized_headers)

  {}
end

#verify(request) ⇒ Object

Verify the request's signature. Returns an Axn::Result: ok? when verified, a failure on mismatch, an exception if the verifier raises.

A standalone entrypoint too (README: "drive it yourself from a controller"), so it carries its own InvokedVia wrap — nests harmlessly when reached via #handle/#to_response/#call, which already wrap themselves.



90
91
92
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 90

def verify(request)
  Axn::Extensions::InvokedVia.with(:webhooks) { Verify.call(request:, verifier: @verifier, vendor: @name) }
end