x402-rack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ class Configuration
5
+ Route = Struct.new(:http_method, :path, :amount_sats, keyword_init: true)
6
+
7
+ GATEWAY_METHODS = %i[challenge_headers proof_header_names settle!].freeze
8
+
9
+ attr_accessor :domain, :payee_locking_script_hex, :gateways
10
+ attr_reader :routes
11
+
12
+ def initialize
13
+ @routes = []
14
+ @gateways = []
15
+ end
16
+
17
+ # Register a protected route.
18
+ #
19
+ # @param method [String] HTTP method or "*" for any
20
+ # @param path [String, Regexp] exact path or pattern
21
+ # @param amount_sats [Integer] required payment in satoshis
22
+ def protect(method:, path:, amount_sats:)
23
+ @routes << Route.new(http_method: method.upcase, path: path, amount_sats: amount_sats)
24
+ end
25
+
26
+ # Find the matching route for a request method and path.
27
+ #
28
+ # @return [Route, nil]
29
+ def find_route(request_method, request_path)
30
+ @routes.find do |route|
31
+ method_matches?(route.http_method, request_method) &&
32
+ path_matches?(route.path, request_path)
33
+ end
34
+ end
35
+
36
+ def validate!
37
+ raise ConfigurationError, "domain is required" if domain.nil? || domain.empty?
38
+
39
+ if payee_locking_script_hex.nil? || payee_locking_script_hex.empty?
40
+ raise ConfigurationError,
41
+ "payee_locking_script_hex is required"
42
+ end
43
+ validate_gateways!
44
+ raise ConfigurationError, "at least one route must be protected" if routes.empty?
45
+ end
46
+
47
+ private
48
+
49
+ def validate_gateways!
50
+ raise ConfigurationError, "at least one gateway is required" if gateways.nil? || gateways.empty?
51
+
52
+ gateways.each_with_index do |gw, i|
53
+ GATEWAY_METHODS.each do |method|
54
+ unless gw.respond_to?(method)
55
+ raise ConfigurationError,
56
+ "gateway at index #{i} does not respond to ##{method}"
57
+ end
58
+ end
59
+ end
60
+
61
+ validate_no_duplicate_proof_headers!
62
+ end
63
+
64
+ def validate_no_duplicate_proof_headers!
65
+ seen = {}
66
+ gateways.each_with_index do |gw, i|
67
+ gw.proof_header_names.each do |name|
68
+ if seen.key?(name)
69
+ raise ConfigurationError,
70
+ "duplicate proof header \"#{name}\" claimed by gateways at indices #{seen[name]} and #{i}"
71
+ end
72
+ seen[name] = i
73
+ end
74
+ end
75
+ end
76
+
77
+ def method_matches?(route_method, request_method)
78
+ route_method == "*" || route_method == request_method.upcase
79
+ end
80
+
81
+ def path_matches?(route_path, request_path)
82
+ case route_path
83
+ when Regexp then route_path.match?(request_path)
84
+ when String then route_path == request_path
85
+ else false
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class VerificationError < Error
9
+ attr_reader :reason, :status
10
+
11
+ def initialize(reason, status: 400)
12
+ @reason = reason
13
+ @status = status
14
+ super(reason)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+
6
+ module X402
7
+ class Middleware
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request = Rack::Request.new(env)
14
+ config = X402.configuration
15
+ route = config.find_route(request.request_method, request.path_info)
16
+
17
+ # Unprotected route — pass through
18
+ return @app.call(env) unless route
19
+
20
+ # Check for a proof/payment header from any gateway
21
+ gateway, header_name, proof_payload = detect_proof(env, config)
22
+
23
+ if gateway
24
+ settle_and_forward(env, gateway, header_name, proof_payload, request, route)
25
+ else
26
+ issue_challenge(request, route, config)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def detect_proof(env, config)
33
+ config.gateways.each do |gw|
34
+ gw.proof_header_names.each do |name|
35
+ rack_key = rack_header_key(name)
36
+ value = env[rack_key]
37
+ return [gw, name, value] if value && !value.empty?
38
+ end
39
+ end
40
+ nil
41
+ end
42
+
43
+ def issue_challenge(request, route, config)
44
+ headers = { "content-type" => "application/json" }
45
+
46
+ config.gateways.each do |gw|
47
+ gw.challenge_headers(request, route).each do |name, value|
48
+ headers[name.downcase] = value
49
+ end
50
+ end
51
+
52
+ body = JSON.generate({ error: "Payment Required" })
53
+ [402, headers, [body]]
54
+ end
55
+
56
+ def settle_and_forward(env, gateway, header_name, proof_payload, request, route)
57
+ result = gateway.settle!(header_name, proof_payload, request, route)
58
+
59
+ status, headers, body = @app.call(env)
60
+
61
+ # Merge any receipt headers from the gateway
62
+ if result.respond_to?(:receipt_headers) && result.receipt_headers
63
+ result.receipt_headers.each do |name, value|
64
+ headers[name.downcase] = value
65
+ end
66
+ end
67
+
68
+ [status, headers, body]
69
+ rescue X402::VerificationError => e
70
+ error_response(e.status, e.reason)
71
+ rescue X402::Error => e
72
+ error_response(400, e.message)
73
+ rescue StandardError => e
74
+ error_response(500, "internal error: #{e.message}")
75
+ end
76
+
77
+ def error_response(status, reason)
78
+ body = JSON.generate({ error: reason })
79
+ [status, { "content-type" => "application/json" }, [body]]
80
+ end
81
+
82
+ def rack_header_key(http_header_name)
83
+ "HTTP_#{http_header_name.upcase.tr("-", "_")}"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module X402
6
+ module Base64Url
7
+ module_function
8
+
9
+ def encode(data)
10
+ Base64.urlsafe_encode64(data, padding: false)
11
+ end
12
+
13
+ def decode(data)
14
+ Base64.urlsafe_decode64(data)
15
+ rescue ArgumentError => e
16
+ raise X402::Error, "invalid base64url: #{e.message}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module X402
7
+ class Challenge
8
+ CURRENT_VERSION = 1
9
+ SUPPORTED_SCHEMES = ["bsv-tx-v1"].freeze
10
+ DEFAULT_TTL = 300 # 5 minutes
11
+
12
+ attr_reader :version, :scheme, :domain, :method, :path, :query,
13
+ :req_headers_sha256, :req_body_sha256,
14
+ :amount_sats, :payee_locking_script_hex,
15
+ :nonce_txid, :nonce_vout, :nonce_satoshis, :nonce_locking_script_hex,
16
+ :expires_at
17
+
18
+ def initialize(attrs = {})
19
+ attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ version: @version,
25
+ scheme: @scheme,
26
+ domain: @domain,
27
+ method: @method,
28
+ path: @path,
29
+ query: @query,
30
+ req_headers_sha256: @req_headers_sha256,
31
+ req_body_sha256: @req_body_sha256,
32
+ amount_sats: @amount_sats,
33
+ payee_locking_script_hex: @payee_locking_script_hex,
34
+ nonce_txid: @nonce_txid,
35
+ nonce_vout: @nonce_vout,
36
+ nonce_satoshis: @nonce_satoshis,
37
+ nonce_locking_script_hex: @nonce_locking_script_hex,
38
+ expires_at: @expires_at
39
+ }
40
+ end
41
+
42
+ def to_canonical_json
43
+ to_h.to_json_c14n
44
+ end
45
+
46
+ def sha256_hex
47
+ OpenSSL::Digest::SHA256.hexdigest(to_canonical_json)
48
+ end
49
+
50
+ def to_header
51
+ Base64Url.encode(to_canonical_json)
52
+ end
53
+
54
+ # Reconstruct a challenge from the echoed X402-Challenge header.
55
+ def self.from_header(header_value)
56
+ json = Base64Url.decode(header_value)
57
+ data = JSON.parse(json, symbolize_names: true)
58
+ new(data)
59
+ rescue JSON::ParserError => e
60
+ raise X402::Error, "invalid challenge JSON: #{e.message}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module X402
6
+ class Proof
7
+ attr_reader :challenge_sha256, :payment
8
+
9
+ def initialize(challenge_sha256:, payment:)
10
+ @challenge_sha256 = challenge_sha256
11
+ @payment = payment
12
+ end
13
+
14
+ # Parse a proof from the X402-Proof header (base64url-encoded JSON).
15
+ def self.from_header(header_value)
16
+ json = Base64Url.decode(header_value)
17
+ data = JSON.parse(json, symbolize_names: true)
18
+
19
+ new(
20
+ challenge_sha256: data[:challenge_sha256],
21
+ payment: data[:payment]
22
+ )
23
+ rescue JSON::ParserError => e
24
+ raise X402::Error, "invalid proof JSON: #{e.message}"
25
+ end
26
+
27
+ # Convenience accessors for payment fields.
28
+ def rawtx_b64
29
+ @payment&.fetch(:rawtx_b64, nil)
30
+ end
31
+
32
+ def txid
33
+ @payment&.fetch(:txid, nil)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module X402
6
+ # Computes canonical hashes of request headers and body for binding
7
+ # challenges and proofs to the actual HTTP request.
8
+ module RequestBinding
9
+ module_function
10
+
11
+ # Compute SHA-256 hex digest of the request body.
12
+ # Returns the hash of an empty string if no body is present.
13
+ def body_sha256(rack_request)
14
+ return sha256_hex("") unless rack_request.body
15
+
16
+ rack_request.body.rewind if rack_request.body.respond_to?(:rewind)
17
+ body = rack_request.body.read || ""
18
+ rack_request.body.rewind if rack_request.body.respond_to?(:rewind)
19
+ sha256_hex(body)
20
+ end
21
+
22
+ # Compute SHA-256 hex digest of canonical request headers.
23
+ # For v1, this is the empty string hash (no headers bound).
24
+ def headers_sha256(_rack_request)
25
+ sha256_hex("")
26
+ end
27
+
28
+ def sha256_hex(data)
29
+ OpenSSL::Digest::SHA256.hexdigest(data)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ # Returned by Gateway#settle! on successful settlement.
5
+ #
6
+ # @attr receipt_headers [Hash] HTTP headers to include in the 200 response
7
+ # @attr txid [String, nil] transaction identifier
8
+ # @attr network [String, nil] CAIP-2 network identifier (e.g. "bsv:mainnet")
9
+ SettlementResult = Struct.new(:receipt_headers, :txid, :network, keyword_init: true) do
10
+ def initialize(receipt_headers: {}, txid: nil, network: nil)
11
+ super
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ module Verification
5
+ module ProtocolChecks
6
+ module_function
7
+
8
+ def check_version!(challenge)
9
+ return if challenge.version == Challenge::CURRENT_VERSION
10
+
11
+ raise VerificationError, "unsupported version: #{challenge.version}"
12
+ end
13
+
14
+ def check_scheme!(challenge)
15
+ return if Challenge::SUPPORTED_SCHEMES.include?(challenge.scheme)
16
+
17
+ raise VerificationError, "unsupported scheme: #{challenge.scheme}"
18
+ end
19
+
20
+ def check_challenge_hash!(challenge, proof)
21
+ raise VerificationError, "challenge hash mismatch" unless challenge.sha256_hex == proof.challenge_sha256
22
+ end
23
+
24
+ def check_request_binding!(challenge, rack_request)
25
+ raise VerificationError, "request method mismatch" unless challenge.method == rack_request.request_method
26
+ raise VerificationError, "request path mismatch" unless challenge.path == rack_request.path_info
27
+ raise VerificationError, "request query mismatch" unless challenge.query == rack_request.query_string
28
+
29
+ expected_body = RequestBinding.body_sha256(rack_request)
30
+ raise VerificationError, "request body hash mismatch" unless challenge.req_body_sha256 == expected_body
31
+
32
+ expected_headers = RequestBinding.headers_sha256(rack_request)
33
+ return if challenge.req_headers_sha256 == expected_headers
34
+
35
+ raise VerificationError, "request headers hash mismatch"
36
+ end
37
+
38
+ def check_expiry!(challenge)
39
+ raise VerificationError.new("challenge expired", status: 402) if challenge.expires_at <= Time.now.to_i
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ VERSION = "0.1.0"
5
+ end
data/lib/x402.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "x402/version"
4
+ require_relative "x402/errors"
5
+ require_relative "x402/settlement_result"
6
+ require_relative "x402/configuration"
7
+ require_relative "x402/middleware"
8
+
9
+ module X402
10
+ class << self
11
+ def configure
12
+ yield(configuration)
13
+ configuration.validate!
14
+ end
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def reset_configuration!
21
+ @configuration = Configuration.new
22
+ end
23
+ end
24
+ end
data/sig/x402.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module X402
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: x402-rack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Bettison
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-03-27 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bsv-sdk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bsv-wallet
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: json-canonicalization
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ description: Rack middleware implementing the x402 stateless settlement-gated HTTP
83
+ protocol using BSV (Bitcoin SV) payments.
84
+ email:
85
+ - simon@bettison.org
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".claude/plans/20260325-bsv-module.md"
91
+ - ".claude/plans/20260326-rack-stack-architecture.md"
92
+ - ".rspec"
93
+ - ".rubocop.yml"
94
+ - CLAUDE.md
95
+ - DESIGN.md
96
+ - LICENSE.txt
97
+ - README.md
98
+ - Rakefile
99
+ - docs/process-flow/pay_gateway.md
100
+ - lib/x402.rb
101
+ - lib/x402/bsv.rb
102
+ - lib/x402/bsv/gateway.rb
103
+ - lib/x402/bsv/pay_gateway.rb
104
+ - lib/x402/bsv/proof_gateway.rb
105
+ - lib/x402/configuration.rb
106
+ - lib/x402/errors.rb
107
+ - lib/x402/middleware.rb
108
+ - lib/x402/protocol/base64url.rb
109
+ - lib/x402/protocol/challenge.rb
110
+ - lib/x402/protocol/proof.rb
111
+ - lib/x402/protocol/request_binding.rb
112
+ - lib/x402/settlement_result.rb
113
+ - lib/x402/verification/protocol_checks.rb
114
+ - lib/x402/version.rb
115
+ - sig/x402.rbs
116
+ homepage: https://github.com/sgbett/x402-rack
117
+ licenses:
118
+ - LicenseRef-OpenBSV
119
+ metadata:
120
+ source_code_uri: https://github.com/sgbett/x402-rack
121
+ changelog_uri: https://github.com/sgbett/x402-rack/blob/master/CHANGELOG.md
122
+ rubygems_mfa_required: 'true'
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 3.1.0
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.6.2
138
+ specification_version: 4
139
+ summary: x402 protocol server middleware for BSV micropayments
140
+ test_files: []