weft-sdk 0.25.0 → 0.32.2

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 +179 -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 +562 -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
@@ -1,189 +1,614 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
4
+
2
5
  require_relative 'client'
6
+ require_relative 'echo'
7
+ require_relative 'extensions'
8
+ require_relative 'handshake'
9
+ require_relative 'product'
10
+ require_relative 'replay'
11
+ require_relative 'settlement'
12
+ require_relative 'x402'
3
13
 
4
14
  module Weft
5
15
  module Facilitator
6
- # Configuration for a route that requires payment.
7
- #
8
- # {
9
- # path: "/api/resource", # string or Regexp
10
- # price: "0.01", # price in asset units
11
- # asset: "USDC",
12
- # network: "base-sepolia",
13
- # pay_to: "0x...", # recipient address
14
- # description: "Access resource",
15
- # max_deadline_seconds: 60,
16
- # }
17
- RouteConfig = Struct.new(
18
- :path, :price, :asset, :network, :pay_to,
19
- :description, :max_deadline_seconds,
20
- keyword_init: true
21
- ) do
22
- def initialize(**kwargs)
23
- super
24
- self.network ||= 'base-sepolia'
25
- self.max_deadline_seconds ||= 60
26
- self.description ||= ''
27
- end
28
- end
29
-
30
16
  class RackMiddleware
31
- PAYMENT_HEADERS = %w[HTTP_X_PAYMENT HTTP_PAYMENT_SIGNATURE].freeze
32
- X402_VERSION = 2
17
+ UNSAFE_FAILURE_HEADERS = [
18
+ SETTLEMENT_OVERRIDES_HEADER,
19
+ 'location',
20
+ 'set-cookie'
21
+ ].freeze
33
22
 
34
- # @param app [#call] The downstream Rack app
35
- # @param routes [Array<Hash>] Route payment configurations
36
- # @param config [Hash] Options including :facilitator_url
37
- def initialize(app, routes: [], config: {})
23
+ def initialize(app, routes: {}, config: {}, **kwargs)
38
24
  @app = app
39
- @config = config
40
- @routes = routes.map do |r|
41
- r.is_a?(RouteConfig) ? r : RouteConfig.new(**r)
25
+ @config = stringify_config(config.merge(kwargs))
26
+ @sync_on_start = !@config.key?('sync_facilitator_on_start') || @config['sync_facilitator_on_start']
27
+ @resume = @config['resume_verified_payment']
28
+ declaration = product_declaration(@config)
29
+ @client = build_client(declaration)
30
+ @server = ResourceServer.new(@client)
31
+ Array(@config['schemes']).each do |entry|
32
+ network = Product.read(entry, 'network')
33
+ scheme = Product.read(entry, 'server') || entry
34
+ @server.register(network, scheme)
42
35
  end
43
- @client = Client.new(url: config[:facilitator_url])
36
+ @routes = Product.apply_product_identity(normalize_routes(routes), declaration)
37
+ Extensions.register_dynamic_extensions(@server, @routes)
38
+ @compiled = compile_routes(@routes)
39
+ validate_registered_schemes!
40
+ @facilitator_synced = false
41
+ @last_failed_sync_at = 0
42
+ @boot_sync = nil
43
+ @sync_mutex = Mutex.new
44
+ start_sync if @sync_on_start
44
45
  end
45
46
 
46
47
  def call(env)
47
- return @app.call(env) if @routes.empty?
48
-
49
- request_path = env['PATH_INFO'] || '/'
50
- route = match_route(request_path)
51
-
48
+ adapter = RackAdapter.new(env)
49
+ context = {
50
+ 'adapter' => adapter,
51
+ 'path' => adapter.get_path,
52
+ 'method' => adapter.get_method,
53
+ 'paymentHeader' => adapter.get_header('payment-signature') || adapter.get_header('x-payment')
54
+ }
55
+ route = match_route(context['path'], context['method'])
52
56
  return @app.call(env) unless route
53
57
 
54
- payment_header = extract_payment_header(env)
58
+ wait_for_boot_sync
59
+ schedule_retry if @sync_on_start && !@facilitator_synced
55
60
 
