mpp-rb 0.1.1 → 0.1.3

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.
@@ -15,14 +15,40 @@ module Mpp
15
15
  # Verify a payment credential or generate a new challenge.
16
16
  #
17
17
  # Returns Challenge (payment required) or [Credential, Receipt] (verified).
18
- sig { params(authorization: T.nilable(String), intent: T.untyped, request: T::Hash[String, T.untyped], realm: String, secret_key: String, method: T.nilable(String), description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String)).returns(T.untyped) }
18
+ sig { params(authorization: T.nilable(String), intent: T.untyped, request: T::Hash[String, T.untyped], realm: String, secret_key: String, method: T.nilable(String), description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String), events: T.nilable(Mpp::Events::Dispatcher), body: T.untyped).returns(T.untyped) }
19
19
  def verify_or_challenge(authorization:, intent:, request:, realm:, secret_key:,
20
- method: nil, description: nil, meta: nil, expires: nil)
20
+ method: nil, description: nil, meta: nil, expires: nil, events: nil, body: nil)
21
21
  method_name = method || "tempo"
22
22
  request = Mpp::Units.transform_units(request)
23
+ dispatcher = events
24
+ events_enabled = dispatcher&.has_handlers?
25
+ method_context = events_enabled ? {name: method_name, intent: intent.name} : nil
23
26
 
24
- new_challenge = Kernel.lambda {
25
- create_challenge(method_name, intent.name, request, realm, secret_key, description, meta, expires)
27
+ new_challenge = Kernel.lambda { |credential = nil, error = nil, submitted_challenge = nil|
28
+ challenge = create_challenge(method_name, intent.name, request, realm, secret_key, description, meta, expires, body)
29
+ if error && dispatcher&.has_handlers?(Mpp::Events::PAYMENT_FAILED)
30
+ emit_payment_failed(
31
+ dispatcher: dispatcher,
32
+ challenge: challenge,
33
+ credential: credential,
34
+ error: error,
35
+ method: T.must(method_context),
36
+ request: request,
37
+ retry_challenge: challenge,
38
+ submitted_challenge: submitted_challenge
39
+ )
40
+ end
41
+ if dispatcher&.has_handlers?(Mpp::Events::CHALLENGE_CREATED)
42
+ emit_challenge_created(
43
+ dispatcher: dispatcher,
44
+ challenge: challenge,
45
+ credential: credential,
46
+ error: error,
47
+ method: T.must(method_context),
48
+ request: request
49
+ )
50
+ end
51
+ challenge
26
52
  }
27
53
 
28
54
  return new_challenge.call if authorization.nil?
@@ -32,8 +58,8 @@ module Mpp
32
58
 
33
59
  begin
34
60
  credential = Mpp::Credential.from_authorization(payment_scheme)
35
- rescue Mpp::ParseError
36
- return new_challenge.call
61
+ rescue Mpp::ParseError => e
62
+ return new_challenge.call(nil, Mpp::MalformedCredentialError.new(reason: e.message))
37
63
  end
38
64
 
39
65
  # Stateless challenge verification
@@ -41,8 +67,8 @@ module Mpp
41
67
  begin
42
68
  echo_request = echo.request.empty? ? {} : Mpp::Parsing.b64_decode(echo.request)
43
69
  echo_opaque = (echo.opaque && !T.must(echo.opaque).empty?) ? Mpp::Parsing.b64_decode(echo.opaque) : nil
44
- rescue Mpp::ParseError
45
- return new_challenge.call
70
+ rescue Mpp::ParseError => e
71
+ return new_challenge.call(credential, Mpp::MalformedCredentialError.new(reason: e.message), echo)
46
72
  end
47
73
 
48
74
  expected_id = Mpp.generate_challenge_id(
@@ -55,54 +81,120 @@ module Mpp
55
81
  digest: echo.digest,
56
82
  opaque: echo_opaque
57
83
  )
58
- return new_challenge.call unless Mpp.secure_compare(echo.id, expected_id)
84
+ unless Mpp.secure_compare(echo.id, expected_id)
85
+ return new_challenge.call(
86
+ credential,
87
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "challenge id mismatch"),
88
+ echo
89
+ )
90
+ end
59
91
 
