weft-sdk 0.25.0 → 0.32.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/docs/FetchApi.md +3 -3
  3. data/docs/FetchErrorResponse.md +1 -1
  4. data/docs/FetchRequest.md +6 -2
  5. data/docs/FetchResponse.md +5 -3
  6. data/docs/GetPurchaseIdParameter.md +48 -0
  7. data/docs/PaymentsApi.md +2 -0
  8. data/docs/Purchase.md +6 -0
  9. data/docs/PurchasesApi.md +2 -2
  10. data/docs/SearchApi.md +1 -1
  11. data/docs/SearchCompactAccess.md +23 -0
  12. data/docs/SearchCompactAccessPrice.md +23 -0
  13. data/docs/SearchCompactNextCall.md +23 -0
  14. data/docs/SearchCompactResult.md +47 -0
  15. data/docs/SearchCompactResultAttribution.md +21 -0
  16. data/docs/SearchCompactResultBlockedScopesInner.md +19 -0
  17. data/docs/SearchCompactResultOperation.md +21 -0
  18. data/docs/SearchCompactResultProvider.md +19 -0
  19. data/docs/SearchEndpointCall.md +1 -1
  20. data/docs/SearchEndpointCallInputSchema.md +14 -0
  21. data/docs/SearchEndpointHit.md +5 -1
  22. data/docs/SearchHelperDocs.md +23 -0
  23. data/docs/SearchRequest.md +3 -1
  24. data/docs/SearchResponse.md +9 -3
  25. data/docs/SearchWeftFetchCompatibility.md +3 -1
  26. data/docs/WebhooksApi.md +414 -0
  27. data/lib/weft/client.rb +271 -0
  28. data/lib/weft/error.rb +41 -0
  29. data/lib/weft/facilitator/client.rb +254 -42
  30. data/lib/weft/facilitator/echo.rb +99 -0
  31. data/lib/weft/facilitator/extensions.rb +183 -0
  32. data/lib/weft/facilitator/fee.rb +26 -11
  33. data/lib/weft/facilitator/handshake.rb +92 -0
  34. data/lib/weft/facilitator/json_wire.rb +74 -0
  35. data/lib/weft/facilitator/middleware.rb +563 -137
  36. data/lib/weft/facilitator/path_match.rb +179 -0
  37. data/lib/weft/facilitator/product.rb +380 -0
  38. data/lib/weft/facilitator/replay.rb +46 -0
  39. data/lib/weft/facilitator/settlement.rb +117 -0
  40. data/lib/weft/facilitator/x402.rb +355 -0
  41. data/lib/weft/generated/api/account_api.rb +2 -2
  42. data/lib/weft/generated/api/agent_bootstrap_api.rb +2 -2
  43. data/lib/weft/generated/api/api_keys_api.rb +2 -2
  44. data/lib/weft/generated/api/auth_api.rb +2 -2
  45. data/lib/weft/generated/api/balance_api.rb +2 -2
  46. data/lib/weft/generated/api/documentation_api.rb +2 -2
  47. data/lib/weft/generated/api/fetch_api.rb +6 -6
  48. data/lib/weft/generated/api/payments_api.rb +4 -2
  49. data/lib/weft/generated/api/purchases_api.rb +4 -4
  50. data/lib/weft/generated/api/resources_api.rb +2 -2
  51. data/lib/weft/generated/api/search_api.rb +4 -4
  52. data/lib/weft/generated/api/webhooks_api.rb +388 -0
  53. data/lib/weft/generated/api_client.rb +2 -2
  54. data/lib/weft/generated/api_error.rb +2 -2
  55. data/lib/weft/generated/api_model_base.rb +2 -2
  56. data/lib/weft/generated/configuration.rb +2 -2
  57. data/lib/weft/generated/models/account_bootstrap_approval.rb +2 -2
  58. data/lib/weft/generated/models/account_bootstrap_created.rb +2 -2
  59. data/lib/weft/generated/models/account_bootstrap_created_approval.rb +2 -2
  60. data/lib/weft/generated/models/account_bootstrap_created_response.rb +2 -2
  61. data/lib/weft/generated/models/account_bootstrap_request.rb +2 -2
  62. data/lib/weft/generated/models/account_bootstrap_status.rb +2 -2
  63. data/lib/weft/generated/models/account_bootstrap_status_response.rb +2 -2
  64. data/lib/weft/generated/models/account_details.rb +2 -2
  65. data/lib/weft/generated/models/api_key.rb +2 -2
  66. data/lib/weft/generated/models/api_key_created.rb +2 -2
  67. data/lib/weft/generated/models/api_key_created_response.rb +2 -2
  68. data/lib/weft/generated/models/api_key_list_response.rb +2 -2
  69. data/lib/weft/generated/models/auth_response.rb +2 -2
  70. data/lib/weft/generated/models/auth_response_data.rb +2 -2
  71. data/lib/weft/generated/models/balance_response.rb +2 -2
  72. data/lib/weft/generated/models/confirm_request.rb +2 -2
  73. data/lib/weft/generated/models/create_api_key_request.rb +2 -2
  74. data/lib/weft/generated/models/enrolled_resource.rb +2 -2
  75. data/lib/weft/generated/models/error.rb +2 -2
  76. data/lib/weft/generated/models/error_response.rb +2 -2
  77. data/lib/weft/generated/models/fetch403_response.rb +2 -2
  78. data/lib/weft/generated/models/fetch_balance_snapshot.rb +2 -2
  79. data/lib/weft/generated/models/fetch_error_response.rb +5 -5
  80. data/lib/weft/generated/models/fetch_request.rb +46 -5
  81. data/lib/weft/generated/models/fetch_request_body.rb +2 -2
  82. data/lib/weft/generated/models/fetch_response.rb +36 -64
  83. data/lib/weft/generated/models/get_purchase_id_parameter.rb +104 -0
  84. data/lib/weft/generated/models/insufficient_scope_response.rb +2 -2
  85. data/lib/weft/generated/models/me_api_key.rb +2 -2
  86. data/lib/weft/generated/models/me_api_key_creator.rb +2 -2
  87. data/lib/weft/generated/models/me_response.rb +2 -2
  88. data/lib/weft/generated/models/me_response_data.rb +2 -2
  89. data/lib/weft/generated/models/message_response.rb +2 -2
  90. data/lib/weft/generated/models/message_response_data.rb +2 -2
  91. data/lib/weft/generated/models/pagination.rb +2 -2
  92. data/lib/weft/generated/models/password_reset_request.rb +2 -2
  93. data/lib/weft/generated/models/password_update_request.rb +2 -2
  94. data/lib/weft/generated/models/payment.rb +2 -2
  95. data/lib/weft/generated/models/payment_list_response.rb +2 -2
  96. data/lib/weft/generated/models/payment_response.rb +2 -2
  97. data/lib/weft/generated/models/principal_wallet.rb +2 -2
  98. data/lib/weft/generated/models/promo_balance.rb +2 -2
  99. data/lib/weft/generated/models/purchase.rb +75 -3
  100. data/lib/weft/generated/models/purchase_artifact.rb +2 -2
  101. data/lib/weft/generated/models/purchase_list_response.rb +2 -2
  102. data/lib/weft/generated/models/purchase_response.rb +2 -2
  103. data/lib/weft/generated/models/rate_limit_response.rb +2 -2
  104. data/lib/weft/generated/models/resend_confirmation_request.rb +2 -2
  105. data/lib/weft/generated/models/resource_enrollment_request.rb +2 -2
  106. data/lib/weft/generated/models/resource_enrollment_response.rb +2 -2
  107. data/lib/weft/generated/models/resource_stats.rb +2 -2
  108. data/lib/weft/generated/models/search_access_method.rb +2 -2
  109. data/lib/weft/generated/models/search_capability_ref.rb +2 -2
  110. data/lib/weft/generated/models/search_compact_access.rb +179 -0
  111. data/lib/weft/generated/models/search_compact_access_price.rb +176 -0
  112. data/lib/weft/generated/models/search_compact_next_call.rb +275 -0
  113. data/lib/weft/generated/models/search_compact_result.rb +389 -0
  114. data/lib/weft/generated/models/search_compact_result_attribution.rb +166 -0
  115. data/lib/weft/generated/models/search_compact_result_blocked_scopes_inner.rb +156 -0
  116. data/lib/weft/generated/models/search_compact_result_operation.rb +183 -0
  117. data/lib/weft/generated/models/search_compact_result_provider.rb +156 -0
  118. data/lib/weft/generated/models/search_curated_callability.rb +2 -2
  119. data/lib/weft/generated/models/search_curated_execution.rb +2 -2
  120. data/lib/weft/generated/models/search_curated_operation.rb +2 -2
  121. data/lib/weft/generated/models/search_curated_service.rb +2 -2
  122. data/lib/weft/generated/models/search_curated_source.rb +2 -2
  123. data/lib/weft/generated/models/search_endpoint_call.rb +4 -4
  124. data/lib/weft/generated/models/search_endpoint_call_input_schema.rb +103 -0
  125. data/lib/weft/generated/models/search_endpoint_hit.rb +25 -6
  126. data/lib/weft/generated/models/search_endpoint_price.rb +2 -2
  127. data/lib/weft/generated/models/search_error_response.rb +4 -4
  128. data/lib/weft/generated/models/search_execution_mode_filter.rb +2 -2
  129. data/lib/weft/generated/models/search_filter_spec.rb +2 -2
  130. data/lib/weft/generated/models/search_helper_docs.rb +175 -0
  131. data/lib/weft/generated/models/search_method_filter.rb +2 -2
  132. data/lib/weft/generated/models/search_payment_offer.rb +2 -2
  133. data/lib/weft/generated/models/search_price_atomic_filter.rb +2 -2
  134. data/lib/weft/generated/models/search_price_usd_filter.rb +2 -2
  135. data/lib/weft/generated/models/search_protocol_filter.rb +2 -2
  136. data/lib/weft/generated/models/search_provider_ref.rb +2 -2
  137. data/lib/weft/generated/models/search_request.rb +50 -6
  138. data/lib/weft/generated/models/search_resource_type_filter.rb +2 -2
  139. data/lib/weft/generated/models/search_response.rb +71 -8
  140. data/lib/weft/generated/models/search_response_warnings_inner.rb +2 -2
  141. data/lib/weft/generated/models/search_result.rb +2 -2
  142. data/lib/weft/generated/models/search_string_set_filter.rb +2 -2
  143. data/lib/weft/generated/models/search_weft_fetch_compatibility.rb +28 -6
  144. data/lib/weft/generated/models/sign_in_request.rb +2 -2
  145. data/lib/weft/generated/models/sign_up_request.rb +2 -2
  146. data/lib/weft/generated/models/spending_policy.rb +2 -2
  147. data/lib/weft/generated/models/user.rb +2 -2
  148. data/lib/weft/generated/models/user_principal.rb +2 -2
  149. data/lib/weft/generated/models/wallet.rb +2 -2
  150. data/lib/weft/generated/version.rb +3 -3
  151. data/lib/weft/sdk.rb +3 -1
  152. metadata +39 -4
