Skip to content

Commit dea5a1e

Browse files
committed
Support object-valued x402 endpoints
1 parent 6be07d4 commit dea5a1e

5 files changed

Lines changed: 189 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
1717

1818
## What It Checks
1919

20-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
20+
- 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+
- 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
2122
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
2223
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
2324
- 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

bin/x402-surface-check.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,47 @@ function openApiProbeUrl(path, operation, baseUrl, document) {
322322
return url.toString()
323323
}
324324

325+
function manifestEndpointPaymentSignal(endpoint) {
326+
if (!endpoint || typeof endpoint !== 'object') return 0
327+
if (Number(endpoint.phase1_response?.status) === 402) return 2
328+
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
329+
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
330+
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
331+
return 0
332+
}
333+
334+
function manifestEndpointBody(endpoint, document) {
335+
const body = endpoint?.request_body ?? endpoint?.requestBody
336+
if (!body || typeof body !== 'object') return undefined
337+
if (body.example !== undefined) return body.example
338+
if (body.safe_example !== undefined) return body.safe_example
339+
if (body.safeExample !== undefined) return body.safeExample
340+
return exampleValue(body, document)
341+
}
342+
343+
function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
344+
const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
345+
const parameters = endpoint?.parameters
346+
if (!parameters || typeof parameters !== 'object') return url.toString()
347+
348+
for (const [name, parameter] of Object.entries(parameters)) {
349+
if (url.pathname.includes(`{${name}}`)) {
350+
const pathValue = parameter?.example ?? parameter?.default
351+
if (pathValue !== undefined && pathValue !== '') {
352+
url.pathname = url.pathname.replaceAll(`{${name}}`, encodeURIComponent(String(pathValue)))
353+
}
354+
continue
355+
}
356+
357+
const value = parameter?.example ?? parameter?.default
358+
if (value !== undefined && value !== '') {
359+
url.searchParams.set(name, String(value))
360+
}
361+
}
362+
363+
return url.toString()
364+
}
365+
325366
function endpointEntries(document, sourceUrl, limit) {
326367
const entries = []
327368
const baseUrl = documentBaseUrl(document, sourceUrl)
@@ -356,6 +397,24 @@ function endpointEntries(document, sourceUrl, limit) {
356397
})
357398
}
358399
}
400+
else if (document.endpoints && typeof document.endpoints === 'object') {
401+
for (const [key, endpoint] of Object.entries(document.endpoints)) {
402+
if (!endpoint || typeof endpoint !== 'object') continue
403+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
404+
if (!rawPath) continue
405+
const method = String(endpoint.method ?? 'POST').toUpperCase()
406+
const paymentSignal = manifestEndpointPaymentSignal(endpoint)
407+
const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
408+
if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
409+
entries.push({
410+
name: endpoint.id ?? endpoint.name ?? key,
411+
url: manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl),
412+
method,
413+
requestBody: manifestEndpointBody(endpoint, document),
414+
publicDiscovery: paymentSignal === 0,
415+
})
416+
}
417+
}
359418