60
92
  # Assert echoed fields match server's values
61
- return new_challenge.call unless echo.realm == realm && echo.method == method_name && echo.intent == intent.name
93
+ unless echo.realm == realm && echo.method == method_name && echo.intent == intent.name
94
+ return new_challenge.call(
95
+ credential,
96
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "challenge binding mismatch"),
97
+ echo
98
+ )
99
+ end
62
100
 
63
101
  # Assert echoed request matches server's current request
64
- return new_challenge.call unless echo_request == request
102
+ unless echo_request == request
103
+ return new_challenge.call(
104
+ credential,
105
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "request mismatch"),
106
+ echo
107
+ )
108
+ end
65
109
 
66
- return new_challenge.call unless echo_opaque == meta
110
+ unless echo_opaque == meta
111
+ return new_challenge.call(
112
+ credential,
113
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "metadata mismatch"),
114
+ echo
115
+ )
116
+ end
67
117
 
68
- # Reject expired challenges as defense-in-depth
69
- if echo.expires
70
- begin
71
- expires_dt = Time.iso8601(T.must(echo.expires).gsub("Z", "+00:00"))
72
- return new_challenge.call if expires_dt < Time.now.utc
73
- rescue ArgumentError
74
- # If we can't parse, continue to stricter check below
75
- end
118
+ unless body_digest_matches?(echo, body)
119
+ return new_challenge.call(
120
+ credential,
121
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "body digest mismatch"),
122
+ echo
123
+ )
76
124
  end
77
125
 
78
126
  # Verify echoed request parameters match endpoint's expected request
79
127
  request.each do |key, value|
80
- return new_challenge.call unless echo_request[key] == value
128
+ unless echo_request[key] == value
129
+ return new_challenge.call(
130
+ credential,
131
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "request field #{key} mismatch"),
132
+ echo
133
+ )
134
+ end
81
135
  end
82
136
 
83
137
  # Enforce challenge expiry - fail closed
84
- return new_challenge.call unless echo.expires
138
+ unless echo.expires
139
+ return new_challenge.call(
140
+ credential,
141
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "missing expiry"),
142
+ echo
143
+ )
144
+ end
85
145
 
86
146
  begin
87
147
  expires_dt = Time.iso8601(T.must(echo.expires).gsub("Z", "+00:00"))
88
148
  rescue ArgumentError
89
- return new_challenge.call
149
+ return new_challenge.call(
150
+ credential,
151
+ Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "invalid expiry"),
152
+ echo
153
+ )
154
+ end
155
+ if expires_dt < Time.now.utc
156
+ return new_challenge.call(credential, Mpp::PaymentExpiredError.new(expires: echo.expires), echo)
90
157
  end
91
- return new_challenge.call if expires_dt < Time.now.utc
92
158
 
93
- receipt = intent.verify(credential, request)
159
+ begin
160
+ receipt = intent.verify(credential, request)
161
+ rescue => e
162
+ if dispatcher&.has_handlers?(Mpp::Events::PAYMENT_FAILED)
163
+ emit_payment_failed(
164
+ dispatcher: dispatcher,
165
+ challenge: echo,
166
+ credential: credential,
167
+ error: e,
168
+ method: T.must(method_context),
169
+ request: request,
170
+ submitted_challenge: echo
171
+ )
172
+ end
173
+ Kernel.raise
174
+ end
175
+ if dispatcher&.has_handlers?(Mpp::Events::PAYMENT_SUCCESS)
176
+ emit_payment_success(
177
+ dispatcher: dispatcher,
178
+ challenge: echo,
179
+ credential: credential,
180
+ method: T.must(method_context),
181
+ receipt: receipt,
182
+ request: request
183
+ )
184
+ end
94
185
  [credential, receipt]
95
186
  end
96
187
 