data/lib/weft/error.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ # Normalized buyer API error.
5
+ #
6
+ # The generated OpenAPI model already owns Weft::Error. This exception uses
7
+ # RequestError so the model and the raised error can both exist.
8
+ #
9
+ # `charge` says whether the failed call can have created a charge. 'none'
10
+ # covers this call only: an earlier call under the same idempotency key can
11
+ # still have paid. After 'possible', retry only with the same idempotency key
12
+ # and request.
13
+ class RequestError < StandardError
14
+ # Codes Weft raises before it signs a payment in that call. Same list as the
15
+ # TypeScript reference.
16
+ PRE_SIGN_FETCH_CODES = %w[
17
+ EXCEEDED_MAX_COST MERCHANT_RETURNED_NON_402 INSUFFICIENT_BALANCE
18
+ DENYLISTED_RECIPIENT WALLET_ENVIRONMENT_MISMATCH UNSUPPORTED_ASSET
19
+ INVALID_REQUEST UNKNOWN_PARAMETER INVALID_URL INVALID_MAX_COST_USD
20
+ UNSUPPORTED_METHOD INVALID_BODY INVALID_HEADERS INVALID_IDEMPOTENCY_KEY
21
+ ].freeze
22
+
23
+ attr_reader :status, :code, :request_id, :retryable, :details, :charge
24
+
25
+ # @api private
26
+ def self.fetch_charge(status, code)
27
+ pre_sign = PRE_SIGN_FETCH_CODES.include?(code) || code.to_s.start_with?('POLICY_VIOLATION_')
28
+ status.between?(400, 499) && pre_sign ? 'none' : 'possible'
29
+ end
30
+
31
+ def initialize(status:, code:, message:, request_id: nil, retryable: false, details: nil, charge: 'possible')
32
+ super(message)
33
+ @status = status
34
+ @code = code
35
+ @request_id = request_id
36
+ @retryable = retryable
37
+ @details = details
38
+ @charge = charge
39
+ end
40
+ end
41
+ end
@@ -1,88 +1,300 @@
1
- require 'net/http'
1
+ # frozen_string_literal: true
2
+
2
3
  require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'handshake'
