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.
@@ -6,16 +6,16 @@ module Mpp
6
6
  module Tempo
7
7
  # EIP-712 proof credentials for zero-amount challenges.
8
8
  #
9
- # Domain: { name: "MPP", version: "1", chainId }
10
- # Types: { Proof: [{ name: "challengeId", type: "string" }] }
11
- # Message: { challengeId: <challenge.id> }
9
+ # Domain: { name: "MPP", version: "2", chainId }
10
+ # Types: { Proof: [{ name: "challengeId", type: "string" }, { name: "realm", type: "string" }] }
11
+ # Message: { challengeId: <challenge.id>, realm: <challenge.realm> }
12
12
  module Proof
13
13
  DOMAIN_NAME = "MPP"
14
- DOMAIN_VERSION = "1"
14
+ DOMAIN_VERSION = "2"
15
15
 
16
16
  # EIP-712 domain separator type hash
17
17
  DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId)"
18
- PROOF_TYPE_HASH = "Proof(string challengeId)"
18
+ PROOF_TYPE_HASH = "Proof(string challengeId,string realm)"
19
19
 
20
20
  module_function
21
21
 
@@ -36,35 +36,36 @@ module Mpp
36
36
  )
37
37
  end
38
38
 
39
- # Compute the EIP-712 struct hash for Proof(challengeId).
40
- def struct_hash(challenge_id)
39
+ # Compute the EIP-712 struct hash for Proof(challengeId, realm).
40
+ def struct_hash(challenge_id, realm)
41
41
  keccak256(
42
42
  abi_encode(
43
43
  keccak256(PROOF_TYPE_HASH),
44
- keccak256(challenge_id)
44
+ keccak256(challenge_id),
45
+ keccak256(realm)
45
46
  )
46
47
  )
47
48
  end
48
49
 
49
50
  # Compute the full EIP-712 signing hash.
50
- def signing_hash(chain_id:, challenge_id:)
51
+ def signing_hash(chain_id:, challenge_id:, realm:)
51
52
  keccak256(
52
- "\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id)
53
+ "\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id, realm)
53
54
  )
54
55
  end
55
56
 
56
57
  # Sign a proof credential (client-side).
57
- def sign(account:, chain_id:, challenge_id:)
58
- hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id)
58
+ def sign(account:, chain_id:, challenge_id:, realm:)
59
+ hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
59
60
  sig = account.sign_hash(hash)
60
61
  "0x#{sig.unpack1("H*")}"
61
62
  end
62
63
 
63
64
  # Verify a proof credential signature (server-side).
64
- def verify(address:, chain_id:, challenge_id:, signature:)
65
+ def verify(address:, chain_id:, challenge_id:, realm:, signature:)
65
66
  Kernel.require "eth"
66
67
 
67
- hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id)
68
+ hash = signing_hash(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
68
69
  sig_bytes = [signature.delete_prefix("0x")].pack("H*")
69
70
 
70
71
  # Recover the signer address from the signature
@@ -106,7 +107,10 @@ module Mpp
106
107
  end
107
108
 
108
109
  def uint256(value)
109
- [value].pack("Q>").rjust(32, "\x00".b)
110
+ value = Integer(value)
111
+ raise ArgumentError, "uint256 out of range" if value.negative? || value >= (1 << 256)
112
+
113
+ [value.to_s(16).rjust(64, "0")].pack("H*")
110
114
  end
111
115
 
112
116
  def recover_address(hash, sig_bytes)
@@ -169,11 +169,12 @@ module Mpp
169
169
  module_function
170
170
 
171
171
  def build_signed_transfer(account:, chain_id:, gas_limit:, gas_price:, nonce:, nonce_key:,
172
- currency:, transfer_data:, valid_before: nil, awaiting_fee_payer: false)
172
+ currency:, transfer_data:, max_priority_fee_per_gas: nil, max_fee_per_gas: nil,
173
+ valid_before: nil, awaiting_fee_payer: false)
173
174
  tx = SignedTransaction.new(
174
175
  chain_id: chain_id,
175
- max_priority_fee_per_gas: gas_price,
176
- max_fee_per_gas: gas_price,
176
+ max_priority_fee_per_gas: max_priority_fee_per_gas || gas_price,
177
+ max_fee_per_gas: max_fee_per_gas || gas_price,
177
178
  gas_limit: gas_limit,
178
179
  calls: [Call.new(to: currency, value: 0, data: transfer_data)],
179
180
  access_list: EMPTY_LIST,
@@ -10,6 +10,7 @@ module Mpp
10
10
  autoload :Attribution, "mpp/methods/tempo/attribution"
11
11
  autoload :Rpc, "mpp/methods/tempo/rpc"
12
12
  autoload :Transaction, "mpp/methods/tempo/transaction"
13
+ autoload :FeePayerPolicy, "mpp/methods/tempo/fee_payer_policy"
13
14
  autoload :Schemas, "mpp/methods/tempo/schemas"
14
15
  # Eagerly require client_method so the Tempo.tempo factory method is available
15
16
  require_relative "tempo/client_method"
data/lib/mpp/parsing.rb CHANGED
@@ -13,6 +13,7 @@ module Mpp
13
13
 
14
14
  # RFC 9110 auth-param regex: key="value" or key=token
15
15
  AUTH_PARAM_RE = /([a-zA-Z_][\w-]*+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]++))/
