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