97
- sig { params(method: String, intent_name: String, request: T::Hash[String, T.untyped], realm: String, secret_key: String, description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String)).returns(Mpp::Challenge) }
188
+ sig { params(method: String, intent_name: String, request: T::Hash[String, T.untyped], realm: String, secret_key: String, description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String), body: T.untyped).returns(Mpp::Challenge) }
98
189
  def create_challenge(method, intent_name, request, realm, secret_key,
99
- description = nil, meta = nil, expires = nil)
190
+ description = nil, meta = nil, expires = nil, body = nil)
100
191
  expires = nil if expires && !expires.is_a?(String)
101
192
 
102
193
  if expires.nil?
103
194
  expires_dt = Time.now.utc + (DEFAULT_EXPIRES_MINUTES * 60)
104
195
  expires = expires_dt.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
105
196
  end
197
+ digest = body.nil? ? nil : Mpp::BodyDigest.compute(body)
106
198
 
107
199
  Mpp::Challenge.create(
108
200
  secret_key: secret_key,
@@ -111,11 +203,22 @@ module Mpp
111
203
  intent: intent_name,
112
204
  request: request,
113
205
  expires: expires,
206
+ digest: digest,
114
207
  description: description,
115
208
  meta: meta
116
209
  )
117
210
  end
118
211
 
212
+ sig { params(echo: Mpp::ChallengeEcho, body: T.untyped).returns(T::Boolean) }
213
+ def body_digest_matches?(echo, body)
214
+ if body.nil?
215
+ return echo.digest.nil?
216
+ end
217
+ return false unless echo.digest
218
+
219
+ Mpp::BodyDigest.verify(T.must(echo.digest), body)
220
+ end
221
+
119
222
  sig { params(header: String).returns(T.nilable(String)) }
120
223
  def extract_payment_scheme(header)
121
224
  header.split(",").each do |scheme|
@@ -124,6 +227,49 @@ module Mpp
124
227
  end
125
228
  nil
126
229
  end
230
+
231
+ sig { params(dispatcher: T.nilable(Mpp::Events::Dispatcher), challenge: T.untyped, credential: T.untyped, error: T.untyped, method: T::Hash[Symbol, T.untyped], request: T::Hash[String, T.untyped]).void }
232
+ def emit_challenge_created(dispatcher:, challenge:, credential:, error:, method:, request:)
233
+ return unless dispatcher&.has_handlers?(Mpp::Events::CHALLENGE_CREATED)
234
+
235
+ payload = {
236
+ challenge: challenge,
237
+ method: method,
238
+ request: request
239
+ }
240
+ payload[:credential] = credential unless credential.nil?
241
+ payload[:error] = error unless error.nil?
242
+ dispatcher.emit(Mpp::Events::CHALLENGE_CREATED, payload)
243
+ end
244
+
245
+ sig { params(dispatcher: Mpp::Events::Dispatcher, challenge: T.untyped, credential: T.untyped, error: T.untyped, method: T::Hash[Symbol, T.untyped], request: T::Hash[String, T.untyped], retry_challenge: T.untyped, submitted_challenge: T.untyped).void }
246
+ def emit_payment_failed(dispatcher:, challenge:, credential:, error:, method:, request:, retry_challenge: nil, submitted_challenge: nil)
247
+ return unless dispatcher.has_handlers?(Mpp::Events::PAYMENT_FAILED)
248
+
249
+ payload = {
250
+ challenge: challenge,
251
+ credential: credential,
252
+ error: error,
253
+ method: method,
254
+ request: request
255
+ }
256
+ payload[:retry_challenge] = retry_challenge unless retry_challenge.nil?
257
+ payload[:submitted_challenge] = submitted_challenge unless submitted_challenge.nil?
258
+ dispatcher.emit(Mpp::Events::PAYMENT_FAILED, payload)
259
+ end
260
+
261
+ sig { params(dispatcher: Mpp::Events::Dispatcher, challenge: T.untyped, credential: T.untyped, method: T::Hash[Symbol, T.untyped], receipt: T.untyped, request: T::Hash[String, T.untyped]).void }
262
+ def emit_payment_success(dispatcher:, challenge:, credential:, method:, receipt:, request:)
263
+ return unless dispatcher.has_handlers?(Mpp::Events::PAYMENT_SUCCESS)
264
+
265
+ dispatcher.emit(Mpp::Events::PAYMENT_SUCCESS, {
266
+ challenge: challenge,
267
+ credential: credential,
268
+ method: method,
269
+ receipt: receipt,
270
+ request: request
271
+ })
272
+ end
127
273
  end
