mpp-rb 0.1.4 → 0.1.6

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -1
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/errors.rb +22 -0
  8. data/lib/mpp/http/headers.rb +61 -0
  9. data/lib/mpp/http.rb +8 -0
  10. data/lib/mpp/methods/evm/assets.rb +103 -0
  11. data/lib/mpp/methods/evm/authorization.rb +140 -0
  12. data/lib/mpp/methods/evm/charge_intent.rb +129 -0
  13. data/lib/mpp/methods/evm/evm_method.rb +133 -0
  14. data/lib/mpp/methods/evm.rb +67 -0
  15. data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
  16. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
  17. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  18. data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
  19. data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
  20. data/lib/mpp/methods/stripe.rb +23 -0
  21. data/lib/mpp/methods/tempo/attribution.rb +3 -9
  22. data/lib/mpp/methods/tempo/client_method.rb +35 -6
  23. data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
  24. data/lib/mpp/methods/tempo/intents.rb +80 -48
  25. data/lib/mpp/methods/tempo/proof.rb +39 -12
  26. data/lib/mpp/methods/tempo/relay.rb +257 -0
  27. data/lib/mpp/methods/tempo.rb +2 -0
  28. data/lib/mpp/parsing.rb +22 -2
  29. data/lib/mpp/receipt.rb +4 -3
  30. data/lib/mpp/server/accept_payment.rb +194 -0
  31. data/lib/mpp/server/compose.rb +399 -0
  32. data/lib/mpp/server/decorator.rb +30 -7
  33. data/lib/mpp/server/method.rb +15 -0
  34. data/lib/mpp/server/middleware.rb +160 -18
  35. data/lib/mpp/server/mpp_handler.rb +322 -34
  36. data/lib/mpp/server/result.rb +100 -0
  37. data/lib/mpp/server/verify.rb +24 -14
  38. data/lib/mpp/server.rb +4 -0
  39. data/lib/mpp/version.rb +1 -1
  40. data/lib/mpp/x402/facilitator.rb +127 -0
  41. data/lib/mpp/x402/header.rb +131 -0
  42. data/lib/mpp/x402/server.rb +247 -0
  43. data/lib/mpp/x402/types.rb +33 -0
  44. data/lib/mpp/x402.rb +12 -0
  45. data/lib/mpp.rb +16 -3
  46. metadata +48 -1
data/lib/mpp/receipt.rb CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra) do
6
- def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil)
5
+ Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra, :subscription_id) do
6
+ def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil, subscription_id: nil)
7
7
  super
8
8
  end
9
9
 
@@ -18,13 +18,14 @@ module Mpp
18
18
  end
19
19
 
20
20
  # Create a success receipt with current timestamp.
21
- def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil)
21
+ def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil, subscription_id: nil)
22
22
  new(
23
23
  status: "success",
24
24
  timestamp: timestamp || Time.now.utc,
25
25
  reference: reference,
26
26
  method: method,
27
27
  external_id: external_id,
28
+ subscription_id: subscription_id,
28
29
  extra: extra
29
30
  )
30
31
  end