16
+ PAYMENT_METHOD_ID_RE = /\A[a-z]+\z/
16
17
 
17
18
  module_function
18
19
 
@@ -65,6 +66,11 @@ module Mpp
65
66
  params
66
67
  end
67
68
 
69
+ sig { params(method: T.untyped).void }
70
+ def validate_payment_method_id(method)
71
+ Kernel.raise Mpp::ParseError, "Invalid payment method ID" unless method.is_a?(String) && PAYMENT_METHOD_ID_RE.match?(method)
72
+ end
73
+
68
74
  # Parse a WWW-Authenticate header into a Challenge.
69
75
  sig { params(header: T.untyped).returns(Mpp::Challenge) }
70
76
  def parse_www_authenticate(header)
@@ -82,6 +88,7 @@ module Mpp
82
88
 
83
89
  method = params["method"]
84
90
  Kernel.raise Mpp::ParseError, "Missing 'method' field" unless method && !method.empty?
91
+ validate_payment_method_id(method)
85
92
 
86
93
  intent = params["intent"]
87
94
  Kernel.raise Mpp::ParseError, "Missing 'intent' field" unless intent && !intent.empty?
@@ -148,10 +155,13 @@ module Mpp
148
155
  Kernel.raise Mpp::ParseError, "Credential challenge must be an object" unless challenge_data.is_a?(Hash)
149
156
  Kernel.raise Mpp::ParseError, "Credential challenge missing required field: id" unless challenge_data.key?("id")
150
157
 
158
+ method = challenge_data["method"]
159
+ validate_payment_method_id(method)
160
+
151
161
  echo = Mpp::ChallengeEcho.new(
152
162
  id: challenge_data["id"].to_s,
153
163
  realm: (challenge_data["realm"] || "").to_s,
154
- method: (challenge_data["method"] || "").to_s,
164
+ method: method,
155
165
  intent: (challenge_data["intent"] || "").to_s,
156
166
  request: (challenge_data["request"] || "").to_s,
157
167
  expires: challenge_data["expires"]&.to_s,
@@ -213,6 +223,8 @@ module Mpp
213
223
  Kernel.raise Mpp::ParseError, "Invalid receipt status" unless status == "success"
214
224
 
215
225
  timestamp = parse_timestamp(data["timestamp"].to_s)
226
+ method = data["method"]
227
+ validate_payment_method_id(method)
216
228
 
217
229
  extra = data["extra"]
218
230
  extra = nil unless extra.is_a?(Hash)
@@ -221,7 +233,7 @@ module Mpp
221
233
  status: status,
222
234
  timestamp: timestamp,
223
235
  reference: data["reference"].to_s,
224
- method: (data["method"] || "").to_s,
236
+ method: method,
225
237
  external_id: data["externalId"]&.to_s,
226
238
  extra: extra
227
239
  )
