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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/lib/mpp/body_digest.rb +37 -0
- data/lib/mpp/challenge.rb +115 -0
- data/lib/mpp/challenge_echo.rb +19 -0
- data/lib/mpp/challenge_id.rb +54 -0
- data/lib/mpp/client/transport.rb +137 -0
- data/lib/mpp/client.rb +9 -0
- data/lib/mpp/credential.rb +20 -0
- data/lib/mpp/errors.rb +190 -0
- data/lib/mpp/expires.rb +60 -0
- data/lib/mpp/extensions/mcp/capabilities.rb +23 -0
- data/lib/mpp/extensions/mcp/constants.rb +17 -0
- data/lib/mpp/extensions/mcp/decorator.rb +44 -0
- data/lib/mpp/extensions/mcp/errors.rb +110 -0
- data/lib/mpp/extensions/mcp/types.rb +205 -0
- data/lib/mpp/extensions/mcp/verify.rb +152 -0
- data/lib/mpp/extensions/mcp.rb +16 -0
- data/lib/mpp/json.rb +32 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +90 -0
- data/lib/mpp/methods/stripe/client_method.rb +42 -0
- data/lib/mpp/methods/stripe/defaults.rb +14 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +63 -0
- data/lib/mpp/methods/stripe.rb +14 -0
- data/lib/mpp/methods/tempo/account.rb +52 -0
- data/lib/mpp/methods/tempo/attribution.rb +112 -0
- data/lib/mpp/methods/tempo/client_method.rb +259 -0
- data/lib/mpp/methods/tempo/defaults.rb +77 -0
- data/lib/mpp/methods/tempo/fee_payer_envelope.rb +74 -0
- data/lib/mpp/methods/tempo/intents.rb +377 -0
- data/lib/mpp/methods/tempo/keychain.rb +31 -0
- data/lib/mpp/methods/tempo/proof.rb +127 -0
- data/lib/mpp/methods/tempo/rpc.rb +60 -0
- data/lib/mpp/methods/tempo/schemas.rb +96 -0
- data/lib/mpp/methods/tempo/transaction.rb +144 -0
- data/lib/mpp/methods/tempo.rb +22 -0
- data/lib/mpp/parsing.rb +252 -0
- data/lib/mpp/receipt.rb +31 -0
- data/lib/mpp/secure_compare.rb +25 -0
- data/lib/mpp/server/decorator.rb +32 -0
- data/lib/mpp/server/defaults.rb +45 -0
- data/lib/mpp/server/intent.rb +40 -0
- data/lib/mpp/server/method.rb +27 -0
- data/lib/mpp/server/middleware.rb +51 -0
- data/lib/mpp/server/mpp_handler.rb +97 -0
- data/lib/mpp/server/verify.rb +129 -0
- data/lib/mpp/server.rb +15 -0
- data/lib/mpp/store.rb +49 -0
- data/lib/mpp/units.rb +57 -0
- data/lib/mpp/version.rb +6 -0
- data/lib/mpp-rb.rb +3 -0
- data/lib/mpp.rb +68 -0
- 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
|
data/lib/mpp/parsing.rb
ADDED
|
@@ -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
|
data/lib/mpp/receipt.rb
ADDED
|
@@ -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
|