56
- return payment_required_response(route, env) unless payment_header
61
+ payment = extract_payment(adapter)
62
+ result = process_request(route, adapter, context, payment)
63
+ case result['type']
64
+ when 'no-payment-required'
65
+ @app.call(env)
66
+ when 'payment-error'
67
+ send_core_response(result['response'])
68
+ when 'payment-verified'
69
+ run_verified(env, result, context)
70
+ else
71
+ @app.call(env)
72
+ end
73
+ end
57
74
 
58
- # Verify the payment via facilitator
59
- payment_payload = parse_payment(payment_header)
60
- requirements = build_requirements(route, env)
75
+ private
61
76
 
62
- begin
63
- verify_result = @client.verify(
64
- payment_payload: payment_payload,
65
- payment_requirements: requirements
77
+ def process_request(route, adapter, context, payment)
78
+ resource = resource_info(route, adapter)
79
+ options = normalize_options(route)
80
+ requirements = options.flat_map { |option| @server.build_requirements(option, context) }
81
+ extensions = Product.read(route, 'extensions')
82
+ extensions = Product.stringify_hash(extensions) if extensions.is_a?(Hash)
83
+ transport = { 'request' => context }
84
+ required = @server.create_payment_required(
85
+ requirements,
86
+ resource,
87
+ payment.nil? ? 'Payment required' : nil,
88
+ extensions,
89
+ transport
90
+ )
91
+ if payment.nil?
92
+ return {
93
+ 'type' => 'payment-error',
94
+ 'response' => http_required_response(required, browser?(adapter))
95
+ }
96
+ end
97
+
98
+ matching = match_requirements(required['accepts'], payment)
99
+ extension_result = Echo.validate(required, payment, dynamic_fields: dynamic_fields_for(required))
100
+ unless extension_result['valid']
101
+ error_required = @server.create_payment_required(
102
+ requirements, resource, extension_result['invalidReason'], extensions, transport
66
103
  )
67
- rescue StandardError => e
68
- return error_response(402, "Payment verification failed: #{e.message}")
104
+ return { 'type' => 'payment-error', 'response' => http_required_response(error_required, false) }
105
+ end
106
+ if matching.nil?
107
+ error_required = @server.create_payment_required(
108
+ requirements, resource, 'No matching payment requirements', extensions, transport
109
+ )
110
+ return { 'type' => 'payment-error', 'response' => http_required_response(error_required, false) }
69
111
  end
70
112
 
71
- unless verify_result['valid']
72
- return error_response(402, verify_result['message'] || 'Payment verification failed')
113
+ resumed = resume_payment(context, payment)
114
+ if resumed
115
+ payload = resumed['paymentPayload'] || payment
116
+ requirements_for_pay = resumed['paymentRequirements'] || matching
117
+ return verified_result(payload, requirements_for_pay, extensions, resumed['beforeHandlerSettlement'])
73
118
  end
74
119
 
75
- # Call the downstream app
76
- status, headers, body = @app.call(env)
120
+ flow = @server.payment_flow(matching)
121
+ phases = X402.payment_flow_phases(flow)
122
+ if phases['verifyBeforeHandler']
123
+ verify = @client.verify(payment_payload: payment, payment_requirements: matching)
124
+ unless verify['isValid']
125
+ error_required = @server.create_payment_required(
126
+ requirements, resource, verify['invalidReason'], extensions, transport
127
+ )
128
+ return { 'type' => 'payment-error', 'response' => http_required_response(error_required, false) }
129
+ end
130
+ end
77
131
 
78
- # If the app returned an error, don't settle
79
- return [status, headers, body] if status >= 400
132
+ before = nil
133
+ if phases['settleBeforeHandler']
134
+ settled = settle_phase(payment, matching, context, nil, 'before-handler')
135
+ unless settled['success']
136
+ return { 'type' => 'payment-error', 'response' => settled['response'] }
137
+ end
138
+ before = settled
139
+ end
140
+ verified_result(payment, matching, extensions, before)
141
+ rescue FacilitatorUnavailableError
142
+ { 'type' => 'payment-error', 'response' => facilitator_unavailable_response }
143
+ rescue StandardError => e
144
+ error_required = @server.create_payment_required(
145
+ requirements || [],
146
+ resource || {},
147
+ e.message,
148
+ extensions,
149
+ transport
150
+ )
151
+ { 'type' => 'payment-error', 'response' => http_required_response(error_required, false) }
152
+ end
80
153
 
81
- # Settle the payment
82
- begin
83
- settle_result = @client.settle(
84
- payment_payload: payment_payload,
85
- payment_requirements: requirements
86
- )
154
+ def run_verified(env, result, context)
155
+ status, headers, body = @app.call(env)
156
+ headers = headers.dup
157
+ if status.to_i >= 400
158
+ strip_unsafe_headers(headers)
159
+ return [status, headers, body]
160
+ end
87
161
 
88
- return error_response(402, "Settlement failed: #{settle_result['message']}") unless settle_result['success']
162
+ method = context['method']
163
+ payload = result['paymentPayload'].dup
164
+ payload['httpMethod'] = method.to_s.upcase
165
+ begin
166
+ requirements = apply_overrides(result['paymentRequirements'], headers)
167
+ rescue ArgumentError => e
168
+ return settlement_failure_tuple(e.message, result['paymentRequirements'])
169
+ end
170
+ settled = settle_phase(payload, requirements, context, headers, 'after-handler', result['beforeHandlerSettlement'])
171
+ unless settled['success']
172
+ if Settlement.facilitator_unavailable?(settled['errorReason'])
173
+ return facilitator_unavailable_tuple
174
+ end
89
175
 
90
- headers['X-Payment-TxHash'] = settle_result['txHash'] if settle_result['txHash']
91
- rescue StandardError => e
92
- return error_response(402, "Settlement failed: #{e.message}")
176
+ return send_core_response(settled['response'])
93
177
  end
94
178
 
179
+ settled['headers'].each { |name, value| headers[name] = value }
180
+ cache = Settlement.header_value(headers, 'cache-control')
181
+ replace_header(headers, 'cache-control', Settlement.with_private_cache_control(cache))
182
+ remove_header(headers, SETTLEMENT_OVERRIDES_HEADER)
95
183
  [status, headers, body]
184
+ rescue FacilitatorUnavailableError
185
+ facilitator_unavailable_tuple
96
186
  end
97
187
 
98
- private
188
+ def settle_phase(payload, requirements, context, response_headers, phase, before = nil)
189
+ flow = before ? before['flow'] : @server.payment_flow(requirements)
190
+ phases = X402.payment_flow_phases(flow)
191
+ if phase != 'before-handler' && !phases['settleAfterHandler']
192
+ if before
193
+ return {
194
+ 'success' => true,
195
+ 'headers' => { 'PAYMENT-RESPONSE' => X402.encode_payment_response(before['result']) },
196
+ 'result' => before['result']
197
+ }
198
+ end
199
+ return { 'success' => true, 'headers' => {}, 'result' => {} }
200
+ end
99
201
 
100
- def match_route(path)
101
- @routes.find do |route|
102
- case route.path
103
- when Regexp
104
- route.path.match?(path)
105
- when '*'
106
- true
107
- else
108
- route.path == path
202
+ settle_payload = payload.dup
203
+ settle_payload['httpMethod'] = (context['method'] || context['adapter'].get_method).to_s.upcase
204
+ begin
205
+ result = @client.settle(payment_payload: settle_payload, payment_requirements: requirements)
206
+ rescue FacilitatorUnavailableError
207
+ return {
208
+ 'success' => false,
209
+ 'errorReason' => FACILITATOR_UNAVAILABLE_ERROR,
210
+ 'response' => facilitator_unavailable_response
211
+ }
212
+ rescue StandardError => e
213
+ reason = e.message
214
+ failure = {
215
+ 'success' => false,
216
+ 'errorReason' => reason,
217
+ 'errorMessage' => reason,
218
+ 'network' => requirements['network'],
219
+ 'transaction' => ''
220
+ }
221
+ return failure.merge('response' => settlement_failure_response(failure))
222
+ end
223
+
224
+ unless result['success']
225
+ reason = result['errorReason'] || 'Settlement failed'
226
+ failure = result.merge('success' => false, 'errorReason' => reason)
227
+ if Settlement.facilitator_unavailable?(reason)
228
+ return failure.merge('response' => facilitator_unavailable_response)
109
229
  end
230
+
231
+ return failure.merge('response' => settlement_failure_response(failure))
110
232
  end
233
+
234
+ {
235
+ 'success' => true,
236
+ 'result' => result,
237
+ 'headers' => { 'PAYMENT-RESPONSE' => X402.encode_payment_response(result) },
238
+ 'flow' => flow
239
+ }
111
240
  end
112
241
 
113
- def extract_payment_header(env)
114
- PAYMENT_HEADERS.each do |header|
115
- value = env[header]
116
- return value if value && !value.empty?
117
- end
118
- nil
242
+ def verified_result(payload, requirements, extensions, before)
243
+ {
244
+ 'type' => 'payment-verified',
245
+ 'paymentPayload' => payload,
246
+ 'paymentRequirements' => requirements,
247
+ 'extensions' => extensions,
248
+ 'beforeHandlerSettlement' => before
249
+ }
119
250
  end
120
251
 
121
- def parse_payment(header_value)
122
- JSON.parse(header_value)
123
- rescue JSON::ParserError
124
- # If not JSON, treat as an opaque token
125
- { 'token' => header_value }
252
+ def resume_payment(context, payment)
253
+ return nil unless @resume.respond_to?(:call)
254
+
255
+ candidate = Replay.payment_resume_candidate(context.merge('paymentHeader' => context['paymentHeader']))
256
+ return nil unless candidate
257
+
258
+ @resume.call(context, candidate)
126
259
  end
127
260
 
128
- def build_requirements(route, env)
129
- {
130
- 'scheme' => 'exact',
131
- 'network' => route.network,
132
- 'asset' => route.asset,
133
- 'amount' => route.price,
134
- 'payTo' => route.pay_to,
135
- 'maxTimeoutSeconds' => route.max_deadline_seconds,
136
- 'resource' => {
137
- 'url' => request_url(env),
138
- 'method' => env['REQUEST_METHOD'],
139
- 'description' => route.description
140
- }
261
+ def http_required_response(required, browser)
262
+ headers = {
263
+ 'PAYMENT-REQUIRED' => X402.encode_payment_required(required),
264
+ 'Cache-Control' => PAYMENT_REQUIRED_CACHE_CONTROL
141
265
  }
266
+ if browser
267
+ headers['Content-Type'] = 'text/html'
268
+ return { 'status' => 402, 'headers' => headers, 'body' => FALLBACK_PAYWALL_HTML }
269
+ end
270
+
271
+ headers['Content-Type'] = 'application/json'
272
+ { 'status' => 402, 'headers' => headers, 'body' => {} }
142
273
  end
143
274
 
144
- def request_url(env)
145
- scheme = env['rack.url_scheme'] || 'https'
146
- host = env['HTTP_HOST'] || env['SERVER_NAME'] || 'localhost'
147
- path = env['PATH_INFO'] || '/'
148
- "#{scheme}://#{host}#{path}"
149
- end
150
-
151
- def payment_required_response(route, env)
152
- body = {
153
- 'x402Version' => X402_VERSION,
154
- 'error' => 'Payment Required',
155
- 'accepts' => [{
156
- 'scheme' => 'exact',
157
- 'network' => route.network,
158
- 'asset' => route.asset,
159
- 'amount' => route.price,
160
- 'payTo' => route.pay_to,
161
- 'maxTimeoutSeconds' => route.max_deadline_seconds,
162
- 'resource' => {
163
- 'url' => request_url(env),
164
- 'method' => env['REQUEST_METHOD'],
165
- 'description' => route.description
166
- }
167
- }]
275
+ def settlement_failure_response(failure)
276
+ headers = {
277
+ 'Content-Type' => 'application/json',
278
+ 'PAYMENT-RESPONSE' => X402.encode_payment_response(failure),
279
+ 'Cache-Control' => PAYMENT_REQUIRED_CACHE_CONTROL
168
280
  }
281
+ { 'status' => 402, 'headers' => headers, 'body' => {} }
282
+ end
169
283
 
170
- [
171
- 402,
172
- {
284
+ def facilitator_unavailable_response
285
+ {
286
+ 'status' => 503,
287
+ 'headers' => {
173
288
  'Content-Type' => 'application/json',
174
- 'X-Payment-Required' => 'true'
289
+ 'retry-after' => '1',
290
+ 'cache-control' => Settlement.with_private_cache_control(nil)
175
291
  },
176
- [JSON.generate(body)]
177
- ]
292
+ 'body' => { 'error' => 'facilitator_unavailable' }
293
+ }
294
+ end
295
+
296
+ def facilitator_unavailable_tuple
297
+ send_core_response(facilitator_unavailable_response)
298
+ end
299
+
300
+ def send_core_response(response)
301
+ body = response['body']
302
+ payload = if body.is_a?(String)
303
+ body
304
+ else
305
+ JsonWire.generate(body || {})
306
+ end
307
+ [response['status'], response['headers'], [payload]]
308
+ end
309
+
310
+ def match_requirements(accepts, payment)
311
+ accepted = payment['accepted']
312
+ return nil unless accepted.is_a?(Hash)
313
+
314
+ accepts.find do |required|
315
+ core_match?(required, accepted) && extra_subset?(required['extra'], accepted['extra'])
316
+ end
317
+ end
318
+
319
+ def core_match?(required, accepted)
320
+ %w[scheme network amount asset payTo maxTimeoutSeconds].all? do |key|
321
+ required[key] == accepted[key]
322
+ end
323
+ end
324
+
325
+ def extra_subset?(required, accepted)
326
+ return true if required.nil?
327
+ return false unless accepted.is_a?(Hash)
328
+
329
+ required.all? do |key, value|
330
+ next false unless accepted.key?(key)
331
+ value.is_a?(Hash) && accepted[key].is_a?(Hash) ? extra_subset?(value, accepted[key]) : accepted[key] == value
332
+ end
333
+ end
334
+
335
+ def apply_overrides(requirements, headers)
336
+ raw = Settlement.header_value(headers, SETTLEMENT_OVERRIDES_HEADER)
337
+ return requirements if raw.nil? || raw.empty?
338
+
339
+ parsed = JSON.parse(raw)
340
+ return requirements unless parsed.is_a?(Hash)
341
+ amount = parsed['amount']
342
+ return requirements unless amount.is_a?(String)
343
+
344
+ decimals = nil
345
+ if amount.match?(/\A\$\d+(?:\.\d+)?\z/)
346
+ scheme = @server.scheme_for(requirements['network'], requirements['scheme'])
347
+ decimals = scheme_decimals(scheme, requirements)
348
+ end
349
+ requirements.merge('amount' => X402.resolve_settlement_override_amount(amount, requirements, decimals))
350
+ rescue JSON::ParserError
351
+ requirements
352
+ end
353
+
354
+ def scheme_decimals(scheme, requirements)
355
+ return nil unless scheme
356
+
357
+ fn = Product.read(scheme, 'getAssetDecimals') || Product.read(scheme, 'get_asset_decimals')
358
+ return fn.call(requirements['asset'], requirements['network']) if fn.respond_to?(:call)
359
+ return scheme.get_asset_decimals(requirements['asset'], requirements['network']) if scheme.respond_to?(:get_asset_decimals)
360
+
361
+ nil
362
+ end
363
+
364
+ def validate_registered_schemes!
365
+ routes = Product.single_route?(@routes) ? { '*' => @routes } : @routes
366
+ errors = []
367
+ routes.each do |pattern, config|
368
+ next unless config.is_a?(Hash)
369
+
370
+ normalize_options(config).each do |option|
371
+ next unless option.is_a?(Hash)
372
+
373
+ scheme_name = Product.read(option, 'scheme')
374
+ network = Product.read(option, 'network')
375
+ next if @server.scheme_for(network, scheme_name)
376
+
377
+ errors << "Route \"#{pattern}\": No scheme implementation registered for \"#{scheme_name}\" on network \"#{network}\""
378
+ end
379
+ end
380
+ return if errors.empty?
381
+
382
+ raise ArgumentError, errors.join("\n")
383
+ end
384
+
385
+ def dynamic_fields_for(required)
386
+ extensions = required.is_a?(Hash) ? required['extensions'] : nil
387
+ return {} unless extensions.is_a?(Hash)
388
+
389
+ extensions.each_key.each_with_object({}) do |key, out|
390
+ fields = @server.dynamic_info_fields(key)
391
+ out[key] = fields if fields
392
+ end
393
+ end
394
+
395
+ def settlement_failure_tuple(message, requirements)
396
+ failure = {
397
+ 'success' => false,
398
+ 'errorReason' => message,
399
+ 'errorMessage' => message,
400
+ 'network' => requirements.is_a?(Hash) ? requirements['network'] : nil,
401
+ 'transaction' => ''
402
+ }
403
+ send_core_response(settlement_failure_response(failure))
404
+ end
405
+
406
+ def extract_payment(adapter)
407
+ header = adapter.get_header('payment-signature') || adapter.get_header('PAYMENT-SIGNATURE')
408
+ return nil if header.nil? || header.empty?
409
+
410
+ X402.decode_payment_signature(header)
411
+ rescue StandardError => e
412
+ warn "Failed to decode PAYMENT-SIGNATURE header: #{e.message}"
413
+ nil
414
+ end
415
+
416
+ def resource_info(route, adapter)
417
+ info = {
418
+ 'url' => Product.read(route, 'resource') || adapter.get_url,
419
+ 'description' => Product.read(route, 'description') || '',
420
+ 'mimeType' => Product.read(route, 'mimeType') || ''
421
+ }
422
+ service_name = Product.read(route, 'serviceName')
423
+ tags = Product.read(route, 'tags')
424
+ icon = Product.read(route, 'iconUrl')
425
+ info['serviceName'] = service_name unless service_name.nil?
426
+ info['tags'] = tags unless tags.nil?
427
+ info['iconUrl'] = icon unless icon.nil?
428
+ info
429
+ end
430
+
431
+ def normalize_options(route)
432
+ accepts = Product.read(route, 'accepts')
433
+ accepts.is_a?(Array) ? accepts : [accepts]
434
+ end
435
+
436
+ def browser?(adapter)
437
+ adapter.get_accept_header.include?('text/html') && adapter.get_user_agent.include?('Mozilla')
438
+ end
439
+
440
+ def compile_routes(routes)
441
+ normalized = Product.single_route?(routes) ? { '*' => routes } : routes
442
+ normalized.map do |pattern, config|
443
+ parsed = X402.parse_route_pattern(pattern.to_s)
444
+ parsed.merge('config' => config)
445
+ end
446
+ end
447
+
448
+ def match_route(path, method)
449
+ normalized = X402.normalize_path(path)
450
+ upper = method.to_s.upcase
451
+ @compiled.each do |route|
452
+ begin
453
+ matched = route['regex'].match?(normalized)
454
+ rescue Regexp::TimeoutError
455
+ return route['config']
456
+ end
457
+ return route['config'] if matched && (route['verb'] == '*' || route['verb'] == upper)
458
+ end
459
+ nil
460
+ end
461
+
462
+ def normalize_routes(routes)
463
+ return {} if routes.nil?
464
+ return routes if routes.is_a?(Hash)
465
+
466
+ {}
467
+ end
468
+
469
+ def product_declaration(config)
470
+ %w[name type tags iconUrl productId manifestHash dimensions].each_with_object({}) do |key, out|
471
+ snake = key.gsub(/([A-Z])/, '_\1').downcase
472
+ if config.key?(key)
473
+ out[key] = config[key]
474
+ elsif config.key?(snake)
475
+ out[key] = config[snake]
476
+ end
477
+ end
478
+ end
479
+
480
+ def build_client(declaration)
481
+ facilitator = @config['facilitator'].is_a?(Hash) ? @config['facilitator'] : {}
482
+ url = Facilitator.config_url(facilitator) || @config['facilitator_url']
483
+ derived = Handshake.build_auth_headers(ADAPTER_NAME, @config['api_key'] || @config['apiKey'], declaration)
484
+ seller = @config['create_auth_headers'] || facilitator['createAuthHeaders'] || facilitator['create_auth_headers']
485
+ create_headers = lambda do
486
+ seller_headers = seller.respond_to?(:call) ? seller.call : nil
487
+ Facilitator.assert_path_keyed_auth_headers(seller_headers) if seller_headers
488
+ merged = {}
489
+ derived.each do |path, headers|
490
+ next if headers.nil? || headers.empty?
491
+
492
+ seller_path = seller_headers.is_a?(Hash) ? (seller_headers[path] || seller_headers[path.to_sym]) : nil
493
+ merged[path] = Facilitator.merge_seller_wins(headers, seller_path)
494
+ end
495
+ if seller_headers.is_a?(Hash)
496
+ seller_headers.each do |path, headers|
497
+ next if merged.key?(path.to_s) || !headers.is_a?(Hash)
498
+
499
+ merged[path.to_s] = headers
500
+ end
501
+ end
502
+ merged
503
+ end
504
+ Client.new(url: url, create_headers: create_headers)
505
+ end
506
+
507
+ def stringify_config(config)
508
+ config.each_with_object({}) do |(key, value), out|
509
+ out[key.to_s] = value
510
+ end
511
+ end
512
+
513
+ def start_sync
514
+ @boot_sync = Thread.new { sync_once }
515
+ end
516
+
517
+ def wait_for_boot_sync
518
+ thread = @boot_sync
519
+ return unless thread
520
+
521
+ thread.join
522
+ @boot_sync = nil
523
+ end
524
+
525
+ def schedule_retry
526
+ now = monotonic_ms
527
+ return if now - @last_failed_sync_at < SYNC_RETRY_FLOOR_MS
528
+
529
+ @sync_mutex.synchronize do
530
+ return if @sync_thread&.alive?
531
+
532
+ @sync_thread = Thread.new { sync_once }
533
+ end
534
+ end
535
+
536
+ def sync_once
537
+ @server.initialize!
538
+ @facilitator_synced = true
539
+ rescue StandardError => e
540
+ @last_failed_sync_at = monotonic_ms
541
+ warn '[weft] facilitator sync failed; payment-protected routes ' \
542
+ 'degrade until a later attempt succeeds: ' \
543
+ "#{e.message}"
544
+ ensure
545
+ @sync_mutex.synchronize { @sync_thread = nil }
546
+ end
547
+
548
+ def monotonic_ms
549
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
550
+ end
551
+
552
+ def strip_unsafe_headers(headers)
553
+ headers.delete_if do |name, value|
554
+ unsafe_header?(name, value)
555
+ end
556
+ end
557
+
558
+ def unsafe_header?(name, value)
559
+ normalized = name.to_s.downcase
560
+ return true if UNSAFE_FAILURE_HEADERS.any? { |item| item.downcase == normalized }
561
+ return true if normalized == 'cache-control' && value.to_s.match?(/\bpublic\b/i)
562
+
563
+ false
564
+ end
565
+
566
+ def replace_header(headers, name, value)
567
+ existing = headers.keys.find { |key| key.to_s.casecmp?(name) }
568
+ headers.delete(existing) if existing && existing != name
569
+ headers[existing || name] = value
570
+ end
571
+
572
+ def remove_header(headers, name)
573
+ headers.delete_if { |key, _| key.to_s.casecmp?(name) }
574
+ end
575
+ end
576
+
577
+ class RackAdapter
578
+ def initialize(env)
579
+ @env = env
580
+ end
581
+
582
+ def get_header(name)
583
+ rack_key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
584
+ value = @env[rack_key] || @env[name]
585
+ value.is_a?(Array) ? value.first : value
586
+ end
587
+
588
+ def get_method
589
+ @env['REQUEST_METHOD'].to_s
590
+ end
591
+
592
+ def get_path
593
+ @env['PATH_INFO'] || '/'
594
+ end
595
+
596
+ def get_url
597
+ scheme = @env['rack.url_scheme'] || 'https'
598
+ host = @env['HTTP_HOST'] || @env['SERVER_NAME'] || 'localhost'
599
+ "#{scheme}://#{host}#{get_path}"
600
+ end
601
+
602
+ def get_accept_header
603
+ get_header('Accept').to_s
604
+ end
605
+
606
+ def get_user_agent
607
+ get_header('User-Agent').to_s
178
608
  end
179
609
 
180
- def error_response(status, message)
181
- body = { 'error' => message }
182
- [
183
- status,
184
- { 'Content-Type' => 'application/json' },
185
- [JSON.generate(body)]
186
- ]
610
+ def get_body
611
+ @env['weft.body']
187
612
  end
188
613
  end
189
614
  end