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
@@ -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