data/lib/mpp/receipt.rb CHANGED
@@ -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)
21
+ def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil)
22
22
  new(
23
23
  status: "success",
24
24
  timestamp: timestamp || Time.now.utc,
25
25
  reference: reference,
26
26
  method: method,
27
- external_id: external_id
27
+ external_id: external_id,
28
+ extra: extra
28
29
  )
29
30
  end
30
31
  end
@@ -20,6 +20,7 @@ module Mpp
20
20
  "Cache-Control" => "no-store",
21
21
  "Content-Type" => "application/problem+json"
22
22
  }
23
+ Mpp::Server::Middleware.mark_authorization_bound_response(headers)
23
24
  {
24
25
  "_mpp_challenge" => true,
25
26
  "status" => 402,
@@ -1,40 +1,50 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "stringio"
5
+
4
6
  module Mpp
5
7
  module Server
6
- # Rack middleware that intercepts requests requiring payment.
8
+ # Rack middleware that gates endpoints behind payment verification.
7
9
  #
8
- # The downstream app signals payment is needed by setting env["mpp.charge"]
9
- # to a hash with at least :amount, and optionally :currency, :recipient,
10
- # :description, :expires, etc.
10
+ # The pricing proc determines which requests require payment and at what
11
+ # price. It receives the Rack env and must return a charge options hash
12
+ # (with at least :amount) or nil for free endpoints. The pricing proc
13
+ # MUST NOT produce side effects.
11
14
  #
12
- # Example:
13
- # use Mpp::Server::Middleware, handler: my_handler
15
+ # Payment is verified BEFORE the downstream app runs — if verification
16
+ # fails, the app never executes and a 402 challenge is returned.
14
17
  #
15
- # # In your app:
16
- # env["mpp.charge"] = { amount: "1.00" }
18
+ # Example:
19
+ # use Mpp::Server::Middleware,
20
+ # handler: my_handler,
21
+ # pricing: ->(env) { {amount: "1.00"} if env["PATH_INFO"] == "/paid" }
17
22
  class Middleware
18
23
  extend T::Sig
19
24
 
20
- sig { params(app: T.untyped, handler: Mpp::Server::MppHandler).void }
21
- def initialize(app, handler:)
25
+ sig { params(app: T.untyped, handler: Mpp::Server::MppHandler, pricing: T.untyped).void }
26
+ def initialize(app, handler:, pricing:)
22
27
  @app = T.let(app, T.untyped)
23
28
  @handler = T.let(handler, Mpp::Server::MppHandler)
29
+ @pricing = T.let(pricing, T.untyped)
24
30
  end
25
31
 
26
32
  sig { params(env: T.untyped).returns(T::Array[T.untyped]) }
27
33
  def call(env)
34
+ charge_opts = @pricing.call(env)
35
+ return @app.call(env) unless charge_opts
36
+
28
37
  authorization = env["HTTP_AUTHORIZATION"]
29
- status, headers, body = @app.call(env)
38
+ body_capture = capture_request_body(env)
30
39
 
31
- charge_opts = env["mpp.charge"]
32
- return [status, headers, body] unless charge_opts
40
+ request_body = body_capture&.materialize
41
+ env["rack.input"] = StringIO.new(request_body || "") if body_capture
33
42
 
34
43
  amount = charge_opts[:amount]
35
- opts = charge_opts.except(:amount)
44
+ opts = charge_opts.except(:amount, :body)
45
+ opts[:mppx_scope] ||= mppx_scope(env)
36
46
 
37
- result = @handler.charge(authorization, amount, **opts)
47
+ result = @handler.charge(authorization, amount, **opts, body: request_body)
38
48
 
39
49
  if result.is_a?(Mpp::Challenge)
40
50
  resp = Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
@@ -42,10 +52,103 @@ module Mpp
42
52
  end
43
53
 
44
54
  _credential, receipt = result
55
+ status, headers, body = @app.call(env)
45
56
  headers["Payment-Receipt"] = receipt.to_payment_receipt
57
+ self.class.mark_authorization_bound_response(headers)
46
58
 
47
59
  [status, headers, body]
48
60
  end
61
+
62
+ sig { params(headers: T::Hash[T.untyped, T.untyped]).void }
63
+ def self.mark_authorization_bound_response(headers)
64
+ headers["Cache-Control"] = "no-store"
65
+
66
+ vary_values = headers["Vary"].to_s.split(",").map do |value|
67
+ value.strip.downcase
68
+ end
69
+ return if vary_values.include?("*") || vary_values.include?("authorization")
70
+
71
+ headers["Vary"] = [headers["Vary"], "Authorization"]
72
+ .compact
73
+ .reject(&:empty?)
74
+ .join(", ")
75
+ end
76
+
77
+ private
78
+
79
+ sig { params(env: T.untyped).returns(T.nilable(RackInputCapture)) }
80
+ def capture_request_body(env)
81
+ input = env["rack.input"]
82
+ return nil unless input&.respond_to?(:read)
83
+
84
+ capture = RackInputCapture.new(input)
85
+ env["rack.input"] = capture
86
+ capture
87
+ end
88
+
89
+ sig { params(env: T.untyped).returns(T::Hash[String, String]) }
90
+ def mppx_scope(env)
91
+ scope = T.let({}, T::Hash[String, String])
92
+ route = env["action_dispatch.route_uri_pattern"] || env["sinatra.route"] || env["roda.route"]
93
+ route = route.split(" ", 2).last if route.is_a?(String) && route.match?(/\A[A-Z]+\s+/)
94
+ scope["route"] = route if route.is_a?(String) && !route.empty?
95
+ path = env["PATH_INFO"]
96
+ scope["resource"] = path if path.is_a?(String) && !path.empty?
97
+ query = env["QUERY_STRING"]
98
+ scope["query"] = query if query.is_a?(String) && !query.empty?
99
+ scope
100
+ end
101
+
102
+ class RackInputCapture
103
+ extend T::Sig
104
+
105
+ sig { params(input: T.untyped).void }
106
+ def initialize(input)
107
+ @input = T.let(input, T.untyped)
108
+ @buffer = T.let(+"".b, String)
109
+ end
110
+
111
+ sig { params(args: T.untyped).returns(T.untyped) }
112
+ def read(*args)
113
+ chunk = @input.read(*args)
114
+ @buffer << chunk.b if chunk && !chunk.empty?
115
+ chunk
116
+ end
117
+
118
+ sig { params(args: T.untyped).returns(T.untyped) }
119
+ def gets(*args)
120
+ chunk = @input.gets(*args)
121
+ @buffer << chunk.b if chunk && !chunk.empty?
122
+ chunk
123
+ end
124
+
125
+ sig { params(block: T.nilable(T.proc.params(chunk: T.untyped).void)).returns(T.untyped) }
126
+ def each(&block)
127
+ return enum_for(:each) unless block
128
+
129
+ @input.each do |chunk|
130
+ @buffer << chunk.b if chunk && !chunk.empty?
131
+ block.call(chunk)
132
+ end
133
+ end
134
+
135
+ sig { returns(T.untyped) }
136
+ def rewind
137
+ @input.rewind if @input.respond_to?(:rewind)
138
+ @buffer = +"".b
139
+ end
140
+
141
+ sig { returns(T.untyped) }
142
+ def close
143
+ @input.close if @input.respond_to?(:close)
144
+ end
145
+
146
+ sig { returns(T.nilable(String)) }
147
+ def materialize
148
+ read
149
+ @buffer.empty? ? nil : @buffer.dup
150
+ end
151
+ end
49
152
  end
50
153
  end
51
154
  end
@@ -22,28 +22,51 @@ module Mpp
22
22
  sig { returns(T::Hash[String, T.untyped]) }
23
23
  attr_reader :defaults
24
24
 
25
- sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped])).void }
26
- def initialize(method:, realm:, secret_key:, defaults: nil)
25
+ sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped]), events: T.nilable(Mpp::Events::Dispatcher)).void }
26
+ def initialize(method:, realm:, secret_key:, defaults: nil, events: nil)
27
27
  @method = T.let(method, T.untyped)
