mpp-rb 0.1.4 → 0.1.5

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.
@@ -0,0 +1,387 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "result"
5
+ require_relative "accept_payment"
6
+
7
+ module Mpp
8
+ module Server
9
+ extend T::Sig
10
+
11
+ # A configured payment offer: a method plus per-request charge options.
12
+ class ComposeOffer
13
+ extend T::Sig
14
+
15
+ sig { returns(T.untyped) }
16
+ attr_reader :method
17
+
18
+ sig { returns(T::Hash[Symbol, T.untyped]) }
19
+ attr_reader :options
20
+
21
+ sig { params(handler: T.untyped, method: T.untyped, options: T::Hash[T.untyped, T.untyped]).void }
22
+ def initialize(handler, method, options)
23
+ @handler = T.let(handler, T.untyped)
24
+ @method = T.let(method, T.untyped)
25
+ @options = T.let(symbolize(options), T::Hash[Symbol, T.untyped])
26
+ amount = @options[:amount]
27
+ Kernel.raise ArgumentError, "compose entry requires amount:" if amount.nil? || amount.to_s.empty?
28
+ end
29
+
30
+ sig { returns(String) }
31
+ def intent_name
32
+ "charge"
33
+ end
34
+
35
+ sig { returns(String) }
36
+ def key
37
+ "#{@method.name}/#{intent_name}"
38
+ end
39
+
40
+ sig { returns(T::Hash[String, T.untyped]) }
41
+ def canonical_request
42
+ @canonical_request ||= @handler.build_charge_request(@method, amount, **charge_kwargs)
43
+ end
44
+
45
+ sig { params(authorization: T.nilable(String), payment_signature: T.nilable(String), body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.untyped) }
46
+ def verify(authorization: nil, payment_signature: nil, body: nil, url: nil, http_method: nil)
47
+ @handler.charge_one(
48
+ @method,
49
+ authorization,
50
+ amount,
51
+ payment_signature: payment_signature,
52
+ url: url,
53
+ body: body,
54
+ http_method: http_method,
55
+ **charge_kwargs
56
+ )
57
+ end
58
+
59
+ sig { params(body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(Mpp::Challenge) }
60
+ def challenge(body: nil, url: nil, http_method: nil)
61
+ result = verify(body: body, url: url, http_method: http_method)
62
+ return result if result.is_a?(Mpp::Challenge)
63
+
64
+ Kernel.raise "expected a challenge from unpaid offer #{key}"
65
+ end
66
+
67
+ sig { params(headers: T::Hash[String, T.untyped], challenge: Mpp::Challenge, url: T.nilable(String), http_method: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
68
+ def decorate_challenge(headers, challenge, url: nil, http_method: nil)
69
+ return headers unless @method.respond_to?(:decorate_challenge)
70
+
71
+ @method.decorate_challenge(headers, challenge, url: url, http_method: http_method, request: canonical_request)
72
+ headers
73
+ end
74
+
75
+ sig { params(headers: T::Hash[String, T.untyped], credential: T.untyped, receipt: Mpp::Receipt, payment_signature: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
76
+ def decorate_receipt(headers, credential, receipt, payment_signature: nil)
77
+ return headers unless payment_signature && @method.respond_to?(:decorate_receipt)
78
+
79
+ @method.decorate_receipt(headers, receipt, credential, payment_signature: payment_signature)
80
+ headers
81
+ end
82
+
83
+ sig { params(payload: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
84
+ def x402_matches?(payload)
85
+ return false unless @method.respond_to?(:x402_matches?)
86
+
87
+ @method.x402_matches?(payload, canonical_request)
88
+ end
89
+
90
+ # Return a per-request offer bound to the Rack request scope.
91
+ sig { params(scope: T::Hash[String, String]).returns(ComposeOffer) }
92
+ def with_scope(scope)
93
+ ComposeOffer.new(@handler, @method, @options.merge(mppx_scope: @options[:mppx_scope] || scope.dup))
94
+ end
95
+
96
+ private
97
+
98
+ sig { returns(String) }
99
+ def amount
100
+ @options[:amount].to_s
101
+ end
102
+
103
+ REQUEST_OPTION_KEYS = [:body, :url, :payment_signature, :accept_payment, :authorization, :http_method].freeze
104
+
105
+ sig { returns(T::Hash[Symbol, T.untyped]) }
106
+ def charge_kwargs
107
+ @options.except(:amount, *REQUEST_OPTION_KEYS)
108
+ end
109
+
110
+ sig { params(options: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
111
+ def symbolize(options)
112
+ options.each_with_object({}) do |(key, value), acc|
113
+ acc[key.to_sym] = value
114
+ end
115
+ end
116
+ end
117
+
118
+ # Combines multiple method offers into a single callable that presents
119
+ # every method via multiple WWW-Authenticate headers (and x402 extras).
120
+ class ComposedHandler
121
+ extend T::Sig
122
+
123
+ sig { returns(T.untyped) }
124
+ attr_reader :handler
125
+
126
+ sig { returns(T::Array[ComposeOffer]) }
127
+ attr_reader :offers
128
+
129
+ sig { params(handler: T.untyped, offers: T::Array[ComposeOffer]).void }
130
+ def initialize(handler:, offers:)
131
+ Kernel.raise ArgumentError, "compose() requires at least one entry" if offers.empty?
132
+
133
+ @handler = T.let(handler, T.untyped)
134
+ @offers = T.let(offers, T::Array[ComposeOffer])
135
+ end
136
+
137
+ sig { params(handler: T.untyped, entries: T::Array[T.untyped]).returns(ComposedHandler) }
138
+ def self.from_entries(handler, entries)
139
+ new(handler: handler, offers: entries.map { |entry| offer_from_entry(handler, entry) })
140
+ end
141
+
142
+ # Flatten nested compositions into a single handler.
143
+ sig { params(handlers: T::Array[T.untyped]).returns(ComposedHandler) }
144
+ def self.compose(handlers)
145
+ Kernel.raise ArgumentError, "compose() requires at least one handler" if handlers.empty?
146
+
147
+ offers = handlers.flat_map do |value|
148
+ if value.is_a?(ComposedHandler)
149
+ value.offers
150
+ elsif value.respond_to?(:offers)
151
+ value.offers
152
+ else
153
+ Kernel.raise ArgumentError, "compose() expected a ComposedHandler, got #{value.class}"
154
+ end
155
+ end
156
+ new(handler: handlers.first.handler, offers: offers)
157
+ end
158
+
159
+ sig do
160
+ params(
161
+ authorization: T.nilable(String),
162
+ payment_signature: T.nilable(String),
163
+ body: T.untyped,
164
+ url: T.nilable(String),
165
+ accept_payment: T.nilable(String),
166
+ http_method: T.nilable(String),
167
+ scope: T.nilable(T::Hash[String, String])
168
+ ).returns(ComposedResult)
169
+ end
170
+ def call(authorization: nil, payment_signature: nil, body: nil, url: nil, accept_payment: nil, http_method: nil, scope: nil)
171
+ if scope && !scope.empty?
172
+ return self.class.new(handler: @handler, offers: @offers.map { |offer| offer.with_scope(scope) }).call(
173
+ authorization: authorization,
174
+ payment_signature: payment_signature,
175
+ body: body,
176
+ url: url,
177
+ accept_payment: accept_payment,
178
+ http_method: http_method
179
+ )
180
+ end
181
+
182
+ dispatched = dispatch_credential(
183
+ authorization: authorization,
184
+ payment_signature: payment_signature,
185
+ body: body,
186
+ url: url,
187
+ http_method: http_method
188
+ )
189
+ return dispatched if dispatched
190
+
191
+ merge_challenges(body: body, url: url, accept_payment: accept_payment, http_method: http_method)
192
+ end
193
+
194
+ private
195
+
196
+ sig { params(handler: T.untyped, entry: T.untyped).returns(ComposeOffer) }
197
+ def self.offer_from_entry(handler, entry)
198
+ unless entry.is_a?(Array) && entry.length == 2
199
+ Kernel.raise ArgumentError, "compose() entries must be [method, options] tuples"
200
+ end
201
+
202
+ method_ref, options = entry
203
+ options = {} if options.nil?
204
+ unless options.is_a?(Hash)
205
+ Kernel.raise ArgumentError, "compose() options must be a Hash"
206
+ end
207
+
208
+ ComposeOffer.new(handler, handler.resolve_method(method_ref), options)
209
+ end
210
+ private_class_method :offer_from_entry
211
+
212
+ sig do
213
+ params(
214
+ authorization: T.nilable(String),
215
+ payment_signature: T.nilable(String),
216
+ body: T.untyped,
217
+ url: T.nilable(String),
218
+ http_method: T.nilable(String)
219
+ ).returns(T.nilable(ComposedResult))
220
+ end
221
+ def dispatch_credential(authorization:, payment_signature:, body:, url:, http_method: nil)
222
+ if payment_signature && !payment_signature.strip.empty?
223
+ result = dispatch_x402(payment_signature, body: body, url: url, http_method: http_method)
224
+ return result if result
225
+ end
226
+
227
+ return nil if authorization.nil? || authorization.strip.empty?
228
+
229
+ dispatch_authorization(authorization, body: body, url: url, http_method: http_method)
230
+ end
231
+
232
+ sig { params(payment_signature: String, body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(ComposedResult)) }
233
+ def dispatch_x402(payment_signature, body:, url:, http_method: nil)
234
+ payload = decode_x402_payload(payment_signature)
235
+ return nil unless payload
236
+
237
+ offer = @offers.find { |candidate| candidate.x402_matches?(payload) }
238
+ return nil unless offer
239
+
240
+ wrap_offer_result(
241
+ offer.verify(payment_signature: payment_signature, body: body, url: url, http_method: http_method),
242
+ offer,
243
+ payment_signature: payment_signature,
244
+ url: url,
245
+ http_method: http_method
246
+ )
247
+ end
248
+
249
+ sig { params(authorization: String, body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(ComposedResult)) }
250
+ def dispatch_authorization(authorization, body:, url:, http_method: nil)
251
+ payment = Mpp::Server::Verify.extract_payment_scheme(authorization)
252
+ return nil unless payment
253
+
254
+ begin
255
+ credential = Mpp::Credential.from_authorization(payment)
256
+ rescue Mpp::ParseError
257
+ return nil
258
+ end
259
+
260
+ offer = matching_offer(credential)
261
+ return nil unless offer
262
+
263
+ wrap_offer_result(
264
+ offer.verify(authorization: authorization, body: body, url: url, http_method: http_method),
265
+ offer,
266
+ url: url,
267
+ http_method: http_method
268
+ )
269
+ end
270
+
271
+ sig { params(credential: Mpp::Credential).returns(T.nilable(ComposeOffer)) }
272
+ def matching_offer(credential)
273
+ echo = credential.challenge
274
+ echo_request = decode_echo_request(echo)
275
+ return nil if echo_request.nil?
276
+
277
+ candidates = @offers.select do |offer|
278
+ offer.method.name == echo.method && offer.intent_name == echo.intent
279
+ end
280
+ return nil if candidates.empty?
281
+
282
+ exact = candidates.select { |offer| offer.canonical_request == echo_request }
283
+ exact.first || candidates.first
284
+ end
285
+
286
+ sig { params(echo: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
287
+ def decode_echo_request(echo)
288
+ return {} if echo.request.nil? || echo.request.empty?
289
+
290
+ Mpp::Parsing.b64_decode(echo.request)
291
+ rescue Mpp::ParseError
292
+ nil
293
+ end
294
+
295
+ sig { params(payment_signature: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
296
+ def decode_x402_payload(payment_signature)
297
+ Mpp::X402::Header.decode_payment_signature(payment_signature)
298
+ rescue Mpp::ParseError, ArgumentError
299
+ nil
300
+ end
301
+
302
+ sig do
303
+ params(
304
+ result: T.untyped,
305
+ offer: ComposeOffer,
306
+ payment_signature: T.nilable(String),
307
+ url: T.nilable(String),
308
+ http_method: T.nilable(String)
309
+ ).returns(ComposedResult)
310
+ end
311
+ def wrap_offer_result(result, offer, payment_signature: nil, url: nil, http_method: nil)
312
+ if result.is_a?(Mpp::Challenge)
313
+ extra = {}
314
+ offer.decorate_challenge(extra, result, url: url, http_method: http_method)
315
+ return ComposedResult.payment_required([result], realm: @handler.realm, extra_headers: extra)
316
+ end
317
+
318
+ credential, receipt = result
319
+ extra = {}
320
+ offer.decorate_receipt(extra, credential, receipt, payment_signature: payment_signature)
321
+ ComposedResult.paid(credential, receipt, realm: @handler.realm, extra_headers: extra)
322
+ end
323
+
324
+ sig do
325
+ params(
326
+ body: T.untyped,
327
+ url: T.nilable(String),
328
+ accept_payment: T.nilable(String),
329
+ http_method: T.nilable(String)
330
+ ).returns(ComposedResult)
331
+ end
332
+ def merge_challenges(body:, url:, accept_payment:, http_method:)
333
+ pairs = @offers.map do |offer|
334
+ challenge = offer.challenge(body: body, url: url, http_method: http_method)
335
+ [offer, challenge]
336
+ end
337
+
338
+ ranked = AcceptPayment.apply(pairs.map { |_offer, challenge| challenge }, accept_payment)
339
+ by_id = pairs.to_h { |offer, challenge| [challenge.id, [offer, challenge]] }
340
+ pairs = ranked.filter_map { |challenge| by_id[challenge.id] }
341
+
342
+ extra = merge_extra_headers(pairs, url: url, http_method: http_method)
343
+ ComposedResult.payment_required(pairs.map { |_offer, challenge| challenge }, realm: @handler.realm, extra_headers: extra)
344
+ end
345
+
346
+ sig do
347
+ params(
348
+ pairs: T::Array[T.untyped],
349
+ url: T.nilable(String),
350
+ http_method: T.nilable(String)
351
+ ).returns(T::Hash[String, T.untyped])
352
+ end
353
+ def merge_extra_headers(pairs, url:, http_method:)
354
+ extra = {}
355
+ required_headers = []
356
+ pairs.each do |offer, challenge|
357
+ headers = {}
358
+ offer.decorate_challenge(headers, challenge, url: url, http_method: http_method)
359
+ required = headers["PAYMENT-REQUIRED"]
360
+ required_headers << required if required
361
+ headers.each do |key, value|
362
+ next if key == "PAYMENT-REQUIRED"
363
+
364
+ extra[key] = value
365
+ end
366
+ end
367
+
368
+ merged = merge_payment_required(required_headers)
369
+ extra["PAYMENT-REQUIRED"] = merged if merged
370
+ extra
371
+ end
372
+
373
+ sig { params(values: T::Array[String]).returns(T.nilable(String)) }
374
+ def merge_payment_required(values)
375
+ return nil if values.empty?
376
+ return values.first if values.length == 1
377
+
378
+ Mpp::X402::Server.merge_payment_required(values)
379
+ end
380
+ end
381
+
382
+ sig { params(handlers: T.untyped).returns(ComposedHandler) }
383
+ def self.compose(*handlers)
384
+ ComposedHandler.compose(handlers)
385
+ end
386
+ end
387
+ end
@@ -10,17 +10,38 @@ module Mpp
10
10
 
11
11
  module_function
12
12
 
13
- # Build a 402 response for a payment challenge with RFC 9457 problem details body.
14
- sig { params(challenge: T.untyped, realm: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
15
- def make_challenge_response(challenge, realm)
16
- error = Mpp::PaymentRequiredError.new(realm: realm, description: challenge.description)
17
- body = JSON.generate(error.to_problem_details(challenge_id: challenge.id))
13
+ # Build a 402 response for one or more payment challenges with RFC 9457
14
+ # problem details. Extra headers (e.g. PAYMENT-REQUIRED) are merged in.
15
+ sig do
16
+ params(
17
+ challenge: T.untyped,
18
+ realm: T.untyped,
19
+ extra_headers: T::Hash[T.untyped, T.untyped]
20
+ ).returns(T::Hash[T.untyped, T.untyped])
21
+ end
22
+ def make_challenge_response(challenge, realm, extra_headers: {})
23
+ challenges = challenge.is_a?(Array) ? challenge : [challenge]
24
+ Kernel.raise ArgumentError, "at least one challenge is required" if challenges.empty?
25
+
26
+ first = challenges.first
27
+ error = Mpp::PaymentRequiredError.new(realm: realm, description: first.description)
28
+ body = JSON.generate(error.to_problem_details(challenge_id: first.id))
29
+ www = challenges.map { |item| item.to_www_authenticate(realm) }
30
+
18
31
  headers = {
19
- "WWW-Authenticate" => challenge.to_www_authenticate(realm),
32
+ "WWW-Authenticate" => ((www.length == 1) ? www.first : www),
20
33
  "Cache-Control" => "no-store",
21
34
  "Content-Type" => "application/problem+json"
22
35
  }
23
- Mpp::Server::Middleware.mark_authorization_bound_response(headers)
36
+ extra_headers.each do |key, value|
37
+ next if value.nil?
38
+
39
+ headers[key] = value
40
+ end
41
+ Mpp::Server::Middleware.mark_authorization_bound_response(
42
+ headers,
43
+ vary: extra_headers.key?("PAYMENT-REQUIRED") ? ["Authorization", "PAYMENT-SIGNATURE"] : ["Authorization"]
44
+ )
24
45
  {
25
46
  "_mpp_challenge" => true,
26
47
  "status" => 402,
@@ -14,6 +14,8 @@ module Mpp
14
14
  #
15
15
  # Payment is verified BEFORE the downstream app runs — if verification
16
16
  # fails, the app never executes and a 402 challenge is returned.
17
+ # Payment-Receipt is attached only when the app then returns 2xx, so a
18
+ # failed fulfillment is not reported as a successful paid response.
17
19
  #
18
20
  # Example:
19
21
  # use Mpp::Server::Middleware,
@@ -22,10 +24,10 @@ module Mpp
22
24
  class Middleware
23
25
  extend T::Sig
24
26
 
25
- sig { params(app: T.untyped, handler: Mpp::Server::MppHandler, pricing: T.untyped).void }
27
+ sig { params(app: T.untyped, handler: T.untyped, pricing: T.untyped).void }
26
28
  def initialize(app, handler:, pricing:)
27
29
  @app = T.let(app, T.untyped)
28
- @handler = T.let(handler, Mpp::Server::MppHandler)
30
+ @handler = T.let(handler, T.untyped)
29
31
  @pricing = T.let(pricing, T.untyped)
30
32
  end
31
33
 
@@ -35,40 +37,56 @@ module Mpp
35
37
  return @app.call(env) unless charge_opts
36
38
 
37
39
  authorization = env["HTTP_AUTHORIZATION"]
40
+ payment_signature = env["HTTP_PAYMENT_SIGNATURE"]
41
+ accept_payment = env["HTTP_ACCEPT_PAYMENT"]
42
+ http_method = env["REQUEST_METHOD"]
43
+ url = request_url(env)
38
44
  body_capture = capture_request_body(env)
39
45
 
40
46
  request_body = body_capture&.materialize
41
47
  env["rack.input"] = StringIO.new(request_body || "") if body_capture
42
48
 
43
- amount = charge_opts[:amount]
44
- opts = charge_opts.except(:amount, :body)
45
- opts[:mppx_scope] ||= mppx_scope(env)
49
+ result = verify_payment(
50
+ env,
51
+ charge_opts,
52
+ authorization: authorization,
53
+ payment_signature: payment_signature,
54
+ accept_payment: accept_payment,
55
+ http_method: http_method,
56
+ url: url,
57
+ request_body: request_body
58
+ )
46
59
 
47
- result = @handler.charge(authorization, amount, **opts, body: request_body)
48
-
49
- if result.is_a?(Mpp::Challenge)
50
- resp = Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
51
- return [resp["status"], resp["headers"], [resp["body"]]]
52
- end
60
+ challenge_response = challenge_rack_response(result, url: url, http_method: http_method)
61
+ return challenge_response if challenge_response
53
62
 
54
- _credential, receipt = result
63
+ credential, receipt, extra_headers = paid_result(result)
55
64
  status, headers, body = @app.call(env)
56
- headers["Payment-Receipt"] = receipt.to_payment_receipt
57
- self.class.mark_authorization_bound_response(headers)
65
+ if success_status?(status)
66
+ headers["Payment-Receipt"] = receipt.to_payment_receipt
67
+ extra_headers.each { |key, value| headers[key] = value unless value.nil? }
68
+ decorate_single_method_receipt(headers, credential, receipt, payment_signature)
69
+ end
70
+ vary = ["Authorization"]
71
+ vary << "PAYMENT-SIGNATURE" if x402_bound?(headers, payment_signature)
72
+ self.class.mark_authorization_bound_response(headers, vary: vary)
58
73
 
59
74
  [status, headers, body]
60
75
  end
61
76
 
62
- sig { params(headers: T::Hash[T.untyped, T.untyped]).void }
63
- def self.mark_authorization_bound_response(headers)
77
+ sig { params(headers: T::Hash[T.untyped, T.untyped], vary: T::Array[String]).void }
78
+ def self.mark_authorization_bound_response(headers, vary: ["Authorization"])
64
79
  headers["Cache-Control"] = "no-store"
65
80
 
66
81
  vary_values = headers["Vary"].to_s.split(",").map do |value|
67
82
  value.strip.downcase
68
83
  end
69
- return if vary_values.include?("*") || vary_values.include?("authorization")
84
+ return if vary_values.include?("*")
85
+
86
+ additions = vary.reject { |field| vary_values.include?(field.downcase) }
87
+ return if additions.empty?
70
88
 
71
- headers["Vary"] = [headers["Vary"], "Authorization"]
89
+ headers["Vary"] = [headers["Vary"], *additions]
72
90
  .compact
73
91
  .reject(&:empty?)
74
92
  .join(", ")
@@ -76,6 +94,116 @@ module Mpp
76
94
 
77
95
  private
78
96
 
97
+ sig do
98
+ params(
99
+ env: T.untyped,
100
+ charge_opts: T.untyped,
101
+ authorization: T.untyped,
102
+ payment_signature: T.untyped,
103
+ accept_payment: T.untyped,
104
+ http_method: T.untyped,
105
+ url: T.untyped,
106
+ request_body: T.untyped
107
+ ).returns(T.untyped)
108
+ end
109
+ def verify_payment(env, charge_opts, authorization:, payment_signature:, accept_payment:, http_method:, url:, request_body:)
110
+ if @handler.is_a?(Mpp::Server::ComposedHandler)
111
+ return @handler.call(
112
+ authorization: authorization,
113
+ payment_signature: payment_signature,
114
+ body: request_body,
115
+ url: url,
116
+ accept_payment: accept_payment,
117
+ http_method: http_method,
118
+ scope: mppx_scope(env)
119
+ )
120
+ end
121
+
122
+ amount = charge_opts[:amount]
123
+ opts = charge_opts.except(:amount, :body)
124
+ opts[:mppx_scope] ||= mppx_scope(env)
125
+ @handler.charge(
126
+ authorization,
127
+ amount,
128
+ **opts,
129
+ body: request_body,
130
+ payment_signature: payment_signature,
131
+ url: url,
132
+ accept_payment: accept_payment,
133
+ http_method: http_method
134
+ )
135
+ end
136
+
137
+ sig { params(result: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(T::Array[T.untyped])) }
138
+ def challenge_rack_response(result, url:, http_method:)
139
+ if result.is_a?(Mpp::Server::ComposedResult)
140
+ return nil unless result.payment_required?
141
+
142
+ resp = result.to_response
143
+ return [resp["status"], resp["headers"], [resp["body"]]]
144
+ end
145
+
146
+ return nil unless result.is_a?(Mpp::Challenge)
147
+
148
+ resp = if @handler.respond_to?(:challenge_response)
149
+ @handler.challenge_response(result, url: url, http_method: http_method)
150
+ else
151
+ Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
152
+ end
153
+ [resp["status"], resp["headers"], [resp["body"]]]
154
+ end
155
+
156
+ sig { params(status: T.untyped).returns(T::Boolean) }
157
+ def success_status?(status)
158
+ code = Integer(status)
159
+ (code >= 200) && (code < 300)
160
+ rescue ArgumentError, TypeError
161
+ false
162
+ end
163
+
164
+ sig { params(result: T.untyped).returns(T::Array[T.untyped]) }
165
+ def paid_result(result)
166
+ if result.is_a?(Mpp::Server::ComposedResult)
167
+ credential, receipt = result.payment
168
+ return [credential, receipt, result.extra_headers]
169
+ end
170
+
171
+ credential, receipt = result
172
+ [credential, receipt, {}]
173
+ end
174
+
175
+ sig { params(headers: T::Hash[T.untyped, T.untyped], credential: T.untyped, receipt: T.untyped, payment_signature: T.untyped).void }
176
+ def decorate_single_method_receipt(headers, credential, receipt, payment_signature)
177
+ return unless payment_signature
178
+ return unless @handler.respond_to?(:method)
179
+ return unless @handler.method.respond_to?(:decorate_receipt)
180
+
181
+ @handler.method.decorate_receipt(headers, receipt, credential, payment_signature: payment_signature)
182
+ end
183
+
184
+ sig { params(headers: T::Hash[T.untyped, T.untyped], payment_signature: T.untyped).returns(T::Boolean) }
185
+ def x402_bound?(headers, payment_signature)
186
+ !payment_signature.to_s.empty? ||
187
+ headers.key?("PAYMENT-RESPONSE") ||
188
+ headers.key?("PAYMENT-REQUIRED")
189
+ end
190
+
191
+ sig { params(env: T.untyped).returns(String) }
192
+ def request_url(env)
193
+ scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
194
+ host = env["HTTP_HOST"]
195
+ unless host
196
+ server = env["SERVER_NAME"] || "localhost"
197
+ port = env["SERVER_PORT"]
198
+ host = (port && !["80", "443"].include?(port.to_s)) ? "#{server}:#{port}" : server
199
+ end
200
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
201
+ query = env["QUERY_STRING"]
202
+ url = "#{scheme}://#{host}#{path}"
203
+ url += "?#{query}" if query && !query.empty?
204
+ url
205
+ end
206
+
79
207
  sig { params(env: T.untyped).returns(T.nilable(RackInputCapture)) }
80
208
  def capture_request_body(env)
81
209
  input = env["rack.input"]