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.
- checksums.yaml +4 -4
- data/docs/FetchApi.md +3 -3
- data/docs/FetchErrorResponse.md +1 -1
- data/docs/FetchRequest.md +6 -2
- data/docs/FetchResponse.md +5 -3
- data/docs/GetPurchaseIdParameter.md +48 -0
- data/docs/PaymentsApi.md +2 -0
- data/docs/Purchase.md +6 -0
- data/docs/PurchasesApi.md +2 -2
- data/docs/SearchApi.md +1 -1
- data/docs/SearchCompactAccess.md +23 -0
- data/docs/SearchCompactAccessPrice.md +23 -0
- data/docs/SearchCompactNextCall.md +23 -0
- data/docs/SearchCompactResult.md +47 -0
- data/docs/SearchCompactResultAttribution.md +21 -0
- data/docs/SearchCompactResultBlockedScopesInner.md +19 -0
- data/docs/SearchCompactResultOperation.md +21 -0
- data/docs/SearchCompactResultProvider.md +19 -0
- data/docs/SearchEndpointCall.md +1 -1
- data/docs/SearchEndpointCallInputSchema.md +14 -0
- data/docs/SearchEndpointHit.md +5 -1
- data/docs/SearchHelperDocs.md +23 -0
- data/docs/SearchRequest.md +3 -1
- data/docs/SearchResponse.md +9 -3
- data/docs/SearchWeftFetchCompatibility.md +3 -1
- data/docs/WebhooksApi.md +414 -0
- data/lib/weft/client.rb +271 -0
- data/lib/weft/error.rb +41 -0
- data/lib/weft/facilitator/client.rb +254 -42
- data/lib/weft/facilitator/echo.rb +99 -0
- data/lib/weft/facilitator/extensions.rb +183 -0
- data/lib/weft/facilitator/fee.rb +26 -11
- data/lib/weft/facilitator/handshake.rb +92 -0
- data/lib/weft/facilitator/json_wire.rb +74 -0
- data/lib/weft/facilitator/middleware.rb +563 -137
- data/lib/weft/facilitator/path_match.rb +179 -0
- data/lib/weft/facilitator/product.rb +380 -0
- data/lib/weft/facilitator/replay.rb +46 -0
- data/lib/weft/facilitator/settlement.rb +117 -0
- data/lib/weft/facilitator/x402.rb +355 -0
- data/lib/weft/generated/api/account_api.rb +2 -2
- data/lib/weft/generated/api/agent_bootstrap_api.rb +2 -2
- data/lib/weft/generated/api/api_keys_api.rb +2 -2
- data/lib/weft/generated/api/auth_api.rb +2 -2
- data/lib/weft/generated/api/balance_api.rb +2 -2
- data/lib/weft/generated/api/documentation_api.rb +2 -2
- data/lib/weft/generated/api/fetch_api.rb +6 -6
- data/lib/weft/generated/api/payments_api.rb +4 -2
- data/lib/weft/generated/api/purchases_api.rb +4 -4
- data/lib/weft/generated/api/resources_api.rb +2 -2
- data/lib/weft/generated/api/search_api.rb +4 -4
- data/lib/weft/generated/api/webhooks_api.rb +388 -0
- data/lib/weft/generated/api_client.rb +2 -2
- data/lib/weft/generated/api_error.rb +2 -2
- data/lib/weft/generated/api_model_base.rb +2 -2
- data/lib/weft/generated/configuration.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_approval.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created_approval.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created_response.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_request.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_status.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_status_response.rb +2 -2
- data/lib/weft/generated/models/account_details.rb +2 -2
- data/lib/weft/generated/models/api_key.rb +2 -2
- data/lib/weft/generated/models/api_key_created.rb +2 -2
- data/lib/weft/generated/models/api_key_created_response.rb +2 -2
- data/lib/weft/generated/models/api_key_list_response.rb +2 -2
- data/lib/weft/generated/models/auth_response.rb +2 -2
- data/lib/weft/generated/models/auth_response_data.rb +2 -2
- data/lib/weft/generated/models/balance_response.rb +2 -2
- data/lib/weft/generated/models/confirm_request.rb +2 -2
- data/lib/weft/generated/models/create_api_key_request.rb +2 -2
- data/lib/weft/generated/models/enrolled_resource.rb +2 -2
- data/lib/weft/generated/models/error.rb +2 -2
- data/lib/weft/generated/models/error_response.rb +2 -2
- data/lib/weft/generated/models/fetch403_response.rb +2 -2
- data/lib/weft/generated/models/fetch_balance_snapshot.rb +2 -2
- data/lib/weft/generated/models/fetch_error_response.rb +5 -5
- data/lib/weft/generated/models/fetch_request.rb +46 -5
- data/lib/weft/generated/models/fetch_request_body.rb +2 -2
- data/lib/weft/generated/models/fetch_response.rb +36 -64
- data/lib/weft/generated/models/get_purchase_id_parameter.rb +104 -0
- data/lib/weft/generated/models/insufficient_scope_response.rb +2 -2
- data/lib/weft/generated/models/me_api_key.rb +2 -2
- data/lib/weft/generated/models/me_api_key_creator.rb +2 -2
- data/lib/weft/generated/models/me_response.rb +2 -2
- data/lib/weft/generated/models/me_response_data.rb +2 -2
- data/lib/weft/generated/models/message_response.rb +2 -2
- data/lib/weft/generated/models/message_response_data.rb +2 -2
- data/lib/weft/generated/models/pagination.rb +2 -2
- data/lib/weft/generated/models/password_reset_request.rb +2 -2
- data/lib/weft/generated/models/password_update_request.rb +2 -2
- data/lib/weft/generated/models/payment.rb +2 -2
- data/lib/weft/generated/models/payment_list_response.rb +2 -2
- data/lib/weft/generated/models/payment_response.rb +2 -2
- data/lib/weft/generated/models/principal_wallet.rb +2 -2
- data/lib/weft/generated/models/promo_balance.rb +2 -2
- data/lib/weft/generated/models/purchase.rb +75 -3
- data/lib/weft/generated/models/purchase_artifact.rb +2 -2
- data/lib/weft/generated/models/purchase_list_response.rb +2 -2
- data/lib/weft/generated/models/purchase_response.rb +2 -2
- data/lib/weft/generated/models/rate_limit_response.rb +2 -2
- data/lib/weft/generated/models/resend_confirmation_request.rb +2 -2
- data/lib/weft/generated/models/resource_enrollment_request.rb +2 -2
- data/lib/weft/generated/models/resource_enrollment_response.rb +2 -2
- data/lib/weft/generated/models/resource_stats.rb +2 -2
- data/lib/weft/generated/models/search_access_method.rb +2 -2
- data/lib/weft/generated/models/search_capability_ref.rb +2 -2
- data/lib/weft/generated/models/search_compact_access.rb +179 -0
- data/lib/weft/generated/models/search_compact_access_price.rb +176 -0
- data/lib/weft/generated/models/search_compact_next_call.rb +275 -0
- data/lib/weft/generated/models/search_compact_result.rb +389 -0
- data/lib/weft/generated/models/search_compact_result_attribution.rb +166 -0
- data/lib/weft/generated/models/search_compact_result_blocked_scopes_inner.rb +156 -0
- data/lib/weft/generated/models/search_compact_result_operation.rb +183 -0
- data/lib/weft/generated/models/search_compact_result_provider.rb +156 -0
- data/lib/weft/generated/models/search_curated_callability.rb +2 -2
- data/lib/weft/generated/models/search_curated_execution.rb +2 -2
- data/lib/weft/generated/models/search_curated_operation.rb +2 -2
- data/lib/weft/generated/models/search_curated_service.rb +2 -2
- data/lib/weft/generated/models/search_curated_source.rb +2 -2
- data/lib/weft/generated/models/search_endpoint_call.rb +4 -4
- data/lib/weft/generated/models/search_endpoint_call_input_schema.rb +103 -0
- data/lib/weft/generated/models/search_endpoint_hit.rb +25 -6
- data/lib/weft/generated/models/search_endpoint_price.rb +2 -2
- data/lib/weft/generated/models/search_error_response.rb +4 -4
- data/lib/weft/generated/models/search_execution_mode_filter.rb +2 -2
- data/lib/weft/generated/models/search_filter_spec.rb +2 -2
- data/lib/weft/generated/models/search_helper_docs.rb +175 -0
- data/lib/weft/generated/models/search_method_filter.rb +2 -2
- data/lib/weft/generated/models/search_payment_offer.rb +2 -2
- data/lib/weft/generated/models/search_price_atomic_filter.rb +2 -2
- data/lib/weft/generated/models/search_price_usd_filter.rb +2 -2
- data/lib/weft/generated/models/search_protocol_filter.rb +2 -2
- data/lib/weft/generated/models/search_provider_ref.rb +2 -2
- data/lib/weft/generated/models/search_request.rb +50 -6
- data/lib/weft/generated/models/search_resource_type_filter.rb +2 -2
- data/lib/weft/generated/models/search_response.rb +71 -8
- data/lib/weft/generated/models/search_response_warnings_inner.rb +2 -2
- data/lib/weft/generated/models/search_result.rb +2 -2
- data/lib/weft/generated/models/search_string_set_filter.rb +2 -2
- data/lib/weft/generated/models/search_weft_fetch_compatibility.rb +28 -6
- data/lib/weft/generated/models/sign_in_request.rb +2 -2
- data/lib/weft/generated/models/sign_up_request.rb +2 -2
- data/lib/weft/generated/models/spending_policy.rb +2 -2
- data/lib/weft/generated/models/user.rb +2 -2
- data/lib/weft/generated/models/user_principal.rb +2 -2
- data/lib/weft/generated/models/wallet.rb +2 -2
- data/lib/weft/generated/version.rb +3 -3
- data/lib/weft/sdk.rb +3 -1
- 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
|
-
|
|
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'
|
|
7
|
-
DEFAULT_ENV = 'X402_FACILITATOR_URL'
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
20
|
-
paymentRequirements
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
208
|
+
raise
|
|
45
209
|
end
|
|
46
210
|
|
|
47
|
-
def
|
|
48
|
-
|
|
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
|
-
|
|
216
|
+
def pending_error?(error)
|
|
217
|
+
error.error_reason == 'settlement_pending' && !error.transaction.to_s.empty?
|
|
51
218
|
end
|
|
52
219
|
|
|
53
|
-
def
|
|
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
|
-
|
|
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.
|
|
64
|
-
request = Net::HTTP::Get.new(uri
|
|
65
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
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
|