8
+ require_relative 'json_wire'
9
+ require_relative 'settlement'
3
10
 
4
11
  module Weft
5
12
  module Facilitator
6
- DEFAULT_URL = 'https://x402.weft.network'.freeze
7
- DEFAULT_ENV = 'X402_FACILITATOR_URL'.freeze
13
+ DEFAULT_URL = 'https://x402.weft.network'
14
+ DEFAULT_ENV = 'X402_FACILITATOR_URL'
15
+ PATH_KEYS = %w[verify settle supported bazaar].freeze
16
+ SUPPORTED_RETRIES = 3
17
+ DEFAULT_TIMEOUT_MS = 30_000
18
+
19
+ module_function
20
+
21
+ def resolve_url(config = nil)
22
+ url = config_url(config)
23
+ return url unless url.nil? || url.empty?
24
+
25
+ env = ENV.fetch(DEFAULT_ENV, nil)
26
+ return env unless env.nil? || env.empty?
27
+
28
+ DEFAULT_URL
29
+ end
30
+
31
+ def validate_url(url)
32
+ if url.nil? || url.strip.empty?
33
+ raise ArgumentError, 'Invalid URL: URL cannot be empty'
34
+ end
35
+ return if url.start_with?('http://') || url.start_with?('https://')
36
+
37
+ raise ArgumentError,
38
+ "Invalid URL format: URL must start with http:// or https://, got: #{url}"
39
+ end
40
+
41
+ def config_url(config)
42
+ return nil unless config.is_a?(Hash)
43
+ return config['url'] if config.key?('url')
44
+ return config[:url] if config.key?(:url)
45
+
46
+ nil
47
+ end
48
+
49
+ def merge_seller_wins(derived, seller)
50
+ merged = derived.dup
51
+ (seller || {}).each do |name, value|
52
+ merged.delete_if { |existing, _| existing.to_s.casecmp?(name.to_s) }
53
+ merged[name.to_s] = value
54
+ end
55
+ merged
56
+ end
57
+
58
+ def assert_path_keyed_auth_headers(headers)
59
+ return unless headers.is_a?(Hash)
60
+
61
+ header_object = lambda do |value|
62
+ value.is_a?(Hash)
63
+ end
64
+ has_path_key = PATH_KEYS.any? { |key| header_object.call(headers[key] || headers[key.to_sym]) }
65
+ looks_flat = !has_path_key && headers.values.any? { |value| !header_object.call(value) }
66
+ return unless looks_flat
67
+
68
+ raise ArgumentError,
69
+ 'createAuthHeaders must return an object keyed by facilitator path, ' \
70
+ 'e.g. { verify: { Authorization: "..." }, settle: { ... }, ' \
71
+ 'supported: { ... } }, but received a flat headers object.'
72
+ end
73
+
74
+ # /supported is also the identity handshake: a call with a valid key makes
75
+ # the facilitator record an sdk_handshake. A seller that builds the
76
+ # middleware per request would otherwise handshake per request, so the
77
+ # result is shared per process for each (url, handshake headers) key. A
78
+ # success is reused for TTL_S; a failure for FAILURE_FLOOR_S, so an outage
79
+ # cannot turn construction rate into facilitator request rate. Concurrent
80
+ # callers share one call. Ruby keeps the state across fork: a worker forked
81
+ # after the call reuses it, a worker forked before makes its own one call.
82
+ module SharedSupported
83
+ TTL_S = 6 * 60 * 60
84
+ FAILURE_FLOOR_S = 30
85
+ CONSTRUCTION_WARN_THRESHOLD = 10
86
+
87
+ Entry = Struct.new(:mutex, :at, :value, :error)
88
+
89
+ @entries = {}
90
+ @mutex = Mutex.new
91
+ @constructions = 0
92
+ @clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
93
+
94
+ class << self
95
+ # Replaceable clock in seconds. Tests use it.
96
+ attr_accessor :clock
97
+
98
+ def fetch(key)
99
+ entry = @mutex.synchronize { @entries[key] ||= Entry.new(Mutex.new) }
100
+ entry.mutex.synchronize do
101
+ age = entry.at && (@clock.call - entry.at)
102
+ raise entry.error if entry.error && age < FAILURE_FLOOR_S
103
+ return entry.value if entry.error.nil? && entry.at && age < TTL_S
104
+
105
+ begin
106
+ value = yield
107
+ entry.value = value
108
+ entry.error = nil
109
+ value
110
+ rescue StandardError => e
111
+ entry.value = nil
112
+ entry.error = e
113
+ raise
114
+ ensure
115
+ entry.at = @clock.call
116
+ end
117
+ end
118
+ end
119
+
120
+ # Warn once per process when the middleware is built over and over.
121
+ def note_construction
122
+ count = @mutex.synchronize { @constructions += 1 }
123
+ return unless count == CONSTRUCTION_WARN_THRESHOLD + 1
124
+
125
+ warn "[weft] the payment middleware was constructed more than #{CONSTRUCTION_WARN_THRESHOLD} " \
126
+ 'times. Construct it once at startup and reuse it; do not build it per request.'
127
+ end
128
+
129
+ # Forget every shared result and the construction count. Tests use it.
130
+ def reset!
131
+ @mutex.synchronize do
132
+ @entries = {}
133
+ @constructions = 0
134
+ end
135
+ end
136
+ end
137
+ end
8
138
 
