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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
module Facilitator
|
|
5
|
+
# Linear scans for route compilation, path normalization, and amounts.
|
|
6
|
+
# Route matching itself is a Core-equivalent regex compiled once per route.
|
|
7
|
+
module PathMatch
|
|
8
|
+
ROUTE_ESCAPE = '$()+.?^{|}\\'
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def compile_route(path)
|
|
13
|
+
trailing = path.end_with?('/*')
|
|
14
|
+
body = trailing ? path[0..-3] : path
|
|
15
|
+
source = +''
|
|
16
|
+
index = 0
|
|
17
|
+
while index < body.length
|
|
18
|
+
char = body[index]
|
|
19
|
+
if char == '['
|
|
20
|
+
close = body.index(']', index + 1)
|
|
21
|
+
if close && close > index + 1
|
|
22
|
+
source << '[^/]+'
|
|
23
|
+
index = close + 1
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
if char == ':'
|
|
28
|
+
length = ident_len(body, index + 1)
|
|
29
|
+
if length.positive?
|
|
30
|
+
source << '[^/]+'
|
|
31
|
+
index += 1 + length
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
if char == '*'
|
|
36
|
+
source << '.*?'
|
|
37
|
+
index += 1
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
source << '\\' if ROUTE_ESCAPE.include?(char)
|
|
41
|
+
source << char
|
|
42
|
+
index += 1
|
|
43
|
+
end
|
|
44
|
+
source << '(?:/.*?)?' if trailing
|
|
45
|
+
Regexp.new("\\A#{source}\\z", Regexp::IGNORECASE | Regexp::MULTILINE, timeout: 0.05)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ident_len(text, start)
|
|
49
|
+
return 0 if start >= text.length || !ident_start?(text[start])
|
|
50
|
+
|
|
51
|
+
index = start + 1
|
|
52
|
+
index += 1 while index < text.length && ident_cont?(text[index])
|
|
53
|
+
index - start
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ident_start?(char)
|
|
57
|
+
char == '_' || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ident_cont?(char)
|
|
61
|
+
ident_start?(char) || (char >= '0' && char <= '9')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def split_verb(pattern)
|
|
65
|
+
return ['*', pattern] unless pattern.include?(' ')
|
|
66
|
+
|
|
67
|
+
parts = []
|
|
68
|
+
token = +''
|
|
69
|
+
pattern.each_char do |char|
|
|
70
|
+
if whitespace?(char)
|
|
71
|
+
next if token.empty?
|
|
72
|
+
|
|
73
|
+
parts << token
|
|
74
|
+
token = +''
|
|
75
|
+
else
|
|
76
|
+
token << char
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
parts << token unless token.empty?
|
|
80
|
+
[parts[0] || '', parts[1] || '']
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def whitespace?(char)
|
|
84
|
+
char == ' ' || char == "\t" || char == "\n" || char == "\r" || char == "\f" || char == "\v"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def glob_match?(pattern, text)
|
|
88
|
+
pattern_index = 0
|
|
89
|
+
text_index = 0
|
|
90
|
+
star_pattern = nil
|
|
91
|
+
star_text = -1
|
|
92
|
+
while text_index < text.length
|
|
93
|
+
if pattern_index < pattern.length && pattern[pattern_index] == '*'
|
|
94
|
+
star_pattern = pattern_index
|
|
95
|
+
star_text = text_index
|
|
96
|
+
pattern_index += 1
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
if pattern_index < pattern.length && pattern[pattern_index] == text[text_index]
|
|
100
|
+
pattern_index += 1
|
|
101
|
+
text_index += 1
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
return false unless star_pattern
|
|
105
|
+
|
|
106
|
+
star_text += 1
|
|
107
|
+
return false if star_text > text.length
|
|
108
|
+
|
|
109
|
+
text_index = star_text
|
|
110
|
+
pattern_index = star_pattern + 1
|
|
111
|
+
end
|
|
112
|
+
pattern_index += 1 while pattern_index < pattern.length && pattern[pattern_index] == '*'
|
|
113
|
+
pattern_index == pattern.length
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def collapse_slashes(value)
|
|
117
|
+
out = +''
|
|
118
|
+
previous_slash = false
|
|
119
|
+
value.each_char do |char|
|
|
120
|
+
if char == '/'
|
|
121
|
+
out << '/' unless previous_slash
|
|
122
|
+
previous_slash = true
|
|
123
|
+
else
|
|
124
|
+
out << char
|
|
125
|
+
previous_slash = false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
out
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def trim_trailing_slashes(value)
|
|
132
|
+
text = value.to_s.dup
|
|
133
|
+
text.chomp!('/') while text.end_with?('/')
|
|
134
|
+
text
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def trim_trailing_slashes_keep_root(value)
|
|
138
|
+
return value if value.length <= 1
|
|
139
|
+
|
|
140
|
+
end_at = value.length
|
|
141
|
+
end_at -= 1 while end_at > 1 && value[end_at - 1] == '/'
|
|
142
|
+
end_at == value.length ? value : value[0, end_at]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def strip_leading_slashes(value)
|
|
146
|
+
text = value.to_s
|
|
147
|
+
index = 0
|
|
148
|
+
index += 1 while index < text.length && text[index] == '/'
|
|
149
|
+
index.zero? ? text : text[index..]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decimal_amount?(text)
|
|
153
|
+
return false if text.nil? || text.empty?
|
|
154
|
+
|
|
155
|
+
index = 0
|
|
156
|
+
index += 1 if text[0] == '-'
|
|
157
|
+
return false if index >= text.length || !digit?(text[index])
|
|
158
|
+
|
|
159
|
+
index += 1 while index < text.length && digit?(text[index])
|
|
160
|
+
if index < text.length && text[index] == '.'
|
|
161
|
+
index += 1
|
|
162
|
+
index += 1 while index < text.length && digit?(text[index])
|
|
163
|
+
end
|
|
164
|
+
index == text.length
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def digit?(char)
|
|
168
|
+
char >= '0' && char <= '9'
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def strip_leading_zeros(text)
|
|
172
|
+
index = 0
|
|
173
|
+
index += 1 while index < text.length && text[index] == '0'
|
|
174
|
+
rest = text[index..]
|
|
175
|
+
rest.nil? || rest.empty? ? '0' : rest
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
module Facilitator
|
|
8
|
+
module Product
|
|
9
|
+
PRODUCT_TYPES = %w[api agent mcp].freeze
|
|
10
|
+
WEFT_TYPE_TAG_PREFIX = 'weft:type:'
|
|
11
|
+
WEFT_PRODUCT_EXTENSION_KEY = 'weft.product'
|
|
12
|
+
MAX_TAGS = 5
|
|
13
|
+
MAX_TAG_CHARS = 32
|
|
14
|
+
MAX_DIMENSIONS = 8
|
|
15
|
+
MAX_DIMENSION_CHARS = 64
|
|
16
|
+
DIMENSION_NAME = /\A[A-Za-z_][A-Za-z0-9_.-]*\z/
|
|
17
|
+
MAX_SERVICE_NAME_CHARS = 32
|
|
18
|
+
MAX_ICON_URL_CHARS = 2048
|
|
19
|
+
PRINTABLE_ASCII = /\A[\x20-\x7e]+\z/
|
|
20
|
+
|
|
21
|
+
WEFT_PRODUCT_INFO_SCHEMA = {
|
|
22
|
+
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
|
|
23
|
+
'type' => 'object',
|
|
24
|
+
'properties' => {
|
|
25
|
+
'kind' => { 'type' => 'string', 'enum' => PRODUCT_TYPES.dup },
|
|
26
|
+
'product_id' => { 'type' => 'string', 'minLength' => 1 },
|
|
27
|
+
'manifest_hash' => { 'type' => 'string', 'minLength' => 1 }
|
|
28
|
+
},
|
|
29
|
+
'additionalProperties' => false
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def product_type_tag(type)
|
|
35
|
+
"#{WEFT_TYPE_TAG_PREFIX}#{type}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_warn
|
|
39
|
+
seen = {}
|
|
40
|
+
lambda do |message, dedupe_key = nil|
|
|
41
|
+
key = dedupe_key || message
|
|
42
|
+
return if seen[key]
|
|
43
|
+
|
|
44
|
+
seen[key] = true
|
|
45
|
+
warn "[weft] #{message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_product_identity(routes, identity)
|
|
50
|
+
identity = stringify_hash(identity)
|
|
51
|
+
return routes unless routes.is_a?(Hash)
|
|
52
|
+
return routes unless has_product_identity?(identity) || has_route_identity?(routes)
|
|
53
|
+
|
|
54
|
+
sink = create_warn
|
|
55
|
+
if single_route?(routes)
|
|
56
|
+
apply_to_route(routes, identity, sink)
|
|
57
|
+
else
|
|
58
|
+
routes.each_with_object({}) do |(pattern, route), out|
|
|
59
|
+
out[pattern] = apply_to_route(route, identity, sink)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sanitize_product_identity(identity)
|
|
65
|
+
identity = stringify_hash(identity)
|
|
66
|
+
silent = ->(*) {}
|
|
67
|
+
type = resolve_type(read(identity, 'type'), 'type', silent)
|
|
68
|
+
name = resolve_service_name(nil, read(identity, 'name'), silent)
|
|
69
|
+
icon = resolve_icon_url(nil, read(identity, 'iconUrl'), silent)
|
|
70
|
+
tags = resolve_tags(nil, read(identity, 'tags'), type, silent)
|
|
71
|
+
tags = tags&.reject { |tag| reserved_type_tag?(tag) }
|
|
72
|
+
out = {}
|
|
73
|
+
out['name'] = name unless name.nil?
|
|
74
|
+
out['type'] = type unless type.nil?
|
|
75
|
+
out['tags'] = tags if tags.is_a?(Array) && !tags.empty?
|
|
76
|
+
out['iconUrl'] = icon unless icon.nil?
|
|
77
|
+
out
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolve_dimensions(value, sink)
|
|
81
|
+
declared = as_tag_list(value, 'dimensions', sink)
|
|
82
|
+
return nil if declared.nil?
|
|
83
|
+
|
|
84
|
+
malformed = []
|
|
85
|
+
named = declared.select do |name|
|
|
86
|
+
if name.length <= MAX_DIMENSION_CHARS && DIMENSION_NAME.match?(name)
|
|
87
|
+
true
|
|
88
|
+
else
|
|
89
|
+
malformed << name
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
unless malformed.empty?
|
|
94
|
+
sink.call(
|
|
95
|
+
"dropping #{malformed.length} dimension(s) that do not name a field " \
|
|
96
|
+
"(max #{MAX_DIMENSION_CHARS} characters, starting with a letter or " \
|
|
97
|
+
"underscore): #{malformed.join(', ')}"
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
deduped = named.uniq
|
|
101
|
+
if deduped.length > MAX_DIMENSIONS
|
|
102
|
+
sink.call(
|
|
103
|
+
"dropping #{deduped.length - MAX_DIMENSIONS} dimension(s): at most " \
|
|
104
|
+
"#{MAX_DIMENSIONS} travel. Dropped: #{deduped.drop(MAX_DIMENSIONS).join(', ')}"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
bounded = deduped.take(MAX_DIMENSIONS)
|
|
108
|
+
bounded.empty? ? nil : bounded
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_to_route(route, identity, sink)
|
|
112
|
+
unless route.is_a?(Hash)
|
|
113
|
+
sink.call(
|
|
114
|
+
"ignoring route #{show(route)}: expected a route config object, got #{type_name(route)}"
|
|
115
|
+
)
|
|
116
|
+
return route
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
rest = route.dup
|
|
120
|
+
declared_type = take(rest, 'type')
|
|
121
|
+
declared_name = take(rest, 'serviceName')
|
|
122
|
+
declared_tags = take(rest, 'tags')
|
|
123
|
+
declared_icon = take(rest, 'iconUrl')
|
|
124
|
+
type = resolve_type(declared_type, 'route type', sink) || resolve_type(read(identity, 'type'), 'type', sink)
|
|
125
|
+
service_name = resolve_service_name(declared_name, read(identity, 'name'), sink)
|
|
126
|
+
icon_url = resolve_icon_url(declared_icon, read(identity, 'iconUrl'), sink)
|
|
127
|
+
tags = resolve_tags(declared_tags, read(identity, 'tags'), type, sink)
|
|
128
|
+
extensions = resolve_product_extensions(read(rest, 'extensions'), type, identity, sink)
|
|
129
|
+
|
|
130
|
+
result = stringify_hash(rest)
|
|
131
|
+
result['serviceName'] = service_name unless service_name.nil?
|
|
132
|
+
result['tags'] = tags unless tags.nil?
|
|
133
|
+
result['iconUrl'] = icon_url unless icon_url.nil?
|
|
134
|
+
result['extensions'] = extensions unless extensions.nil?
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_product_extensions(route_extensions, type, declaration, sink)
|
|
139
|
+
product_id = resolve_opaque_string('productId', read(declaration, 'productId'), sink)
|
|
140
|
+
manifest_hash = resolve_opaque_string('manifestHash', read(declaration, 'manifestHash'), sink)
|
|
141
|
+
info = {}
|
|
142
|
+
info['kind'] = type unless type.nil?
|
|
143
|
+
info['product_id'] = product_id unless product_id.nil?
|
|
144
|
+
info['manifest_hash'] = manifest_hash unless manifest_hash.nil?
|
|
145
|
+
return nil if info.empty?
|
|
146
|
+
|
|
147
|
+
unless route_extensions.nil?
|
|
148
|
+
unless route_extensions.is_a?(Hash)
|
|
149
|
+
sink.call(
|
|
150
|
+
"route extensions #{show(route_extensions)} are #{type_name(route_extensions)}, " \
|
|
151
|
+
'not an object; leaving them untouched and skipping the ' \
|
|
152
|
+
"#{WEFT_PRODUCT_EXTENSION_KEY} declaration for this route"
|
|
153
|
+
)
|
|
154
|
+
return nil
|
|
155
|
+
end
|
|
156
|
+
return nil if has_key?(route_extensions, WEFT_PRODUCT_EXTENSION_KEY)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
merged = stringify_hash(route_extensions || {})
|
|
160
|
+
merged[WEFT_PRODUCT_EXTENSION_KEY] = {
|
|
161
|
+
'info' => info,
|
|
162
|
+
'schema' => WEFT_PRODUCT_INFO_SCHEMA
|
|
163
|
+
}
|
|
164
|
+
merged
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def resolve_tags(route_tags, identity_tags, type, sink)
|
|
168
|
+
declared = as_tag_list(route_tags, 'route tags', sink)
|
|
169
|
+
declared = as_tag_list(identity_tags, 'tags', sink) if declared.nil?
|
|
170
|
+
return nil if declared.nil? && type.nil?
|
|
171
|
+
|
|
172
|
+
seller_tags = (declared || []).select do |tag|
|
|
173
|
+
next true unless reserved_type_tag?(tag)
|
|
174
|
+
|
|
175
|
+
sink.call(
|
|
176
|
+
"dropping reserved tag #{show(tag)}: declare the product kind with " \
|
|
177
|
+
"`type` (#{PRODUCT_TYPES.join(', ')}) on the middleware config or " \
|
|
178
|
+
'on the route'
|
|
179
|
+
)
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
malformed = []
|
|
183
|
+
carried = []
|
|
184
|
+
carried << product_type_tag(type) unless type.nil?
|
|
185
|
+
carried.concat(seller_tags)
|
|
186
|
+
carried.select! do |tag|
|
|
187
|
+
if tag.length <= MAX_TAG_CHARS && PRINTABLE_ASCII.match?(tag)
|
|
188
|
+
true
|
|
189
|
+
else
|
|
190
|
+
malformed << tag
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
unless malformed.empty?
|
|
195
|
+
sink.call(
|
|
196
|
+
"dropping #{malformed.length} tag(s) the x402 protocol cannot carry " \
|
|
197
|
+
"(max #{MAX_TAG_CHARS} printable-ASCII characters each): #{malformed.join(', ')}"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
deduped = carried.uniq
|
|
201
|
+
if deduped.length > MAX_TAGS
|
|
202
|
+
dropped = deduped.drop(MAX_TAGS)
|
|
203
|
+
sink.call(
|
|
204
|
+
"dropping #{dropped.length} tag(s): the x402 protocol carries #{MAX_TAGS}" \
|
|
205
|
+
"#{type.nil? ? '' : ' and the declared type uses one of them'}. " \
|
|
206
|
+
"Dropped: #{dropped.join(', ')}"
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
bounded = deduped.take(MAX_TAGS)
|
|
210
|
+
bounded.empty? ? nil : bounded
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def resolve_service_name(route_name, identity_name, sink)
|
|
214
|
+
declared = as_string('route serviceName', route_name, sink) || as_string('name', identity_name, sink)
|
|
215
|
+
return nil if declared.nil?
|
|
216
|
+
unless PRINTABLE_ASCII.match?(declared)
|
|
217
|
+
sink.call(
|
|
218
|
+
"dropping product name #{show(declared)}: the x402 protocol carries " \
|
|
219
|
+
"1-#{MAX_SERVICE_NAME_CHARS} printable-ASCII characters (U+0020-U+007E)"
|
|
220
|
+
)
|
|
221
|
+
return nil
|
|
222
|
+
end
|
|
223
|
+
if declared.length > MAX_SERVICE_NAME_CHARS
|
|
224
|
+
truncated = declared[0, MAX_SERVICE_NAME_CHARS]
|
|
225
|
+
sink.call(
|
|
226
|
+
"product name #{show(declared)} is #{declared.length} characters; the " \
|
|
227
|
+
"x402 protocol carries #{MAX_SERVICE_NAME_CHARS}, so it travels as #{show(truncated)}"
|
|
228
|
+
)
|
|
229
|
+
return truncated
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
declared
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def resolve_icon_url(route_icon, identity_icon, sink)
|
|
236
|
+
declared = as_string('route iconUrl', route_icon, sink) || as_string('iconUrl', identity_icon, sink)
|
|
237
|
+
return nil if declared.nil?
|
|
238
|
+
if declared.length > MAX_ICON_URL_CHARS
|
|
239
|
+
sink.call(
|
|
240
|
+
"dropping iconUrl: #{declared.length} characters exceeds the " \
|
|
241
|
+
"#{MAX_ICON_URL_CHARS} the x402 protocol carries"
|
|
242
|
+
)
|
|
243
|
+
return nil
|
|
244
|
+
end
|
|
245
|
+
scheme = declared[/\A([A-Za-z][A-Za-z0-9+.-]*):/, 1]&.downcase
|
|
246
|
+
if scheme.nil?
|
|
247
|
+
sink.call("dropping iconUrl #{show(declared)}: expected an absolute http or https URL")
|
|
248
|
+
return nil
|
|
249
|
+
end
|
|
250
|
+
if scheme != 'http' && scheme != 'https'
|
|
251
|
+
sink.call(
|
|
252
|
+
"dropping iconUrl #{show(declared)}: only http and https are carried " \
|
|
253
|
+
'(a dashboard renders this URL)'
|
|
254
|
+
)
|
|
255
|
+
return nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
declared
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def resolve_type(value, field, sink)
|
|
262
|
+
return nil if value.nil?
|
|
263
|
+
return value if value.is_a?(String) && PRODUCT_TYPES.include?(value)
|
|
264
|
+
|
|
265
|
+
sink.call("ignoring #{field} #{show(value)}: expected one of #{PRODUCT_TYPES.join(', ')}")
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def resolve_opaque_string(field, value, sink)
|
|
270
|
+
declared = as_string(field, value, sink)
|
|
271
|
+
return nil if declared.nil?
|
|
272
|
+
|
|
273
|
+
trimmed = declared.strip
|
|
274
|
+
if trimmed.empty?
|
|
275
|
+
sink.call("ignoring empty #{field}")
|
|
276
|
+
return nil
|
|
277
|
+
end
|
|
278
|
+
trimmed
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def as_string(field, value, sink)
|
|
282
|
+
return nil if value.nil?
|
|
283
|
+
return value if value.is_a?(String)
|
|
284
|
+
|
|
285
|
+
sink.call("ignoring #{field} #{show(value)}: expected a string, got #{type_name(value)}")
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def as_tag_list(value, field, sink)
|
|
290
|
+
return nil if value.nil?
|
|
291
|
+
unless value.is_a?(Array)
|
|
292
|
+
sink.call(
|
|
293
|
+
"ignoring #{field} #{show(value)}: expected an array of strings, got #{type_name(value)}"
|
|
294
|
+
)
|
|
295
|
+
return nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
value.select do |entry|
|
|
299
|
+
next true if entry.is_a?(String)
|
|
300
|
+
|
|
301
|
+
sink.call("dropping tag #{show(entry)}: expected a string, got #{type_name(entry)}")
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def reserved_type_tag?(tag)
|
|
307
|
+
tag.strip.downcase.start_with?(WEFT_TYPE_TAG_PREFIX)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def has_product_identity?(identity)
|
|
311
|
+
%w[name type tags iconUrl productId manifestHash].any? { |key| !read(identity, key).nil? }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def has_route_identity?(routes)
|
|
315
|
+
if single_route?(routes)
|
|
316
|
+
!read(routes, 'type').nil?
|
|
317
|
+
else
|
|
318
|
+
routes.any? { |_, route| route.is_a?(Hash) && !read(route, 'type').nil? }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def single_route?(routes)
|
|
323
|
+
has_key?(routes, 'accepts')
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def read(hash, key)
|
|
327
|
+
return nil unless hash.is_a?(Hash)
|
|
328
|
+
return hash[key] if hash.key?(key)
|
|
329
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
|
330
|
+
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def has_key?(hash, key)
|
|
335
|
+
return false unless hash.is_a?(Hash)
|
|
336
|
+
|
|
337
|
+
hash.key?(key) || hash.key?(key.to_sym)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def take(hash, key)
|
|
341
|
+
value = read(hash, key)
|
|
342
|
+
hash.delete(key)
|
|
343
|
+
hash.delete(key.to_sym)
|
|
344
|
+
value
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def stringify_hash(value)
|
|
348
|
+
return {} if value.nil?
|
|
349
|
+
return value unless value.is_a?(Hash)
|
|
350
|
+
|
|
351
|
+
value.each_with_object({}) do |(key, item), out|
|
|
352
|
+
out[key.to_s] = item
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def show(value)
|
|
357
|
+
JSON.generate(value)
|
|
358
|
+
rescue StandardError
|
|
359
|
+
type_name(value)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def type_name(value)
|
|
363
|
+
return 'null' if value.nil?
|
|
364
|
+
return 'an array' if value.is_a?(Array)
|
|
365
|
+
|
|
366
|
+
"a #{js_type(value)}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def js_type(value)
|
|
370
|
+
case value
|
|
371
|
+
when String then 'string'
|
|
372
|
+
when Integer, Float then 'number'
|
|
373
|
+
when TrueClass, FalseClass then 'boolean'
|
|
374
|
+
when Hash then 'object'
|
|
375
|
+
else 'object'
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative 'json_wire'
|
|
7
|
+
|
|
8
|
+
module Weft
|
|
9
|
+
module Facilitator
|
|
10
|
+
module Replay
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def payment_resume_candidate(context)
|
|
14
|
+
header = payment_header(context)
|
|
15
|
+
return nil if header.nil? || header.empty?
|
|
16
|
+
|
|
17
|
+
payload = JSON.parse(JsonWire.base64_decode(header))
|
|
18
|
+
return nil unless payload.is_a?(Hash)
|
|
19
|
+
return nil unless payload['x402Version'] == 2
|
|
20
|
+
return nil unless payload['accepted'].is_a?(Hash)
|
|
21
|
+
return nil unless payload['payload'].is_a?(Hash)
|
|
22
|
+
return nil unless payload['accepted']['scheme'].is_a?(String)
|
|
23
|
+
return nil unless payload['accepted']['network'].is_a?(String)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
'paymentPayload' => payload,
|
|
27
|
+
'paymentRequirements' => payload['accepted']
|
|
28
|
+
}
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def payment_header(context)
|
|
34
|
+
return nil unless context.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
direct = Product.read(context, 'paymentHeader')
|
|
37
|
+
return direct unless direct.nil?
|
|
38
|
+
|
|
39
|
+
adapter = Product.read(context, 'adapter')
|
|
40
|
+
return nil unless adapter.respond_to?(:get_header)
|
|
41
|
+
|
|
42
|
+
adapter.get_header('payment-signature') || adapter.get_header('PAYMENT-SIGNATURE')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|