128
274
  end
129
275
  end
data/lib/mpp/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.3"
6
6
  end
data/lib/mpp.rb CHANGED
@@ -19,6 +19,7 @@ module Mpp
19
19
  autoload :Json, "mpp/json"
20
20
  autoload :BodyDigest, "mpp/body_digest"
21
21
  autoload :Expires, "mpp/expires"
22
+ autoload :Events, "mpp/events"
22
23
  autoload :Units, "mpp/units"
23
24
  autoload :MemoryStore, "mpp/store"
24
25
 
@@ -39,9 +40,9 @@ module Mpp
39
40
  autoload :MCP, "mpp/extensions/mcp"
40
41
  end
41
42
 
42
- sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.untyped) }
43
- def self.create(method:, realm: nil, secret_key: nil)
44
- Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key)
43
+ sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.untyped) }
44
+ def self.create(method:, realm: nil, secret_key: nil, events: nil)
45
+ Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key, events: events)
45
46
  end
46
47
 
47
48
  # Error hierarchy
@@ -57,6 +58,7 @@ module Mpp
57
58
  autoload :PaymentActionRequiredError, "mpp/errors"
58
59
  autoload :BadRequestError, "mpp/errors"
59
60
  autoload :VerificationError, "mpp/errors"
61
+ autoload :TransactionPendingError, "mpp/errors"
60
62
  autoload :ParseError, "mpp/errors"
61
63
  autoload :InsufficientBalanceError, "mpp/errors"
62
64
  autoload :InvalidSignatureError, "mpp/errors"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mpp-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stripe
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: base64
@@ -26,7 +25,6 @@ dependencies:
26
25
  version: '0.3'
27
26
  description: Ruby SDK for the Machine Payments Protocol (MPP) — an HTTP 402 Payment
28
27
  Authentication scheme.
29
- email:
30
28
  executables: []
31
29
  extensions: []
32
30
  extra_rdoc_files: []
@@ -43,6 +41,7 @@ files:
43
41
  - lib/mpp/client/transport.rb
44
42
  - lib/mpp/credential.rb
45
43
  - lib/mpp/errors.rb
44
+ - lib/mpp/events.rb
46
45
  - lib/mpp/expires.rb
47
46
  - lib/mpp/extensions/mcp.rb
48
47
  - lib/mpp/extensions/mcp/capabilities.rb
@@ -63,6 +62,7 @@ files:
63
62
  - lib/mpp/methods/tempo/client_method.rb
64
63
  - lib/mpp/methods/tempo/defaults.rb
65
64
  - lib/mpp/methods/tempo/fee_payer_envelope.rb
65
+ - lib/mpp/methods/tempo/fee_payer_policy.rb
66
66
  - lib/mpp/methods/tempo/intents.rb
67
67
  - lib/mpp/methods/tempo/keychain.rb
68
68
  - lib/mpp/methods/tempo/proof.rb
@@ -89,7 +89,6 @@ licenses:
89
89
  metadata:
90
90
  rubygems_mfa_required: 'true'
91
91
  source_code_uri: https://github.com/stripe/mpp-rb
92
- post_install_message:
93
92
  rdoc_options: []
94
93
  require_paths:
95
94
  - lib
@@ -104,8 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
103
  - !ruby/object:Gem::Version
105
104
  version: '0'
106
105
  requirements: []
107
- rubygems_version: 3.5.22
108
- signing_key:
106
+ rubygems_version: 3.6.9
109
107
  specification_version: 4
110
108
  summary: HTTP 402 Payment Authentication for Ruby
111
109
  test_files: []