Skip to content

Commit b3aa33c

Browse files
committed
Support nested x402 discovery manifests
1 parent 8416e48 commit b3aa33c

5 files changed

Lines changed: 115 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
1818

1919
## What It Checks
2020

21-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
21+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
2222
- Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
23-
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
23+
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links
2424
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
2525
- OpenAPI query/path examples, JSON request-body examples, nested request schemas, local `$ref` request schemas, and explicit direct-endpoint bodies for safer no-payment probes
2626
- OpenAPI paid-operation prioritization, so docs and discovery routes do not consume the probe limit before payment-bearing operations

bin/x402-surface-check.mjs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,22 @@ function openApiServerBaseUrl(document, sourceUrl) {
178178
}
179179

180180
function linkedDiscoveryUrl(document, sourceUrl) {
181+
const discovery = document?.discovery && typeof document.discovery === 'object'
182+
? document.discovery
183+
: {}
181184
const rawUrl = document?.discovery_url
182185
?? document?.discoveryUrl
183186
?? document?.resources_url
184187
?? document?.resourcesUrl
188+
?? (typeof document?.discovery === 'string' ? document.discovery : undefined)
189+
?? discovery.x402_json
190+
?? discovery.x402Json
191+
?? discovery.resources_json
192+
?? discovery.resourcesJson
193+
?? discovery.resources
194+
?? discovery.openapi
195+
?? discovery.openapi_url
196+
?? discovery.openapiUrl
185197
?? (/^(https?:\/\/|\/)/i.test(String(document?.openapi ?? '')) ? document.openapi : '')
186198
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
187199
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
@@ -331,6 +343,7 @@ function manifestEndpointPaymentSignal(endpoint) {
331343
if (!endpoint || typeof endpoint !== 'object') return 0
332344
if (Number(endpoint.phase1_response?.status) === 402) return 2
333345
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
346+
if (/^\$?\d+(\.\d+)?/.test(String(endpoint.price ?? endpoint.cost ?? endpoint.amount ?? ''))) return 1
334347
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
335348
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
336349
return 0
@@ -402,10 +415,28 @@ function endpointEntries(document, sourceUrl, limit) {
402415
})
403416
}
404417
}
405-
else if (document.endpoints && typeof document.endpoints === 'object') {
406-
for (const [key, endpoint] of Object.entries(document.endpoints)) {
418+
const endpointMaps = []
419+
if (!Array.isArray(document.endpoints) && document.endpoints && typeof document.endpoints === 'object') {
420+
endpointMaps.push(document.endpoints)
421+
}
422+
if (document.tools && typeof document.tools === 'object') {
423+
endpointMaps.push(document.tools)
424+
}
425+
for (const endpointMap of endpointMaps) {
426+
for (const [key, endpoint] of Object.entries(endpointMap)) {
427+
if (typeof endpoint === 'string') {
428+
const methodMatch = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
429+
const rawPath = methodMatch?.[2] ?? key
430+
entries.push({
431+
name: String(rawPath).split('/').filter(Boolean).at(-1) ?? key,
432+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
433+
method: String(methodMatch?.[1] ?? 'GET').toUpperCase(),
434+
})
435+
continue
436+
}
407437
if (!endpoint || typeof endpoint !== 'object') continue
408-
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
438+
const keyPath = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)?.[2] ?? key
439+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path ?? keyPath
409440
if (!rawPath) continue
410441
const method = String(endpoint.method ?? 'POST').toUpperCase()
411442
const paymentSignal = manifestEndpointPaymentSignal(endpoint)

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.28",
3+
"version": "0.2.29",
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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,37 @@ const server = createServer((request, response) => {
302302
return
303303
}
304304

305+
if (request.url === '/nested-discovery.json') {
306+
response.setHeader('content-type', 'application/json')
307+
response.end(JSON.stringify({
308+
name: 'Nested Discovery Fixture',
309+
discovery: {
310+
x402_json: '/items.json',
311+
openapi: '/unused-openapi.json',
312+
},
313+
}))
314+
return
315+
}
316+
317+
if (request.url === '/string-endpoints.json') {
318+
response.setHeader('content-type', 'application/json')
319+
response.end(JSON.stringify({
320+
name: 'String Endpoint Fixture',
321+
endpoints: {
322+
'/string-paid': '$0.04 USDC -- string-valued endpoint metadata',
323+
'POST /string-post': '$0.08 USDC -- method-prefixed endpoint metadata',
324+
},
325+
tools: {
326+
'/tool-paid': {
327+
method: 'GET',
328+
price: '$0.02',
329+
description: 'Tool map endpoint metadata',
330+
},
331+
},
332+
}))
333+
return
334+
}
335+
305336
if (request.url === '/resources.json') {
306337
response.setHeader('content-type', 'application/json')
307338
response.end(JSON.stringify({
@@ -323,6 +354,25 @@ const server = createServer((request, response) => {
323354
return
324355
}
325356

357+
if (request.url === '/string-paid' || request.url === '/string-post' || request.url === '/tool-paid') {
358+
response.statusCode = 402
359+
response.setHeader('content-type', 'application/json')
360+
response.setHeader('access-control-allow-origin', '*')
361+
response.end(JSON.stringify({
362+
x402Version: 1,
363+
error: 'Payment required',
364+
accepts: [{
365+
scheme: 'exact',
366+
network: 'eip155:8453',
367+
maxAmountRequired: request.url === '/string-post' ? '80000' : request.url === '/tool-paid' ? '20000' : '40000',
368+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
369+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
370+
resource: `${serverUrl}${request.url}`,
371+
}],
372+
}))
373+
return
374+
}
375+
326376
if (request.url === '/registry-leak.json') {
327377
response.setHeader('content-type', 'application/json')
328378
response.end(JSON.stringify({
@@ -1115,6 +1165,32 @@ try {
11151165
assert.match(linkedDiscovery.stdout, new RegExp(`Document: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/items\\.json`))
11161166
assert.match(linkedDiscovery.stdout, /Premium routing recommendations/)
11171167

1168+
const nestedDiscovery = await execFileAsync('node', [
1169+
'bin/x402-surface-check.mjs',
1170+
`${serverUrl}/nested-discovery.json`,
1171+
'--origin',
1172+
'https://example.com',
1173+
], { cwd: new URL('..', import.meta.url) })
1174+
1175+
assert.match(nestedDiscovery.stdout, new RegExp(`Source: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/nested-discovery\\.json`))
1176+
assert.match(nestedDiscovery.stdout, new RegExp(`Document: ${serverUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/items\\.json`))
1177+
assert.match(nestedDiscovery.stdout, /Premium routing recommendations/)
1178+
1179+
const stringEndpoints = await execFileAsync('node', [
1180+
'bin/x402-surface-check.mjs',
1181+
`${serverUrl}/string-endpoints.json`,
1182+
'--origin',
1183+
'https://example.com',
1184+
], { cwd: new URL('..', import.meta.url) })
1185+
1186+
assert.match(stringEndpoints.stdout, /string-paid/)
1187+
assert.match(stringEndpoints.stdout, /string-post/)
1188+
assert.match(stringEndpoints.stdout, /tool-paid/)
1189+
assert.match(stringEndpoints.stdout, /Probed endpoints: 3/)
1190+
assert.match(stringEndpoints.stdout, /\$0\.02/)
1191+
assert.match(stringEndpoints.stdout, /\$0\.04/)
1192+
assert.match(stringEndpoints.stdout, /\$0\.08/)
1193+
11181194
const linkedOpenApi = await execFileAsync('node', [
11191195
'bin/x402-surface-check.mjs',
11201196
`${serverUrl}/openapi-link.json`,

0 commit comments

Comments
 (0)