mpp-rb 0.1.2 → 0.1.4
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 +37 -5
- data/lib/mpp/body_digest.rb +1 -1
- data/lib/mpp/challenge.rb +83 -8
- data/lib/mpp/client/transport.rb +158 -22
- data/lib/mpp/errors.rb +3 -0
- data/lib/mpp/events.rb +124 -0
- data/lib/mpp/extensions/mcp/types.rb +2 -1
- data/lib/mpp/methods/stripe/charge_intent.rb +41 -16
- data/lib/mpp/methods/stripe/client_method.rb +5 -1
- data/lib/mpp/methods/stripe/stripe_method.rb +15 -4
- data/lib/mpp/methods/tempo/client_method.rb +28 -8
- data/lib/mpp/methods/tempo/fee_payer_policy.rb +45 -0
- data/lib/mpp/methods/tempo/intents.rb +394 -64
- data/lib/mpp/methods/tempo/proof.rb +19 -15
- data/lib/mpp/methods/tempo/transaction.rb +4 -3
- data/lib/mpp/methods/tempo.rb +1 -0
- data/lib/mpp/parsing.rb +14 -2
- data/lib/mpp/receipt.rb +3 -2
- data/lib/mpp/server/decorator.rb +1 -0
- data/lib/mpp/server/middleware.rb +118 -15
- data/lib/mpp/server/mpp_handler.rb +40 -8
- data/lib/mpp/server/verify.rb +173 -27
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +5 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c71efa5e58021d08e4ed10c0156ed9b140923b0f3ebeb5ce6b42839594c27dd6
|
|
4
|
+
data.tar.gz: d40e3c4994086eea6f2c11023f47fda635637ae6815dc263d63c12ca10235ae8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af6ba306f236fb01dd140f63ce51c764fdee655a7f1282d7adad87d8586e4838d43f096ca79a4053dbab703afdc6fa1806d3e20ea6b6afa75c7c70bf6d3885f3
|
|
7
|
+
data.tar.gz: db9bc204d61dcdb4c3e9d0b8f9034f935dd38d4291b71b37e61a29889240b27667400e5c48333d2cf39e2d6513b5af70368a354a8a20fd5a543558b07b68f700
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Ruby SDK for the [**Machine Payments Protocol**](https://mpp.dev)
|
|
4
4
|
|
|
5
|
-
[](https://rubygems.org/gems/mpp-rb)
|
|
5
|
+
[](https://rubygems.org/gems/mpp-rb)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
## Documentation
|
|
@@ -68,6 +68,35 @@ transport = Mpp::Client::Transport.new(
|
|
|
68
68
|
response = transport.request(:get, "https://mpp.dev/api/ping/paid")
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### Event hooks
|
|
72
|
+
|
|
73
|
+
Register hooks to observe the automatic payment lifecycle. Each registration returns an unsubscribe proc.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
server.on_challenge_created do |payload|
|
|
77
|
+
puts "challenge: #{payload[:challenge].id}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
server.on_payment_success do |payload|
|
|
81
|
+
puts "paid: #{payload[:receipt].reference}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
transport.on_challenge_received do |payload|
|
|
85
|
+
puts "received: #{payload[:challenge].id}"
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
transport.on_payment_response do |payload|
|
|
90
|
+
puts "retry status: #{payload[:response].code}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
transport.on("*") do |event|
|
|
94
|
+
puts "payment event: #{event.name}"
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
|
|
99
|
+
|
|
71
100
|
### Rack Middleware
|
|
72
101
|
|
|
73
102
|
```ruby
|
|
@@ -123,10 +152,13 @@ Built on the ["Payment" HTTP Authentication Scheme](https://datatracker.ietf.org
|
|
|
123
152
|
|
|
124
153
|
## Releasing
|
|
125
154
|
|
|
126
|
-
1.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
155
|
+
1. Create a release PR:
|
|
156
|
+
- Update the version in `lib/mpp/version.rb`
|
|
157
|
+
- Run `bundle lock --update mpp-rb` in the root and each `examples/` subdirectory
|
|
158
|
+
- Commit and open a PR to verify CI passes
|
|
159
|
+
2. Merge the PR
|
|
160
|
+
3. Tag the merge commit: `git tag v0.x.x`
|
|
161
|
+
4. Push the tag: `git push origin --tags`
|
|
130
162
|
|
|
131
163
|
## License
|
|
132
164
|
|
data/lib/mpp/body_digest.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Mpp
|
|
|
21
21
|
when String
|
|
22
22
|
# use as-is
|
|
23
23
|
end
|
|
24
|
-
body = body.
|
|
24
|
+
body = body.b if body.is_a?(String)
|
|
25
25
|
digest = OpenSSL::Digest::SHA256.digest(body)
|
|
26
26
|
encoded = Base64.strict_encode64(digest)
|
|
27
27
|
"sha-256=#{encoded}"
|
data/lib/mpp/challenge.rb
CHANGED
|
@@ -58,20 +58,95 @@ module Mpp
|
|
|
58
58
|
Mpp::Parsing.parse_www_authenticate(header)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
61
|
+
# Split a merged WWW-Authenticate header value into the individual
|
|
62
|
+
# `Payment ...` challenge chunks it contains, handling RFC 9110 §11.6.1
|
|
63
|
+
# comma-separated authentication schemes. Returns the raw chunk strings so
|
|
64
|
+
# callers can parse each challenge independently; a malformed chunk then
|
|
65
|
+
# does not prevent the remaining chunks from being considered.
|
|
66
|
+
def self.www_authenticate_chunks(header)
|
|
67
|
+
payment_scheme_indices(header).map do |start_idx|
|
|
68
|
+
# End each chunk at the next scheme boundary of any kind, so an
|
|
69
|
+
# interleaved non-Payment scheme (e.g. "Payment ..., Bearer ...,
|
|
70
|
+
# Payment ...") is not folded into the preceding Payment challenge.
|
|
71
|
+
end_idx = next_auth_scheme_index(header, start_idx + "Payment".length) || header.length
|
|
72
|
+
T.must(header[start_idx...end_idx]).sub(/,\s*$/, "")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse every Payment challenge from a merged WWW-Authenticate header.
|
|
77
|
+
# Raises Mpp::ParseError on the first malformed chunk; callers that need to
|
|
78
|
+
# tolerate a malformed challenge among valid ones should iterate
|
|
79
|
+
# `www_authenticate_chunks` and parse each chunk independently instead.
|
|
63
80
|
def self.from_www_authenticate_list(header)
|
|
81
|
+
www_authenticate_chunks(header).map { |chunk| from_www_authenticate(chunk) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.payment_scheme_indices(header)
|
|
64
85
|
indices = []
|
|
65
|
-
header
|
|
66
|
-
|
|
86
|
+
each_auth_scheme_index(header) do |index, scheme|
|
|
87
|
+
indices << index if scheme.casecmp("Payment").zero?
|
|
88
|
+
end
|
|
89
|
+
indices
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.next_auth_scheme_index(header, offset)
|
|
93
|
+
each_auth_scheme_index(header, offset) do |index, _scheme|
|
|
94
|
+
return index
|
|
95
|
+
end
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.each_auth_scheme_index(header, offset = 0)
|
|
100
|
+
in_quote = T.let(false, T::Boolean)
|
|
101
|
+
escaped = T.let(false, T::Boolean)
|
|
102
|
+
i = offset
|
|
103
|
+
|
|
104
|
+
while i < header.length
|
|
105
|
+
char = T.must(header[i])
|
|
106
|
+
|
|
107
|
+
if in_quote
|
|
108
|
+
if escaped
|
|
109
|
+
escaped = false
|
|
110
|
+
elsif char == "\\"
|
|
111
|
+
escaped = true
|
|
112
|
+
elsif char == "\""
|
|
113
|
+
in_quote = false
|
|
114
|
+
end
|
|
115
|
+
i += 1
|
|
116
|
+
next
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if char == "\""
|
|
120
|
+
in_quote = true
|
|
121
|
+
i += 1
|
|
122
|
+
next
|
|
123
|
+
end
|
|
67
124
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
125
|
+
if scheme_boundary?(header, i)
|
|
126
|
+
match = T.must(header[i..]).match(/\A([A-Za-z][A-Za-z0-9._~+\/-]*)\s+/)
|
|
127
|
+
# An auth-param permits OWS around "=" (key\s*=\s*value), so a token
|
|
128
|
+
# followed by whitespace then "=" is a parameter, not a new scheme.
|
|
129
|
+
if match && header[i + T.must(match[0]).length] != "="
|
|
130
|
+
yield i, T.must(match[1])
|
|
131
|
+
i += T.must(match[0]).length
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
i += 1
|
|
72
137
|
end
|
|
73
138
|
end
|
|
74
139
|
|
|
140
|
+
def self.scheme_boundary?(header, index)
|
|
141
|
+
previous = T.must(header[0...index]).rstrip
|
|
142
|
+
# Start of the header, including the case where only optional whitespace
|
|
143
|
+
# (RFC 9110 OWS) precedes the first scheme; otherwise a scheme is a
|
|
144
|
+
# boundary only immediately after a comma separating two schemes.
|
|
145
|
+
return true if previous.empty?
|
|
146
|
+
|
|
147
|
+
previous.end_with?(",")
|
|
148
|
+
end
|
|
149
|
+
|
|
75
150
|
# Serialize to a WWW-Authenticate header value.
|
|
76
151
|
def to_www_authenticate(realm)
|
|
77
152
|
Mpp::Parsing.format_www_authenticate(self, realm)
|
data/lib/mpp/client/transport.rb
CHANGED
|
@@ -18,9 +18,35 @@ module Mpp
|
|
|
18
18
|
class Transport
|
|
19
19
|
extend T::Sig
|
|
20
20
|
|
|
21
|
-
sig { params(methods: T::Array[T.untyped]).void }
|
|
22
|
-
def initialize(methods:)
|
|
21
|
+
sig { params(methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher)).void }
|
|
22
|
+
def initialize(methods:, events: nil)
|
|
23
23
|
@methods = T.let(methods.to_h { |m| [m.name, m] }, T::Hash[String, T.untyped])
|
|
24
|
+
@events = T.let(events || Mpp::Events.client_dispatcher, Mpp::Events::Dispatcher)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
28
|
+
def on(name, handler = nil, &block)
|
|
29
|
+
@events.on(name, handler, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
33
|
+
def on_challenge_received(handler = nil, &block)
|
|
34
|
+
on(Mpp::Events::CHALLENGE_RECEIVED, handler, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
38
|
+
def on_credential_created(handler = nil, &block)
|
|
39
|
+
on(Mpp::Events::CREDENTIAL_CREATED, handler, &block)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
43
|
+
def on_payment_failed(handler = nil, &block)
|
|
44
|
+
on(Mpp::Events::PAYMENT_FAILED, handler, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
48
|
+
def on_payment_response(handler = nil, &block)
|
|
49
|
+
on(Mpp::Events::PAYMENT_RESPONSE, handler, &block)
|
|
24
50
|
end
|
|
25
51
|
|
|
26
52
|
# Send an HTTP request with automatic 402 payment handling.
|
|
@@ -34,7 +60,7 @@ module Mpp
|
|
|
34
60
|
|
|
35
61
|
# Parse WWW-Authenticate headers
|
|
36
62
|
www_auth_headers = response.get_fields("www-authenticate") || []
|
|
37
|
-
challenge, matched_method = find_matching_challenge(www_auth_headers)
|
|
63
|
+
challenge, matched_method = find_matching_challenge(www_auth_headers, input: url, response: response)
|
|
38
64
|
return response unless challenge && matched_method
|
|
39
65
|
|
|
40
66
|
# Check expiry before paying (client-side guardrail)
|
|
@@ -47,11 +73,90 @@ module Mpp
|
|
|
47
73
|
end
|
|
48
74
|
end
|
|
49
75
|
|
|
50
|
-
|
|
51
|
-
|
|
76
|
+
auth_header = T.let(nil, T.nilable(String))
|
|
77
|
+
create_credential = Kernel.lambda do
|
|
78
|
+
auth_header ||= credential_authorization(matched_method.create_credential(challenge))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
event_credential = nil
|
|
83
|
+
if @events.has_handlers?(Mpp::Events::CHALLENGE_RECEIVED)
|
|
84
|
+
# challenge.received can override credential creation; first non-empty credential wins.
|
|
85
|
+
event_credential = @events.emit_first(Mpp::Events::CHALLENGE_RECEIVED, {
|
|
86
|
+
challenge: challenge,
|
|
87
|
+
challenges: [challenge],
|
|
88
|
+
create_credential: create_credential,
|
|
89
|
+
input: url,
|
|
90
|
+
method: matched_method,
|
|
91
|
+
response: response
|
|
92
|
+
})
|
|
93
|
+
end
|
|
94
|
+
auth_header = credential_authorization(event_credential) unless event_credential.nil?
|
|
95
|
+
auth_header ||= create_credential.call
|
|
96
|
+
|
|
97
|
+
if @events.has_handlers?(Mpp::Events::CREDENTIAL_CREATED)
|
|
98
|
+
@events.emit(Mpp::Events::CREDENTIAL_CREATED, {
|
|
99
|
+
challenge: challenge,
|
|
100
|
+
credential: auth_header,
|
|
101
|
+
input: url,
|
|
102
|
+
method: matched_method,
|
|
103
|
+
response: response
|
|
104
|
+
})
|
|
105
|
+
end
|
|
106
|
+
rescue => e
|
|
107
|
+
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
|
|
108
|
+
@events.emit(Mpp::Events::PAYMENT_FAILED, {
|
|
109
|
+
challenge: challenge,
|
|
110
|
+
challenges: [challenge],
|
|
111
|
+
error: e,
|
|
112
|
+
input: url,
|
|
113
|
+
method: matched_method,
|
|
114
|
+
response: response
|
|
115
|
+
})
|
|
116
|
+
end
|
|
117
|
+
raise
|
|
118
|
+
end
|
|
52
119
|
|
|
53
120
|
retry_headers = headers.merge("Authorization" => auth_header)
|
|
54
|
-
|
|
121
|
+
payment_response = nil
|
|
122
|
+
begin
|
|
123
|
+
payment_response = send_request(uri, method, retry_headers, body)
|
|
124
|
+
rescue => e
|
|
125
|
+
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
|
|
126
|
+
@events.emit(Mpp::Events::PAYMENT_FAILED, {
|
|
127
|
+
challenge: challenge,
|
|
128
|
+
challenges: [challenge],
|
|
129
|
+
credential: auth_header,
|
|
130
|
+
error: e,
|
|
131
|
+
input: url,
|
|
132
|
+
method: matched_method,
|
|
133
|
+
response: response
|
|
134
|
+
})
|
|
135
|
+
end
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if payment_response.code.to_i.between?(200, 299) && @events.has_handlers?(Mpp::Events::PAYMENT_RESPONSE)
|
|
140
|
+
@events.emit(Mpp::Events::PAYMENT_RESPONSE, {
|
|
141
|
+
challenge: challenge,
|
|
142
|
+
credential: auth_header,
|
|
143
|
+
input: url,
|
|
144
|
+
method: matched_method,
|
|
145
|
+
response: payment_response
|
|
146
|
+
})
|
|
147
|
+
elsif @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
|
|
148
|
+
@events.emit(Mpp::Events::PAYMENT_FAILED, {
|
|
149
|
+
challenge: challenge,
|
|
150
|
+
challenges: [challenge],
|
|
151
|
+
credential: auth_header,
|
|
152
|
+
error: Mpp::VerificationFailedError.new(reason: "retry returned HTTP #{payment_response.code}"),
|
|
153
|
+
input: url,
|
|
154
|
+
method: matched_method,
|
|
155
|
+
response: payment_response
|
|
156
|
+
})
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
payment_response
|
|
55
160
|
end
|
|
56
161
|
|
|
57
162
|
sig { params(url: T.any(URI::Generic, String), kwargs: T.untyped).returns(T.untyped) }
|
|
@@ -97,20 +202,51 @@ module Mpp
|
|
|
97
202
|
http.request(req)
|
|
98
203
|
end
|
|
99
204
|
|
|
100
|
-
sig { params(www_auth_headers: T.untyped).returns(T::Array[T.untyped]) }
|
|
101
|
-
def find_matching_challenge(www_auth_headers)
|
|
205
|
+
sig { params(www_auth_headers: T.untyped, input: T.untyped, response: T.untyped).returns(T::Array[T.untyped]) }
|
|
206
|
+
def find_matching_challenge(www_auth_headers, input: nil, response: nil)
|
|
102
207
|
www_auth_headers.each do |header|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
begin
|
|
106
|
-
parsed = Mpp::Challenge.from_www_authenticate(header)
|
|
208
|
+
Mpp::Challenge.www_authenticate_chunks(header).each do |chunk|
|
|
209
|
+
parsed = Mpp::Challenge.from_www_authenticate(chunk)
|
|
107
210
|
return [parsed, @methods[parsed.method]] if @methods.key?(parsed.method)
|
|
108
|
-
rescue Mpp::ParseError
|
|
211
|
+
rescue Mpp::ParseError => e
|
|
212
|
+
# Skip a malformed challenge but keep scanning the rest: a bad chunk
|
|
213
|
+
# earlier in a merged value must not hide a supported challenge that
|
|
214
|
+
# follows it.
|
|
215
|
+
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
|
|
216
|
+
@events.emit(Mpp::Events::PAYMENT_FAILED, {
|
|
217
|
+
error: e,
|
|
218
|
+
input: input,
|
|
219
|
+
response: response
|
|
220
|
+
})
|
|
221
|
+
end
|
|
109
222
|
next
|
|
110
223
|
end
|
|
111
224
|
end
|
|
112
225
|
[nil, nil]
|
|
113
226
|
end
|
|
227
|
+
|
|
228
|
+
sig { params(credential: T.untyped).returns(String) }
|
|
229
|
+
def credential_authorization(credential)
|
|
230
|
+
auth_header = if credential.respond_to?(:to_authorization)
|
|
231
|
+
credential.to_authorization
|
|
232
|
+
elsif credential.is_a?(String)
|
|
233
|
+
credential
|
|
234
|
+
else
|
|
235
|
+
raise ArgumentError, "Credential must be a String or respond to #to_authorization"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
validate_authorization_header(auth_header)
|
|
239
|
+
auth_header
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
sig { params(auth_header: String).void }
|
|
243
|
+
def validate_authorization_header(auth_header)
|
|
244
|
+
unless auth_header.start_with?("Payment ") && auth_header.length > 8
|
|
245
|
+
raise ArgumentError, "Credential must be a non-empty Payment authorization header"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
raise ArgumentError, "Credential contains invalid header characters" if auth_header.match?(/[[:cntrl:]]/)
|
|
249
|
+
end
|
|
114
250
|
end
|
|
115
251
|
|
|
116
252
|
# Module-level convenience methods
|
|
@@ -118,20 +254,20 @@ module Mpp
|
|
|
118
254
|
|
|
119
255
|
module_function
|
|
120
256
|
|
|
121
|
-
sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
|
|
122
|
-
def request(method, url, methods:, **kwargs)
|
|
123
|
-
transport = Transport.new(methods: methods)
|
|
257
|
+
sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
|
|
258
|
+
def request(method, url, methods:, events: nil, **kwargs)
|
|
259
|
+
transport = Transport.new(methods: methods, events: events)
|
|
124
260
|
transport.request(method, url, **kwargs)
|
|
125
261
|
end
|
|
126
262
|
|
|
127
|
-
sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
|
|
128
|
-
def get(url, methods:, **kwargs)
|
|
129
|
-
request("GET", url, **T.unsafe({methods: methods, **kwargs}))
|
|
263
|
+
sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
|
|
264
|
+
def get(url, methods:, events: nil, **kwargs)
|
|
265
|
+
request("GET", url, **T.unsafe({methods: methods, events: events, **kwargs}))
|
|
130
266
|
end
|
|
131
267
|
|
|
132
|
-
sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
|
|
133
|
-
def post(url, methods:, **kwargs)
|
|
134
|
-
request("POST", url, **T.unsafe({methods: methods, **kwargs}))
|
|
268
|
+
sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
|
|
269
|
+
def post(url, methods:, events: nil, **kwargs)
|
|
270
|
+
request("POST", url, **T.unsafe({methods: methods, events: events, **kwargs}))
|
|
135
271
|
end
|
|
136
272
|
end
|
|
137
273
|
end
|
data/lib/mpp/errors.rb
CHANGED
|
@@ -10,6 +10,9 @@ module Mpp
|
|
|
10
10
|
# Base verification error.
|
|
11
11
|
class VerificationError < StandardError; end
|
|
12
12
|
|
|
13
|
+
# Retryable verification error for transactions that were accepted but not mined yet.
|
|
14
|
+
class TransactionPendingError < VerificationError; end
|
|
15
|
+
|
|
13
16
|
# Base class for all payment-related errors with RFC 9457 support.
|
|
14
17
|
class PaymentError < StandardError
|
|
15
18
|
extend T::Sig
|
data/lib/mpp/events.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Events
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
ANY = "*"
|
|
9
|
+
CHALLENGE_CREATED = "challenge.created"
|
|
10
|
+
CHALLENGE_RECEIVED = "challenge.received"
|
|
11
|
+
CREDENTIAL_CREATED = "credential.created"
|
|
12
|
+
PAYMENT_FAILED = "payment.failed"
|
|
13
|
+
PAYMENT_RESPONSE = "payment.response"
|
|
14
|
+
PAYMENT_SUCCESS = "payment.success"
|
|
15
|
+
|
|
16
|
+
class Event < T::Struct
|
|
17
|
+
const :name, String
|
|
18
|
+
const :payload, T::Hash[Symbol, T.untyped]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Dispatcher
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
sig { params(event_names: T::Array[String]).void }
|
|
25
|
+
def initialize(event_names:)
|
|
26
|
+
@event_names = T.let(event_names.to_h { |name| [name, true] }, T::Hash[String, T::Boolean])
|
|
27
|
+
@event_names[ANY] = true
|
|
28
|
+
@handlers = T.let(@event_names.keys.to_h { |name| [name, []] }, T::Hash[String, T::Array[T.untyped]])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
|
|
32
|
+
def on(name, handler = nil, &block)
|
|
33
|
+
raise ArgumentError, "Unknown event: #{name}" unless @event_names.key?(name)
|
|
34
|
+
|
|
35
|
+
callback = handler || block
|
|
36
|
+
raise ArgumentError, "handler is required" unless callback
|
|
37
|
+
|
|
38
|
+
handlers = T.must(@handlers[name])
|
|
39
|
+
handlers << callback
|
|
40
|
+
Kernel.lambda { handlers.delete(callback) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
|
|
44
|
+
def emit(name, payload)
|
|
45
|
+
return unless has_handlers?(name)
|
|
46
|
+
|
|
47
|
+
emit_observers(name, payload)
|
|
48
|
+
emit_any(name, payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
|
|
52
|
+
def emit_first(name, payload)
|
|
53
|
+
return nil unless has_handlers?(name)
|
|
54
|
+
|
|
55
|
+
result = T.let(nil, T.untyped)
|
|
56
|
+
|
|
57
|
+
T.must(@handlers[name]).each do |handler|
|
|
58
|
+
value = call(handler, payload)
|
|
59
|
+
next if empty_result?(value)
|
|
60
|
+
|
|
61
|
+
result = value
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
emit_any(name, payload)
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(name: T.nilable(String)).returns(T::Boolean) }
|
|
70
|
+
def has_handlers?(name = nil)
|
|
71
|
+
return @handlers.any? { |_event_name, handlers| !handlers.empty? } unless name
|
|
72
|
+
|
|
73
|
+
!T.must(@handlers[name]).empty? || !T.must(@handlers[ANY]).empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
|
|
79
|
+
def emit_observers(name, payload)
|
|
80
|
+
T.must(@handlers[name]).each { |handler| call(handler, payload) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
|
|
84
|
+
def emit_any(name, payload)
|
|
85
|
+
any_handlers = T.must(@handlers[ANY])
|
|
86
|
+
return if any_handlers.empty?
|
|
87
|
+
|
|
88
|
+
event = Event.new(name: name, payload: payload)
|
|
89
|
+
any_handlers.each { |handler| call(handler, event) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sig { params(handler: T.untyped, payload: T.untyped).returns(T.untyped) }
|
|
93
|
+
def call(handler, payload)
|
|
94
|
+
handler.call(payload)
|
|
95
|
+
rescue
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
100
|
+
def empty_result?(value)
|
|
101
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sig { returns(Dispatcher) }
|
|
106
|
+
def self.client_dispatcher
|
|
107
|
+
Dispatcher.new(event_names: [
|
|
108
|
+
CHALLENGE_RECEIVED,
|
|
109
|
+
CREDENTIAL_CREATED,
|
|
110
|
+
PAYMENT_FAILED,
|
|
111
|
+
PAYMENT_RESPONSE
|
|
112
|
+
])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { returns(Dispatcher) }
|
|
116
|
+
def self.server_dispatcher
|
|
117
|
+
Dispatcher.new(event_names: [
|
|
118
|
+
CHALLENGE_CREATED,
|
|
119
|
+
PAYMENT_FAILED,
|
|
120
|
+
PAYMENT_SUCCESS
|
|
121
|
+
])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -11,10 +11,11 @@ module Mpp
|
|
|
11
11
|
class ChargeIntent
|
|
12
12
|
attr_reader :name
|
|
13
13
|
|
|
14
|
-
def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE)
|
|
14
|
+
def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil)
|
|
15
15
|
@name = "charge"
|
|
16
16
|
@secret_key = secret_key
|
|
17
17
|
@api_base = api_base
|
|
18
|
+
@client = client
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def verify(credential, request)
|
|
@@ -31,7 +32,25 @@ module Mpp
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
spt = payload_data["spt"]
|
|
34
|
-
|
|
35
|
+
credential_external_id = payload_data["externalId"]
|
|
36
|
+
request_external_id = request["externalId"]
|
|
37
|
+
if !request_external_id.nil? && credential_external_id != request_external_id
|
|
38
|
+
raise Mpp::InvalidChallengeError.new(
|
|
39
|
+
challenge_id: credential.challenge.id,
|
|
40
|
+
reason: "credential externalId does not match request externalId"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
method_details = request["methodDetails"]
|
|
45
|
+
method_details = {} unless method_details.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
# Enforce the payment method types allowlist from the challenge
|
|
48
|
+
payment_method_types = method_details["paymentMethodTypes"]
|
|
49
|
+
unless payment_method_types.is_a?(Array) &&
|
|
50
|
+
payment_method_types.any? &&
|
|
51
|
+
payment_method_types.all? { |type| type.is_a?(String) && !type.strip.empty? }
|
|
52
|
+
raise Mpp::VerificationError, "Invalid or missing methodDetails.paymentMethodTypes"
|
|
53
|
+
end
|
|
35
54
|
|
|
36
55
|
# Build PaymentIntent params
|
|
37
56
|
params = {
|
|
@@ -39,28 +58,28 @@ module Mpp
|
|
|
39
58
|
currency: request["currency"],
|
|
40
59
|
shared_payment_granted_token: spt,
|
|
41
60
|
confirm: true,
|
|
42
|
-
|
|
43
|
-
enabled: true,
|
|
44
|
-
allow_redirects: "never"
|
|
45
|
-
}
|
|
61
|
+
payment_method_types: payment_method_types
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
# Include metadata from methodDetails if present
|
|
49
|
-
method_details
|
|
50
|
-
if method_details.is_a?(Hash) && method_details["metadata"].is_a?(Hash)
|
|
65
|
+
if method_details["metadata"].is_a?(Hash)
|
|
51
66
|
params[:metadata] = method_details["metadata"].transform_values(&:to_s)
|
|
52
67
|
end
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
unless @client
|
|
70
|
+
begin
|
|
71
|
+
Kernel.require "stripe"
|
|
72
|
+
rescue LoadError
|
|
73
|
+
raise "stripe gem is required for Stripe charge verification. Install with: gem install stripe"
|
|
74
|
+
end
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
begin
|
|
62
|
-
client = ::Stripe::StripeClient.new(@secret_key)
|
|
63
|
-
result = client.v1.payment_intents.create(
|
|
78
|
+
client = @client || ::Stripe::StripeClient.new(@secret_key)
|
|
79
|
+
result = client.v1.payment_intents.create(
|
|
80
|
+
params,
|
|
81
|
+
{idempotency_key: stripe_idempotency_key(credential)}
|
|
82
|
+
)
|
|
64
83
|
rescue => e
|
|
65
84
|
raise Mpp::VerificationError, e.message
|
|
66
85
|
end
|
|
@@ -82,7 +101,13 @@ module Mpp
|
|
|
82
101
|
raise Mpp::VerificationError, "PaymentIntent #{pi_id} has status: #{status}"
|
|
83
102
|
end
|
|
84
103
|
|
|
85
|
-
Mpp::Receipt.success(pi_id, method: "stripe", external_id:
|
|
104
|
+
Mpp::Receipt.success(pi_id, method: "stripe", external_id: request_external_id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def stripe_idempotency_key(credential)
|
|
110
|
+
"mpp-stripe-charge-#{credential.challenge.id}"
|
|
86
111
|
end
|
|
87
112
|
end
|
|
88
113
|
end
|
|
@@ -29,7 +29,11 @@ module Mpp
|
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
payload = {"spt" => spt_id}
|
|
32
|
-
|
|
32
|
+
request_external_id = request["externalId"]
|
|
33
|
+
payload["externalId"] = request_external_id if request_external_id.is_a?(String)
|
|
34
|
+
if @external_id && !payload.key?("externalId")
|
|
35
|
+
raise ArgumentError, "external_id must be bound by the challenge request"
|
|
36
|
+
end
|
|
33
37
|
|
|
34
38
|
Mpp::Credential.new(
|
|
35
39
|
challenge: challenge.to_echo,
|