28
28
  @realm = T.let(realm, String)
29
29
  @secret_key = T.let(secret_key, String)
30
30
  @defaults = T.let(defaults || {}, T::Hash[String, T.untyped])
31
+ @events = T.let(events || Mpp::Events.server_dispatcher, Mpp::Events::Dispatcher)
31
32
  end
32
33
 
33
34
  # Create with auto-detected realm and secret_key.
34
- sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.attached_class) }
35
- def self.create(method:, realm: nil, secret_key: nil)
35
+ sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.attached_class) }
36
+ def self.create(method:, realm: nil, secret_key: nil, events: nil)
36
37
  new(
37
38
  method: method,
38
39
  realm: realm || Defaults.detect_realm,
39
- secret_key: secret_key || Defaults.detect_secret_key
40
+ secret_key: secret_key || Defaults.detect_secret_key,
41
+ events: events
40
42
  )
41
43
  end
42
44
 
45
+ sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
46
+ def on(name, handler = nil, &block)
47
+ @events.on(name, handler, &block)
48
+ end
49
+
50
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
51
+ def on_challenge_created(handler = nil, &block)
52
+ on(Mpp::Events::CHALLENGE_CREATED, handler, &block)
53
+ end
54
+
55
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
56
+ def on_payment_failed(handler = nil, &block)
57
+ on(Mpp::Events::PAYMENT_FAILED, handler, &block)
58
+ end
59
+
60
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
61
+ def on_payment_success(handler = nil, &block)
62
+ on(Mpp::Events::PAYMENT_SUCCESS, handler, &block)
63
+ end
64
+
43
65
  # Handle a charge intent.
