mpp-rb 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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +133 -0
  4. data/lib/mpp/body_digest.rb +37 -0
  5. data/lib/mpp/challenge.rb +115 -0
  6. data/lib/mpp/challenge_echo.rb +19 -0
  7. data/lib/mpp/challenge_id.rb +54 -0
  8. data/lib/mpp/client/transport.rb +137 -0
  9. data/lib/mpp/client.rb +9 -0
  10. data/lib/mpp/credential.rb +20 -0
  11. data/lib/mpp/errors.rb +190 -0
  12. data/lib/mpp/expires.rb +60 -0
  13. data/lib/mpp/extensions/mcp/capabilities.rb +23 -0
  14. data/lib/mpp/extensions/mcp/constants.rb +17 -0
  15. data/lib/mpp/extensions/mcp/decorator.rb +44 -0
  16. data/lib/mpp/extensions/mcp/errors.rb +110 -0
  17. data/lib/mpp/extensions/mcp/types.rb +205 -0
  18. data/lib/mpp/extensions/mcp/verify.rb +152 -0
  19. data/lib/mpp/extensions/mcp.rb +16 -0
  20. data/lib/mpp/json.rb +32 -0
  21. data/lib/mpp/methods/stripe/charge_intent.rb +90 -0
  22. data/lib/mpp/methods/stripe/client_method.rb +42 -0
  23. data/lib/mpp/methods/stripe/defaults.rb +14 -0
  24. data/lib/mpp/methods/stripe/stripe_method.rb +63 -0
  25. data/lib/mpp/methods/stripe.rb +14 -0
  26. data/lib/mpp/methods/tempo/account.rb +52 -0
  27. data/lib/mpp/methods/tempo/attribution.rb +112 -0
  28. data/lib/mpp/methods/tempo/client_method.rb +259 -0
  29. data/lib/mpp/methods/tempo/defaults.rb +77 -0
  30. data/lib/mpp/methods/tempo/fee_payer_envelope.rb +74 -0
  31. data/lib/mpp/methods/tempo/intents.rb +377 -0
  32. data/lib/mpp/methods/tempo/keychain.rb +31 -0
  33. data/lib/mpp/methods/tempo/proof.rb +127 -0
  34. data/lib/mpp/methods/tempo/rpc.rb +60 -0
  35. data/lib/mpp/methods/tempo/schemas.rb +96 -0
  36. data/lib/mpp/methods/tempo/transaction.rb +144 -0
  37. data/lib/mpp/methods/tempo.rb +22 -0
  38. data/lib/mpp/parsing.rb +252 -0
  39. data/lib/mpp/receipt.rb +31 -0
  40. data/lib/mpp/secure_compare.rb +25 -0
  41. data/lib/mpp/server/decorator.rb +32 -0
  42. data/lib/mpp/server/defaults.rb +45 -0
  43. data/lib/mpp/server/intent.rb +40 -0
  44. data/lib/mpp/server/method.rb +27 -0
  45. data/lib/mpp/server/middleware.rb +51 -0
  46. data/lib/mpp/server/mpp_handler.rb +97 -0
  47. data/lib/mpp/server/verify.rb +129 -0
  48. data/lib/mpp/server.rb +15 -0
  49. data/lib/mpp/store.rb +49 -0
  50. data/lib/mpp/units.rb +57 -0
  51. data/lib/mpp/version.rb +6 -0
  52. data/lib/mpp-rb.rb +3 -0
  53. data/lib/mpp.rb +68 -0
  54. metadata +111 -0