@@ -0,0 +1,194 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Parse, format, and rank the Accept-Payment client-preference header.
7
+ #
8
+ # Syntax mirrors HTTP content negotiation: `method/intent[;q=value]`,
9
+ # comma-separated. Wildcards (`*` / `tempo/*` / `*/charge`) are allowed.
10
+ # Entries with q=0 are excluded. If the header is empty, invalid, or
11
+ # filters out every offer, the original offer list is returned.
12
+ module AcceptPayment
13
+ extend T::Sig
14
+
15
+ TOKEN_RE = /\A(?:\*|[a-z0-9-]+)\z/
16
+ ENTRY_RE = %r{\A(?<method>[^/;\s]+|\*)\s*/\s*(?<intent>[^/;\s]+|\*)(?<params>(?:\s*;\s*.+)?)\z}
17
+ PARAM_RE = /\A(?<name>[A-Za-z0-9_-]+)\s*=\s*(?<value>\S+)\z/
18
+ QVALUE_RE = /\A(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\z/
19
+
20
+ Entry = T.type_alias { T::Hash[Symbol, T.untyped] }
21
+
22
+ module_function
23
+
24
+ # Parse an Accept-Payment header. Raises ArgumentError on malformed input.
25
+ sig { params(header: String).returns(T::Array[Entry]) }
26
+ def parse(header)
27
+ parts = header.split(",").map(&:strip).reject(&:empty?)
28
+ Kernel.raise ArgumentError, "Accept-Payment header is empty." if parts.empty?
29
+
30
+ parts.each_with_index.map { |part, index| parse_entry(part, index) }
31
+ end
32
+
33
+ # Filter and reorder offers by Accept-Payment. Returns `offers` unchanged
34
+ # when the header is missing, malformed, or matches nothing.
35
+ sig { params(offers: T::Array[T.untyped], header: T.nilable(String)).returns(T::Array[T.untyped]) }
36
+ def apply(offers, header)
37
+ return offers if header.nil? || header.strip.empty?
38
+
39
+ begin
40
+ preferences = parse(header)
41
+ rescue ArgumentError
42
+ return offers
43
+ end
44
+
45
+ ranked = rank(offers, preferences)
46
+ ranked.empty? ? offers : ranked
47
+ end
48
+
49
+ # Order offers by the best matching client preference.
50
+ # More specific matches win before comparing q-values.
51
+ sig { params(offers: T::Array[T.untyped], preferences: T::Array[Entry]).returns(T::Array[T.untyped]) }
52
+ def rank(offers, preferences)
53
+ scored = []
54
+ offers.each_with_index do |offer, index|
55
+ match = best_match(offer, preferences)
56
+ next unless match && T.unsafe(match[:q]) > 0
57
+
58
+ scored << {match: match, offer: offer, index: index}
59
+ end
60
+
61
+ scored.sort_by! { |row| [-T.unsafe(row[:match][:q]), T.unsafe(row[:index])] }
62
+ scored.map { |row| row[:offer] }
63
+ end
64
+
65
+ sig { params(value: T.untyped).returns(String) }
66
+ def key_of(value)
67
+ method, intent = method_intent(value)
68
+ Kernel.raise ArgumentError, "Missing payment method name." if method.empty?
69
+
70
+ "#{method}/#{intent}"
71
+ end
72
+
73
+ sig { params(part: String, index: Integer).returns(Entry) }
74
+ def parse_entry(part, index)
75
+ match = ENTRY_RE.match(part)
76
+ Kernel.raise ArgumentError, "Invalid Accept-Payment entry: #{part}" unless match
77
+
78
+ method = T.must(match[:method])
79
+ intent = T.must(match[:intent])
80
+ assert_token!(method, "method")
81
+ assert_token!(intent, "intent")
82
+
83
+ q = 1.0
84
+ split_parameters(match[:params]).each do |param|
85
+ next if param.empty?
86
+
87
+ parameter_match = PARAM_RE.match(param)
88
+ Kernel.raise ArgumentError, "Invalid Accept-Payment parameter: #{param}" unless parameter_match
89
+
90
+ next unless parameter_match[:name] == "q"
91
+
92
+ q = parse_header_q(T.must(parameter_match[:value]), %(Accept-Payment entry "#{part}"))
93
+ end
94
+
95
+ {method: method, intent: intent, q: q, index: index}
96
+ end
97
+ private_class_method :parse_entry
98
+
99
+ sig { params(offer: T.untyped, preferences: T::Array[Entry]).returns(T.nilable(Entry)) }
100
+ def best_match(offer, preferences)
101
+ method, intent = method_intent(offer)
102
+ best = T.let(nil, T.nilable(Entry))
103
+
104
+ preferences.each do |preference|
105
+ next unless matches?(method, intent, preference)
106
+
107
+ candidate = preference.merge(specificity: specificity(preference))
108
+ if better_match?(candidate, best)
109
+ best = candidate
110
+ end
111
+ end
112
+
113
+ best
114
+ end
115
+ private_class_method :best_match
116
+
117
+ sig { params(candidate: Entry, best: T.nilable(Entry)).returns(T::Boolean) }
118
+ def better_match?(candidate, best)
119
+ return true if best.nil?
120
+ return true if T.unsafe(candidate[:specificity]) > T.unsafe(best[:specificity])
121
+ return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
122
+ T.unsafe(candidate[:q]) > T.unsafe(best[:q])
123
+ return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
124
+ T.unsafe(candidate[:q]) == T.unsafe(best[:q]) &&
125
+ T.unsafe(candidate[:index]) < T.unsafe(best[:index])
126
+
127
+ false
128
+ end
129
+ private_class_method :better_match?
130
+
131
+ sig { params(method: String, intent: String, preference: Entry).returns(T::Boolean) }
132
+ def matches?(method, intent, preference)
133
+ (preference[:method] == "*" || preference[:method] == method) &&
134
+ (preference[:intent] == "*" || preference[:intent] == intent)
135
+ end
136
+ private_class_method :matches?
137
+
138
+ sig { params(preference: Entry).returns(Integer) }
139
+ def specificity(preference)
140
+ ((preference[:method] == "*") ? 0 : 1) + ((preference[:intent] == "*") ? 0 : 1)
141
+ end
142
+ private_class_method :specificity
143
+
144
+ sig { params(offer: T.untyped).returns([String, String]) }
145
+ def method_intent(offer)
146
+ if offer.is_a?(Hash)
147
+ [(offer[:method] || offer["method"]).to_s, (offer[:intent] || offer["intent"]).to_s]
148
+ else
149
+ [offer.method.to_s, offer.intent.to_s]
150
+ end
151
+ end
152
+ private_class_method :method_intent
153
+
154
+ sig { params(value: T.nilable(String)).returns(T::Array[String]) }
155
+ def split_parameters(value)
156
+ return [] if value.nil? || value.empty?
157
+
158
+ value.split(";").map(&:strip).reject(&:empty?)
159
+ end
160
+ private_class_method :split_parameters
161
+
162
+ sig { params(value: String, context: String).returns(Float) }
163
+ def parse_header_q(value, context)
164
+ unless QVALUE_RE.match?(value)
165
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected an HTTP qvalue."
166
+ end
167
+
168
+ assert_q(Kernel.Float(value), context)
169
+ end
170
+ private_class_method :parse_header_q
171
+
172
+ sig { params(value: Float, context: String).returns(Float) }
173
+ def assert_q(value, context)
174
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected a value between 0 and 1." if value.negative? || value > 1
175
+
176
+ rounded = (value * 1000).round
177
+ if (value * 1000 - rounded).abs > 1e-9
178
+ Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected at most 3 decimal places."
179
+ end
180
+
181
+ rounded / 1000.0
182
+ end
183
+ private_class_method :assert_q
184
+
185
+ sig { params(value: String, label: String).void }
186
+ def assert_token!(value, label)
187
+ return if TOKEN_RE.match?(value)
188
+
189
+ Kernel.raise ArgumentError, "Invalid Accept-Payment #{label}: #{value}"
190
+ end
191
+ private_class_method :assert_token!
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,399 @@
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), request_guard: T.nilable(T.proc.params(request: T::Hash[String, T.untyped]).returns(T::Boolean))).returns(T.untyped) }
46
+ def verify(authorization: nil, payment_signature: nil, body: nil, url: nil, http_method: nil, &request_guard)
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
+ &request_guard
57
+ )
58
+ end
59
+
60
+ sig { params(body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(Mpp::Challenge)) }
61
+ def challenge(body: nil, url: nil, http_method: nil)
62
+ result = verify(body: body, url: url, http_method: http_method) do |request|
63
+ Mpp::Server::MethodHelper.can_offer?(@method, request)
64
+ end
65
+ return nil if result.nil?
66
+ return result if result.is_a?(Mpp::Challenge)
67
+
68
+ Kernel.raise "expected a challenge from unpaid offer #{key}"
69
+ end
70
+
71
+ 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]) }
72
+ def decorate_challenge(headers, challenge, url: nil, http_method: nil)
73
+ return headers unless @method.respond_to?(:decorate_challenge)
74
+
75
+ @method.decorate_challenge(headers, challenge, url: url, http_method: http_method, request: challenge.request)
76
+ headers
77
+ end
78
+
79
+ 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]) }
80
+ def decorate_receipt(headers, credential, receipt, payment_signature: nil)
81
+ return headers unless payment_signature && @method.respond_to?(:decorate_receipt)
82
+
83
+ @method.decorate_receipt(headers, receipt, credential, payment_signature: payment_signature)
84
+ headers
85
+ end
86
+
87
+ sig { params(payload: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
88
+ def x402_matches?(payload)
89
+ return false unless @method.respond_to?(:x402_matches?)
90
+
91
+ @method.x402_matches?(payload, canonical_request)
92
+ end
93
+
94
+ # Return a per-request offer bound to the Rack request scope.
95
+ sig { params(scope: T::Hash[String, String]).returns(ComposeOffer) }
96
+ def with_scope(scope)
97
+ ComposeOffer.new(@handler, @method, @options.merge(mppx_scope: @options[:mppx_scope] || scope.dup))
98
+ end
99
+
100
+ private
101
+
102
+ sig { returns(String) }
103
+ def amount
104
+ @options[:amount].to_s
105
+ end
106
+
107
+ REQUEST_OPTION_KEYS = [:body, :url, :payment_signature, :accept_payment, :authorization, :http_method, :payment_authorization].freeze
108
+
109
+ sig { returns(T::Hash[Symbol, T.untyped]) }
110
+ def charge_kwargs
111
+ @options.except(:amount, *REQUEST_OPTION_KEYS)
112
+ end
113
+
114
+ sig { params(options: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
115
+ def symbolize(options)
116
+ options.each_with_object({}) do |(key, value), acc|
117
+ acc[key.to_sym] = value
118
+ end
119
+ end
120
+ end
121
+
122
+ # Combines multiple method offers into a single callable that presents
123
+ # every method via multiple WWW-Authenticate headers (and x402 extras).
124
+ class ComposedHandler
125
+ extend T::Sig
126
+
127
+ sig { returns(T.untyped) }
128
+ attr_reader :handler
129
+
130
+ sig { returns(T::Array[ComposeOffer]) }
131
+ attr_reader :offers
132
+
133
+ sig { params(handler: T.untyped, offers: T::Array[ComposeOffer]).void }
134
+ def initialize(handler:, offers:)
135
+ Kernel.raise ArgumentError, "compose() requires at least one entry" if offers.empty?
136
+
137
+ @handler = T.let(handler, T.untyped)
138
+ @offers = T.let(offers, T::Array[ComposeOffer])
139
+ end
140
+
141
+ sig { params(handler: T.untyped, entries: T::Array[T.untyped]).returns(ComposedHandler) }
142
+ def self.from_entries(handler, entries)
143
+ new(handler: handler, offers: entries.map { |entry| offer_from_entry(handler, entry) })
144
+ end
145
+
146
+ # Flatten nested compositions into a single handler.
147
+ sig { params(handlers: T::Array[T.untyped]).returns(ComposedHandler) }
148
+ def self.compose(handlers)
149
+ Kernel.raise ArgumentError, "compose() requires at least one handler" if handlers.empty?
150
+
151
+ offers = handlers.flat_map do |value|
152
+ if value.is_a?(ComposedHandler)
153
+ value.offers
154
+ elsif value.respond_to?(:offers)
155
+ value.offers
156
+ else
157
+ Kernel.raise ArgumentError, "compose() expected a ComposedHandler, got #{value.class}"
158
+ end
159
+ end
160
+ new(handler: handlers.first.handler, offers: offers)
161
+ end
162
+
163
+ sig do
164
+ params(
165
+ authorization: T.nilable(String),
166
+ payment_authorization: T.nilable(String),
167
+ payment_signature: T.nilable(String),
168
+ body: T.untyped,
169
+ url: T.nilable(String),
170
+ accept_payment: T.nilable(String),
171
+ http_method: T.nilable(String),
172
+ scope: T.nilable(T::Hash[String, String])
173
+ ).returns(ComposedResult)
174
+ end
175
+ def call(authorization: nil, payment_authorization: nil, payment_signature: nil, body: nil, url: nil, accept_payment: nil, http_method: nil, scope: nil)
176
+ if scope && !scope.empty?
177
+ return self.class.new(handler: @handler, offers: @offers.map { |offer| offer.with_scope(scope) }).call(
178
+ authorization: authorization,
179
+ payment_authorization: payment_authorization,
180
+ payment_signature: payment_signature,
181
+ body: body,
182
+ url: url,
183
+ accept_payment: accept_payment,
184
+ http_method: http_method
185
+ )
186
+ end
187
+
188
+ credential = @handler.payment_credential_value(authorization, payment_authorization)
189
+ dispatched = dispatch_credential(
190
+ authorization: credential,
191
+ payment_signature: payment_signature,
192
+ body: body,
193
+ url: url,
194
+ http_method: http_method
195
+ )
196
+ return dispatched if dispatched
197
+
198
+ merge_challenges(body: body, url: url, accept_payment: accept_payment, http_method: http_method)
199
+ end
200
+
201
+ private
202
+
203
+ sig { params(handler: T.untyped, entry: T.untyped).returns(ComposeOffer) }
204
+ def self.offer_from_entry(handler, entry)
205
+ unless entry.is_a?(Array) && entry.length == 2
206
+ Kernel.raise ArgumentError, "compose() entries must be [method, options] tuples"
207
+ end
208
+
209
+ method_ref, options = entry
210
+ options = {} if options.nil?
211
+ unless options.is_a?(Hash)
212
+ Kernel.raise ArgumentError, "compose() options must be a Hash"
213
+ end
214
+
215
+ ComposeOffer.new(handler, handler.resolve_method(method_ref), options)
216
+ end
217
+ private_class_method :offer_from_entry
218
+
219
+ sig do
220
+ params(
221
+ authorization: T.nilable(String),
222
+ payment_signature: T.nilable(String),
223
+ body: T.untyped,
224
+ url: T.nilable(String),
225
+ http_method: T.nilable(String)
226
+ ).returns(T.nilable(ComposedResult))
227
+ end
228
+ def dispatch_credential(authorization:, payment_signature:, body:, url:, http_method: nil)
229
+ if payment_signature && !payment_signature.strip.empty?
230
+ result = dispatch_x402(payment_signature, body: body, url: url, http_method: http_method)
231
+ return result if result
232
+ end
233
+
234
+ return nil if authorization.nil? || authorization.strip.empty?
235
+
236
+ dispatch_authorization(authorization, body: body, url: url, http_method: http_method)
237
+ end
238
+
239
+ sig { params(payment_signature: String, body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(ComposedResult)) }
240
+ def dispatch_x402(payment_signature, body:, url:, http_method: nil)
241
+ payload = decode_x402_payload(payment_signature)
242
+ return nil unless payload
243
+
244
+ offer = @offers.find { |candidate| candidate.x402_matches?(payload) }
245
+ return nil unless offer
246
+
247
+ wrap_offer_result(
248
+ offer.verify(payment_signature: payment_signature, body: body, url: url, http_method: http_method),
249
+ offer,
250
+ payment_signature: payment_signature,
251
+ url: url,
252
+ http_method: http_method
253
+ )
254
+ end
255
+
256
+ sig { params(authorization: String, body: T.untyped, url: T.nilable(String), http_method: T.nilable(String)).returns(T.nilable(ComposedResult)) }
257
+ def dispatch_authorization(authorization, body:, url:, http_method: nil)
258
+ payment = Mpp::Server::Verify.extract_payment_scheme(authorization)
259
+ return nil unless payment
260
+
261
+ begin
262
+ credential = Mpp::Credential.from_authorization(payment)
263
+ rescue Mpp::ParseError
264
+ return nil
265
+ end
266
+
267
+ offer = matching_offer(credential)
268
+ return nil unless offer
269
+
270
+ wrap_offer_result(
271
+ offer.verify(authorization: authorization, body: body, url: url, http_method: http_method),
272
+ offer,
273
+ url: url,
274
+ http_method: http_method
275
+ )
276
+ end
277
+
278
+ sig { params(credential: Mpp::Credential).returns(T.nilable(ComposeOffer)) }
279
+ def matching_offer(credential)
280
+ echo = credential.challenge
281
+ echo_request = decode_echo_request(echo)
282
+ return nil if echo_request.nil?
283
+
284
+ candidates = @offers.select do |offer|
285
+ offer.method.name == echo.method && offer.intent_name == echo.intent
286
+ end
287
+ return nil if candidates.empty?
288
+
289
+ exact = candidates.select { |offer| offer.canonical_request == echo_request }
290
+ exact.first || candidates.first
291
+ end
292
+
293
+ sig { params(echo: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
294
+ def decode_echo_request(echo)
295
+ return {} if echo.request.nil? || echo.request.empty?
296
+
297
+ Mpp::Parsing.b64_decode(echo.request)
298
+ rescue Mpp::ParseError
299
+ nil
300
+ end
301
+
302
+ sig { params(payment_signature: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
303
+ def decode_x402_payload(payment_signature)
304
+ Mpp::X402::Header.decode_payment_signature(payment_signature)
305
+ rescue Mpp::ParseError, ArgumentError
306
+ nil
307
+ end
308
+
309
+ sig do
310
+ params(
311
+ result: T.untyped,
312
+ offer: ComposeOffer,
313
+ payment_signature: T.nilable(String),
314
+ url: T.nilable(String),
315
+ http_method: T.nilable(String)
316
+ ).returns(ComposedResult)
317
+ end
318
+ def wrap_offer_result(result, offer, payment_signature: nil, url: nil, http_method: nil)
319
+ if result.is_a?(Mpp::Challenge)
320
+ extra = {}
321
+ offer.decorate_challenge(extra, result, url: url, http_method: http_method)
322
+ return ComposedResult.payment_required([result], realm: @handler.realm, extra_headers: extra)
323
+ end
324
+
325
+ credential, receipt = result
326
+ extra = {}
327
+ offer.decorate_receipt(extra, credential, receipt, payment_signature: payment_signature)
328
+ ComposedResult.paid(credential, receipt, realm: @handler.realm, extra_headers: extra)
329
+ end
330
+
331
+ sig do
332
+ params(
333
+ body: T.untyped,
334
+ url: T.nilable(String),
335
+ accept_payment: T.nilable(String),
336
+ http_method: T.nilable(String)
337
+ ).returns(ComposedResult)
338
+ end
339
+ def merge_challenges(body:, url:, accept_payment:, http_method:)
340
+ pairs = @offers.filter_map do |offer|
341
+ challenge = offer.challenge(body: body, url: url, http_method: http_method)
342
+ next if challenge.nil?
343
+
344
+ [offer, challenge]
345
+ end
346
+ if pairs.empty?
347
+ Kernel.raise ArgumentError, "No payment offers are available for this request"
348
+ end
349
+
350
+ ranked = AcceptPayment.apply(pairs.map { |_offer, challenge| challenge }, accept_payment)
351
+ by_id = pairs.to_h { |offer, challenge| [challenge.id, [offer, challenge]] }
352
+ pairs = ranked.filter_map { |challenge| by_id[challenge.id] }
353
+
354
+ extra = merge_extra_headers(pairs, url: url, http_method: http_method)
355
+ ComposedResult.payment_required(pairs.map { |_offer, challenge| challenge }, realm: @handler.realm, extra_headers: extra)
356
+ end
357
+
358
+ sig do
359
+ params(
360
+ pairs: T::Array[T.untyped],
361
+ url: T.nilable(String),
362
+ http_method: T.nilable(String)
363
+ ).returns(T::Hash[String, T.untyped])
364
+ end
365
+ def merge_extra_headers(pairs, url:, http_method:)
366
+ extra = {}
367
+ required_headers = []
368
+ pairs.each do |offer, challenge|
369
+ headers = {}
370
+ offer.decorate_challenge(headers, challenge, url: url, http_method: http_method)
371
+ required = headers["PAYMENT-REQUIRED"]
372
+ required_headers << required if required
373
+ headers.each do |key, value|
374
+ next if key == "PAYMENT-REQUIRED"
375
+
376
+ extra[key] = value
377
+ end
378
+ end
379
+
380
+ merged = merge_payment_required(required_headers)
381
+ extra["PAYMENT-REQUIRED"] = merged if merged
382
+ extra
383
+ end
384
+
385
+ sig { params(values: T::Array[String]).returns(T.nilable(String)) }
386
+ def merge_payment_required(values)
387
+ return nil if values.empty?
388
+ return values.first if values.length == 1
389
+
390
+ Mpp::X402::Server.merge_payment_required(values)
391
+ end
392
+ end
393
+
394
+ sig { params(handlers: T.untyped).returns(ComposedHandler) }
395
+ def self.compose(*handlers)
396
+ ComposedHandler.compose(handlers)
397
+ end
398
+ end
399
+ end
@@ -10,17 +10,40 @@ 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
+ credential_headers = challenges.map(&:credential_header).uniq
42
+ vary = extra_headers.key?("PAYMENT-REQUIRED") ? credential_headers + ["PAYMENT-SIGNATURE"] : credential_headers
43
+ Mpp::Server::Middleware.mark_authorization_bound_response(
44
+ headers,
45
+ vary: vary
46
+ )
24
47
  {
25
48
  "_mpp_challenge" => true,
26
49
  "status" => 402,
@@ -7,6 +7,7 @@ module Mpp
7
7
  # name -> String
8
8
  # intents -> Hash[String, Intent]
9
9
  # create_credential(challenge) -> Credential
10
+ # on_payment_success -> optional callable receiving a payment.success payload
10
11
 
11
12
  module MethodHelper
12
13
  extend T::Sig
@@ -22,6 +23,20 @@ module Mpp
22
23
  request
23
24
  end
24
25
  end
26
+
27
+ # Check whether a method should be advertised for a canonical request.
28
+ # This only governs composing new 402 offers, never credential redemption.
29
+ sig { params(method: T.untyped, request: T::Hash[String, T.untyped]).returns(T::Boolean) }
30
+ def can_offer?(method, request)
31
+ return true unless method.respond_to?(:can_offer?)
32
+
33
+ available = method.can_offer?(request)
34
+ unless available == true || available == false
35
+ Kernel.raise ArgumentError, "can_offer? must return true or false"
36
+ end
37
+
38
+ available
39
+ end
25
40
  end
26
41
  end
27
42
  end