Skip to content

Commit db9d275

Browse files
committed
Use OpenAPI examples for probes
1 parent ca7232e commit db9d275

5 files changed

Lines changed: 205 additions & 9 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
1515
## What It Checks
1616

1717
- 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 `resourcesUrl`
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
1920
- No-payment HTTP 402 challenge shape
2021
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
2122
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
@@ -29,7 +30,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
2930
- Over-broad public method surfaces
3031
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
3132
- 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
3334

3435
## Public Proof
3536

@@ -41,7 +42,8 @@ Recent public no-payment checks have found and verified real launch fixes:
4142
- UZPROOF: schemes-style Solana x402 challenge and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/28#issuecomment-4455613892
4243
- HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
4344
- 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: live discovery URL and browser-compatibility notes for adversarial agent-payment resources. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4455484414
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
4547

4648
Field notes and browser version: https://tateprograms.com/x402-surface-check.html
4749

bin/x402-surface-check.mjs

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ function linkedDiscoveryUrl(document, sourceUrl) {
137137
?? document?.discoveryUrl
138138
?? document?.resources_url
139139
?? document?.resourcesUrl
140+
?? (/^(https?:\/\/|\/)/i.test(String(document?.openapi ?? '')) ? document.openapi : '')
140141
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
141142
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
142143
}
@@ -150,6 +151,79 @@ function operationExpectedPrice(operation) {
150151
return numeric === null ? null : numeric
151152
}
152153

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+
153227
function endpointEntries(document, sourceUrl, limit) {
154228
const entries = []
155229
const baseUrl = documentBaseUrl(document, sourceUrl)
@@ -207,12 +281,13 @@ function endpointEntries(document, sourceUrl, limit) {
207281
for (const method of methods) {
208282
const operation = operations[method]
209283
if (!operation || typeof operation !== 'object') continue
210-
const url = path.startsWith('http') ? path : new URL(path, baseUrl).toString()
284+
const url = openApiProbeUrl(path, operation, baseUrl)
211285
entries.push({
212286
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
213287
url,
214288
method: method.toUpperCase(),
215289
expectedPriceUsd: operationExpectedPrice(operation),
290+
requestBody: operationRequestBody(operation),
216291
})
217292
}
218293
}
@@ -347,7 +422,9 @@ async function probeEndpoint(entry) {
347422
accept: 'application/json',
348423
'content-type': 'application/json',
349424
},
350-
body: method === 'GET' || method === 'HEAD' ? undefined : '{}',
425+
body: method === 'GET' || method === 'HEAD'
426+
? undefined
427+
: JSON.stringify(entry.requestBody ?? {}),
351428
})
352429
const body = await readText(response)
353430
const headerChallenge = parseEncodedChallenge(
@@ -395,7 +472,7 @@ async function probePreflight(entry, origin) {
395472
}
396473

397474
function valueList(value) {
398-
if (Array.isArray(value)) return value.map(String)
475+
if (Array.isArray(value)) return value.map(displayMetadataValue)
399476
if (value && typeof value === 'object') return Object.keys(value)
400477
if (typeof value === 'string') return [value]
401478
return []

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-surface-check",
3-
"version": "0.2.12",
3+
"version": "0.2.13",
44
"description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
55
"type": "module",
66
"bin": {

test/smoke.mjs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ const server = createServer((request, response) => {
6363
return
6464
}
6565

66+
if (request.url === '/examples-openapi.json') {
67+
response.setHeader('content-type', 'application/json')
68+
response.end(JSON.stringify({
69+
openapi: '3.1.0',
70+
info: { title: 'Examples Fixture', version: '1.0.0' },
71+
servers: [{ url: serverUrl }],
72+
paths: {
73+
'/brands': {
74+
get: {
75+
operationId: 'listBrandsWithExample',
76+
parameters: [{
77+
name: 'country_code',
78+
in: 'query',
79+
required: true,
80+
schema: { type: 'string', example: 'us' },
81+
}],
82+
},
83+
},
84+
'/orders': {
85+
post: {
86+
operationId: 'createOrderWithExample',
87+
requestBody: {
88+
content: {
89+
'application/json': {
90+
example: {
91+
email: 'agent@example.com',
92+
items: [{ product_id: 'sku_123', product_value: 25 }],
93+
},
94+
},
95+
},
96+
},
97+
},
98+
},
99+
},
100+
}))
101+
return
102+
}
103+
66104
if (request.url === '/x402.json') {
67105
response.setHeader('content-type', 'application/json')
68106
response.end(JSON.stringify({
@@ -116,6 +154,20 @@ const server = createServer((request, response) => {
116154
return
117155
}
118156

157+
if (request.url === '/openapi-link.json') {
158+
response.setHeader('content-type', 'application/json')
159+
response.end(JSON.stringify({
160+
name: 'Linked OpenAPI Fixture',
161+
openapi: `${serverUrl}/examples-openapi.json`,
162+
networks: [{
163+
id: 'solana',
164+
network: 'solana-mainnet',
165+
asset: 'USDC',
166+
}],
167+
}))
168+
return
169+
}
170+
119171
if (request.url === '/health') {
120172
response.statusCode = 200
121173
response.setHeader('content-type', 'application/json')
@@ -203,6 +255,46 @@ const server = createServer((request, response) => {
203255
return
204256
}
205257

258+
if (request.url === '/brands?country_code=us') {
259+
response.statusCode = 200
260+
response.setHeader('content-type', 'application/json')
261+
response.end(JSON.stringify({ ok: true, brands: ['Example'] }))
262+
return
263+
}
264+
265+
if (request.url === '/orders') {
266+
let body = ''
267+
request.on('data', chunk => {
268+
body += chunk
269+
})
270+
request.on('end', () => {
271+
const parsed = body ? JSON.parse(body) : {}
272+
if (parsed.email !== 'agent@example.com' || !Array.isArray(parsed.items)) {
273+
response.statusCode = 400
274+
response.setHeader('content-type', 'application/json')
275+
response.end(JSON.stringify({ error: 'missing order example' }))
276+
return
277+
}
278+
response.statusCode = 402
279+
response.setHeader('content-type', 'application/json')
280+
response.setHeader('access-control-allow-origin', '*')
281+
response.end(JSON.stringify({
282+
x402Version: 1,
283+
error: 'Payment required',
284+
accepts: [{
285+
scheme: 'exact',
286+
network: 'solana',
287+
maxAmountRequired: '25000',
288+
asset: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
289+
payTo: '2Ynf2xxaiLbPy9p8iWE5ZiUd1wojJ45pRwCEN3mgK8aE',
290+
resource: `${serverUrl}/orders`,
291+
maxTimeoutSeconds: 60,
292+
}],
293+
}))
294+
})
295+
return
296+
}
297+
206298
if (request.url === '/mpp/eth') {
207299
const mppRequest = Buffer.from(JSON.stringify({
208300
amount: '1000',
@@ -343,6 +435,19 @@ try {
343435
assert.match(mismatch.stdout, /priceMismatch/)
344436
assert.match(mismatch.stdout, /documented price \$0\.002 does not match live 402 challenge price \$0\.001/)
345437

438+
const examples = await execFileAsync('node', [
439+
'bin/x402-surface-check.mjs',
440+
`${serverUrl}/examples-openapi.json`,
441+
'--origin',
442+
'https://example.com',
443+
], { cwd: new URL('..', import.meta.url) })
444+
445+
assert.match(examples.stdout, /listBrandsWithExample/)
446+
assert.match(examples.stdout, /createOrderWithExample/)
447+
assert.match(examples.stdout, /\$0\.025/)
448+
assert.doesNotMatch(examples.stdout, /listBrandsWithExample returned validation HTTP 400/)
449+
assert.doesNotMatch(examples.stdout, /createOrderWithExample returned validation HTTP 400/)
450+
346451
const manifest = await execFileAsync('node', [
347452
'bin/x402-surface-check.mjs',
348453
`${serverUrl}/x402.json`,
@@ -379,6 +484,18 @@ try {
379484
assert.match(linkedDiscovery.stdout, new RegExp(`Document: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/items\\.json`))
380485
assert.match(linkedDiscovery.stdout, /Premium routing recommendations/)
381486

487+
const linkedOpenApi = await execFileAsync('node', [
488+
'bin/x402-surface-check.mjs',
489+
`${serverUrl}/openapi-link.json`,
490+
'--origin',
491+
'https://example.com',
492+
], { cwd: new URL('..', import.meta.url) })
493+
494+
assert.match(linkedOpenApi.stdout, new RegExp(`Source: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/openapi-link\\.json`))
495+
assert.match(linkedOpenApi.stdout, new RegExp(`Document: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/examples-openapi\\.json`))
496+
assert.match(linkedOpenApi.stdout, /createOrderWithExample/)
497+
assert.doesNotMatch(linkedOpenApi.stdout, /\[object Object\]/)
498+
382499
const mpp = await execFileAsync('node', [
383500
'bin/x402-surface-check.mjs',
384501
'--endpoint',

0 commit comments

Comments
 (0)