@@ -0,0 +1,144 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ module Transaction
8
+ TYPE_ID = 0x76
9
+ EMPTY_SIGNATURE = "\x00".b
10
+ EMPTY_LIST = [].freeze
11
+
12
+ Call = Data.define(:to, :value, :data) do
13
+ def as_rlp_list
14
+ [pack_address(to), encode_uint(value), pack_bytes(data)]
15
+ end
16
+
17
+ private
18
+
19
+ def pack_address(value)
20
+ [value.delete_prefix("0x")].pack("H*")
21
+ end
22
+
23
+ def pack_bytes(value)
24
+ [value.delete_prefix("0x")].pack("H*")
25
+ end
26
+
27
+ def encode_uint(value)
28
+ Integer(value)
29
+ end
30
+ end
31
+
32
+ SignedTransaction = Data.define(
33
+ :chain_id, :max_priority_fee_per_gas, :max_fee_per_gas, :gas_limit,
34
+ :calls, :access_list, :nonce_key, :nonce, :valid_before, :valid_after,
35
+ :fee_token, :sender_signature, :fee_payer_signature, :sender_address,
36
+ :tempo_authorization_list, :key_authorization
37
+ ) do
38
+ def encoded_2718
39
+ require_rlp!
40
+
41
+ [TYPE_ID].pack("C") + RLP.encode(rlp_fields)
42
+ end
43
+
44
+ def signature_hash
45
+ require_eth!
46
+ require_rlp!
47
+
48
+ Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(unsigned_rlp_fields))
49
+ end
50
+
51
+ # Hash for fee payer to sign — includes sender_signature in the RLP.
52
+ def fee_payer_signature_hash
53
+ require_eth!
54
+ require_rlp!
55
+
56
+ fields = unsigned_rlp_fields
57
+ fields.insert(11, sender_signature)
58
+ Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(fields))
59
+ end
60
+
61
+ private
62
+
63
+ def rlp_fields
64
+ fields = unsigned_rlp_fields
65
+ fields.insert(11, sender_signature)
66
+ fields.insert(12, fee_payer_signature || EMPTY_SIGNATURE)
67
+ fields
68
+ end
69
+
70
+ def unsigned_rlp_fields
71
+ fields = [
72
+ chain_id,
73
+ max_priority_fee_per_gas,
74
+ max_fee_per_gas,
75
+ gas_limit,
76
+ calls.map(&:as_rlp_list),
77
+ access_list || EMPTY_LIST,
78
+ nonce_key,
79
+ nonce,
80
+ encode_optional_uint(valid_before),
81
+ encode_optional_uint(valid_after),
82
+ fee_token ? pack_hex(fee_token) : "".b,
83
+ tempo_authorization_list || EMPTY_LIST
84
+ ]
85
+ fields << key_authorization if key_authorization
86
+ fields
87
+ end
88
+
89
+ def pack_hex(value)
90
+ [value.delete_prefix("0x")].pack("H*")
91
+ end
92
+
93
+ def encode_optional_uint(value)
94
+ return "".b if value.nil?
95
+
96
+ Integer(value)
97
+ end
98
+
99
+ def require_eth!
100
+ Kernel.require "eth"
101
+ rescue LoadError
102
+ raise LoadError, "eth gem is required for Tempo transaction signing. Install with: gem install eth"
103
+ end
104
+
105
+ def require_rlp!
106
+ Kernel.require "rlp"
107
+ rescue LoadError
108
+ raise LoadError, "rlp gem is required for Tempo transaction encoding. Install with: gem install rlp"
109
+ end
110
+ end
111
+
112
+ module_function
113
+
114
+ def build_signed_transfer(account:, chain_id:, gas_limit:, gas_price:, nonce:, nonce_key:,
115
+ currency:, transfer_data:, valid_before: nil, awaiting_fee_payer: false)
116
+ tx = SignedTransaction.new(
117
+ chain_id: chain_id,
118
+ max_priority_fee_per_gas: gas_price,
119
+ max_fee_per_gas: gas_price,
120
+ gas_limit: gas_limit,
121
+ calls: [Call.new(to: currency, value: 0, data: transfer_data)],
122
+ access_list: EMPTY_LIST,
123
+ nonce_key: nonce_key,
124
+ nonce: nonce,
125
+ valid_before: valid_before,
126
+ valid_after: nil,
127
+ fee_token: awaiting_fee_payer ? nil : currency,
128
+ sender_signature: nil,
129
+ fee_payer_signature: EMPTY_SIGNATURE,
130
+ sender_address: account.address,
131
+ tempo_authorization_list: EMPTY_LIST,
132
+ key_authorization: nil
133
+ )
134
+
135
+ signature = account.sign_hash(tx.signature_hash)
136
+ signed = tx.with(sender_signature: signature)
137
+ raw = awaiting_fee_payer ? FeePayer.encode(signed) : signed.encoded_2718
138
+
139
+ ["0x#{raw.unpack1("H*")}", chain_id]
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Tempo
7
+ autoload :Defaults, "mpp/methods/tempo/defaults"
8
+ autoload :Account, "mpp/methods/tempo/account"
9
+ autoload :Keychain, "mpp/methods/tempo/keychain"
10
+ autoload :Attribution, "mpp/methods/tempo/attribution"
11
+ autoload :Rpc, "mpp/methods/tempo/rpc"
12
+ autoload :Transaction, "mpp/methods/tempo/transaction"
13
+ autoload :Schemas, "mpp/methods/tempo/schemas"
14
+ # Eagerly require client_method so the Tempo.tempo factory method is available
15
+ require_relative "tempo/client_method"
16
+ autoload :Intents, "mpp/methods/tempo/intents"
17
+ autoload :ChargeIntent, "mpp/methods/tempo/intents"
18
+ autoload :FeePayer, "mpp/methods/tempo/fee_payer_envelope"
19
+ autoload :Proof, "mpp/methods/tempo/proof"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,252 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "base64"
5
+ require "json"
6
+ require "time"
7
+
8
+ module Mpp
9
+ module Parsing
10
+ extend T::Sig
11
+
12
+ MAX_HEADER_PAYLOAD_SIZE = T.let(16 * 1024, Integer)
13
+
14
+ # RFC 9110 auth-param regex: key="value" or key=token
15
+ AUTH_PARAM_RE = /([a-zA-Z_][\w-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/
16
+
17
+ module_function
18
+
19
+ # Encode dict as URL-safe base64 JSON (compact, no padding).
20
+ sig { params(data: T.untyped).returns(String) }
21
+ def b64_encode(data)
22
+ compact_json = Mpp::Json.compact_encode(data)
23
+ Base64.urlsafe_encode64(compact_json, padding: false)
24
+ end
25
+
26
+ # Decode URL-safe base64 JSON to hash.
27
+ sig { params(encoded: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
28
+ def b64_decode(encoded)
29
+ Kernel.raise Mpp::ParseError, "Header payload exceeds maximum size" if encoded.length > MAX_HEADER_PAYLOAD_SIZE
30
+
31
+ padded = encoded + ("=" * ((-encoded.length) % 4))
32
+ decoded = Base64.urlsafe_decode64(padded)
33
+ obj = JSON.parse(decoded)
34
+ Kernel.raise Mpp::ParseError, "Expected JSON object" unless obj.is_a?(Hash)
35
+
36
+ obj
37
+ rescue ArgumentError, JSON::ParserError
38
+ Kernel.raise Mpp::ParseError, "Invalid base64 or JSON encoding"
39
+ end
40
+
41
+ # Escape a string for use in a quoted-string. Rejects CRLF.
42
+ sig { params(str: String).returns(String) }
43
+ def escape_quoted(str)
44
+ Kernel.raise Mpp::ParseError, "Header value contains invalid CRLF characters" if str.include?("\r") || str.include?("\n")
45
+
46
+ str.gsub("\\", "\\\\\\\\").gsub('"', '\\"')
47
+ end
48
+
49
+ # Unescape a quoted-string value.
50
+ sig { params(str: String).returns(String) }
51
+ def unescape_quoted(str)
52
+ str.gsub(/\\(.)/, '\1')
53
+ end
54
+
55
+ # Parse RFC 9110 auth-params into a hash.
56
+ sig { params(params_str: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
57
+ def parse_auth_params(params_str)
58
+ params = {}
59
+ params_str.scan(AUTH_PARAM_RE) do |key, quoted_val, token_val|
60
+ Kernel.raise Mpp::ParseError, "Duplicate parameter: #{key}" if params.key?(key)
61
+
62
+ value = quoted_val.nil? ? token_val : unescape_quoted(quoted_val)
63
+ params[key] = value
64
+ end
65
+ params
66
+ end
67
+
68
+ # Parse a WWW-Authenticate header into a Challenge.
69
+ sig { params(header: T.untyped).returns(Mpp::Challenge) }
70
+ def parse_www_authenticate(header)
71
+ header = header.strip
72
+ Kernel.raise Mpp::ParseError, "Expected 'Payment' authentication scheme" unless header.downcase.start_with?("payment ")
73
+
74
+ params_str = header[8..].strip
75
+ params = parse_auth_params(params_str)
76
+
77
+ id = params["id"]
78
+ Kernel.raise Mpp::ParseError, "Missing 'id' field" unless id && !id.empty?
79
+
80
+ realm = params["realm"]
81
+ Kernel.raise Mpp::ParseError, "Missing 'realm' field" unless realm && !realm.empty?
82
+
83
+ method = params["method"]
84
+ Kernel.raise Mpp::ParseError, "Missing 'method' field" unless method && !method.empty?
85
+
86
+ intent = params["intent"]
87
+ Kernel.raise Mpp::ParseError, "Missing 'intent' field" unless intent && !intent.empty?
88
+
89
+ request_b64 = params["request"]
90
+ Kernel.raise Mpp::ParseError, "Missing 'request' field" unless request_b64 && !request_b64.empty?
91
+
92
+ request = b64_decode(request_b64)
93
+
94
+ opaque_b64 = params["opaque"]
95
+ opaque = (opaque_b64 && !opaque_b64.empty?) ? b64_decode(opaque_b64) : nil
96
+
97
+ Mpp::Challenge.new(
98
+ id: id,
99
+ method: method,
100
+ intent: intent,
101
+ request: request,
102
+ realm: realm,
103
+ request_b64: request_b64,
104
+ digest: params["digest"],
105
+ expires: params["expires"],
106
+ description: params["description"],
107
+ opaque: opaque
108
+ )
109
+ end
110
+
111
+ # Format a Challenge as a WWW-Authenticate header value.
112
+ sig { params(challenge: T.untyped, realm: T.untyped).returns(String) }
113
+ def format_www_authenticate(challenge, realm)
114
+ request_b64 = b64_encode(challenge.request)
115
+
116
+ parts = [
117
+ "id=\"#{escape_quoted(challenge.id)}\"",
118
+ "realm=\"#{escape_quoted(realm)}\"",
119
+ "method=\"#{escape_quoted(challenge.method)}\"",
120
+ "intent=\"#{escape_quoted(challenge.intent)}\"",
121
+ "request=\"#{request_b64}\""
122
+ ]
123
+
124
+ parts << "digest=\"#{escape_quoted(challenge.digest)}\"" if challenge.digest
125
+ parts << "expires=\"#{escape_quoted(challenge.expires)}\"" if challenge.expires
126
+ parts << "description=\"#{escape_quoted(challenge.description)}\"" if challenge.description
127
+ if challenge.opaque
128
+ opaque_b64 = b64_encode(challenge.opaque)
129
+ parts << "opaque=\"#{opaque_b64}\""
130
+ end
131
+
132
+ "Payment #{parts.join(", ")}"
133
+ end
134
+
135
+ # Parse an Authorization header into a Credential.
136
+ sig { params(header: T.untyped).returns(Mpp::Credential) }
137
+ def parse_authorization(header)
138
+ header = header.strip
139
+ Kernel.raise Mpp::ParseError, "Expected 'Payment' authentication scheme" unless header.downcase.start_with?("payment ")
140
+
141
+ credential_b64 = header[8..].strip
142
+ data = b64_decode(credential_b64)
143
+
144
+ Kernel.raise Mpp::ParseError, "Credential missing required field: challenge" unless data.key?("challenge")
145
+ Kernel.raise Mpp::ParseError, "Credential missing required field: payload" unless data.key?("payload")
146
+
147
+ challenge_data = data["challenge"]
148
+ Kernel.raise Mpp::ParseError, "Credential challenge must be an object" unless challenge_data.is_a?(Hash)
149
+ Kernel.raise Mpp::ParseError, "Credential challenge missing required field: id" unless challenge_data.key?("id")
150
+
151
+ echo = Mpp::ChallengeEcho.new(
152
+ id: challenge_data["id"].to_s,
153
+ realm: (challenge_data["realm"] || "").to_s,
154
+ method: (challenge_data["method"] || "").to_s,
155
+ intent: (challenge_data["intent"] || "").to_s,
156
+ request: (challenge_data["request"] || "").to_s,
157
+ expires: challenge_data["expires"]&.to_s,
158
+ digest: challenge_data["digest"]&.to_s,
159
+ opaque: challenge_data["opaque"]&.to_s
160
+ )
161
+
162
+ Mpp::Credential.new(
163
+ challenge: echo,
164
+ payload: data["payload"],
165
+ source: data["source"]&.to_s
166
+ )
167
+ end
168
+
169
+ # Format a Credential as an Authorization header value.
170
+ sig { params(credential: T.untyped).returns(String) }
171
+ def format_authorization(credential)
172
+ challenge_dict = {
173
+ "id" => credential.challenge.id,
174
+ "realm" => credential.challenge.realm,
175
+ "method" => credential.challenge.method,
176
+ "intent" => credential.challenge.intent,
177
+ "request" => credential.challenge.request
178
+ }
179
+ challenge_dict["expires"] = credential.challenge.expires if credential.challenge.expires
180
+ challenge_dict["digest"] = credential.challenge.digest if credential.challenge.digest
181
+ challenge_dict["opaque"] = credential.challenge.opaque if credential.challenge.opaque
182
+
183
+ payload = {
184
+ "challenge" => challenge_dict,
185
+ "payload" => credential.payload
186
+ }
187
+ payload["source"] = credential.source if credential.source
188
+
189
+ encoded = b64_encode(payload)
190
+ "Payment #{encoded}"
191
+ end
192
+
193
+ # Parse an ISO 8601 timestamp string to Time.
194
+ sig { params(value: T.untyped).returns(Time) }
195
+ def parse_timestamp(value)
196
+ ts_str = value.gsub("Z", "+00:00")
197
+ Time.iso8601(ts_str)
198
+ rescue ArgumentError
199
+ Kernel.raise Mpp::ParseError, "Invalid timestamp format"
200
+ end
201
+
202
+ # Parse a Payment-Receipt header into a Receipt.
203
+ sig { params(header: T.untyped).returns(Mpp::Receipt) }
204
+ def parse_payment_receipt(header)
205
+ header = header.strip
206
+ data = b64_decode(header)
207
+
208
+ required = %w[status timestamp reference method]
209
+ missing = required - data.keys
210
+ Kernel.raise Mpp::ParseError, "Receipt missing required fields: #{missing}" unless missing.empty?
211
+
212
+ status = data["status"]
213
+ Kernel.raise Mpp::ParseError, "Invalid receipt status" unless status == "success"
214
+
215
+ timestamp = parse_timestamp(data["timestamp"].to_s)
216
+
217
+ extra = data["extra"]
218
+ extra = nil unless extra.is_a?(Hash)
219
+
220
+ Mpp::Receipt.new(
221
+ status: status,
222
+ timestamp: timestamp,
223
+ reference: data["reference"].to_s,
224
+ method: (data["method"] || "").to_s,
225
+ external_id: data["externalId"]&.to_s,
226
+ extra: extra
227
+ )
228
+ end
229
+
230
+ # Format a Receipt as a Payment-Receipt header value.
231
+ sig { params(receipt: Mpp::Receipt).returns(String) }
232
+ def format_payment_receipt(receipt)
233
+ t = receipt.timestamp.utc
234
+ timestamp_str = if t.usec == 0
235
+ t.strftime("%Y-%m-%dT%H:%M:%SZ")
236
+ else
237
+ t.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
238
+ end
239
+
240
+ payload = {
241
+ "method" => receipt.method,
242
+ "reference" => receipt.reference,
243
+ "status" => receipt.status,
244
+ "timestamp" => timestamp_str
245
+ }
246
+ payload["externalId"] = receipt.external_id if receipt.external_id
247
+ payload["extra"] = receipt.extra if receipt.extra
248
+
249
+ b64_encode(payload)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,31 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra) do
6
+ def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil)
7
+ super
8
+ end
9
+
10
+ # Parse a Receipt from a Payment-Receipt header value.
11
+ def self.from_payment_receipt(header)
12
+ Mpp::Parsing.parse_payment_receipt(header)
13
+ end
14
+
15
+ # Serialize to a Payment-Receipt header value.
16
+ def to_payment_receipt
17
+ Mpp::Parsing.format_payment_receipt(self)
18
+ end
19
+
20
+ # Create a success receipt with current timestamp.
21
+ def self.success(reference, timestamp: nil, method: "tempo", external_id: nil)
22
+ new(
23
+ status: "success",
24
+ timestamp: timestamp || Time.now.utc,
25
+ reference: reference,
26
+ method: method,
27
+ external_id: external_id
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "openssl"
5
+
6
+ module Mpp
7
+ extend T::Sig
8
+
9
+ module_function
10
+
11
+ # Timing-safe string comparison to prevent timing attacks.
12
+ # Falls back to OpenSSL.fixed_length_secure_compare when lengths match,
13
+ # otherwise uses double-HMAC comparison for variable-length safety.
14
+ sig { params(a: T.untyped, b: T.untyped).returns(T::Boolean) }
15
+ def secure_compare(a, b)
16
+ return false if a.nil? || b.nil?
17
+
18
+ a_bytes = a.encode(Encoding::UTF_8)
19
+ b_bytes = b.encode(Encoding::UTF_8)
20
+
21
+ return false unless a_bytes.bytesize == b_bytes.bytesize
22
+
23
+ OpenSSL.fixed_length_secure_compare(a_bytes, b_bytes)
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Mpp
7
+ module Server
8
+ module Decorator
9
+ extend T::Sig
10
+
11
+ module_function
12
+
13
+ # Build a 402 response for a payment challenge with RFC 9457 problem details body.
14
+ sig { params(challenge: T.untyped, realm: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
15
+ def make_challenge_response(challenge, realm)
16
+ error = Mpp::PaymentRequiredError.new(realm: realm, description: challenge.description)
17
+ body = JSON.generate(error.to_problem_details(challenge_id: challenge.id))
18
+ headers = {
19
+ "WWW-Authenticate" => challenge.to_www_authenticate(realm),
20
+ "Cache-Control" => "no-store",
21
+ "Content-Type" => "application/problem+json"
22
+ }
23
+ {
24
+ "_mpp_challenge" => true,
25
+ "status" => 402,
26
+ "headers" => headers,
27
+ "body" => body
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ module Defaults
7
+ extend T::Sig
8
+
9
+ SECRET_KEY_NAME = "MPP_SECRET_KEY"
10
+
11
+ REALM_ENV_VARS = %w[
12
+ MPP_REALM
13
+ FLY_APP_NAME
14
+ HEROKU_APP_NAME
15
+ HOST
16
+ HOSTNAME
17
+ RAILWAY_PUBLIC_DOMAIN
18
+ RENDER_EXTERNAL_HOSTNAME
19
+ VERCEL_URL
20
+ WEBSITE_HOSTNAME
21
+ ].freeze
22
+
23
+ module_function
24
+
25
+ # Detect server realm from environment.
26
+ sig { returns(String) }
27
+ def detect_realm
28
+ REALM_ENV_VARS.each do |var|
29
+ value = ENV.fetch(var, nil)
30
+ return value if value && !value.empty?
31
+ end
32
+ "localhost"
33
+ end
34
+
35
+ # Get server secret key from environment.
36
+ sig { returns(String) }
37
+ def detect_secret_key
38
+ value = ENV.fetch(SECRET_KEY_NAME, nil)
39
+ return value if value && !value.strip.empty?
40
+
41
+ Kernel.raise ArgumentError, "Missing secret key. Set MPP_SECRET_KEY or pass secret_key explicitly."
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ extend T::Sig
7
+
8
+ # Intent interface (duck type):
9
+ # name -> String
10
+ # verify(credential, request) -> Receipt
11
+ #
12
+ # Implement this interface for custom payment intents.
13
+
14
+ # Function-based intent wrapper.
15
+ class FunctionalIntent
16
+ extend T::Sig
17
+
18
+ sig { returns(String) }
19
+ attr_reader :name
20
+
21
+ sig { params(name: String, verify_fn: T.proc.params(arg0: Mpp::Credential, arg1: T::Hash[String, T.untyped]).returns(Mpp::Receipt)).void }
22
+ def initialize(name, &verify_fn)
23
+ @name = T.let(name, String)
24
+ @verify_fn = T.let(verify_fn, T.proc.params(arg0: Mpp::Credential, arg1: T::Hash[String, T.untyped]).returns(Mpp::Receipt))
25
+ end
26
+
27
+ sig { params(credential: Mpp::Credential, request: T::Hash[String, T.untyped]).returns(Mpp::Receipt) }
28
+ def verify(credential, request)
29
+ @verify_fn.call(credential, request)
30
+ end
31
+ end
32
+
33
+ # Decorator to define an intent from a block.
34
+ # intent = Mpp::Server.intent("charge") { |credential, request| ... }
35
+ sig { params(name: String, blk: T.proc.params(arg0: Mpp::Credential, arg1: T::Hash[String, T.untyped]).returns(Mpp::Receipt)).returns(Mpp::Server::FunctionalIntent) }
36
+ def self.intent(name, &blk)
37
+ FunctionalIntent.new(name, &blk)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Method interface (duck type):
7
+ # name -> String
8
+ # intents -> Hash[String, Intent]
9
+ # create_credential(challenge) -> Credential
10
+
11
+ module MethodHelper
12
+ extend T::Sig
13
+
14
+ module_function
15
+
16
+ # Transform request using method's transform_request if available.
17
+ sig { params(method: T.untyped, request: T::Hash[String, T.untyped], credential: T.untyped).returns(T::Hash[String, T.untyped]) }
18
+ def transform_request(method, request, credential)
19
+ if method.respond_to?(:transform_request)
20
+ method.transform_request(request, credential)
21
+ else
22
+ request
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Rack middleware that intercepts requests requiring payment.
7
+ #
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.
11
+ #
12
+ # Example:
13
+ # use Mpp::Server::Middleware, handler: my_handler
14
+ #
15
+ # # In your app:
16
+ # env["mpp.charge"] = { amount: "1.00" }
17
+ class Middleware
18
+ extend T::Sig
19
+
20
+ sig { params(app: T.untyped, handler: Mpp::Server::MppHandler).void }
21
+ def initialize(app, handler:)
22
+ @app = T.let(app, T.untyped)
23
+ @handler = T.let(handler, Mpp::Server::MppHandler)
24
+ end
25
+
26
+ sig { params(env: T.untyped).returns(T::Array[T.untyped]) }
27
+ def call(env)
28
+ authorization = env["HTTP_AUTHORIZATION"]
29
+ status, headers, body = @app.call(env)
30
+
31
+ charge_opts = env["mpp.charge"]
32
+ return [status, headers, body] unless charge_opts
33
+
34
+ amount = charge_opts[:amount]
35
+ opts = charge_opts.except(:amount)
36
+
37
+ result = @handler.charge(authorization, amount, **opts)
38
+
39
+ if result.is_a?(Mpp::Challenge)
40
+ resp = Mpp::Server::Decorator.make_challenge_response(result, @handler.realm)
41
+ return [resp["status"], resp["headers"], [resp["body"]]]
42
+ end
43
+
44
+ _credential, receipt = result
45
+ headers["Payment-Receipt"] = receipt.to_payment_receipt
46
+
47
+ [status, headers, body]
48
+ end
49
+ end
50
+ end
51
+ end