44
- sig { params(authorization: T.nilable(String), amount: String, currency: T.nilable(String), recipient: T.nilable(String), expires: T.nilable(String), description: T.nilable(String), memo: T.nilable(String), fee_payer: T::Boolean, chain_id: T.nilable(Integer), extra: T.nilable(T::Hash[String, String])).returns(T.untyped) }
66
+ sig { params(authorization: T.nilable(String), amount: String, currency: T.nilable(String), recipient: T.nilable(String), expires: T.nilable(String), description: T.nilable(String), external_id: T.nilable(String), memo: T.nilable(String), fee_payer: T::Boolean, chain_id: T.nilable(Integer), extra: T.nilable(T::Hash[String, String]), mppx_scope: T.nilable(T::Hash[String, String]), body: T.untyped).returns(T.untyped) }
45
67
  def charge(authorization, amount, currency: nil, recipient: nil, expires: nil,
46
- description: nil, memo: nil, fee_payer: false, chain_id: nil, extra: nil)
68
+ description: nil, external_id: nil, memo: nil, fee_payer: false, chain_id: nil,
69
+ extra: nil, mppx_scope: nil, body: nil)
47
70
  intent = @method.intents["charge"]
48
71
  raise ArgumentError, "Method #{@method.name} does not support charge intent" unless intent
49
72
 
@@ -60,6 +83,7 @@ module Mpp
60
83
  "currency" => resolved_currency,
61
84
  "recipient" => resolved_recipient
62
85
  }
86
+ request["externalId"] = external_id unless external_id.nil?
63
87
 
64
88
  if extra
65
89
  extra.each do |k, v|
@@ -67,6 +91,12 @@ module Mpp
67
91
  end
68
92
  request["extra"] = extra
69
93
  end
94
+ if mppx_scope
95
+ mppx_scope.each do |k, v|
96
+ raise ArgumentError, "mppx_scope must be a dict[str, str]" unless k.is_a?(String) && v.is_a?(String)
97
+ end
98
+ request["_mppx_scope"] = mppx_scope
99
+ end
70
100
 
71
101
  resolved_chain_id = chain_id
72
102
  resolved_chain_id ||= @method.chain_id if @method.respond_to?(:chain_id)
@@ -89,7 +119,9 @@ module Mpp
89
119
  secret_key: @secret_key,
90
120
  method: @method.name,
91
121
  description: description,
92
- expires: expires
122
+ expires: expires,
123
+ events: @events,
124
+ body: body
93
125
  )
94
126
  end
95
127
  end