9
139
  class Client
10
- def initialize(url: nil, create_headers: nil)
11
- @url = resolve_url(url)
12
- validate_url(@url)
140
+ # share_headers: the SDK handshake headers for /supported. When given,
141
+ # /supported is shared per process for (url, share_headers).
142
+ def initialize(url: nil, create_headers: nil, timeout_ms: DEFAULT_TIMEOUT_MS, share_headers: nil)
143
+ resolved = Facilitator.resolve_url(url.nil? ? nil : { 'url' => url })
144
+ Facilitator.validate_url(resolved)
145
+ @url = trim_trailing_slashes(resolved)
13
146
  @create_headers = create_headers
147
+ @timeout_ms = timeout_ms
148
+ @share_key = share_headers && "#{@url}\n#{JSON.generate(share_headers.sort.to_h)}"
149
+ end
150
+
151
+ def trim_trailing_slashes(value)
152
+ text = value.dup
153
+ text.chomp!('/') while text.end_with?('/')
154
+ text
14
155
  end
15
156
 
16
157
  def verify(payment_payload:, payment_requirements:)
17
- post_json('/verify', {
18
- x402Version: 2,
19
- paymentPayload: payment_payload,
20
- paymentRequirements: payment_requirements
158
+ post_json('/verify', 'verify', {
159
+ 'x402Version' => payment_payload['x402Version'] || payment_payload[:x402Version] || 2,
160
+ 'paymentPayload' => payment_payload,
161
+ 'paymentRequirements' => payment_requirements
21
162
  })
22
163
  end
23
164
 
24
165
  def settle(payment_payload:, payment_requirements:)
25
- post_json('/settle', {
26
- x402Version: 2,
27
- paymentPayload: payment_payload,
28
- paymentRequirements: payment_requirements
29
- })
166
+ settle_once(payment_payload, payment_requirements, allow_retry: true)
30
167
  end
31
168
 
32
169
  def supported
33
- get_json('/supported')
170
+ return fetch_supported unless @share_key
171
+
172
+ SharedSupported.fetch(@share_key) { fetch_supported }
173
+ end
174
+
175
+ def fetch_supported
176
+ last_error = nil
177
+ SUPPORTED_RETRIES.times do |attempt|
178
+ begin
179
+ return get_json('/supported', 'supported')
180
+ rescue RateLimited => e
181
+ last_error = e
182
+ sleep(e.retry_after) if attempt < SUPPORTED_RETRIES - 1
183
+ end
184
+ end
185
+ raise last_error || StandardError, 'Facilitator getSupported failed'
34
186
  end
187
+ private :fetch_supported
35
188
 
36
189
  private
37
190
 
38
- def resolve_url(url)
39
- return url if url && !url.empty?
191
+ def settle_once(payment_payload, payment_requirements, allow_retry:)
192
+ result = post_json('/settle', 'settle', {
193
+ 'x402Version' => payment_payload['x402Version'] || payment_payload[:x402Version] || 2,
194
+ 'paymentPayload' => payment_payload,
195
+ 'paymentRequirements' => payment_requirements
196
+ })
197
+ if allow_retry && pending_result?(result)
198
+ return settle_once(payment_payload, payment_requirements, allow_retry: false)
199
+ end
40
200
 
41
- env = ENV.fetch(DEFAULT_ENV, nil)
42
- return env if env && !env.empty?
201
+ result
202
+ rescue SettleError => e
203
+ if allow_retry && pending_error?(e)
204
+ return settle_once(payment_payload, payment_requirements, allow_retry: false)
205
+ end
206
+ raise FacilitatorUnavailableError if SettleErrors.unavailable?(e)
43
207
 
44
- DEFAULT_URL
208
+ raise
45
209
  end
46
210
 
47
- def validate_url(url)
48
- return unless url.empty? || (!url.start_with?('http://') && !url.start_with?('https://'))
211
+ def pending_result?(result)
212
+ result.is_a?(Hash) && result['success'] == false &&
213
+ result['errorReason'] == 'settlement_pending' && !result['transaction'].to_s.empty?
214
+ end
49
215
 
50
- raise ArgumentError, "Invalid URL: #{url}"
216
+ def pending_error?(error)
217
+ error.error_reason == 'settlement_pending' && !error.transaction.to_s.empty?
51
218
  end
52
219
 
53
- def build_headers(scope)
220
+ def headers_for(scope)
54
221
  headers = { 'Content-Type' => 'application/json' }
55
222
  return headers unless @create_headers
56
223
 
57
224
  custom = @create_headers.call
58
- scope_headers = custom && custom[scope]
225
+ Facilitator.assert_path_keyed_auth_headers(custom) if custom
226
+ scope_headers = custom.is_a?(Hash) ? (custom[scope] || custom[scope.to_sym]) : nil
59
227
  headers.merge(scope_headers || {})
60
228
  end
61
229
 
62
- def get_json(path)
63
- uri = URI.join(@url, path)
64
- request = Net::HTTP::Get.new(uri, build_headers('supported'))
65
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
230
+ def get_json(path, scope)
231
+ uri = URI.parse("#{@url}#{path}")
232
+ request = Net::HTTP::Get.new(uri)
233
+ headers_for(scope).each { |name, value| request[name] = value }
234
+ response = perform(uri, request)
235
+ parse_response(response, scope)
236
+ end
237
+
238
+ def post_json(path, scope, body)
239
+ uri = URI.parse("#{@url}#{path}")
240
+ request = Net::HTTP::Post.new(uri)
241
+ headers_for(scope).each { |name, value| request[name] = value }
242
+ request.body = JsonWire.generate(body)
243
+ response = perform(uri, request)
244
+ parse_response(response, scope)
245
+ end
246
+
247
+ def perform(uri, request)
248
+ Net::HTTP.start(
249
+ uri.host,
250
+ uri.port,
251
+ use_ssl: uri.scheme == 'https',
252
+ open_timeout: @timeout_ms / 1000.0,
253
+ read_timeout: @timeout_ms / 1000.0
254
+ ) do |http|
66
255
  http.request(request)
67
256
  end
68
- parse_response(response)
69
257
  end
70
258
 
71
- def post_json(path, body)
72
- uri = URI.join(@url, path)
73
- scope = path.include?('verify') ? 'verify' : 'settle'
74
- request = Net::HTTP::Post.new(uri, build_headers(scope))
75
- request.body = JSON.generate(body)
76
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
77
- http.request(request)
259
+ def parse_response(response, scope)
260
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
261
+
262
+ text = response.body.to_s
263
+ data = begin
264
+ JSON.parse(text)
265
+ rescue JSON::ParserError
266
+ nil
267
+ end
268
+ if scope == 'settle' && data.is_a?(Hash) && data.key?('success')
269
+ raise SettleError.new(response.code.to_i, data)
78
270
  end
79
- parse_response(response)
271
+ if scope == 'verify' && data.is_a?(Hash) && data.key?('isValid')
272
+ raise StandardError, verify_failure_message(response.code, data)
273
+ end
274
+ raise RateLimited.new(response['retry-after']) if response.code.to_i == 429
275
+
276
+ excerpt = text.length > 200 ? "#{text[0, 200]}..." : text
277
+ raise StandardError, "Facilitator #{scope_name(scope)} failed (#{response.code}): #{excerpt}"
278
+ end
279
+
280
+ def scope_name(scope)
281
+ scope == 'supported' ? 'getSupported' : scope
80
282
  end
81
283
 
82
- def parse_response(response)
83
- raise "Facilitator request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
284
+ def verify_failure_message(code, data)
285
+ reason = data['invalidReason'] || 'unknown reason'
286
+ message = data['invalidMessage']
287
+ message ? "#{reason}: #{message}" : reason.to_s
288
+ end
289
+ end
290
+
291
+ class RateLimited < StandardError
292
+ attr_reader :retry_after
84
293
 
85
- JSON.parse(response.body)
294
+ def initialize(header)
295
+ @retry_after = header.to_i
296
+ @retry_after = 1 if @retry_after <= 0
297
+ super('Facilitator getSupported failed (429)')
86
298
  end
87
299
  end
88
300
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module Facilitator
5
+ module Echo
6
+ ADDITIVE_ARRAY_INFO_FIELDS = { 'builder-code' => ['s'] }.freeze
7
+ ADDITIVE_ARRAY_MAX_LENGTHS = { 'builder-code' => { 's' => 10 } }.freeze
8
+
9
+ module_function
10
+
11
+ def validate(payment_required, payment, dynamic_fields: {})
12
+ return { 'valid' => true } unless payment.is_a?(Hash) && payment['x402Version'] == 2
13
+
14
+ server_extensions = payment_required['extensions']
15
+ return { 'valid' => true } unless server_extensions.is_a?(Hash) && !server_extensions.empty?
16
+
17
+ client_extensions = payment['extensions']
18
+ return { 'valid' => true } unless client_extensions.is_a?(Hash) && !client_extensions.empty?
19
+
20
+ client_extensions.each do |key, echoed|
21
+ next unless server_extensions.key?(key)
22
+
23
+ fields = dynamic_fields[key]
24
+ advertised = omit_fields(extension_info(server_extensions[key]), fields)
25
+ echoed_info = omit_fields(extension_info(echoed), fields)
26
+ additive = ADDITIVE_ARRAY_INFO_FIELDS[key]
27
+ limits = ADDITIVE_ARRAY_MAX_LENGTHS[key]
28
+ next if contains_subset?(advertised, echoed_info, additive, limits)
29
+
30
+ return { 'valid' => false, 'invalidReason' => 'extension_echo_mismatch', 'extensionKey' => key }
31
+ end
32
+ { 'valid' => true }
33
+ end
34
+
35
+ def extension_info(value)
36
+ return value['info'] if value.is_a?(Hash) && value.key?('info')
37
+
38
+ value
39
+ end
40
+
41
+ def omit_fields(value, fields)
42
+ return value if fields.nil? || fields.empty? || !value.is_a?(Hash)
43
+
44
+ copy = value.dup
45
+ fields.each { |field| copy.delete(field) }
46
+ copy
47
+ end
48
+
49
+ def contains_subset?(expected, actual, additive = nil, max_lengths = nil, field_key = nil)
50
+ if field_key && additive&.include?(field_key) && (expected.is_a?(Array) || actual.is_a?(Array))
51
+ expected_array = comparable_array(expected)
52
+ actual_array = comparable_array(actual)
53
+ return false unless expected_array && actual_array
54
+
55
+ max = max_lengths && max_lengths[field_key]
56
+ return false if max && actual_array.length > max
57
+
58
+ return expected_array.all? { |item| actual_array.any? { |other| deep_equal?(item, other) } }
59
+ end
60
+ return deep_equal?(expected, actual) if expected.nil? || !expected.is_a?(Hash)
61
+ return false unless actual.is_a?(Hash)
62
+
63
+ expected.all? do |key, value|
64
+ if actual.key?(key)
65
+ contains_subset?(value, actual[key], additive, max_lengths, key)
66
+ else
67
+ value.nil?
68
+ end
69
+ end
70
+ end
71
+
72
+ def comparable_array(value)
73
+ return value if value.is_a?(Array)
74
+ return nil if value.nil? || value.is_a?(Hash)
75
+
76
+ [value]
77
+ end
78
+
79
+ def deep_equal?(left, right)
80
+ return true if left.nil? && right.nil?
81
+ return false if left.nil? || right.nil?
82
+
83
+ case left
84
+ when Hash
85
+ return false unless right.is_a?(Hash)
86
+ return false unless left.keys.map(&:to_s).sort == right.keys.map(&:to_s).sort
87
+
88
+ left.all? { |key, value| deep_equal?(value, right[key] || right[key.to_s]) }
89
+ when Array
90
+ return false unless right.is_a?(Array) && left.length == right.length
91
+
92
+ left.zip(right).all? { |item, other| deep_equal?(item, other) }
93
+ else
94
+ left == right
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'json_wire'
4
+ require_relative 'product'
5
+
6
+ module Weft
7
+ module Facilitator
8
+ WEFT_REQUEST_EXTENSION_KEY = 'weft.request'
9
+ MAX_EXTENSION_BYTES = 16 * 1024
10
+ WEFT_REQUEST_INFO_SCHEMA = {
11
+ '$schema' => 'https://json-schema.org/draft/2020-12/schema',
12
+ 'title' => 'Weft per-request context',
13
+ 'description' => 'Seller-authored context for one paid request, for display only. ' \
14
+ 'Unauthenticated buyer input by the time it is read; key nothing on it.',
15
+ 'type' => 'object',
16
+ 'additionalProperties' => true
17
+ }.freeze
18
+
19
+ module Extensions
20
+ module_function
21
+
22
+ def dynamic_extension(key, sink = nil)
23
+ sink ||= Product.create_warn
24
+ Hook.new(key, sink)
25
+ end
26
+
27
+ def register_dynamic_extensions(server, routes)
28
+ keys = dynamic_keys(routes)
29
+ return if keys.empty?
30
+
31
+ sink = Product.create_warn
32
+ unless server.respond_to?(:register_extension)
33
+ sink.call(
34
+ 'this @x402/core build has no registerExtension, so per-request route ' \
35
+ "extensions (#{keys.join(', ')}) cannot be resolved and will not ship"
36
+ )
37
+ return
38
+ end
39
+
40
+ keys.each { |key| server.register_extension(dynamic_extension(key, sink)) }
41
+ end
42
+
43
+ def dynamic_keys(routes)
44
+ keys = []
45
+ route_configs(routes).each do |config|
46
+ extensions = Product.read(config, 'extensions')
47
+ next unless extensions.is_a?(Hash)
48
+
49
+ extensions.each do |key, value|
50
+ keys << key.to_s if value.respond_to?(:call)
51
+ end
52
+ end
53
+ keys.uniq
54
+ end
55
+
56
+ def route_configs(routes)
57
+ return [] unless routes.is_a?(Hash)
58
+
59
+ configs = Product.single_route?(routes) ? [routes] : routes.values
60
+ configs.select { |config| config.is_a?(Hash) }
61
+ end
62
+ end
63
+
64
+ class Hook
65
+ attr_reader :key
66
+
67
+ def initialize(key, sink)
68
+ @key = key
69
+ @sink = sink
70
+ end
71
+
72
+ def enrich_payment_required_response(declaration, context)
73
+ return nil unless declaration.respond_to?(:call)
74
+
75
+ request = request_of(context)
76
+ unless request
77
+ @sink.call(
78
+ "extensions[#{@key}] is a callback but no HTTP request context " \
79
+ 'reached it; dropping the key from the challenge',
80
+ "#{@key}:no-request"
81
+ )
82
+ return drop_key(context)
83
+ end
84
+
85
+ begin
86
+ resolved = declaration.call(request)
87
+ rescue StandardError => e
88
+ @sink.call(
89
+ "extensions[#{@key}] callback failed; dropping the key from the " \
90
+ "challenge: #{e.message}",
91
+ "#{@key}:threw"
92
+ )
93
+ return drop_key(context)
94
+ end
95
+
96
+ return drop_key(context) if resolved.nil?
97
+
98
+ json = JsonWire.generate(resolved)
99
+ if json.nil?
100
+ @sink.call(
101
+ "extensions[#{@key}] callback returned a value JSON cannot carry (a " \
102
+ 'function, a circular reference, a BigInt or similar); dropping ' \
103
+ 'the key from the challenge',
104
+ "#{@key}:unserializable"
105
+ )
106
+ return drop_key(context)
107
+ end
108
+
109
+ wire = JSON.parse(json)
110
+ unless wire.is_a?(Hash)
111
+ @sink.call(
112
+ "extensions[#{@key}] callback returned #{wire.is_a?(Array) ? 'an array' : "a #{js_type(wire)}"}; " \
113
+ 'the x402 extensions channel carries objects, so the key is ' \
114
+ 'dropped from the challenge',
115
+ "#{@key}:not-an-object"
116
+ )
117
+ return drop_key(context)
118
+ end
119
+
120
+ value = if @key == WEFT_REQUEST_EXTENSION_KEY
121
+ { 'info' => wire, 'schema' => WEFT_REQUEST_INFO_SCHEMA }
122
+ else
123
+ wire
124
+ end
125
+ bytes = projected_bytes(context, value) || json.bytesize
126
+ if bytes > MAX_EXTENSION_BYTES
127
+ @sink.call(
128
+ "extensions[#{@key}] takes the challenge's extensions to #{bytes} " \
129
+ "bytes, over the #{MAX_EXTENSION_BYTES}-byte facilitator relay " \
130
+ 'cap; dropping the key so the rest of the declaration still ' \
131
+ 'reaches settlement',
132
+ "#{@key}:over-cap"
133
+ )
134
+ return drop_key(context)
135
+ end
136
+
137
+ value
138
+ end
139
+
140
+ private
141
+
142
+ def request_of(context)
143
+ transport = read_context(context, 'transportContext')
144
+ return nil unless transport.is_a?(Hash)
145
+
146
+ Product.read(transport, 'request')
147
+ end
148
+
149
+ def drop_key(context)
150
+ response = read_context(context, 'paymentRequiredResponse')
151
+ extensions = response.is_a?(Hash) ? Product.read(response, 'extensions') : nil
152
+ if extensions.is_a?(Hash)
153
+ extensions.delete(@key)
154
+ extensions.delete(@key.to_sym)
155
+ end
156
+ nil
157
+ end
158
+
159
+ def projected_bytes(context, value)
160
+ response = read_context(context, 'paymentRequiredResponse')
161
+ extensions = response.is_a?(Hash) ? Product.read(response, 'extensions') : nil
162
+ projected = Product.stringify_hash(extensions || {})
163
+ projected[@key] = value
164
+ json = JsonWire.generate(projected)
165
+ json&.bytesize
166
+ end
167
+
168
+ def read_context(context, key)
169
+ Product.read(context, key)
170
+ end
171
+
172
+ def js_type(value)
173
+ case value
174
+ when String then 'string'
175
+ when Integer, Float then 'number'
176
+ when TrueClass, FalseClass then 'boolean'
177
+ when nil then 'object'
178
+ else 'object'
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end