360419
if (Array.isArray(document.items)) {
361420
for (const item of document.items) {
@@ -768,6 +827,13 @@ function findingList(documentResult, challengeResults, preflightResults, entries
768827
if (summary.network) challengeNetworks.add(summary.network)
769828
const hasChallenge = hasPaymentChallenge(result)
770829

830+
if (result.publicDiscovery && !hasChallenge) {
831+
if (result.status < 200 || result.status >= 300) {
832+
findings.push(`P2 - ${result.name} is documented as a public discovery route but returned HTTP ${result.status}; check the manifest example parameters or route availability.`)
833+
}
834+
continue
835+
}
836+
771837
if (result.status !== 402) {
772838
if (result.status >= 200 && result.status < 300) {
773839
if (!looksLikeOperationalHealthEndpoint(result)) {

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.24",
3+
"version": "0.2.25",
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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,58 @@ const server = createServer((request, response) => {
323323
return
324324
}
325325

326+
if (request.url === '/object-endpoints.json') {
327+
response.setHeader('content-type', 'application/json')
328+
response.end(JSON.stringify({
329+
version: '1.0',
330+
service: {
331+
name: 'Object Endpoint Fixture',
332+
url: serverUrl,
333+
},
334+
networks: [{ id: 'base', network: 'eip155:8453' }],
335+
endpoints: {
336+
brandsObject: {
337+
method: 'GET',
338+
path: '/object/brands',
339+
description: 'Discover public brands before checkout.',
340+
parameters: {
341+
country_code: { type: 'string', required: true, example: 'us' },
342+
},
343+
},
344+
catalogObject: {
345+
method: 'GET',
346+
path: '/object/catalog',
347+
description: 'Get public catalog products before checkout.',
348+
parameters: {
349+
country_code: { type: 'string', required: true, example: 'us' },
350+
brand_name: { type: 'string', required: false, example: 'Example Store' },
351+
},
352+
},
353+
createOrderObject: {
354+
method: 'POST',
355+
path: '/object/order',
356+
description: 'Two-phase endpoint. First call returns 402 PAYMENT-REQUIRED.',
357+
request_body: {
358+
example: {
359+
email: 'agent@example.com',
360+
items: [{ product_id: 'sku_123', beneficiary_account: 'recipient@example.com' }],
361+
},
362+
},
363+
phase1_response: {
364+
status: 402,
365+
header: 'PAYMENT-REQUIRED',
366+
},
367+
},
368+
getOrderObject: {
369+
method: 'GET',
370+
path: '/object/orders/{order_id}',
371+
description: 'Poll after a paid order exists.',
372+
},
373+
},
374+
}))
375+
return
376+
}
377+
326378
if (request.url === '/openapi-link.json') {
327379
response.setHeader('content-type', 'application/json')
328380
response.end(JSON.stringify({
@@ -424,6 +476,54 @@ const server = createServer((request, response) => {
424476
return
425477
}
426478

479+
if (request.url === '/object/brands?country_code=us') {
480+
response.statusCode = 200
481+
response.setHeader('content-type', 'application/json')
482+
response.end(JSON.stringify([{ brand_name: 'Example Store' }]))
483+
return
484+
}
485+
486+
if (request.url === '/object/catalog?country_code=us&brand_name=Example+Store') {
487+
response.statusCode = 200
488+
response.setHeader('content-type', 'application/json')
489+
response.end(JSON.stringify([{ product_id: 'sku_123', price_usdc: '0.02' }]))
490+
return
491+
}
492+
493+
if (request.url === '/object/order') {
494+
let body = ''
495+
request.on('data', chunk => {
496+
body += chunk
497+
})
498+
request.on('end', () => {
499+
const parsed = body ? JSON.parse(body) : {}
500+
if (parsed.email !== 'agent@example.com' || parsed.items?.[0]?.product_id !== 'sku_123') {
501+
response.statusCode = 400
502+
response.setHeader('content-type', 'application/json')
503+
response.end(JSON.stringify({ error: 'missing object endpoint body example' }))
504+
return
505+
}
506+
response.statusCode = 402
507+
response.setHeader('content-type', 'application/json')
508+
response.setHeader('access-control-allow-origin', '*')
509+
response.end(JSON.stringify({
510+
x402Version: 2,
511+
error: 'Payment required',
512+
resource: { url: `${serverUrl}/object/order` },
513+
accepts: [{
514+
scheme: 'exact',
515+
network: 'eip155:8453',
516+
amount: '20000',
517+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
518+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
519+
resource: `${serverUrl}/object/order`,
520+
extra: { resource: `${serverUrl}/object/order` },
521+
}],
522+
}))
523+
})
524+
return
525+
}
526+
427527
if (request.url === '/gateway/pay/v1/protected') {
428528
response.statusCode = 402
429529
response.setHeader('content-type', 'application/json')
@@ -933,6 +1033,24 @@ try {
9331033
assert.match(resourceManifest.stdout, /eip155:8453/)
9341034
assert.doesNotMatch(resourceManifest.stdout, /Document does not expose/)
9351035

1036+
const objectEndpoints = await execFileAsync('node', [
1037+
'bin/x402-surface-check.mjs',
1038+
`${serverUrl}/object-endpoints.json`,
1039+
'--origin',
1040+
'https://example.com',
1041+
], { cwd: new URL('..', import.meta.url) })
1042+
1043+
assert.match(objectEndpoints.stdout, /brandsObject/)
1044+
assert.match(objectEndpoints.stdout, /catalogObject/)
1045+
assert.match(objectEndpoints.stdout, /createOrderObject/)
1046+
assert.match(objectEndpoints.stdout, /Probed endpoints: 3/)
1047+
assert.match(objectEndpoints.stdout, /\$0\.02/)
1048+
assert.doesNotMatch(objectEndpoints.stdout, /getOrderObject/)
1049+
assert.doesNotMatch(objectEndpoints.stdout, /Document does not expose/)
1050+
assert.doesNotMatch(objectEndpoints.stdout, /brandsObject returned 200 without a payment challenge/)
1051+
assert.doesNotMatch(objectEndpoints.stdout, /catalogObject returned 200 without a payment challenge/)
1052+
assert.doesNotMatch(objectEndpoints.stdout, /createOrderObject returned validation HTTP 400/)
1053+
9361054
const linkedDiscovery = await execFileAsync('node', [
9371055
'bin/x402-surface-check.mjs',
9381056
`${serverUrl}/well-known.json`,

0 commit comments

Comments
 (0)