diff --git a/README.md b/README.md index d7f36ec..16ee138 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,35 @@ transport = Mpp::Client::Transport.new( response = transport.request(:get, "https://mpp.dev/api/ping/paid") ``` +### Event hooks + +Register hooks to observe the automatic payment lifecycle. Each registration returns an unsubscribe proc. + +```ruby +server.on_challenge_created do |payload| + puts "challenge: #{payload[:challenge].id}" +end + +server.on_payment_success do |payload| + puts "paid: #{payload[:receipt].reference}" +end + +transport.on_challenge_received do |payload| + puts "received: #{payload[:challenge].id}" + nil +end + +transport.on_payment_response do |payload| + puts "retry status: #{payload[:response].code}" +end + +transport.on("*") do |event| + puts "payment event: #{event.name}" +end +``` + +Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`. + ### Rack Middleware ```ruby diff --git a/lib/mpp.rb b/lib/mpp.rb index ff38fd9..77449b2 100644 --- a/lib/mpp.rb +++ b/lib/mpp.rb @@ -19,6 +19,7 @@ module Mpp autoload :Json, "mpp/json" autoload :BodyDigest, "mpp/body_digest" autoload :Expires, "mpp/expires" + autoload :Events, "mpp/events" autoload :Units, "mpp/units" autoload :MemoryStore, "mpp/store" @@ -39,9 +40,9 @@ module Extensions autoload :MCP, "mpp/extensions/mcp" end - sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.untyped) } - def self.create(method:, realm: nil, secret_key: nil) - Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key) + sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.untyped) } + def self.create(method:, realm: nil, secret_key: nil, events: nil) + Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key, events: events) end # Error hierarchy diff --git a/lib/mpp/client/transport.rb b/lib/mpp/client/transport.rb index 7d897a2..a658416 100644 --- a/lib/mpp/client/transport.rb +++ b/lib/mpp/client/transport.rb @@ -18,9 +18,35 @@ module Client class Transport extend T::Sig - sig { params(methods: T::Array[T.untyped]).void } - def initialize(methods:) + sig { params(methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher)).void } + def initialize(methods:, events: nil) @methods = T.let(methods.to_h { |m| [m.name, m] }, T::Hash[String, T.untyped]) + @events = T.let(events || Mpp::Events.client_dispatcher, Mpp::Events::Dispatcher) + end + + sig { params(name: String, handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on(name, handler = nil, &block) + @events.on(name, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_challenge_received(handler = nil, &block) + on(Mpp::Events::CHALLENGE_RECEIVED, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_credential_created(handler = nil, &block) + on(Mpp::Events::CREDENTIAL_CREATED, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_payment_failed(handler = nil, &block) + on(Mpp::Events::PAYMENT_FAILED, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_payment_response(handler = nil, &block) + on(Mpp::Events::PAYMENT_RESPONSE, handler, &block) end # Send an HTTP request with automatic 402 payment handling. @@ -34,7 +60,7 @@ def request(method, url, headers: {}, body: nil) # Parse WWW-Authenticate headers www_auth_headers = response.get_fields("www-authenticate") || [] - challenge, matched_method = find_matching_challenge(www_auth_headers) + challenge, matched_method = find_matching_challenge(www_auth_headers, input: url, response: response) return response unless challenge && matched_method # Check expiry before paying (client-side guardrail) @@ -47,11 +73,90 @@ def request(method, url, headers: {}, body: nil) end end - credential = matched_method.create_credential(challenge) - auth_header = credential.to_authorization + auth_header = nil + create_credential = Kernel.lambda do + auth_header ||= credential_authorization(matched_method.create_credential(challenge)) + end + + begin + event_credential = nil + if @events.has_handlers?(Mpp::Events::CHALLENGE_RECEIVED) + # challenge.received can override credential creation; first non-empty credential wins. + event_credential = @events.emit_first(Mpp::Events::CHALLENGE_RECEIVED, { + challenge: challenge, + challenges: [challenge], + create_credential: create_credential, + input: url, + method: matched_method, + response: response + }) + end + auth_header = credential_authorization(event_credential) unless event_credential.nil? + auth_header ||= create_credential.call + + if @events.has_handlers?(Mpp::Events::CREDENTIAL_CREATED) + @events.emit(Mpp::Events::CREDENTIAL_CREATED, { + challenge: challenge, + credential: auth_header, + input: url, + method: matched_method, + response: response + }) + end + rescue => e + if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED) + @events.emit(Mpp::Events::PAYMENT_FAILED, { + challenge: challenge, + challenges: [challenge], + error: e, + input: url, + method: matched_method, + response: response + }) + end + raise + end retry_headers = headers.merge("Authorization" => auth_header) - send_request(uri, method, retry_headers, body) + payment_response = nil + begin + payment_response = send_request(uri, method, retry_headers, body) + rescue => e + if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED) + @events.emit(Mpp::Events::PAYMENT_FAILED, { + challenge: challenge, + challenges: [challenge], + credential: auth_header, + error: e, + input: url, + method: matched_method, + response: response + }) + end + raise + end + + if payment_response.code.to_i.between?(200, 299) && @events.has_handlers?(Mpp::Events::PAYMENT_RESPONSE) + @events.emit(Mpp::Events::PAYMENT_RESPONSE, { + challenge: challenge, + credential: auth_header, + input: url, + method: matched_method, + response: payment_response + }) + elsif @events.has_handlers?(Mpp::Events::PAYMENT_FAILED) + @events.emit(Mpp::Events::PAYMENT_FAILED, { + challenge: challenge, + challenges: [challenge], + credential: auth_header, + error: Mpp::VerificationFailedError.new(reason: "retry returned HTTP #{payment_response.code}"), + input: url, + method: matched_method, + response: payment_response + }) + end + + payment_response end sig { params(url: T.any(URI::Generic, String), kwargs: T.untyped).returns(T.untyped) } @@ -97,20 +202,50 @@ def send_request(uri, method, headers, body) http.request(req) end - sig { params(www_auth_headers: T.untyped).returns(T::Array[T.untyped]) } - def find_matching_challenge(www_auth_headers) + sig { params(www_auth_headers: T.untyped, input: T.untyped, response: T.untyped).returns(T::Array[T.untyped]) } + def find_matching_challenge(www_auth_headers, input: nil, response: nil) www_auth_headers.each do |header| next unless header.downcase.start_with?("payment ") begin parsed = Mpp::Challenge.from_www_authenticate(header) return [parsed, @methods[parsed.method]] if @methods.key?(parsed.method) - rescue Mpp::ParseError + rescue Mpp::ParseError => e + if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED) + @events.emit(Mpp::Events::PAYMENT_FAILED, { + error: e, + input: input, + response: response + }) + end next end end [nil, nil] end + + sig { params(credential: T.untyped).returns(String) } + def credential_authorization(credential) + auth_header = if credential.respond_to?(:to_authorization) + credential.to_authorization + elsif credential.is_a?(String) + credential + else + raise ArgumentError, "Credential must be a String or respond to #to_authorization" + end + + validate_authorization_header(auth_header) + auth_header + end + + sig { params(auth_header: String).void } + def validate_authorization_header(auth_header) + unless auth_header.start_with?("Payment ") && auth_header.length > 8 + raise ArgumentError, "Credential must be a non-empty Payment authorization header" + end + + raise ArgumentError, "Credential contains invalid header characters" if auth_header.match?(/[[:cntrl:]]/) + end end # Module-level convenience methods @@ -118,20 +253,20 @@ def find_matching_challenge(www_auth_headers) module_function - sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) } - def request(method, url, methods:, **kwargs) - transport = Transport.new(methods: methods) + sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) } + def request(method, url, methods:, events: nil, **kwargs) + transport = Transport.new(methods: methods, events: events) transport.request(method, url, **kwargs) end - sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) } - def get(url, methods:, **kwargs) - request("GET", url, **T.unsafe({methods: methods, **kwargs})) + sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) } + def get(url, methods:, events: nil, **kwargs) + request("GET", url, **T.unsafe({methods: methods, events: events, **kwargs})) end - sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) } - def post(url, methods:, **kwargs) - request("POST", url, **T.unsafe({methods: methods, **kwargs})) + sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) } + def post(url, methods:, events: nil, **kwargs) + request("POST", url, **T.unsafe({methods: methods, events: events, **kwargs})) end end end diff --git a/lib/mpp/events.rb b/lib/mpp/events.rb new file mode 100644 index 0000000..7d15128 --- /dev/null +++ b/lib/mpp/events.rb @@ -0,0 +1,120 @@ +# typed: strict +# frozen_string_literal: true + +module Mpp + module Events + extend T::Sig + + ANY = "*" + CHALLENGE_CREATED = "challenge.created" + CHALLENGE_RECEIVED = "challenge.received" + CREDENTIAL_CREATED = "credential.created" + PAYMENT_FAILED = "payment.failed" + PAYMENT_RESPONSE = "payment.response" + PAYMENT_SUCCESS = "payment.success" + + Event = Data.define(:name, :payload) + + class Dispatcher + extend T::Sig + + sig { params(event_names: T::Array[String]).void } + def initialize(event_names:) + @event_names = T.let(event_names.to_h { |name| [name, true] }, T::Hash[String, T::Boolean]) + @event_names[ANY] = true + @handlers = T.let(@event_names.keys.to_h { |name| [name, []] }, T::Hash[String, T::Array[T.untyped]]) + end + + sig { params(name: String, handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on(name, handler = nil, &block) + raise ArgumentError, "Unknown event: #{name}" unless @event_names.key?(name) + + callback = handler || block + raise ArgumentError, "handler is required" unless callback + + @handlers[name] << callback + Kernel.lambda { @handlers[name].delete(callback) } + end + + sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void } + def emit(name, payload) + return unless has_handlers?(name) + + emit_observers(name, payload) + emit_any(name, payload) + end + + sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).returns(T.untyped) } + def emit_first(name, payload) + return nil unless has_handlers?(name) + + result = nil + + T.must(@handlers[name]).each do |handler| + value = call(handler, payload) + next if empty_result?(value) + + result = value + break + end + + emit_any(name, payload) + result + end + + sig { params(name: T.nilable(String)).returns(T::Boolean) } + def has_handlers?(name = nil) + return @handlers.any? { |_event_name, handlers| !handlers.empty? } unless name + + !T.must(@handlers[name]).empty? || !T.must(@handlers[ANY]).empty? + end + + private + + sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void } + def emit_observers(name, payload) + T.must(@handlers[name]).each { |handler| call(handler, payload) } + end + + sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void } + def emit_any(name, payload) + any_handlers = T.must(@handlers[ANY]) + return if any_handlers.empty? + + event = Event.new(name: name, payload: payload) + any_handlers.each { |handler| call(handler, event) } + end + + sig { params(handler: T.untyped, payload: T.untyped).returns(T.untyped) } + def call(handler, payload) + handler.call(payload) + rescue + nil + end + + sig { params(value: T.untyped).returns(T::Boolean) } + def empty_result?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + end + + sig { returns(Dispatcher) } + def self.client_dispatcher + Dispatcher.new(event_names: [ + CHALLENGE_RECEIVED, + CREDENTIAL_CREATED, + PAYMENT_FAILED, + PAYMENT_RESPONSE + ]) + end + + sig { returns(Dispatcher) } + def self.server_dispatcher + Dispatcher.new(event_names: [ + CHALLENGE_CREATED, + PAYMENT_FAILED, + PAYMENT_SUCCESS + ]) + end + end +end diff --git a/lib/mpp/server/mpp_handler.rb b/lib/mpp/server/mpp_handler.rb index d7df9ed..7d8ee18 100644 --- a/lib/mpp/server/mpp_handler.rb +++ b/lib/mpp/server/mpp_handler.rb @@ -22,24 +22,46 @@ class MppHandler sig { returns(T::Hash[String, T.untyped]) } attr_reader :defaults - sig { params(method: T.untyped, realm: String, secret_key: String, defaults: T.nilable(T::Hash[String, T.untyped])).void } - def initialize(method:, realm:, secret_key:, defaults: nil) + 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 } + def initialize(method:, realm:, secret_key:, defaults: nil, events: nil) @method = T.let(method, T.untyped) @realm = T.let(realm, String) @secret_key = T.let(secret_key, String) @defaults = T.let(defaults || {}, T::Hash[String, T.untyped]) + @events = T.let(events || Mpp::Events.server_dispatcher, Mpp::Events::Dispatcher) end # Create with auto-detected realm and secret_key. - sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.attached_class) } - def self.create(method:, realm: nil, secret_key: nil) + sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.attached_class) } + def self.create(method:, realm: nil, secret_key: nil, events: nil) new( method: method, realm: realm || Defaults.detect_realm, - secret_key: secret_key || Defaults.detect_secret_key + secret_key: secret_key || Defaults.detect_secret_key, + events: events ) end + sig { params(name: String, handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on(name, handler = nil, &block) + @events.on(name, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_challenge_created(handler = nil, &block) + on(Mpp::Events::CHALLENGE_CREATED, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_payment_failed(handler = nil, &block) + on(Mpp::Events::PAYMENT_FAILED, handler, &block) + end + + sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) } + def on_payment_success(handler = nil, &block) + on(Mpp::Events::PAYMENT_SUCCESS, handler, &block) + end + # Handle a charge intent. 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) } def charge(authorization, amount, currency: nil, recipient: nil, expires: nil, @@ -89,7 +111,8 @@ def charge(authorization, amount, currency: nil, recipient: nil, expires: nil, secret_key: @secret_key, method: @method.name, description: description, - expires: expires + expires: expires, + events: @events ) end end diff --git a/lib/mpp/server/verify.rb b/lib/mpp/server/verify.rb index 3b30380..2d1e526 100644 --- a/lib/mpp/server/verify.rb +++ b/lib/mpp/server/verify.rb @@ -15,14 +15,40 @@ module Verify # Verify a payment credential or generate a new challenge. # # Returns Challenge (payment required) or [Credential, Receipt] (verified). - sig { params(authorization: T.nilable(String), intent: T.untyped, request: T::Hash[String, T.untyped], realm: String, secret_key: String, method: T.nilable(String), description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String)).returns(T.untyped) } + sig { params(authorization: T.nilable(String), intent: T.untyped, request: T::Hash[String, T.untyped], realm: String, secret_key: String, method: T.nilable(String), description: T.nilable(String), meta: T.nilable(T::Hash[String, T.untyped]), expires: T.nilable(String), events: T.nilable(Mpp::Events::Dispatcher)).returns(T.untyped) } def verify_or_challenge(authorization:, intent:, request:, realm:, secret_key:, - method: nil, description: nil, meta: nil, expires: nil) + method: nil, description: nil, meta: nil, expires: nil, events: nil) method_name = method || "tempo" request = Mpp::Units.transform_units(request) - - new_challenge = Kernel.lambda { - create_challenge(method_name, intent.name, request, realm, secret_key, description, meta, expires) + dispatcher = events + events_enabled = dispatcher&.has_handlers? + method_context = events_enabled ? {name: method_name, intent: intent.name} : nil + + new_challenge = Kernel.lambda { |credential = nil, error = nil, submitted_challenge = nil| + challenge = create_challenge(method_name, intent.name, request, realm, secret_key, description, meta, expires) + if error && dispatcher&.has_handlers?(Mpp::Events::PAYMENT_FAILED) + emit_payment_failed( + dispatcher: dispatcher, + challenge: challenge, + credential: credential, + error: error, + method: T.must(method_context), + request: request, + retry_challenge: challenge, + submitted_challenge: submitted_challenge + ) + end + if dispatcher&.has_handlers?(Mpp::Events::CHALLENGE_CREATED) + emit_challenge_created( + dispatcher: dispatcher, + challenge: challenge, + credential: credential, + error: error, + method: T.must(method_context), + request: request + ) + end + challenge } return new_challenge.call if authorization.nil? @@ -32,8 +58,8 @@ def verify_or_challenge(authorization:, intent:, request:, realm:, secret_key:, begin credential = Mpp::Credential.from_authorization(payment_scheme) - rescue Mpp::ParseError - return new_challenge.call + rescue Mpp::ParseError => e + return new_challenge.call(nil, Mpp::MalformedCredentialError.new(reason: e.message)) end # Stateless challenge verification @@ -41,8 +67,8 @@ def verify_or_challenge(authorization:, intent:, request:, realm:, secret_key:, begin echo_request = echo.request.empty? ? {} : Mpp::Parsing.b64_decode(echo.request) echo_opaque = (echo.opaque && !T.must(echo.opaque).empty?) ? Mpp::Parsing.b64_decode(echo.opaque) : nil - rescue Mpp::ParseError - return new_challenge.call + rescue Mpp::ParseError => e + return new_challenge.call(credential, Mpp::MalformedCredentialError.new(reason: e.message), echo) end expected_id = Mpp.generate_challenge_id( @@ -55,42 +81,99 @@ def verify_or_challenge(authorization:, intent:, request:, realm:, secret_key:, digest: echo.digest, opaque: echo_opaque ) - return new_challenge.call unless Mpp.secure_compare(echo.id, expected_id) + unless Mpp.secure_compare(echo.id, expected_id) + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "challenge id mismatch"), + echo + ) + end # Assert echoed fields match server's values - return new_challenge.call unless echo.realm == realm && echo.method == method_name && echo.intent == intent.name + unless echo.realm == realm && echo.method == method_name && echo.intent == intent.name + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "challenge binding mismatch"), + echo + ) + end # Assert echoed request matches server's current request - return new_challenge.call unless echo_request == request - - return new_challenge.call unless echo_opaque == meta + unless echo_request == request + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "request mismatch"), + echo + ) + end - # Reject expired challenges as defense-in-depth - if echo.expires - begin - expires_dt = Time.iso8601(T.must(echo.expires).gsub("Z", "+00:00")) - return new_challenge.call if expires_dt < Time.now.utc - rescue ArgumentError - # If we can't parse, continue to stricter check below - end + unless echo_opaque == meta + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "metadata mismatch"), + echo + ) end # Verify echoed request parameters match endpoint's expected request request.each do |key, value| - return new_challenge.call unless echo_request[key] == value + unless echo_request[key] == value + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "request field #{key} mismatch"), + echo + ) + end end # Enforce challenge expiry - fail closed - return new_challenge.call unless echo.expires + unless echo.expires + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "missing expiry"), + echo + ) + end begin expires_dt = Time.iso8601(T.must(echo.expires).gsub("Z", "+00:00")) rescue ArgumentError - return new_challenge.call + return new_challenge.call( + credential, + Mpp::InvalidChallengeError.new(challenge_id: echo.id, reason: "invalid expiry"), + echo + ) + end + if expires_dt < Time.now.utc + return new_challenge.call(credential, Mpp::PaymentExpiredError.new(expires: echo.expires), echo) end - return new_challenge.call if expires_dt < Time.now.utc - receipt = intent.verify(credential, request) + begin + receipt = intent.verify(credential, request) + rescue => e + if dispatcher&.has_handlers?(Mpp::Events::PAYMENT_FAILED) + emit_payment_failed( + dispatcher: dispatcher, + challenge: echo, + credential: credential, + error: e, + method: T.must(method_context), + request: request, + submitted_challenge: echo + ) + end + raise + end + if dispatcher&.has_handlers?(Mpp::Events::PAYMENT_SUCCESS) + emit_payment_success( + dispatcher: dispatcher, + challenge: echo, + credential: credential, + method: T.must(method_context), + receipt: receipt, + request: request + ) + end [credential, receipt] end @@ -124,6 +207,49 @@ def extract_payment_scheme(header) end nil end + + sig { params(dispatcher: T.nilable(Mpp::Events::Dispatcher), challenge: T.untyped, credential: T.untyped, error: T.untyped, method: T::Hash[Symbol, T.untyped], request: T::Hash[String, T.untyped]).void } + def emit_challenge_created(dispatcher:, challenge:, credential:, error:, method:, request:) + return unless dispatcher&.has_handlers?(Mpp::Events::CHALLENGE_CREATED) + + payload = { + challenge: challenge, + method: method, + request: request + } + payload[:credential] = credential unless credential.nil? + payload[:error] = error unless error.nil? + dispatcher.emit(Mpp::Events::CHALLENGE_CREATED, payload) + end + + sig { params(dispatcher: Mpp::Events::Dispatcher, challenge: T.untyped, credential: T.untyped, error: T.untyped, method: T::Hash[Symbol, T.untyped], request: T::Hash[String, T.untyped], retry_challenge: T.untyped, submitted_challenge: T.untyped).void } + def emit_payment_failed(dispatcher:, challenge:, credential:, error:, method:, request:, retry_challenge: nil, submitted_challenge: nil) + return unless dispatcher.has_handlers?(Mpp::Events::PAYMENT_FAILED) + + payload = { + challenge: challenge, + credential: credential, + error: error, + method: method, + request: request + } + payload[:retry_challenge] = retry_challenge unless retry_challenge.nil? + payload[:submitted_challenge] = submitted_challenge unless submitted_challenge.nil? + dispatcher.emit(Mpp::Events::PAYMENT_FAILED, payload) + end + + sig { params(dispatcher: Mpp::Events::Dispatcher, challenge: T.untyped, credential: T.untyped, method: T::Hash[Symbol, T.untyped], receipt: T.untyped, request: T::Hash[String, T.untyped]).void } + def emit_payment_success(dispatcher:, challenge:, credential:, method:, receipt:, request:) + return unless dispatcher.has_handlers?(Mpp::Events::PAYMENT_SUCCESS) + + dispatcher.emit(Mpp::Events::PAYMENT_SUCCESS, { + challenge: challenge, + credential: credential, + method: method, + receipt: receipt, + request: request + }) + end end end end diff --git a/test/mpp/test_client.rb b/test/mpp/test_client.rb index 3ee5b3b..34e2e3f 100644 --- a/test/mpp/test_client.rb +++ b/test/mpp/test_client.rb @@ -21,6 +21,12 @@ def create_credential(challenge) end end +class FailingClientMethod < MockClientMethod + def create_credential(_challenge) + raise Mpp::VerificationFailedError.new(reason: "client signing failed") + end +end + class TestClientTransport < Minitest::Test def setup @method = MockClientMethod.new @@ -129,6 +135,188 @@ def test_skips_unrecognized_method assert_equal "402", response.code end + + def test_emits_client_lifecycle_events + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + www_auth = challenge.to_www_authenticate("api.example.com") + events = [] + + @transport.on_challenge_received do |payload| + events << [Mpp::Events::CHALLENGE_RECEIVED, payload[:challenge].id] + nil + end + @transport.on_credential_created do |payload| + events << [Mpp::Events::CREDENTIAL_CREATED, payload[:credential].start_with?("Payment ")] + end + @transport.on_payment_response do |payload| + events << [Mpp::Events::PAYMENT_RESPONSE, payload[:response].code] + end + @transport.on(Mpp::Events::ANY) do |event| + events << [Mpp::Events::ANY, event.name] + end + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => www_auth}) + .then + .to_return(status: 200, body: "paid") + + response = @transport.get("https://api.example.com/resource") + + assert_equal "200", response.code + assert_equal [ + [Mpp::Events::CHALLENGE_RECEIVED, challenge.id], + [Mpp::Events::ANY, Mpp::Events::CHALLENGE_RECEIVED], + [Mpp::Events::CREDENTIAL_CREATED, true], + [Mpp::Events::ANY, Mpp::Events::CREDENTIAL_CREATED], + [Mpp::Events::PAYMENT_RESPONSE, "200"], + [Mpp::Events::ANY, Mpp::Events::PAYMENT_RESPONSE] + ], events + end + + def test_challenge_received_can_override_credential + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + override = Mpp::Credential.new( + challenge: challenge.to_echo, + payload: {"type" => "hash", "hash" => "0xoverride"} + ).to_authorization + + @transport.on_challenge_received { |_payload| override } + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => challenge.to_www_authenticate("api.example.com")}) + .then + .to_return(status: 200, body: "paid") + + @transport.get("https://api.example.com/resource") + + assert_requested(:get, "https://api.example.com/resource", + headers: {"Authorization" => override}, + times: 1) + end + + def test_rejects_invalid_hook_credential_header + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + seen = [] + @transport.on_challenge_received { |_payload| "Payment valid\r\nX-Injected: true" } + @transport.on_payment_failed { |payload| seen << payload[:error] } + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => challenge.to_www_authenticate("api.example.com")}) + + error = assert_raises(ArgumentError) do + @transport.get("https://api.example.com/resource") + end + + assert_equal "Credential contains invalid header characters", error.message + assert_equal [error], seen + end + + def test_emits_payment_failed_for_challenge_parse_failure + seen = [] + @transport.on_payment_failed { |payload| seen << payload[:error] } + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => "Payment invalidbase64!!!"}) + + response = @transport.get("https://api.example.com/resource") + + assert_equal "402", response.code + assert_equal 1, seen.length + assert_instance_of Mpp::ParseError, seen.first + end + + def test_client_hook_errors_do_not_stop_payment_flow + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + @transport.on_credential_created { |_payload| raise "observer failed" } + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => challenge.to_www_authenticate("api.example.com")}) + .then + .to_return(status: 200, body: "paid") + + response = @transport.get("https://api.example.com/resource") + + assert_equal "200", response.code + end + + def test_emits_payment_failed_when_credential_creation_fails + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + transport = Mpp::Client::Transport.new(methods: [FailingClientMethod.new]) + seen = [] + transport.on_payment_failed do |payload| + seen << [payload[:challenge].id, payload[:error].message] + end + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => challenge.to_www_authenticate("api.example.com")}) + + error = assert_raises(Mpp::VerificationFailedError) do + transport.get("https://api.example.com/resource") + end + + assert_equal "Payment verification failed: client signing failed.", error.message + assert_equal [[challenge.id, "Payment verification failed: client signing failed."]], seen + end + + def test_emits_payment_failed_for_non_success_retry + challenge = Mpp::Challenge.create( + secret_key: "test-secret", + realm: "api.example.com", + method: "tempo", + intent: "charge", + request: {"amount" => "1000000"}, + expires: Mpp::Expires.minutes(5) + ) + seen = [] + @transport.on_payment_failed do |payload| + seen << [payload[:challenge].id, payload[:response].code, payload[:error].message] + end + + stub_request(:get, "https://api.example.com/resource") + .to_return(status: 402, headers: {"WWW-Authenticate" => challenge.to_www_authenticate("api.example.com")}) + .then + .to_return(status: 403, body: "forbidden") + + response = @transport.get("https://api.example.com/resource") + + assert_equal "403", response.code + assert_equal [[challenge.id, "403", "Payment verification failed: retry returned HTTP 403."]], seen + end end class TestClientConvenience < Minitest::Test diff --git a/test/mpp/test_server.rb b/test/mpp/test_server.rb index f04e7fa..7e60692 100644 --- a/test/mpp/test_server.rb +++ b/test/mpp/test_server.rb @@ -108,6 +108,38 @@ def test_successful_verification assert_equal "success", receipt.status end + def test_verification_emits_payment_success + request = {"amount" => "1000000"} + challenge = Mpp::Challenge.create( + secret_key: SECRET, + realm: REALM, + method: "tempo", + intent: "charge", + request: request, + expires: Mpp::Expires.minutes(5) + ) + credential = Mpp::Credential.new( + challenge: challenge.to_echo, + payload: {"type" => "transaction", "signature" => "0xabc"} + ) + events = Mpp::Events.server_dispatcher + seen = [] + events.on(Mpp::Events::PAYMENT_SUCCESS) do |payload| + seen << [payload[:challenge].id, payload[:receipt].status, payload[:method]] + end + + Mpp::Server::Verify.verify_or_challenge( + authorization: credential.to_authorization, + intent: @intent, + request: request, + realm: REALM, + secret_key: SECRET, + events: events + ) + + assert_equal [[challenge.id, "success", {name: "tempo", intent: "charge"}]], seen + end + def test_rejects_wrong_secret request = {"amount" => "1000000"} challenge = Mpp::Challenge.create( @@ -133,6 +165,44 @@ def test_rejects_wrong_secret assert_instance_of Mpp::Challenge, result end + def test_verification_failure_emits_payment_failed_and_challenge_created + request = {"amount" => "1000000"} + challenge = Mpp::Challenge.create( + secret_key: "different-secret", + realm: REALM, + method: "tempo", + intent: "charge", + request: request, + expires: Mpp::Expires.minutes(5) + ) + credential = Mpp::Credential.new(challenge: challenge.to_echo, payload: {"type" => "hash", "hash" => "0x123"}) + events = Mpp::Events.server_dispatcher + seen = [] + events.on(Mpp::Events::PAYMENT_FAILED) do |payload| + seen << [Mpp::Events::PAYMENT_FAILED, payload[:submitted_challenge].id, payload[:error].class] + end + events.on(Mpp::Events::CHALLENGE_CREATED) do |payload| + seen << [Mpp::Events::CHALLENGE_CREATED, payload[:challenge].id, payload[:error].class] + end + + result = Mpp::Server::Verify.verify_or_challenge( + authorization: credential.to_authorization, + intent: @intent, + request: request, + realm: REALM, + secret_key: SECRET, + events: events + ) + + assert_instance_of Mpp::Challenge, result + assert_equal Mpp::Events::PAYMENT_FAILED, seen[0][0] + assert_equal challenge.id, seen[0][1] + assert_equal Mpp::InvalidChallengeError, seen[0][2] + assert_equal Mpp::Events::CHALLENGE_CREATED, seen[1][0] + assert_equal result.id, seen[1][1] + assert_equal Mpp::InvalidChallengeError, seen[1][2] + end + def test_rejects_expired_challenge request = {"amount" => "1000000"} challenge = Mpp::Challenge.create( @@ -277,6 +347,56 @@ def test_challenge_response assert response["headers"].key?("WWW-Authenticate") assert_equal "application/problem+json", response["headers"]["Content-Type"] end + + def test_handler_exposes_server_lifecycle_hooks + intent = MockIntent.new + method = MockMethod.new( + intents: {"charge" => intent}, + currency: "0x20c0000000000000000000000000000000000000", + recipient: "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" + ) + handler = Mpp::Server::MppHandler.new( + method: method, + realm: "api.example.com", + secret_key: "test-secret" + ) + seen = [] + off = handler.on_challenge_created do |payload| + seen << [Mpp::Events::CHALLENGE_CREATED, payload[:request]["amount"]] + end + handler.on(Mpp::Events::ANY) do |event| + seen << [Mpp::Events::ANY, event.name] + end + + handler.charge(nil, "0.50") + off.call + handler.charge(nil, "0.50") + + assert_equal [ + [Mpp::Events::CHALLENGE_CREATED, "500000"], + [Mpp::Events::ANY, Mpp::Events::CHALLENGE_CREATED], + [Mpp::Events::ANY, Mpp::Events::CHALLENGE_CREATED] + ], seen + end + + def test_server_hook_errors_do_not_stop_charge + intent = MockIntent.new + method = MockMethod.new( + intents: {"charge" => intent}, + currency: "0x20c0000000000000000000000000000000000000", + recipient: "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" + ) + handler = Mpp::Server::MppHandler.new( + method: method, + realm: "api.example.com", + secret_key: "test-secret" + ) + handler.on_challenge_created { |_payload| raise "observer failed" } + + result = handler.charge(nil, "0.50") + + assert_instance_of Mpp::Challenge, result + end end class TestDefaults < Minitest::Test