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

#nameObject (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).



130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 130

def call(env)
  built = BuildRequest.call(env:, vendor: @name)
  return 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

#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.

Returns:

  • (Boolean)


68
69
70
71
72
73
74
75
76
77
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 68

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

  # 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 the safe answer).
  checked = ChallengeRequired.call(request:, predicate:, vendor: @name)
  checked.ok? && checked.required
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.



117
118
119
120
121
122
123
124
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 117

def challenge_response(request)
  return 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

#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.



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

def handle(request)
  verified = verify(request)
  return verified unless verified.ok? && @dispatch

  Dispatch.call(request:, router: @dispatch[:router], parse: @dispatch[:parse],
                mode: @dispatch[:mode], respond_declared: !@respond.nil?, vendor: @name)
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.



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 99

def to_response(request)
  # 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*.
  return Response.new(status: 401, headers: unauthorized_headers) if challenge_required?(request)

  verified = verify(request)
  return Response.new(status: 401, headers: unauthorized_headers) unless verified.ok?
  return 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

#unauthorized_headersObject

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.



81
82
83
# File 'lib/axn/webhooks/inbound/endpoint.rb', line 81

def verify(request)
  Verify.call(request:, verifier: @verifier, vendor: @name)
end