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.
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mppx
6
+ module Server
7
+ module Handler
8
+ module_function
9
+
10
+ def create(methods:, secret_key:, realm: nil, transport: nil)
11
+ realm ||= Env.get(:realm) || "MPP Payment"
12
+ secret_key ||= Env.get(:secret_key)
13
+ transport ||= Transport.http
14
+
15
+ raise "Missing secret key. Set the MPP_SECRET_KEY environment variable or pass secret_key to Handler.create()." unless secret_key
16
+
17
+ flat_methods = methods.flatten
18
+ handlers = {}
19
+ intent_count = Hash.new(0)
20
+
21
+ flat_methods.each do |mi|
22
+ intent_count[mi[:intent]] += 1
23
+ key = "#{mi[:name]}/#{mi[:intent]}"
24
+ handlers[key] = create_method_fn(
25
+ method: mi,
26
+ realm: realm,
27
+ secret_key: secret_key,
28
+ transport: transport,
29
+ verify: mi[:verify],
30
+ defaults: mi[:defaults],
31
+ request_fn: mi[:request],
32
+ respond_fn: mi[:respond]
33
+ )
34
+ end
35
+
36
+ # Shorthand intent keys when unique
37
+ flat_methods.each do |mi|
38
+ handlers[mi[:intent]] = handlers["#{mi[:name]}/#{mi[:intent]}"] if intent_count[mi[:intent]] == 1
39
+ end
40
+
41
+ # Nested accessors: handler[:tempo][:charge]
42
+ flat_methods.each do |mi|
43
+ handlers[mi[:name]] ||= {}
44
+ handlers[mi[:name]][mi[:intent]] = handlers["#{mi[:name]}/#{mi[:intent]}"]
45
+ end
46
+
47
+ {
48
+ methods: flat_methods,
49
+ realm: realm,
50
+ transport: transport,
51
+ handlers: handlers,
52
+ compose: ->(*entries) { compose_handlers(handlers, entries) }
53
+ }
54
+ end
55
+
56
+ def call_handler(handler, method_key, options)
57
+ fn = handler[:handlers][method_key]
58
+ raise "No handler for \"#{method_key}\"." unless fn
59
+
60
+ fn.call(options)
61
+ end
62
+
63
+ def compose_handlers(handlers, entries)
64
+ raise "compose() requires at least one entry" if entries.empty?
65
+
66
+ configured = entries.map do |method_or_key, options|
67
+ key = if method_or_key.is_a?(String)
68
+ method_or_key
69
+ else
70
+ "#{method_or_key[:name]}/#{method_or_key[:intent]}"
71
+ end
72
+ handler_fn = handlers[key]
73
+ raise "No handler for \"#{key}\". Is this method in your methods array?" unless handler_fn
74
+
75
+ handler_fn.call(options)
76
+ end
77
+
78
+ compose(*configured)
79
+ end
80
+
81
+ def compose(*configured_handlers)
82
+ raise "compose() requires at least one handler" if configured_handlers.empty?
83
+
84
+ ->(env) {
85
+ # Try to extract a Payment credential
86
+ header = env["HTTP_AUTHORIZATION"]
87
+ payment_header = header ? Credential.extract_payment_scheme(header) : nil
88
+
89
+ if payment_header
90
+ # Parse credential to find method+intent for dispatch
91
+ credential = begin
92
+ Credential.deserialize(payment_header)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ if credential
98
+ cred_method = credential[:challenge][:method]
99
+ cred_intent = credential[:challenge][:intent]
100
+ cred_req = credential[:challenge][:request]
101
+
102
+ # Filter by name+intent, then narrow by request fields
103
+ candidates = configured_handlers.select do |h|
104
+ meta = h.respond_to?(:_internal) ? h._internal : (h.respond_to?(:[]) && h[:_internal])
105
+ next false unless meta && meta[:name] == cred_method && meta[:intent] == cred_intent
106
+
107
+ canonical = meta[:_canonical_request]
108
+ next true unless canonical
109
+
110
+ match = true
111
+ %w[amount currency recipient chainId].each do |field|
112
+ if canonical[field] && cred_req[field] && canonical[field].to_s != cred_req[field].to_s
113
+ match = false
114
+ break
115
+ end
116
+ end
117
+ match
118
+ end
119
+
120
+ match = candidates.first || configured_handlers.find do |h|
121
+ meta = h.respond_to?(:_internal) ? h._internal : (h.respond_to?(:[]) && h[:_internal])
122
+ meta && meta[:name] == cred_method && meta[:intent] == cred_intent
123
+ end
124
+
125
+ return match.call(env) if match
126
+ end
127
+
128
+ # Credential present but no match, dispatch to first handler
129
+ return configured_handlers.first.call(env)
130
+ end
131
+
132
+ # No credential: call all handlers and merge 402 challenges
133
+ results = configured_handlers.map { |h| h.call(env) }
134
+
135
+ merged_headers = {
136
+ "Cache-Control" => "no-store"
137
+ }
138
+ www_auth_parts = []
139
+ body = nil
140
+ content_type = nil
141
+
142
+ results.each do |result|
143
+ next unless result[:status] == 402
144
+
145
+ challenge_response = result[:challenge]
146
+ if challenge_response.is_a?(Array)
147
+ _status, headers, resp_body = challenge_response
148
+ www_auth = headers["WWW-Authenticate"]
149
+ www_auth_parts << www_auth if www_auth
150
+ unless body
151
+ content_type = headers["Content-Type"]
152
+ body = resp_body.is_a?(Array) ? resp_body.join : resp_body
153
+ end
154
+ end
155
+ end
156
+
157
+ merged_headers["WWW-Authenticate"] = www_auth_parts.join(", ") unless www_auth_parts.empty?
158
+ merged_headers["Content-Type"] = content_type if content_type
159
+
160
+ {
161
+ status: 402,
162
+ challenge: [402, merged_headers, body ? [body] : []]
163
+ }
164
+ }
165
+ end
166
+
167
+ # @private
168
+ def create_method_fn(method:, realm:, secret_key:, transport:, verify:, defaults: nil, request_fn: nil, respond_fn: nil)
169
+ ->(options) {
170
+ description = options[:description]
171
+ meta = options[:meta]
172
+ merged = (defaults || {}).merge(options.reject { |k, _| %i[description meta expires].include?(k) })
173
+
174
+ configured = ->(env) {
175
+ expires = options.key?(:expires) ? options[:expires] : Expires.minutes(5)
176
+
177
+ # Extract credential
178
+ credential = nil
179
+ credential_error = nil
180
+ begin
181
+ credential = transport[:get_credential].call(env)
182
+ rescue StandardError => e
183
+ credential_error = e
184
+ end
185
+
186
+ # Transform request if method provides a request function
187
+ request = if request_fn
188
+ request_fn.call(credential: credential, request: merged)
189
+ else
190
+ merged
191
+ end
192
+
193
+ # Recompute challenge from options (HMAC-bound, stateless)
194
+ challenge = Challenge.from_method(method, {
195
+ description: description,
196
+ expires: expires,
197
+ meta: meta,
198
+ realm: realm,
199
+ request: request,
200
+ secret_key: secret_key
201
+ })
202
+
203
+ # Credential was provided but malformed
204
+ if credential_error
205
+ response = transport[:respond_challenge].call(
206
+ challenge: challenge,
207
+ error: Errors::MalformedCredentialError.new(reason: credential_error.message)
208
+ )
209
+ next { status: 402, challenge: response }
210
+ end
211
+
212
+ # No credential provided
213
+ unless credential
214
+ response = transport[:respond_challenge].call(
215
+ challenge: challenge,
216
+ error: Errors::PaymentRequiredError.new(description: description)
217
+ )
218
+ next { status: 402, challenge: response }
219
+ end
220
+
221
+ # Verify HMAC
222
+ unless Challenge.verify(credential[:challenge], secret_key: secret_key)
223
+ response = transport[:respond_challenge].call(
224
+ challenge: challenge,
225
+ error: Errors::InvalidChallengeError.new(
226
+ id: credential[:challenge][:id],
227
+ reason: "challenge was not issued by this server"
228
+ )
229
+ )
230
+ next { status: 402, challenge: response }
231
+ end
232
+
233
+ # Cross-route scope confusion check
234
+ route_req = challenge[:request]
235
+ echoed_req = credential[:challenge][:request]
236
+ scope_error = nil
237
+ %w[amount currency recipient].each do |field|
238
+ field_sym = field.to_sym
239
+ route_val = route_req[field_sym] || route_req[field]
240
+ echoed_val = echoed_req[field_sym] || echoed_req[field]
241
+ if route_val && echoed_val && route_val.to_s != echoed_val.to_s
242
+ scope_error = field
243
+ break
244
+ end
245
+ end
246
+
247
+ if scope_error
248
+ response = transport[:respond_challenge].call(
249
+ challenge: challenge,
250
+ error: Errors::InvalidChallengeError.new(
251
+ id: credential[:challenge][:id],
252
+ reason: "credential #{scope_error} does not match this route's requirements"
253
+ )
254
+ )
255
+ next { status: 402, challenge: response }
256
+ end
257
+
258
+ # Reject expired credentials
259
+ if credential[:challenge][:expires] && Time.parse(credential[:challenge][:expires]) < Time.now
260
+ response = transport[:respond_challenge].call(
261
+ challenge: challenge,
262
+ error: Errors::PaymentExpiredError.new(expires: credential[:challenge][:expires])
263
+ )
264
+ next { status: 402, challenge: response }
265
+ end
266
+
267
+ # Validate payload schema
268
+ if method[:schema][:credential] && method[:schema][:credential][:payload]
269
+ begin
270
+ method[:schema][:credential][:payload].call(credential[:payload])
271
+ rescue StandardError => e
272
+ response = transport[:respond_challenge].call(
273
+ challenge: challenge,
274
+ error: Errors::InvalidPayloadError.new(reason: e.message)
275
+ )
276
+ next { status: 402, challenge: response }
277
+ end
278
+ end
279
+
280
+ # User-provided verification
281
+ receipt_data = nil
282
+ begin
283
+ receipt_data = verify.call(credential: credential, request: request)
284
+ rescue Errors::PaymentError => e
285
+ response = transport[:respond_challenge].call(
286
+ challenge: challenge,
287
+ error: e
288
+ )
289
+ next { status: 402, challenge: response }
290
+ rescue StandardError => e
291
+ response = transport[:respond_challenge].call(
292
+ challenge: challenge,
293
+ error: Errors::VerificationFailedError.new(reason: e.message)
294
+ )
295
+ next { status: 402, challenge: response }
296
+ end
297
+
298
+ # Management response via respond hook
299
+ management_response = respond_fn ? respond_fn.call(
300
+ credential: credential,
301
+ receipt: receipt_data,
302
+ request: request
303
+ ) : nil
304
+
305
+ {
306
+ status: 200,
307
+ with_receipt: ->(response = nil) {
308
+ if management_response
309
+ transport[:respond_receipt].call(
310
+ receipt: receipt_data,
311
+ response: management_response,
312
+ challenge_id: credential[:challenge][:id]
313
+ )
314
+ else
315
+ raise "with_receipt() requires a response argument" unless response
316
+
317
+ transport[:respond_receipt].call(
318
+ receipt: receipt_data,
319
+ response: response,
320
+ challenge_id: credential[:challenge][:id]
321
+ )
322
+ end
323
+ }
324
+ }
325
+ }
326
+
327
+ # Attach internal metadata for compose dispatch
328
+ configured.define_singleton_method(:_internal) do
329
+ {
330
+ name: method[:name],
331
+ intent: method[:intent],
332
+ _canonical_request: merged
333
+ }
334
+ end
335
+
336
+ configured.define_singleton_method(:[]) do |key|
337
+ return _internal if key == :_internal
338
+ super(key)
339
+ end
340
+
341
+ configured
342
+ }
343
+ end
344
+ end
345
+
346
+ # Rack middleware
347
+ class Middleware
348
+ def initialize(app, handler:, method_key:, options:)
349
+ @app = app
350
+ @handler = handler
351
+ @method_key = method_key
352
+ @options = options
353
+ end
354
+
355
+ def call(env)
356
+ fn = Handler.call_handler(@handler, @method_key, @options)
357
+ result = fn.call(env)
358
+
359
+ if result[:status] == 402
360
+ result[:challenge]
361
+ else
362
+ response = @app.call(env)
363
+ result[:with_receipt].call(response)
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mppx
6
+ module Server
7
+ module Transport
8
+ module_function
9
+
10
+ def http
11
+ {
12
+ name: "http",
13
+
14
+ get_credential: ->(env) {
15
+ header = env["HTTP_AUTHORIZATION"]
16
+ return nil unless header
17
+
18
+ payment = Credential.extract_payment_scheme(header)
19
+ return nil unless payment
20
+
21
+ Credential.deserialize(payment)
22
+ },
23
+
24
+ respond_challenge: ->(challenge:, error: nil, **_opts) {
25
+ headers = {
26
+ "WWW-Authenticate" => Challenge.serialize(challenge),
27
+ "Cache-Control" => "no-store"
28
+ }
29
+
30
+ body = nil
31
+ if error
32
+ headers["Content-Type"] = "application/problem+json"
33
+ body = JSON.generate(error.to_problem_details(challenge[:id]))
34
+ end
35
+
36
+ [error&.status || 402, headers, body ? [body] : []]
37
+ },
38
+
39
+ respond_receipt: ->(receipt:, response:, challenge_id: nil) {
40
+ status, headers, body = response
41
+ new_headers = headers.dup
42
+ new_headers["Payment-Receipt"] = Receipt.serialize(receipt)
43
+ [status, new_headers, body]
44
+ }
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/mppx/store.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mppx
6
+ module Store
7
+ module_function
8
+
9
+ def memory
10
+ store = {}
11
+ from(
12
+ get: ->(key) { store.key?(key) ? JSON.parse(store[key]) : nil },
13
+ put: ->(key, value) { store[key] = JSON.generate(value) },
14
+ delete: ->(key) { store.delete(key) }
15
+ )
16
+ end
17
+
18
+ def redis(client)
19
+ from(
20
+ get: ->(key) {
21
+ raw = client.get(key)
22
+ raw.nil? ? nil : JSON.parse(raw)
23
+ },
24
+ put: ->(key, value) { client.set(key, JSON.generate(value)) },
25
+ delete: ->(key) { client.del(key) }
26
+ )
27
+ end
28
+
29
+ def from(store)
30
+ store
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mppx
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mppx.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "mppx/version"
6
+ require_relative "mppx/base64url"
7
+ require_relative "mppx/canonical_json"
8
+ require_relative "mppx/constant_time_equal"
9
+ require_relative "mppx/env"
10
+ require_relative "mppx/errors"
11
+ require_relative "mppx/expires"
12
+ require_relative "mppx/body_digest"
13
+ require_relative "mppx/payment_request"
14
+ require_relative "mppx/challenge"
15
+ require_relative "mppx/credential"
16
+ require_relative "mppx/receipt"
17
+ require_relative "mppx/method"
18
+ require_relative "mppx/store"
19
+ require_relative "mppx/mcp"
20
+ require_relative "mppx/server/transport"
21
+ require_relative "mppx/server/handler"
22
+
23
+ module Mppx
24
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mppx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - CJ Avilla
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: Implements the Payment HTTP Authentication Scheme (HTTP 402) for payment-gated
55
+ APIs.
56
+ email:
57
+ - wave@cjav.dev
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/mppx.rb
65
+ - lib/mppx/base64url.rb
66
+ - lib/mppx/body_digest.rb
67
+ - lib/mppx/canonical_json.rb
68
+ - lib/mppx/challenge.rb
69
+ - lib/mppx/constant_time_equal.rb
70
+ - lib/mppx/credential.rb
71
+ - lib/mppx/env.rb
72
+ - lib/mppx/errors.rb
73
+ - lib/mppx/expires.rb
74
+ - lib/mppx/mcp.rb
75
+ - lib/mppx/method.rb
76
+ - lib/mppx/payment_request.rb
77
+ - lib/mppx/receipt.rb
78
+ - lib/mppx/server/handler.rb
79
+ - lib/mppx/server/transport.rb
80
+ - lib/mppx/store.rb
81
+ - lib/mppx/version.rb
82
+ homepage: https://github.com/cjavdev/mppx-rb
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/cjavdev/mppx-rb
87
+ source_code_uri: https://github.com/cjavdev/mppx-rb
88
+ rubygems_mfa_required: 'true'
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '3.1'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.7.2
104
+ specification_version: 4
105
+ summary: Ruby implementation of the Machine Payments Protocol SDK
106
+ test_files: []