x402-surface-check 0.2.12 → 0.2.14
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.
- package/README.md +8 -5
- package/bin/x402-surface-check.mjs +109 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,9 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
14
14
|
|
|
15
15
|
## What It Checks
|
|
16
16
|
|
|
17
|
-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
|
-
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, or
|
|
17
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
|
+
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
19
|
+
- OpenAPI query/path examples and JSON request-body examples for safer no-payment probes
|
|
19
20
|
- No-payment HTTP 402 challenge shape
|
|
20
21
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
21
22
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
@@ -25,11 +26,11 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
25
26
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
26
27
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
27
28
|
- HTTPS resource URLs and stable resource metadata
|
|
28
|
-
- Browser CORS allowance for `X-PAYMENT`
|
|
29
|
+
- Browser CORS allowance for the requesting origin and `X-PAYMENT`
|
|
29
30
|
- Over-broad public method surfaces
|
|
30
31
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
31
32
|
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
32
|
-
- Object-valued document metadata such as facilitator objects, without `[object Object]` report artifacts
|
|
33
|
+
- Object-valued document metadata such as facilitator or network objects, without `[object Object]` report artifacts
|
|
33
34
|
|
|
34
35
|
## Public Proof
|
|
35
36
|
|
|
@@ -41,7 +42,9 @@ Recent public no-payment checks have found and verified real launch fixes:
|
|
|
41
42
|
- UZPROOF: schemes-style Solana x402 challenge and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/28#issuecomment-4455613892
|
|
42
43
|
- HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
|
|
43
44
|
- anchor-x402: multi-rail x402 challenges verified, with browser preflight blockers isolated before merge. https://github.com/solana-foundation/pay-skills/pull/47#issuecomment-4455678163
|
|
44
|
-
- Agent Trust Bench:
|
|
45
|
+
- Agent Trust Bench: linked discovery URL and browser-compatibility notes verified clean for adversarial agent-payment resources. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4455722170
|
|
46
|
+
- Solrouter: private LLM inference route verified with HTTPS resource-binding and price-alignment notes. https://github.com/solana-foundation/pay-skills/pull/39#issuecomment-4455800060
|
|
47
|
+
- Tetrac: Solana market-data payment gates verified, with browser payment-header preflight blocker isolated. https://github.com/solana-foundation/pay-skills/pull/32#issuecomment-4455923744
|
|
45
48
|
|
|
46
49
|
Field notes and browser version: https://tateprograms.com/x402-surface-check.html
|
|
47
50
|
|
|
@@ -137,6 +137,7 @@ function linkedDiscoveryUrl(document, sourceUrl) {
|
|
|
137
137
|
?? document?.discoveryUrl
|
|
138
138
|
?? document?.resources_url
|
|
139
139
|
?? document?.resourcesUrl
|
|
140
|
+
?? (/^(https?:\/\/|\/)/i.test(String(document?.openapi ?? '')) ? document.openapi : '')
|
|
140
141
|
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
|
|
141
142
|
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
|
|
142
143
|
}
|
|
@@ -150,6 +151,79 @@ function operationExpectedPrice(operation) {
|
|
|
150
151
|
return numeric === null ? null : numeric
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
function exampleValue(schemaOrParameter) {
|
|
155
|
+
if (!schemaOrParameter || typeof schemaOrParameter !== 'object') return undefined
|
|
156
|
+
const schema = schemaOrParameter.schema ?? schemaOrParameter
|
|
157
|
+
const value = schemaOrParameter.example
|
|
158
|
+
?? schema.example
|
|
159
|
+
?? schema.default
|
|
160
|
+
?? (Array.isArray(schema.enum) ? schema.enum[0] : undefined)
|
|
161
|
+
if (value !== undefined) return value
|
|
162
|
+
if (schema.type === 'string') return ''
|
|
163
|
+
if (schema.type === 'number' || schema.type === 'integer') return 0
|
|
164
|
+
if (schema.type === 'boolean') return false
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mediaExample(media) {
|
|
169
|
+
if (!media || typeof media !== 'object') return undefined
|
|
170
|
+
if (media.example !== undefined) return media.example
|
|
171
|
+
const examples = media.examples && typeof media.examples === 'object'
|
|
172
|
+
? Object.values(media.examples)
|
|
173
|
+
: []
|
|
174
|
+
const firstExample = examples.find(Boolean)
|
|
175
|
+
if (firstExample?.value !== undefined) return firstExample.value
|
|
176
|
+
if (firstExample?.externalValue) return undefined
|
|
177
|
+
|
|
178
|
+
const schema = media.schema
|
|
179
|
+
if (!schema || typeof schema !== 'object' || schema.type !== 'object') return undefined
|
|
180
|
+
const body = {}
|
|
181
|
+
const properties = schema.properties && typeof schema.properties === 'object'
|
|
182
|
+
? schema.properties
|
|
183
|
+
: {}
|
|
184
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : Object.keys(properties))
|
|
185
|
+
|
|
186
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
187
|
+
if (!required.has(name)) continue
|
|
188
|
+
const value = exampleValue(property)
|
|
189
|
+
if (value !== undefined) body[name] = value
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Object.keys(body).length ? body : undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function operationRequestBody(operation) {
|
|
196
|
+
const content = operation?.requestBody?.content
|
|
197
|
+
if (!content || typeof content !== 'object') return undefined
|
|
198
|
+
const media = content['application/json']
|
|
199
|
+
?? content['application/*+json']
|
|
200
|
+
?? Object.entries(content).find(([type]) => /json/i.test(type))?.[1]
|
|
201
|
+
return mediaExample(media)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function openApiProbeUrl(path, operation, baseUrl) {
|
|
205
|
+
const parameters = Array.isArray(operation?.parameters) ? operation.parameters : []
|
|
206
|
+
let resolvedPath = path
|
|
207
|
+
const searchParams = new URLSearchParams()
|
|
208
|
+
|
|
209
|
+
for (const parameter of parameters) {
|
|
210
|
+
const value = exampleValue(parameter)
|
|
211
|
+
if (value === undefined || value === '') continue
|
|
212
|
+
if (parameter.in === 'path') {
|
|
213
|
+
resolvedPath = resolvedPath.replaceAll(`{${parameter.name}}`, encodeURIComponent(String(value)))
|
|
214
|
+
}
|
|
215
|
+
else if (parameter.in === 'query') {
|
|
216
|
+
searchParams.set(parameter.name, String(value))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const url = path.startsWith('http') ? new URL(resolvedPath) : new URL(resolvedPath, baseUrl)
|
|
221
|
+
for (const [name, value] of searchParams.entries()) {
|
|
222
|
+
url.searchParams.set(name, value)
|
|
223
|
+
}
|
|
224
|
+
return url.toString()
|
|
225
|
+
}
|
|
226
|
+
|
|
153
227
|
function endpointEntries(document, sourceUrl, limit) {
|
|
154
228
|
const entries = []
|
|
155
229
|
const baseUrl = documentBaseUrl(document, sourceUrl)
|
|
@@ -207,29 +281,45 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
207
281
|
for (const method of methods) {
|
|
208
282
|
const operation = operations[method]
|
|
209
283
|
if (!operation || typeof operation !== 'object') continue
|
|
210
|
-
const url =
|
|
284
|
+
const url = openApiProbeUrl(path, operation, baseUrl)
|
|
211
285
|
entries.push({
|
|
212
286
|
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
213
287
|
url,
|
|
214
288
|
method: method.toUpperCase(),
|
|
215
289
|
expectedPriceUsd: operationExpectedPrice(operation),
|
|
290
|
+
requestBody: operationRequestBody(operation),
|
|
216
291
|
})
|
|
217
292
|
}
|
|
218
293
|
}
|
|
219
294
|
}
|
|
220
295
|
|
|
221
296
|
for (const resource of document.resources ?? []) {
|
|
222
|
-
if (typeof resource
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
297
|
+
if (typeof resource === 'string') {
|
|
298
|
+
const match = resource.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
|
|
299
|
+
if (!match) continue
|
|
300
|
+
const [, method, rawPath] = match
|
|
301
|
+
const url = rawPath.startsWith('http')
|
|
302
|
+
? rawPath
|
|
303
|
+
: new URL(rawPath, document.baseUrl ?? sourceUrl).toString()
|
|
304
|
+
entries.push({
|
|
305
|
+
name: rawPath.split('/').filter(Boolean).at(-1) ?? rawPath,
|
|
306
|
+
url,
|
|
307
|
+
method: method.toUpperCase(),
|
|
308
|
+
})
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!resource || typeof resource !== 'object') continue
|
|
313
|
+
const rawPath = resource.url ?? resource.endpoint ?? resource.resource ?? resource.path
|
|
314
|
+
if (!rawPath) continue
|
|
229
315
|
entries.push({
|
|
230
|
-
name:
|
|
231
|
-
|
|
232
|
-
|
|
316
|
+
name: resource.id
|
|
317
|
+
?? resource.name
|
|
318
|
+
?? resource.title
|
|
319
|
+
?? String(resource.path ?? rawPath).split('/').filter(Boolean).at(-1)
|
|
320
|
+
?? String(rawPath),
|
|
321
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
322
|
+
method: String(resource.method ?? 'GET').toUpperCase(),
|
|
233
323
|
})
|
|
234
324
|
}
|
|
235
325
|
|
|
@@ -347,7 +437,9 @@ async function probeEndpoint(entry) {
|
|
|
347
437
|
accept: 'application/json',
|
|
348
438
|
'content-type': 'application/json',
|
|
349
439
|
},
|
|
350
|
-
body: method === 'GET' || method === 'HEAD'
|
|
440
|
+
body: method === 'GET' || method === 'HEAD'
|
|
441
|
+
? undefined
|
|
442
|
+
: JSON.stringify(entry.requestBody ?? {}),
|
|
351
443
|
})
|
|
352
444
|
const body = await readText(response)
|
|
353
445
|
const headerChallenge = parseEncodedChallenge(
|
|
@@ -395,7 +487,7 @@ async function probePreflight(entry, origin) {
|
|
|
395
487
|
}
|
|
396
488
|
|
|
397
489
|
function valueList(value) {
|
|
398
|
-
if (Array.isArray(value)) return value.map(
|
|
490
|
+
if (Array.isArray(value)) return value.map(displayMetadataValue)
|
|
399
491
|
if (value && typeof value === 'object') return Object.keys(value)
|
|
400
492
|
if (typeof value === 'string') return [value]
|
|
401
493
|
return []
|
|
@@ -592,6 +684,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
592
684
|
for (const result of preflightResults) {
|
|
593
685
|
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
594
686
|
if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
|
|
687
|
+
const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
|
|
688
|
+
if (!allowedOrigin) {
|
|
689
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
|
|
690
|
+
}
|
|
595
691
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
596
692
|
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
597
693
|
const observed = result.status >= 400
|