x402-rack 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98f4be71b3158fb9a60f48c7fd22fab18bf11f7e299bbd482a34ed1ff0d43f5b
4
- data.tar.gz: 52abd02a198410c35b10d07247a290c1e2d1ec6c971758b160be7c5956d01fa7
3
+ metadata.gz: f3b7c632a276b60aa31022985a02b8b9e1722cef5e11fbe0ee626f8d5c2fac98
4
+ data.tar.gz: 791f68f8b62750f1e3741404e3ed551b6a7f97f8160f42eeb76be1db551dc4c8
5
5
  SHA512:
6
- metadata.gz: 22f092945c96c25969074be4f70d9af80ced8e2a60fff3ff02d4b851e77758a754cfca4b337e341abcb7fcb6c0eb8623ee0aa9b46526991ea406579ed9c3e483
7
- data.tar.gz: ce2373b9e168bf1dea3d5f39a97b870007f58d1b74fe6cec8c7dd172b10c4daa4dda1bbe9156fe94ca43cd1dc3399db67f92db1b4e92e860d0ac982f682cff1f
6
+ metadata.gz: 8d3df96ff711728a23d5e8bc6ef2b62085e19904ec7810e0dfd26af47114b258e23d0064aa952ea19a0a0479c04398ac682b4f0b596a7597ef31721e2b1c02c6
7
+ data.tar.gz: f270706245222660c689860335f8d204d484819ddb8d9dc176fe10d51633d339d137c8a53de0d71f805cbd3eb8605dd6c3f1c1836f2251b7b2ad67bb26403b74
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+ --plugin markdown
2
+ --markup markdown
3
+ --output-dir doc
4
+ lib/**/*.rb
5
+ -
6
+ CHANGELOG.md
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-04-05
9
+
10
+ ### Added
11
+
12
+ - **Configurable structured logging** — pluggable `logger` on configuration with structured request lifecycle messages (route match, identity key, proof dispatch, settlement outcome). (#102)
13
+ - **BRC-105 settlement logging** — structured log output for derivation, key ID, locking script verification, and settlement result in `BRC105Gateway`. (#96)
14
+ - **BRC-105 §6.2 response body** — settlement success and error responses now include spec-conformant JSON body with receipt details. Spec-anchored tests. (#91)
15
+ - **BRC-105 response headers** — `x-bsv-payment-version` and `x-bsv-payment-satoshis-paid` headers on successful settlement. (#91)
16
+ - **API documentation** — YARD-style docs for public interfaces. (#89)
17
+
18
+ ### Changed
19
+
20
+ - **Client identity key required for BRC-105** — `x-bsv-auth-identity-key` header is now mandatory for BRC-105 settlement (§7.1). Requests without it receive a 401 error. (#99)
21
+
22
+ ### Fixed
23
+
24
+ - **ARC broadcast error details** — error messages from ARC are now logged and surfaced in the 502 response rather than swallowed silently.
25
+ - **Base64 `derivationSuffix` accepted** — client-generated suffixes may be base64-encoded (not just hex). Validation updated to accept both formats. (#93)
26
+
27
+ ### Build
28
+
29
+ - **Dependency updates** — bsv-sdk 0.6.0 → 0.6.1, rack 3.2.6.
30
+
8
31
  ## [0.5.1] - 2026-04-04
9
32
 
10
33
  ### Fixed
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Project
6
6
 
7
- x402-rack is a Ruby gem providing Rack middleware for the x402 protocol (BSV settlement-gated HTTP). Currently in early development (v0.1.0). Requires Ruby >= 3.1.
7
+ x402-rack is a Ruby gem providing Rack middleware for the x402 protocol (BSV settlement-gated HTTP). Requires Ruby >= 3.1.
8
8
 
9
9
  ## Commands
10
10
 
@@ -31,20 +31,9 @@ bundle exec rubocop -A
31
31
  bundle exec rake
32
32
  ```
33
33
 
34
- ## Architecture
34
+ ## Specifications
35
35
 
36
- Standard Ruby gem layout generated by `bundle gem`:
37
-
38
- - `lib/x402.rb` — main entry point, defines `X402` module
39
- - `lib/x402/version.rb` — version constant
40
- - `lib/x402/middleware.rb` — pure dispatcher (no blockchain knowledge)
41
- - `lib/x402/bsv/gateway.rb` — base class for template-based gateways
42
- - `lib/x402/bsv/pay_gateway.rb` — Coinbase v2 headers, server broadcasts
43
- - `lib/x402/bsv/proof_gateway.rb` — merkleworks headers, client broadcasts
44
- - `lib/x402/bsv/brc105_gateway.rb` — BSV Association BRC-105, BRC-29 derivation (no inheritance from Gateway)
45
- - `lib/x402/bsv/prefix_store.rb` — pluggable replay protection for BRC-105 derivation prefixes
46
- - `sig/x402.rbs` — RBS type signatures
47
- - `x402-rack.gemspec` — gem specification (dependencies defined here, not in Gemfile)
36
+ This project implements published protocol specifications (BRC-105, BRC-29, etc.). When writing or modifying code that implements a spec, consult the spec directly (via `bsv-protocol-docs` MCP) and verify conformance — including optional features unless there is a documented reason to omit them. Tests should be anchored to spec requirements, not just implementation behaviour.
48
37
 
49
38
  ## Code Style
50
39
 
data/Rakefile CHANGED
@@ -16,3 +16,49 @@ require "rubocop/rake_task"
16
16
  RuboCop::RakeTask.new
17
17
 
18
18
  task default: %i[spec rubocop]
19
+
20
+ def generate_reference_index(output_dir)
21
+ require "csv"
22
+ csv_path = File.join(output_dir, "index.csv")
23
+ return unless File.exist?(csv_path)
24
+
25
+ modules = []
26
+ classes = []
27
+ CSV.foreach(csv_path, headers: true) do |row|
28
+ next unless %w[Module Class].include?(row["type"])
29
+
30
+ entry = { name: row["name"], path: row["path"] }
31
+ row["type"] == "Module" ? modules << entry : classes << entry
32
+ end
33
+
34
+ File.open(File.join(output_dir, "index.md"), "w") do |f|
35
+ f.puts "# API Reference"
36
+ f.puts
37
+ f.puts "Auto-generated from source using [YARD](https://yardoc.org/)."
38
+ f.puts
39
+ f.puts "## Modules"
40
+ f.puts
41
+ modules.sort_by { |e| e[:name] }.each { |e| f.puts "- [#{e[:name]}](#{e[:path]})" }
42
+ f.puts
43
+ f.puts "## Classes"
44
+ f.puts
45
+ classes.sort_by { |e| e[:name] }.each { |e| f.puts "- [#{e[:name]}](#{e[:path]})" }
46
+ end
47
+ end
48
+
49
+ namespace :docs do
50
+ desc "Generate YARD markdown into docs/reference/"
51
+ task :generate do
52
+ require "fileutils"
53
+ output_dir = "docs/reference"
54
+ FileUtils.rm_rf(output_dir)
55
+ FileUtils.mkdir_p(output_dir)
56
+ sh "bundle exec yardoc --plugin markdown --format markdown --output-dir docs/reference lib/**/*.rb"
57
+ generate_reference_index(output_dir)
58
+ end
59
+
60
+ desc "Generate docs and serve locally with MkDocs"
61
+ task serve: :generate do
62
+ sh "mkdocs serve"
63
+ end
64
+ end
data/docs/index.md ADDED
@@ -0,0 +1,21 @@
1
+ # x402-rack
2
+
3
+ Rack middleware for payment-gated HTTP using BSV and the [x402 protocol](https://docs.x402.org/).
4
+
5
+ The middleware is a pure dispatcher — it matches routes, issues payment challenges, and routes proofs to pluggable gateway backends for settlement. It has no blockchain knowledge and holds no keys.
6
+
7
+ ## Three BSV settlement schemes
8
+
9
+ - **BSV-pay** (Coinbase v2 headers) — server broadcasts via ARC. No nonces, minimal infrastructure.
10
+ - **BSV-proof** (merkleworks x402) — client broadcasts, server checks mempool. Nonce-bound, request-binding.
11
+ - **BRC-105** (BSV Association `x-bsv-*` headers) — BRC-29 key derivation for unique payment addresses. Works standalone or composes with BRC-103 mutual authentication.
12
+
13
+ ## Getting started
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "x402-rack", git: "https://github.com/sgbett/x402-rack.git"
19
+ ```
20
+
21
+ See the [Architecture](architecture.md) guide for how the pieces fit together, or jump to the [API Reference](reference/index.md) for class and method documentation.
@@ -0,0 +1,3 @@
1
+ mkdocs>=1.6.0
2
+ mkdocs-material>=9.6.0
3
+ mkdocs-minify-plugin>=0.8.0
@@ -1,5 +1,9 @@
1
1
  # BRC-105 Scheme (BSV Association)
2
2
 
3
+ The official BSV Association [BRC-105](https://github.com/bitcoin-sv/BRCs/blob/master/payments/0105.md) payment protocol. Currently standalone (no-auth) mode — BRC-103 mutual authentication coming soon.
4
+
5
+ ## Description
6
+
3
7
  BRC-29 derived payment addresses with AtomicBEEF transactions. BSV Association `x-bsv-*` headers. Optional BRC-103 mutual authentication.
4
8
 
5
9
  Unlike BSV-pay and BSV-proof, this scheme does not use partial transaction templates. The client builds the entire transaction using a derived payment address.
@@ -1,5 +1,9 @@
1
1
  # BSV-pay Scheme
2
2
 
3
+ The default gateway. Direct peer-to-peer payment — client pays server, server broadcasts to the network.
4
+
5
+ ## Description
6
+
3
7
  Server broadcasts via ARC. Uses Coinbase v2 header spec. Minimal infrastructure — ARC only, no treasury, no nonces.
4
8
 
5
9
  ## Headers
@@ -70,4 +74,4 @@ See [operations/performance.md](../operations/performance.md) for scaling consid
70
74
 
71
75
  ## Process Flow
72
76
 
73
- See [process-flow/pay_gateway.md](../process-flow/pay_gateway.md) for sequence diagrams.
77
+ See [process-flow/pay-gateway.md](../process-flow/pay-gateway.md) for sequence diagrams.
@@ -1,5 +1,16 @@
1
1
  # BSV-proof Scheme (merkleworks x402)
2
2
 
3
+ The [merkleworks x402](https://x402.merkleworks.io) implementation. Client broadcasts and proves payment via mempool.
4
+
5
+ !!! danger "Experimental — not production-ready"
6
+ The ProofGateway implementation is incomplete and under active development.
7
+ The nonce provider interface, Profile B provenance verification, and mempool
8
+ checking behaviour may change without notice. **Do not use in production.**
9
+ For production BSV payments, use [BSV-pay](bsv-pay.md) (PayGateway) or
10
+ [BRC-105](brc-105.md) (BRC105Gateway).
11
+
12
+ ## Description
13
+
3
14
  Client broadcasts, server checks mempool. Proof-of-payment model. Nonce-bound with request binding.
4
15
 
5
16
  ## Headers
@@ -22,8 +22,9 @@ module X402
22
22
  PROTOCOL_ID = [2, "3241645161d8"].freeze
23
23
  PROOF_HEADER = "x-bsv-payment"
24
24
  NETWORK = "bsv:mainnet"
25
- COMPRESSED_PUBKEY_HEX = /\A0[23][0-9a-fA-F]{64}\z/
25
+ COMPRESSED_PUBKEY_HEX = /\A0[23][0-9a-f]{64}\z/
26
26
  MAX_DERIVATION_BYTES = 64
27
+ PRINTABLE_ASCII = /\A[\x20-\x7E]+\z/
27
28
 
28
29
  # @param key_deriver [BSV::Wallet::KeyDeriver] provides identity key + BRC-42 derivation
29
30
  # @param prefix_store [#store!, #valid?, #consume!] replay protection for derivation prefixes
@@ -48,11 +49,15 @@ module X402
48
49
  end
49
50
 
50
51
  headers = {
52
+ "x-bsv-payment-version" => "1.0",
51
53
  "x-bsv-payment-satoshis-required" => route.resolve_amount_sats.to_s,
52
54
  "x-bsv-payment-derivation-prefix" => prefix
53
55
  }
54
56
 
55
- # Include identity key only in standalone mode (no BRC-103 present)
57
+ # The 402 challenge is issued before the client authenticates (no
58
+ # x-bsv-auth-identity-key yet). Include the server's identity key so
59
+ # the client knows who to derive the payment address for. When BRC-103
60
+ # mutual auth is already established, the client already has this key.
56
61
  headers["x-bsv-payment-identity-key"] = @key_deriver.identity_key unless validated_brc103_key(rack_request)
57
62
 
58
63
  headers
@@ -73,17 +78,23 @@ module X402
73
78
  # @param route [X402::Configuration::Route]
74
79
  # @return [SettlementResult]
75
80
  def settle!(_header_name, proof_payload, rack_request, route)
81
+ # §7.1: fail fast if unauthenticated — before parsing untrusted payload
82
+ counterparty = resolve_counterparty(rack_request)
76
83
  required_sats = route.resolve_amount_sats
77
84
  payment = parse_payment(proof_payload)
78
85
  prefix = payment["derivationPrefix"]
79
86
  suffix = payment["derivationSuffix"]
80
87
  validate_prefix_and_suffix!(prefix, suffix)
81
88
  subject_tx = parse_beef_transaction(payment["transaction"])
89
+ log_derivation_inputs(prefix, suffix, counterparty)
82
90
  expected_script = derive_payment_script(prefix, suffix, rack_request)
83
- verify_payment_output!(subject_tx, required_sats, expected_script)
91
+ log_expected_script(expected_script)
92
+ log_tx_outputs(subject_tx, required_sats, expected_script)
93
+ paid_sats = verify_payment_output!(subject_tx, required_sats, expected_script)
84
94
  consume_prefix!(prefix)
85
95
  broadcast!(subject_tx)
86
- build_settlement_result(subject_tx)
96
+ log_settlement_success(subject_tx, paid_sats, required_sats)
97
+ build_settlement_result(subject_tx, paid_sats)
87
98
  end
88
99
 
89
100
  private
@@ -95,11 +106,12 @@ module X402
95
106
  end
96
107
 
97
108
  def validate_prefix_and_suffix!(prefix, suffix)
98
- validate_derivation_component!("derivationPrefix", prefix)
99
- validate_derivation_component!("derivationSuffix", suffix)
109
+ validate_hex!("derivationPrefix", prefix)
110
+ validate_suffix!("derivationSuffix", suffix)
100
111
  end
101
112
 
102
- def validate_derivation_component!(name, value)
113
+ # Prefix is server-generated hex (SecureRandom.hex).
114
+ def validate_hex!(name, value)
103
115
  raise VerificationError.new("missing #{name}", status: 400) if value.nil?
104
116
  unless value.is_a?(String) && !value.empty? &&
105
117
  value.bytesize <= MAX_DERIVATION_BYTES && value.match?(/\A[0-9a-f]+\z/)
@@ -107,6 +119,17 @@ module X402
107
119
  end
108
120
  end
109
121
 
122
+ # Suffix is client-generated — the BRC-105 spec does not constrain the
123
+ # format. Reference implementations (BRC-121, bsv-x402) use base64.
124
+ # We accept any printable ASCII string within the size limit.
125
+ def validate_suffix!(name, value)
126
+ raise VerificationError.new("missing #{name}", status: 400) if value.nil?
127
+ unless value.is_a?(String) && !value.empty? &&
128
+ value.bytesize <= MAX_DERIVATION_BYTES && value.match?(PRINTABLE_ASCII)
129
+ raise VerificationError.new("invalid #{name} format", status: 400)
130
+ end
131
+ end
132
+
110
133
  def consume_prefix!(prefix)
111
134
  return if @prefix_store.consume!(prefix)
112
135
 
@@ -138,42 +161,95 @@ module X402
138
161
  ::BSV::Script::Script.from_hex("76a914#{h160}88ac")
139
162
  end
140
163
 
164
+ # BRC-105 §7.1: "If not authenticated, respond 401 Unauthorized."
165
+ # The client's identity key is required for BRC-42 key derivation.
141
166
  def resolve_counterparty(rack_request)
142
- validated_brc103_key(rack_request) || "anyone"
167
+ key = rack_request.env["brc103.identity_key"]
168
+ return key if key.is_a?(String) && key.match?(COMPRESSED_PUBKEY_HEX)
169
+
170
+ if key.nil? || (key.is_a?(String) && key.empty?)
171
+ raise VerificationError.new("missing client identity key (x-bsv-auth-identity-key)", status: 401)
172
+ end
173
+
174
+ raise VerificationError.new("invalid client identity key (x-bsv-auth-identity-key)", status: 401)
143
175
  end
144
176
 
145
177
  # Returns the validated BRC-103 identity key from the Rack env, or nil
146
178
  # if absent or not a valid compressed public key hex.
179
+ # Used by challenge_headers to check for BRC-103 presence.
147
180
  def validated_brc103_key(rack_request)
148
181
  key = rack_request.env["brc103.identity_key"]
149
182
  key if key.is_a?(String) && key.match?(COMPRESSED_PUBKEY_HEX)
150
183
  end
151
184
 
152
185
  def verify_payment_output!(transaction, required_sats, expected_script)
153
- found = transaction.outputs.any? do |output|
186
+ matching = transaction.outputs.find do |output|
154
187
  output.locking_script == expected_script && output.satoshis >= required_sats
155
188
  end
156
- return if found
157
189
 
158
- raise VerificationError.new(
159
- "no output pays >= #{required_sats} sats to derived address", status: 402
160
- )
190
+ unless matching
191
+ raise VerificationError.new(
192
+ "no output pays >= #{required_sats} sats to derived address", status: 402
193
+ )
194
+ end
195
+
196
+ matching.satoshis
161
197
  end
162
198
 
163
199
  def broadcast!(transaction)
164
200
  @arc_client.broadcast(transaction)
165
- rescue StandardError
166
- raise VerificationError.new("ARC broadcast failed", status: 502)
201
+ rescue StandardError => e
202
+ logger.error "[brc105] ARC broadcast error: #{e.class}: #{e.message}"
203
+ raise VerificationError.new("ARC broadcast failed: #{e.message}", status: 502)
204
+ end
205
+
206
+ # NOTE: x-bsv-payment-satoshis-paid reflects the amount claimed in the
207
+ # BEEF transaction, verified against the derived payment script but set
208
+ # before ARC broadcast confirmation. It is not an on-chain-confirmed
209
+ # value. Downstream consumers requiring confirmed amounts should use
210
+ # the txid to query chain state independently.
211
+ # --- Settlement logging (tagged [brc105]) ---
212
+
213
+ def logger
214
+ X402.configuration.logger
215
+ end
216
+
217
+ def log_derivation_inputs(prefix, suffix, counterparty)
218
+ logger.info "[brc105] Derivation: prefix=#{prefix} suffix=#{suffix} counterparty=#{counterparty}"
219
+ logger.debug "[brc105] Key ID: #{prefix} #{suffix}"
220
+ end
221
+
222
+ def log_expected_script(script)
223
+ logger.info "[brc105] Expected locking script: #{script.to_hex}"
224
+ end
225
+
226
+ def log_tx_outputs(transaction, required_sats, expected_script)
227
+ logger.info "[brc105] Verifying #{transaction.outputs.length} output(s) against #{required_sats} sats required"
228
+ transaction.outputs.each_with_index do |output, i|
229
+ script_match = output.locking_script == expected_script
230
+ sats_match = output.satoshis >= required_sats
231
+ logger.info "[brc105] output[#{i}]: #{output.satoshis} sats, " \
232
+ "script=#{output.locking_script.to_hex[0..15]}... " \
233
+ "script_match=#{script_match} sats_match=#{sats_match}"
234
+ end
235
+ end
236
+
237
+ def log_settlement_success(transaction, paid_sats, required_sats)
238
+ logger.info "[brc105] Settlement OK: txid=#{transaction.txid_hex} " \
239
+ "paid=#{paid_sats} required=#{required_sats}"
167
240
  end
168
241
 
169
- def build_settlement_result(transaction)
242
+ def build_settlement_result(transaction, paid_sats)
170
243
  receipt = {
171
244
  "success" => true,
172
245
  "transaction" => transaction.txid_hex,
173
246
  "network" => NETWORK
174
247
  }
175
248
  SettlementResult.new(
176
- receipt_headers: { "x-bsv-payment-result" => Base64.strict_encode64(JSON.generate(receipt)) },
249
+ receipt_headers: {
250
+ "x-bsv-payment-satoshis-paid" => paid_sats.to_s,
251
+ "x-bsv-payment-result" => Base64.strict_encode64(JSON.generate(receipt))
252
+ },
177
253
  txid: transaction.txid_hex,
178
254
  network: NETWORK
179
255
  )
@@ -44,15 +44,29 @@ module X402
44
44
  @txid_store = txid_store
45
45
  end
46
46
 
47
+ # Build a 402 challenge with Coinbase v2 +Payment-Required+ header.
48
+ #
49
+ # @param rack_request [Rack::Request]
50
+ # @param route [X402::Configuration::Route]
51
+ # @return [Hash] challenge headers
47
52
  def challenge_headers(rack_request, route)
48
53
  challenge = build_challenge(rack_request, route)
49
54
  { "Payment-Required" => Base64.strict_encode64(JSON.generate(challenge)) }
50
55
  end
51
56
 
57
+ # @return [Array<String>] proof header names this gateway responds to
52
58
  def proof_header_names
53
59
  ["Payment-Signature"]
54
60
  end
55
61
 
62
+ # Verify and broadcast a Coinbase v2 payment.
63
+ #
64
+ # @param _header_name [String] which proof header matched
65
+ # @param proof_payload [String] base64-encoded payment payload
66
+ # @param rack_request [Rack::Request]
67
+ # @param route [X402::Configuration::Route]
68
+ # @return [SettlementResult]
69
+ # @raise [VerificationError] on invalid payment, insufficient amount, or broadcast failure
56
70
  def settle!(_header_name, proof_payload, rack_request, route)
57
71
  required_sats = route.resolve_amount_sats
58
72
  payload = decode_payment_payload(proof_payload)
@@ -41,6 +41,8 @@ module X402
41
41
 
42
42
  # Record a prefix as issued.
43
43
  #
44
+ # @param prefix [String] hex derivation prefix
45
+ # @return [void]
44
46
  # @raise [StoreFullError] if max_issued unconsumed prefixes are held
45
47
  def store!(prefix)
46
48
  @monitor.synchronize do
@@ -53,6 +55,9 @@ module X402
53
55
  end
54
56
 
55
57
  # Non-binding read: returns true if the prefix was issued and not yet consumed.
58
+ #
59
+ # @param prefix [String] hex derivation prefix
60
+ # @return [Boolean]
56
61
  def valid?(prefix)
57
62
  @monitor.synchronize do
58
63
  entry = @prefixes[prefix]
@@ -62,6 +67,9 @@ module X402
62
67
 
63
68
  # Atomically mark a prefix as consumed. Returns false if already consumed,
64
69
  # unknown, or expired.
70
+ #
71
+ # @param prefix [String] hex derivation prefix
72
+ # @return [Boolean] true if successfully consumed, false if replay/unknown/expired
65
73
  def consume!(prefix)
66
74
  @monitor.synchronize do
67
75
  entry = @prefixes[prefix]
@@ -35,6 +35,12 @@ module X402
35
35
  @arc_client = arc_client
36
36
  end
37
37
 
38
+ # Build a 402 challenge with merkleworks +X402-Challenge+ header.
39
+ #
40
+ # @param rack_request [Rack::Request]
41
+ # @param route [X402::Configuration::Route]
42
+ # @return [Hash] challenge headers
43
+ # @raise [ConfigurationError] if route uses callable amount_sats (fiat pricing)
38
44
  def challenge_headers(rack_request, route)
39
45
  if route.amount_sats.respond_to?(:call)
40
46
  raise ConfigurationError,
@@ -44,10 +50,19 @@ module X402
44
50
  { "X402-Challenge" => challenge.to_header }
45
51
  end
46
52
 
53
+ # @return [Array<String>] proof header names this gateway responds to
47
54
  def proof_header_names
48
55
  ["X402-Proof"]
49
56
  end
50
57
 
58
+ # Verify a merkleworks x402 proof against the challenge and check mempool.
59
+ #
60
+ # @param _header_name [String] which proof header matched
61
+ # @param proof_payload [String] base64url-encoded proof
62
+ # @param rack_request [Rack::Request]
63
+ # @param route [X402::Configuration::Route]
64
+ # @return [SettlementResult]
65
+ # @raise [VerificationError] on invalid proof, binding mismatch, or mempool failure
51
66
  def settle!(_header_name, proof_payload, rack_request, route)
52
67
  required_sats = route.resolve_amount_sats
53
68
  proof = Proof.from_header(proof_payload)
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module X402
4
+ # DSL for configuring X402 middleware, gateways, and protected routes.
5
+ #
6
+ # @example Minimal configuration
7
+ # X402.configure do |config|
8
+ # config.domain = "api.example.com"
9
+ # config.server_wif = ENV["SERVER_WIF"]
10
+ # config.arc_url = "https://arc.taal.com"
11
+ # config.enable :pay_gateway
12
+ # config.protect method: :GET, path: "/api/expensive", amount_sats: 100
13
+ # end
4
14
  class Configuration
5
15
  # Route holds a raw +amount_sats+ that may be an Integer or a callable.
6
16
  # The +resolve_amount_sats+ method evaluates callables at access time,
@@ -37,15 +47,28 @@ module X402
37
47
 
38
48
  attr_accessor :domain, :payee_locking_script_hex, :gateways,
39
49
  :arc_url, :arc_api_key, :arc_client, :server_wif,
40
- :exchange_rate_provider
50
+ :exchange_rate_provider, :logger
41
51
  attr_reader :routes, :gateway_specs
42
52
 
43
53
  def initialize
44
54
  @routes = []
45
55
  @gateways = []
46
56
  @gateway_specs = []
57
+ @logger = default_logger
47
58
  end
48
59
 
60
+ private
61
+
62
+ def default_logger
63
+ require "logger"
64
+ logger = ::Logger.new($stderr)
65
+ logger.formatter = proc { |_sev, _time, _prog, msg| "#{msg}\n" }
66
+ logger.level = ::Logger::DEBUG
67
+ logger
68
+ end
69
+
70
+ public
71
+
49
72
  # Record a gateway to be constructed later during +validate!+.
50
73
  #
51
74
  # @param name [Symbol] registered gateway name (e.g. +:pay_gateway+)
@@ -114,6 +137,13 @@ module X402
114
137
  end
115
138
  end
116
139
 
140
+ # Validate the configuration and construct gateways from specs.
141
+ #
142
+ # Called automatically at the end of +X402.configure+. Builds gateways
143
+ # from +enable+ specs, validates all constraints, and emits operational
144
+ # warnings for development defaults.
145
+ #
146
+ # @raise [ConfigurationError] if required fields are missing or invalid
117
147
  def validate!
118
148
  raise ConfigurationError, "domain is required" if domain.nil? || domain.empty?
119
149
 
@@ -140,9 +170,9 @@ module X402
140
170
  end
141
171
  return unless needs_warning
142
172
 
143
- warn "[x402] challenge_secret is auto-generated. " \
144
- "In-flight challenges will fail after process restart. " \
145
- "Set challenge_secret: explicitly for production."
173
+ logger.warn "[x402] challenge_secret is auto-generated. " \
174
+ "In-flight challenges will fail after process restart. " \
175
+ "Set challenge_secret: explicitly for production."
146
176
  end
147
177
 
148
178
  def warn_in_memory_prefix_store!
@@ -151,9 +181,9 @@ module X402
151
181
  end
152
182
  return unless needs_warning
153
183
 
154
- warn "[x402] BRC105Gateway using in-memory PrefixStore. " \
155
- "No replay protection across processes. " \
156
- "Use a shared backend (Redis) for multi-process deployments."
184
+ logger.warn "[x402] BRC105Gateway using in-memory PrefixStore. " \
185
+ "No replay protection across processes. " \
186
+ "Use a shared backend (Redis) for multi-process deployments."
157
187
  end
158
188
 
159
189
  def validate_gateways!
@@ -4,11 +4,30 @@ require "rack"
4
4
  require "json"
5
5
 
6
6
  module X402
7
+ # Pure Rack dispatcher for payment-gated HTTP.
8
+ #
9
+ # The middleware has no blockchain knowledge — it matches routes, issues
10
+ # payment challenges by polling configured gateways, and dispatches proofs
11
+ # to the matching gateway for settlement.
12
+ #
13
+ # @example config.ru
14
+ # X402.configure do |config|
15
+ # config.domain = "api.example.com"
16
+ # config.server_wif = ENV["SERVER_WIF"]
17
+ # config.arc_url = "https://arc.taal.com"
18
+ # config.enable :pay_gateway
19
+ # config.protect method: :GET, path: "/api/expensive", amount_sats: 100
20
+ # end
21
+ #
22
+ # use X402::Middleware
7
23
  class Middleware
24
+ # @param app [#call] next Rack app in the middleware stack
8
25
  def initialize(app)
9
26
  @app = app
10
27
  end
11
28
 
29
+ # @param env [Hash] Rack environment
30
+ # @return [Array(Integer, Hash, Array)] Rack response triple
12
31
  def call(env)
13
32
  request = Rack::Request.new(env)
14
33
  config = X402.configuration
@@ -17,12 +36,21 @@ module X402
17
36
  # Unprotected route — pass through
18
37
  return @app.call(env) unless route
19
38
 
39
+ log = config.logger
40
+ log.info "[x402] #{request.request_method} #{request.path_info} " \
41
+ "— protected route, #{route.resolve_amount_sats} sats"
42
+
43
+ # BRC-104 §6.2: extract client identity key from x-bsv-auth-identity-key
44
+ extract_brc103_identity_key!(env, log)
45
+
20
46
  # Check for a proof/payment header from any gateway
21
47
  gateway, header_name, proof_payload = detect_proof(env, config)
22
48
 
23
49
  if gateway
24
- settle_and_forward(env, gateway, header_name, proof_payload, request, route)
50
+ log.info "[x402] Proof header #{header_name} detected dispatching to #{gateway.class.name}"
51
+ settle_and_forward(env, gateway, header_name, proof_payload, request, route, log)
25
52
  else
53
+ log.info "[x402] No proof header — issuing 402 challenge"
26
54
  issue_challenge(request, route, config)
27
55
  end
28
56
  end
@@ -40,6 +68,11 @@ module X402
40
68
  nil
41
69
  end
42
70
 
71
+ # Challenge response body follows BRC-105 §6.2 format.
72
+ # Settlement errors (e.g. underpayment 402, bad request 400) use the
73
+ # generic {"error": reason} shape via error_response — these are
74
+ # distinct: the challenge tells the client what to pay, whereas a
75
+ # settlement error explains why a submitted payment was rejected.
43
76
  def issue_challenge(request, route, config)
44
77
  headers = { "content-type" => "application/json" }
45
78
 
@@ -49,13 +82,20 @@ module X402
49
82
  end
50
83
  end
51
84
 
52
- body = JSON.generate({ error: "Payment Required" })
85
+ body = JSON.generate(
86
+ status: "error",
87
+ code: "ERR_PAYMENT_REQUIRED",
88
+ satoshisRequired: route.resolve_amount_sats,
89
+ description: "A BSV payment is required to access this resource."
90
+ )
53
91
  [402, headers, [body]]
54
92
  end
55
93
 
56
- def settle_and_forward(env, gateway, header_name, proof_payload, request, route)
94
+ def settle_and_forward(env, gateway, header_name, proof_payload, request, route, log)
57
95
  result = gateway.settle!(header_name, proof_payload, request, route)
58
96
 
97
+ log.info "[x402] Settlement OK — txid=#{result.txid}"
98
+
59
99
  status, headers, body = @app.call(env)
60
100
 
61
101
  # Merge any receipt headers from the gateway
@@ -67,10 +107,13 @@ module X402
67
107
 
68
108
  [status, headers, body]
69
109
  rescue X402::VerificationError => e
110
+ log.warn "[x402] Settlement failed: #{e.status} #{e.reason}"
70
111
  error_response(e.status, e.reason)
71
112
  rescue X402::Error => e
113
+ log.warn "[x402] Error: #{e.message}"
72
114
  error_response(400, e.message)
73
- rescue StandardError
115
+ rescue StandardError => e
116
+ log.error "[x402] Unexpected error: #{e.class}: #{e.message}"
74
117
  error_response(500, "internal error")
75
118
  end
76
119
 
@@ -79,6 +122,29 @@ module X402
79
122
  [status, { "content-type" => "application/json" }, [body]]
80
123
  end
81
124
 
125
+ # BRC-104 §6.2: x-bsv-auth-identity-key carries the client's public
126
+ # identity key (33-byte compressed secp256k1 pubkey, hex). Populate
127
+ # brc103.identity_key in the Rack env so gateways can use it as the
128
+ # counterparty in BRC-42 key derivation.
129
+ #
130
+ # NOTE: This is the CLAIMED identity key — not authenticated. BRC-103
131
+ # signature verification must occur in a separate middleware if identity
132
+ # assertions are required for authorisation decisions.
133
+ #
134
+ # Does not overwrite an identity key already set by upstream middleware
135
+ # (e.g. a BRC-103/104 auth layer that has verified the signature).
136
+ def extract_brc103_identity_key!(env, log)
137
+ return if env["brc103.identity_key"].is_a?(String) && !env["brc103.identity_key"].empty?
138
+
139
+ key = env["HTTP_X_BSV_AUTH_IDENTITY_KEY"]
140
+ if key && !key.empty?
141
+ log.info "[x402] Client identity key: #{key[0..15]}..."
142
+ env["brc103.identity_key"] = key.downcase
143
+ else
144
+ log.debug "[x402] No x-bsv-auth-identity-key header"
145
+ end
146
+ end
147
+
82
148
  def rack_header_key(http_header_name)
83
149
  "HTTP_#{http_header_name.upcase.tr("-", "_")}"
84
150
  end
@@ -55,6 +55,8 @@ module X402
55
55
  @on_payment = on_payment
56
56
  end
57
57
 
58
+ # @param env [Hash] Rack environment
59
+ # @return [Array(Integer, Hash, Array)] Rack response triple (always passes through)
58
60
  def call(env)
59
61
  observe_payment(env)
60
62
  @app.call(env)
@@ -129,8 +131,11 @@ module X402
129
131
 
130
132
  # Built-in extractor for the Coinbase v2 envelope format.
131
133
  # Decodes +Base64(JSON({ payload: { rawtx: "hex" } }))+.
132
- # Returns a +BSV::Transaction::Transaction+ or +nil+.
134
+ #
135
+ # @see PaymentObserver extractor: parameter
133
136
  class CoinbaseV2Extractor
137
+ # @param proof_payload [String] raw proof header value
138
+ # @return [BSV::Transaction::Transaction, nil] parsed transaction or nil on failure
134
139
  def call(proof_payload)
135
140
  json = Base64.strict_decode64(proof_payload)
136
141
  payload = JSON.parse(json)
@@ -146,11 +151,16 @@ module X402
146
151
 
147
152
  # Built-in recogniser for a single static payee address.
148
153
  # Used when +payee_locking_script_hex+ is provided without a custom recogniser.
154
+ #
155
+ # @see PaymentObserver recogniser: parameter
149
156
  class StaticRecogniser
157
+ # @param payee_locking_script_hex [String] hex-encoded locking script to match
150
158
  def initialize(payee_locking_script_hex)
151
159
  @payee_hex = ::BSV::Script::Script.from_hex(payee_locking_script_hex).to_hex
152
160
  end
153
161
 
162
+ # @param locking_script_hex [String] hex-encoded locking script to test
163
+ # @return [Boolean] true if the script matches the configured payee
154
164
  def ours?(locking_script_hex)
155
165
  locking_script_hex == @payee_hex
156
166
  end
@@ -45,6 +45,10 @@ module X402
45
45
  @queue.push(tx_binary)
46
46
  end
47
47
 
48
+ # Drain the queue and stop the background thread.
49
+ # Safe to call when no thread has started (no-op).
50
+ #
51
+ # @return [void]
48
52
  def stop
49
53
  thread_to_join = nil
50
54
  @mutex.synchronize do
data/lib/x402/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module X402
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/mkdocs.yml ADDED
@@ -0,0 +1,68 @@
1
+ site_name: x402-rack
2
+ site_description: Rack middleware for payment-gated HTTP using BSV
3
+ site_url: https://sgbett.github.io/x402-rack/
4
+ repo_url: https://github.com/sgbett/x402-rack
5
+ repo_name: sgbett/x402-rack
6
+
7
+ theme:
8
+ name: material
9
+ palette:
10
+ - media: '(prefers-color-scheme: light)'
11
+ scheme: default
12
+ primary: deep purple
13
+ accent: amber
14
+ toggle:
15
+ icon: material/brightness-7
16
+ name: Switch to dark mode
17
+ - media: '(prefers-color-scheme: dark)'
18
+ scheme: slate
19
+ primary: deep purple
20
+ accent: amber
21
+ toggle:
22
+ icon: material/brightness-4
23
+ name: Switch to light mode
24
+ features:
25
+ - navigation.sections
26
+ - navigation.expand
27
+ - navigation.top
28
+ - search.suggest
29
+ - content.code.copy
30
+
31
+ markdown_extensions:
32
+ - admonition
33
+ - pymdownx.details
34
+ - pymdownx.superfences:
35
+ custom_fences:
36
+ - name: mermaid
37
+ class: mermaid
38
+ format: !!python/name:pymdownx.superfences.fence_code_format
39
+ - pymdownx.highlight:
40
+ anchor_linenums: true
41
+ - pymdownx.inlinehilite
42
+ - toc:
43
+ permalink: true
44
+
45
+ plugins:
46
+ - search
47
+ - minify:
48
+ minify_html: true
49
+
50
+ nav:
51
+ - Home: index.md
52
+ - Architecture: architecture.md
53
+ - Ecosystem: ecosystem.md
54
+ - Schemes:
55
+ - BSV-pay: schemes/bsv-pay.md
56
+ - BSV-proof: schemes/bsv-proof.md
57
+ - BRC-105: schemes/brc-105.md
58
+ - Process Flows:
59
+ - PayGateway: process-flow/pay-gateway.md
60
+ - ProofGateway: process-flow/proof-gateway.md
61
+ - BRC105Gateway: process-flow/brc105-gateway.md
62
+ - Operations:
63
+ - Deployment: operations/deployment.md
64
+ - Performance: operations/performance.md
65
+ - Treasury: operations/treasury.md
66
+ - Client Integration: client-integration.md
67
+ - Security: security.md
68
+ - API Reference: reference/index.md
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: x402-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-04 00:00:00.000000000 Z
10
+ date: 2026-04-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -91,6 +91,7 @@ files:
91
91
  - ".claude/plans/20260326-rack-stack-architecture.md"
92
92
  - ".rspec"
93
93
  - ".rubocop.yml"
94
+ - ".yardopts"
94
95
  - CHANGELOG.md
95
96
  - CLAUDE.md
96
97
  - DESIGN.md
@@ -100,12 +101,14 @@ files:
100
101
  - docs/architecture.md
101
102
  - docs/client-integration.md
102
103
  - docs/ecosystem.md
104
+ - docs/index.md
103
105
  - docs/operations/deployment.md
104
106
  - docs/operations/performance.md
105
107
  - docs/operations/treasury.md
106
108
  - docs/process-flow/brc105-gateway.md
107
109
  - docs/process-flow/pay-gateway.md
108
110
  - docs/process-flow/proof-gateway.md
111
+ - docs/requirements.txt
109
112
  - docs/schemes/brc-105.md
110
113
  - docs/schemes/bsv-pay.md
111
114
  - docs/schemes/bsv-proof.md
@@ -130,6 +133,7 @@ files:
130
133
  - lib/x402/settlement_worker.rb
131
134
  - lib/x402/verification/protocol_checks.rb
132
135
  - lib/x402/version.rb
136
+ - mkdocs.yml
133
137
  - sig/x402.rbs
134
138
  homepage: https://github.com/sgbett/x402-rack
135
139
  licenses: