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.
- checksums.yaml +4 -4
- data/README.md +128 -1
- data/lib/mpp/challenge.rb +31 -8
- data/lib/mpp/challenge_echo.rb +14 -3
- data/lib/mpp/challenge_id.rb +30 -6
- data/lib/mpp/client/transport.rb +1 -1
- data/lib/mpp/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +133 -0
- data/lib/mpp/methods/evm.rb +67 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
- data/lib/mpp/methods/stripe.rb +23 -0
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +35 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +80 -48
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +22 -2
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +399 -0
- data/lib/mpp/server/decorator.rb +30 -7
- data/lib/mpp/server/method.rb +15 -0
- data/lib/mpp/server/middleware.rb +160 -18
- data/lib/mpp/server/mpp_handler.rb +322 -34
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server/verify.rb +24 -14
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +16 -3
- 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
|
data/lib/mpp/server/decorator.rb
CHANGED
|
@@ -10,17 +10,40 @@ module Mpp
|
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
# Build a 402 response for
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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" =>
|
|
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
|
-
|
|
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,
|
data/lib/mpp/server/method.rb
CHANGED
|
@@ -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
|