mppx 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d92f36a4729fc57e8a72788d5b67b53945692f399654c4167b57b9686a22d75a
4
+ data.tar.gz: 7eeb66bf6bc9807f30d38b0816f5d85ce088a3bee282b57b1a008f0a637f35f8
5
+ SHA512:
6
+ metadata.gz: 2861beda451ae26de90d3b013c8a4404098e83da91bd2746d3e54a9f2cd92652e6777b1d1ed031fe95c35fd4d08d284fb49b73712c0190f16d65c207a08cc378
7
+ data.tar.gz: e82ae5aefa538ce07ff18bd69e727cdc28e70e4df25c7a428cca221cc8f55e059d33b20b8d7dc68d35ec3fbb2dc52c0af75f45158727b08c087c2e16950fc451
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CJ Avilla
4
+
5
+ This project is a Ruby port of the official MPP SDK (https://github.com/mppx/mppx),
6
+ originally created by the MPP team. All credit for the protocol design and original
7
+ implementation belongs to the original authors.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # mppx
2
+
3
+ Ruby SDK for the [Machine Payments Protocol](https://paymentauth.org) (MPP). Implements the Payment HTTP Authentication Scheme (HTTP 402) for payment-gated APIs.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "mppx"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```sh
16
+ bundle install
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```sh
22
+ gem install mppx
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ### Server: Protect an endpoint with payment gating
28
+
29
+ ```ruby
30
+ require "mppx"
31
+
32
+ # Define a payment method with name, intent, and schema
33
+ method = Mppx::Method.from(
34
+ name: "tempo",
35
+ intent: "charge",
36
+ schema: {
37
+ request: ->(r) { r },
38
+ credential: { payload: ->(p) { p } }
39
+ }
40
+ )
41
+
42
+ # Configure the server method with a verification callback
43
+ server_method = Mppx::Method.to_server(method,
44
+ verify: ->(credential:, request:) {
45
+ # Verify the payment and return receipt data
46
+ Mppx::Receipt.from(
47
+ method: "tempo",
48
+ reference: "tx_123",
49
+ status: "success",
50
+ timestamp: Time.now.utc.iso8601
51
+ )
52
+ }
53
+ )
54
+
55
+ # Create a handler
56
+ handler = Mppx::Server::Handler.create(
57
+ methods: [server_method],
58
+ secret_key: ENV["MPP_SECRET_KEY"],
59
+ realm: "My API"
60
+ )
61
+ ```
62
+
63
+ ### Server: Rack middleware
64
+
65
+ ```ruby
66
+ # In your Rack app or Rails config
67
+ use Mppx::Server::Middleware,
68
+ handler: handler,
69
+ method_key: "charge",
70
+ options: { amount: "0.01", currency: "USD" }
71
+ ```
72
+
73
+ ### Client: Build and send a credential
74
+
75
+ ```ruby
76
+ # Parse the 402 challenge from response headers
77
+ challenge = Mppx::Challenge.from_headers(response_headers)
78
+
79
+ # Create a credential with payment proof
80
+ credential = Mppx::Credential.from(
81
+ challenge: challenge,
82
+ payload: { transaction_hash: "0xabc..." }
83
+ )
84
+
85
+ # Serialize for the Authorization header
86
+ auth_header = Mppx::Credential.serialize(credential)
87
+ # => "Payment eyJjaGFsbGVuZ2U..."
88
+ ```
89
+
90
+ ### Parse a receipt
91
+
92
+ ```ruby
93
+ receipt = Mppx::Receipt.from_response(response_headers)
94
+ # => { method: "tempo", reference: "tx_123", status: "success", timestamp: "..." }
95
+ ```
96
+
97
+ ## Modules
98
+
99
+ | Module | Purpose |
100
+ |--------|---------|
101
+ | `Mppx::Challenge` | Create, serialize, deserialize, and verify 402 challenges |
102
+ | `Mppx::Credential` | Build and parse `Authorization: Payment ...` credentials |
103
+ | `Mppx::Receipt` | Create and parse `Payment-Receipt` headers |
104
+ | `Mppx::PaymentRequest` | Serialize/deserialize payment request objects |
105
+ | `Mppx::Method` | Define payment methods with schemas for client and server use |
106
+ | `Mppx::Store` | Pluggable key-value stores (in-memory, Redis) |
107
+ | `Mppx::Server::Handler` | Server-side handler that validates credentials and issues challenges |
108
+ | `Mppx::Server::Transport` | HTTP transport layer for challenge/receipt responses |
109
+ | `Mppx::Server::Middleware` | Rack middleware for payment gating |
110
+ | `Mppx::BodyDigest` | SHA-256 body digest computation and verification |
111
+ | `Mppx::Errors` | Structured error types following RFC 9457 Problem Details |
112
+
113
+ ## Configuration
114
+
115
+ The handler reads these environment variables:
116
+
117
+ - `MPP_SECRET_KEY` - HMAC secret for signing challenges (required unless passed directly)
118
+ - `MPP_REALM` - Default realm for challenges (optional)
119
+
120
+ ## Development
121
+
122
+ ```sh
123
+ bundle install
124
+ bundle exec rake spec
125
+ ```
126
+
127
+ ## Requirements
128
+
129
+ - Ruby >= 3.1
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Mppx
6
+ module Base64Url
7
+ module_function
8
+
9
+ def encode(data)
10
+ Base64.urlsafe_encode64(data, padding: false)
11
+ end
12
+
13
+ def decode(data)
14
+ # Add padding if needed
15
+ padded = case data.length % 4
16
+ when 2 then "#{data}=="
17
+ when 3 then "#{data}="
18
+ else data
19
+ end
20
+ Base64.urlsafe_decode64(padded)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module Mppx
8
+ module BodyDigest
9
+ module_function
10
+
11
+ def compute(body)
12
+ str = body.is_a?(Hash) ? JSON.generate(body) : body
13
+ hash = OpenSSL::Digest::SHA256.digest(str)
14
+ "sha-256=#{Base64.strict_encode64(hash)}"
15
+ end
16
+
17
+ def verify(digest, body)
18
+ ConstantTimeEqual.call(compute(body), digest)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mppx
6
+ module CanonicalJson
7
+ module_function
8
+
9
+ def canonicalize(obj)
10
+ JSON.generate(deep_sort(obj))
11
+ end
12
+
13
+ def deep_sort(obj)
14
+ case obj
15
+ when Hash
16
+ obj.keys.sort.each_with_object({}) do |key, sorted|
17
+ sorted[key] = deep_sort(obj[key])
18
+ end
19
+ when Array
20
+ obj.map { |item| deep_sort(item) }
21
+ else
22
+ obj
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module Mppx
7
+ module Challenge
8
+ METHOD_PATTERN = /\A[a-z][a-z0-9:_-]*\z/
9
+
10
+ module_function
11
+
12
+ def from(params)
13
+ secret_key = params[:secret_key]
14
+ meta = params[:meta]
15
+
16
+ challenge = {
17
+ realm: params[:realm],
18
+ method: params[:method],
19
+ intent: params[:intent],
20
+ request: params[:request]
21
+ }
22
+ challenge[:description] = params[:description] if params[:description]
23
+ challenge[:digest] = params[:digest] if params[:digest]
24
+ challenge[:expires] = params[:expires] if params[:expires]
25
+ challenge[:opaque] = meta if meta
26
+
27
+ challenge[:id] = if secret_key
28
+ compute_id(challenge, secret_key)
29
+ else
30
+ params[:id]
31
+ end
32
+
33
+ challenge
34
+ end
35
+
36
+ def from_method(method, params)
37
+ request = PaymentRequest.from_method(method, params[:request])
38
+
39
+ from(
40
+ id: params[:id],
41
+ secret_key: params[:secret_key],
42
+ realm: params[:realm],
43
+ method: method[:name],
44
+ intent: method[:intent],
45
+ request: request,
46
+ description: params[:description],
47
+ digest: params[:digest],
48
+ expires: params[:expires],
49
+ meta: params[:meta]
50
+ )
51
+ end
52
+
53
+ def serialize(challenge)
54
+ parts = [
55
+ "id=\"#{challenge[:id]}\"",
56
+ "realm=\"#{challenge[:realm]}\"",
57
+ "method=\"#{challenge[:method]}\"",
58
+ "intent=\"#{challenge[:intent]}\"",
59
+ "request=\"#{PaymentRequest.serialize(challenge[:request])}\""
60
+ ]
61
+
62
+ parts << "description=\"#{challenge[:description]}\"" if challenge[:description]
63
+ parts << "digest=\"#{challenge[:digest]}\"" if challenge[:digest]
64
+ parts << "expires=\"#{challenge[:expires]}\"" if challenge[:expires]
65
+ parts << "opaque=\"#{PaymentRequest.serialize(challenge[:opaque])}\"" if challenge[:opaque]
66
+
67
+ "Payment #{parts.join(", ")}"
68
+ end
69
+
70
+ def deserialize(value)
71
+ params_str = extract_payment_auth_params(value)
72
+ raise "Missing Payment scheme." unless params_str
73
+
74
+ result = parse_auth_params(params_str)
75
+
76
+ request_encoded = result.delete("request")
77
+ raise "Missing request parameter." unless request_encoded
78
+
79
+ method_name = result["method"]
80
+ if method_name && !METHOD_PATTERN.match?(method_name)
81
+ raise "Invalid method: \"#{method_name}\". Must be lowercase per spec."
82
+ end
83
+
84
+ opaque_encoded = result.delete("opaque")
85
+
86
+ challenge = {
87
+ id: result["id"],
88
+ realm: result["realm"],
89
+ method: result["method"],
90
+ intent: result["intent"],
91
+ request: PaymentRequest.deserialize(request_encoded)
92
+ }
93
+ challenge[:description] = result["description"] if result["description"]
94
+ challenge[:digest] = result["digest"] if result["digest"]
95
+ challenge[:expires] = result["expires"] if result["expires"]
96
+ challenge[:opaque] = PaymentRequest.deserialize(opaque_encoded) if opaque_encoded
97
+
98
+ challenge
99
+ end
100
+
101
+ def deserialize_list(value)
102
+ starts = []
103
+ value.scan(/Payment\s+/i) do
104
+ starts << Regexp.last_match.begin(0)
105
+ end
106
+ raise "No Payment schemes found." if starts.empty?
107
+
108
+ starts.each_with_index.map do |start, i|
109
+ end_pos = i + 1 < starts.length ? starts[i + 1] : value.length
110
+ chunk = value[start...end_pos].sub(/,\s*\z/, "")
111
+ deserialize(chunk)
112
+ end
113
+ end
114
+
115
+ def from_headers(headers)
116
+ header = headers["WWW-Authenticate"] || headers["www-authenticate"]
117
+ raise "Missing WWW-Authenticate header." unless header
118
+
119
+ deserialize(header)
120
+ end
121
+
122
+ def from_response(status:, headers:)
123
+ raise "Response status is not 402." unless status == 402
124
+
125
+ from_headers(headers)
126
+ end
127
+
128
+ def verify(challenge, secret_key:)
129
+ expected_id = compute_id(challenge, secret_key)
130
+ ConstantTimeEqual.call(challenge[:id], expected_id)
131
+ end
132
+
133
+ def meta(challenge)
134
+ challenge[:opaque]
135
+ end
136
+
137
+ # @private
138
+ def compute_id(challenge, secret_key)
139
+ input = [
140
+ challenge[:realm],
141
+ challenge[:method],
142
+ challenge[:intent],
143
+ PaymentRequest.serialize(challenge[:request]),
144
+ challenge[:expires] || "",
145
+ challenge[:digest] || "",
146
+ challenge[:opaque] ? PaymentRequest.serialize(challenge[:opaque]) : ""
147
+ ].join("|")
148
+
149
+ mac = OpenSSL::HMAC.digest("SHA256", secret_key, input)
150
+ Base64Url.encode(mac)
151
+ end
152
+
153
+ # @private - Extract Payment scheme from a multi-scheme WWW-Authenticate header
154
+ def extract_payment_auth_params(header)
155
+ token = "Payment"
156
+ in_quotes = false
157
+ escaped = false
158
+
159
+ header.length.times do |i|
160
+ char = header[i]
161
+
162
+ if in_quotes
163
+ if escaped
164
+ escaped = false
165
+ elsif char == "\\"
166
+ escaped = true
167
+ elsif char == '"'
168
+ in_quotes = false
169
+ end
170
+ next
171
+ end
172
+
173
+ if char == '"'
174
+ in_quotes = true
175
+ next
176
+ end
177
+
178
+ next unless starts_with_scheme_token?(header, i, token)
179
+
180
+ prefix = header[0...i]
181
+ next if prefix.strip.length > 0 && !prefix.rstrip.end_with?(",")
182
+
183
+ params_start = i + token.length
184
+ params_start += 1 while params_start < header.length && header[params_start] =~ /\s/
185
+ return header[params_start..]
186
+ end
187
+
188
+ nil
189
+ end
190
+
191
+ # @private
192
+ def starts_with_scheme_token?(value, index, token)
193
+ return false unless value[index..].downcase.start_with?(token.downcase)
194
+
195
+ next_char = value[index + token.length]
196
+ next_char && next_char =~ /\s/
197
+ end
198
+
199
+ # @private - Parse auth-params with support for escaped quoted-string values
200
+ def parse_auth_params(input)
201
+ result = {}
202
+ i = 0
203
+
204
+ while i < input.length
205
+ # Skip whitespace and commas
206
+ i += 1 while i < input.length && input[i] =~ /[\s,]/
207
+ break if i >= input.length
208
+
209
+ # Read key
210
+ key_start = i
211
+ i += 1 while i < input.length && input[i] =~ /[A-Za-z0-9_-]/
212
+ key = input[key_start...i]
213
+ raise "Malformed auth-param." if key.empty?
214
+
215
+ # Skip whitespace
216
+ i += 1 while i < input.length && input[i] =~ /\s/
217
+
218
+ # If no '=' after token, likely another auth scheme
219
+ break if input[i] != "="
220
+ i += 1
221
+
222
+ # Skip whitespace
223
+ i += 1 while i < input.length && input[i] =~ /\s/
224
+
225
+ # Read value
226
+ value, i = read_auth_param_value(input, i)
227
+
228
+ raise "Duplicate parameter: #{key}" if result.key?(key)
229
+
230
+ result[key] = value
231
+ end
232
+
233
+ result
234
+ end
235
+
236
+ # @private
237
+ def read_auth_param_value(input, start)
238
+ if input[start] == '"'
239
+ read_quoted_auth_param_value(input, start + 1)
240
+ else
241
+ i = start
242
+ i += 1 while i < input.length && input[i] != ","
243
+ [input[start...i].strip, i]
244
+ end
245
+ end
246
+
247
+ # @private
248
+ def read_quoted_auth_param_value(input, start)
249
+ i = start
250
+ value = +""
251
+ escaped = false
252
+
253
+ while i < input.length
254
+ char = input[i]
255
+ i += 1
256
+
257
+ if escaped
258
+ value << char
259
+ escaped = false
260
+ next
261
+ end
262
+
263
+ if char == "\\"
264
+ escaped = true
265
+ next
266
+ end
267
+
268
+ return [value, i] if char == '"'
269
+
270
+ value << char
271
+ end
272
+
273
+ raise "Unterminated quoted-string."
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Mppx
6
+ module ConstantTimeEqual
7
+ module_function
8
+
9
+ def call(a, b)
10
+ hash_a = OpenSSL::Digest::SHA256.hexdigest(a)
11
+ hash_b = OpenSSL::Digest::SHA256.hexdigest(b)
12
+ result = 0
13
+ hash_a.bytes.each_with_index do |byte, i|
14
+ result |= byte ^ hash_b.bytes[i]
15
+ end
16
+ result == 0
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mppx
6
+ module Credential
7
+ module_function
8
+
9
+ def from(challenge:, payload:, source: nil)
10
+ credential = {
11
+ challenge: challenge,
12
+ payload: payload
13
+ }
14
+ credential[:source] = source if source
15
+ credential
16
+ end
17
+
18
+ def serialize(credential)
19
+ wire = {
20
+ challenge: credential[:challenge].merge(
21
+ request: PaymentRequest.serialize(credential[:challenge][:request])
22
+ ),
23
+ payload: credential[:payload]
24
+ }
25
+ wire[:source] = credential[:source] if credential[:source]
26
+ json = JSON.generate(wire)
27
+ encoded = Base64Url.encode(json)
28
+ "Payment #{encoded}"
29
+ end
30
+
31
+ def deserialize(value)
32
+ match = value.match(/\APayment\s+(.+)\z/i)
33
+ raise "Missing Payment scheme." unless match
34
+
35
+ begin
36
+ json = Base64Url.decode(match[1])
37
+ parsed = JSON.parse(json)
38
+
39
+ challenge_data = parsed["challenge"]
40
+ request = PaymentRequest.deserialize(challenge_data["request"])
41
+
42
+ challenge = {
43
+ id: challenge_data["id"],
44
+ realm: challenge_data["realm"],
45
+ method: challenge_data["method"],
46
+ intent: challenge_data["intent"],
47
+ request: request
48
+ }
49
+ challenge[:description] = challenge_data["description"] if challenge_data["description"]
50
+ challenge[:digest] = challenge_data["digest"] if challenge_data["digest"]
51
+ challenge[:expires] = challenge_data["expires"] if challenge_data["expires"]
52
+ challenge[:opaque] = challenge_data["opaque"] if challenge_data["opaque"]
53
+
54
+ credential = {
55
+ challenge: challenge,
56
+ payload: parsed["payload"]
57
+ }
58
+ credential[:source] = parsed["source"] if parsed["source"]
59
+ credential
60
+ rescue StandardError
61
+ raise "Invalid base64url or JSON."
62
+ end
63
+ end
64
+
65
+ def from_request(request)
66
+ header = request[:headers]["Authorization"] || request[:headers]["HTTP_AUTHORIZATION"]
67
+ raise "Missing Authorization header." unless header
68
+
69
+ payment = extract_payment_scheme(header)
70
+ raise "Missing Payment scheme." unless payment
71
+
72
+ deserialize(payment)
73
+ end
74
+
75
+ def from_rack_request(env)
76
+ header = env["HTTP_AUTHORIZATION"]
77
+ raise "Missing Authorization header." unless header
78
+
79
+ payment = extract_payment_scheme(header)
80
+ raise "Missing Payment scheme." unless payment
81
+
82
+ deserialize(payment)
83
+ end
84
+
85
+ def extract_payment_scheme(header)
86
+ schemes = header.split(",").map(&:strip)
87
+ schemes.find { |s| s =~ /\APayment\s+/i }
88
+ end
89
+ end
90
+ end
data/lib/mppx/env.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mppx
4
+ module Env
5
+ VARIABLES = {
6
+ realm: %w[
7
+ FLY_APP_NAME
8
+ HEROKU_APP_NAME
9
+ HOST
10
+ HOSTNAME
11
+ MPP_REALM
12
+ RAILWAY_PUBLIC_DOMAIN
13
+ RENDER_EXTERNAL_HOSTNAME
14
+ VERCEL_URL
15
+ WEBSITE_HOSTNAME
16
+ ].freeze,
17
+ secret_key: %w[MPP_SECRET_KEY].freeze
18
+ }.freeze
19
+
20
+ DEFAULTS = {
21
+ realm: "MPP Payment"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def get(key)
27
+ vars = VARIABLES[key]
28
+ return nil unless vars
29
+
30
+ vars.each do |name|
31
+ value = ENV[name]
32
+ return value if value && !value.empty?
33
+ end
34
+
35
+ DEFAULTS[key]
36
+ end
37
+ end
38
+ end