Skip to content

Commit e22f389

Browse files
committed
Support x402 resource catalogs
1 parent db9d275 commit e22f389

5 files changed

Lines changed: 106 additions & 15 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
1414

1515
## What It Checks
1616

17-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
17+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
1818
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
1919
- OpenAPI query/path examples and JSON request-body examples for safer no-payment probes
2020
- No-payment HTTP 402 challenge shape
@@ -26,7 +26,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
2626
- Placeholder recipients such as zero addresses and Solana system-program values
2727
- Testnet or staging rails such as Base Sepolia and Solana devnet
2828
- HTTPS resource URLs and stable resource metadata
29-
- Browser CORS allowance for `X-PAYMENT`
29+
- Browser CORS allowance for the requesting origin and `X-PAYMENT`
3030
- Over-broad public method surfaces
3131
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
3232
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
@@ -44,6 +44,7 @@ Recent public no-payment checks have found and verified real launch fixes:
4444
- 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
4545
- 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
4646
- 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
4748

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

bin/x402-surface-check.mjs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -294,17 +294,32 @@ function endpointEntries(document, sourceUrl, limit) {
294294
}
295295

296296
for (const resource of document.resources ?? []) {
297-
if (typeof resource !== 'string') continue
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()
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
304315
entries.push({
305-
name: rawPath.split('/').filter(Boolean).at(-1) ?? rawPath,
306-
url,
307-
method: method.toUpperCase(),
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(),
308323
})
309324
}
310325

@@ -669,6 +684,10 @@ function findingList(documentResult, challengeResults, preflightResults, entries
669684
for (const result of preflightResults) {
670685
const challengeResult = challengesByEntry.get(entryKey(result))
671686
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+
}
672691
const allowed = result.headers['access-control-allow-headers'] ?? ''
673692
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
674693
const observed = result.status >= 400

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.13",
3+
"version": "0.2.14",
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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { strict as assert } from 'node:assert'
66
const execFileAsync = promisify(execFile)
77

88
const server = createServer((request, response) => {
9+
if (request.method === 'OPTIONS' && request.url === '/no-origin') {
10+
response.statusCode = 204
11+
response.setHeader('access-control-allow-headers', 'content-type,x-payment')
12+
response.setHeader('access-control-allow-methods', 'GET, OPTIONS')
13+
response.end()
14+
return
15+
}
16+
917
if (request.method === 'OPTIONS') {
1018
response.statusCode = 204
1119
response.setHeader('access-control-allow-origin', '*')
@@ -154,6 +162,27 @@ const server = createServer((request, response) => {
154162
return
155163
}
156164

165+
if (request.url === '/resources.json') {
166+
response.setHeader('content-type', 'application/json')
167+
response.end(JSON.stringify({
168+
x402Version: 2,
169+
resources: [{
170+
path: '/api/premium/routing',
171+
url: `${serverUrl}/api/premium/routing`,
172+
method: 'GET',
173+
description: 'Premium routing resource object',
174+
accepts: [{
175+
scheme: 'exact',
176+
network: 'eip155:8453',
177+
amount: '20000',
178+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
179+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
180+
}],
181+
}],
182+
}))
183+
return
184+
}
185+
157186
if (request.url === '/openapi-link.json') {
158187
response.setHeader('content-type', 'application/json')
159188
response.end(JSON.stringify({
@@ -335,6 +364,24 @@ const server = createServer((request, response) => {
335364
return
336365
}
337366

367+
if (request.url === '/no-origin') {
368+
response.statusCode = 402
369+
response.setHeader('content-type', 'application/json')
370+
response.end(JSON.stringify({
371+
x402Version: 2,
372+
error: 'Payment required',
373+
resource: { url: `${serverUrl}/no-origin` },
374+
accepts: [{
375+
scheme: 'exact',
376+
network: 'eip155:8453',
377+
amount: '20000',
378+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
379+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
380+
}],
381+
}))
382+
return
383+
}
384+
338385
if (request.url === '/legacy/v1') {
339386
response.statusCode = 402
340387
response.setHeader('content-type', 'application/json')
@@ -473,6 +520,18 @@ try {
473520
assert.match(itemsManifest.stdout, /Fixture Facilitator \/ https:\/\/facilitator\.example \/ US/)
474521
assert.doesNotMatch(itemsManifest.stdout, /\[object Object\]/)
475522

523+
const resourceManifest = await execFileAsync('node', [
524+
'bin/x402-surface-check.mjs',
525+
`${serverUrl}/resources.json`,
526+
'--origin',
527+
'https://example.com',
528+
], { cwd: new URL('..', import.meta.url) })
529+
530+
assert.match(resourceManifest.stdout, /premium\/routing/)
531+
assert.match(resourceManifest.stdout, /\$0\.02/)
532+
assert.match(resourceManifest.stdout, /eip155:8453/)
533+
assert.doesNotMatch(resourceManifest.stdout, /Document does not expose/)
534+
476535
const linkedDiscovery = await execFileAsync('node', [
477536
'bin/x402-surface-check.mjs',
478537
`${serverUrl}/well-known.json`,
@@ -549,6 +608,18 @@ try {
549608
assert.doesNotMatch(schemes.stdout, /\$0\.000/)
550609
assert.doesNotMatch(schemes.stdout, /challenge is missing amount/)
551610

611+
const noOrigin = await execFileAsync('node', [
612+
'bin/x402-surface-check.mjs',
613+
'--endpoint',
614+
'--method',
615+
'GET',
616+
`${serverUrl}/no-origin`,
617+
'--origin',
618+
'https://example.com',
619+
], { cwd: new URL('..', import.meta.url) })
620+
621+
assert.match(noOrigin.stdout, /CORS preflight does not allow the requesting origin/)
622+
552623
const needsParam = await execFileAsync('node', [
553624
'bin/x402-surface-check.mjs',
554625
'--endpoint',

0 commit comments

Comments
 (0)