x402-surface-check 0.1.0 → 0.2.2
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 -1
- package/bin/x402-surface-check.mjs +122 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,16 +4,20 @@ No-payment CLI for checking x402 launch surfaces before a real agent spends.
|
|
|
4
4
|
|
|
5
5
|
It accepts an x402 manifest or OpenAPI URL, derives public endpoints, sends no-payment probes, checks browser preflight behavior, and returns a Markdown patch queue. It never sends `X-PAYMENT`, never signs, and never attempts a paid call.
|
|
6
6
|
|
|
7
|
+
npm: https://www.npmjs.com/package/x402-surface-check
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
npx --yes x402-surface-check https://api.example.com/.well-known/x402
|
|
9
11
|
npx --yes x402-surface-check https://api.example.com/openapi.json report.md
|
|
12
|
+
npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/eth
|
|
10
13
|
```
|
|
11
14
|
|
|
12
15
|
## What It Checks
|
|
13
16
|
|
|
14
|
-
- Manifest
|
|
17
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
15
18
|
- No-payment HTTP 402 challenge shape
|
|
16
19
|
- x402 v1 and v2 price fields
|
|
20
|
+
- MPP `WWW-Authenticate: Payment` challenges
|
|
17
21
|
- `amount` / `maxAmountRequired`, `asset`, `network`, and `payTo`
|
|
18
22
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
19
23
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
@@ -25,7 +29,10 @@ npx --yes x402-surface-check https://api.example.com/openapi.json report.md
|
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
31
|
x402-surface-check <manifest-or-openapi-url> [output.md]
|
|
32
|
+
x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
|
|
28
33
|
|
|
34
|
+
--endpoint Treat the URL as one paid endpoint instead of a discovery document
|
|
35
|
+
--method <verb> HTTP method for direct endpoint mode, default POST
|
|
29
36
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
30
37
|
--limit <n> Maximum endpoints to probe, default 6
|
|
31
38
|
--json Print JSON instead of Markdown
|
|
@@ -13,8 +13,11 @@ function usage() {
|
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
15
15
|
x402-surface-check <manifest-or-openapi-url> [output.md]
|
|
16
|
+
x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
|
|
16
17
|
|
|
17
18
|
Options:
|
|
19
|
+
--endpoint Treat the URL as one paid endpoint instead of a discovery document
|
|
20
|
+
--method <verb> HTTP method for direct endpoint mode, default POST
|
|
18
21
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
19
22
|
--limit <n> Maximum endpoints to probe, default ${defaultLimit}
|
|
20
23
|
--json Print JSON instead of Markdown
|
|
@@ -26,7 +29,9 @@ Options:
|
|
|
26
29
|
function parseArgs(argv) {
|
|
27
30
|
const args = {
|
|
28
31
|
json: false,
|
|
32
|
+
endpoint: false,
|
|
29
33
|
limit: Number(process.env.X402_CHECK_LIMIT ?? defaultLimit),
|
|
34
|
+
method: 'POST',
|
|
30
35
|
origin: process.env.X402_CHECK_ORIGIN,
|
|
31
36
|
outputPath: '',
|
|
32
37
|
url: '',
|
|
@@ -43,6 +48,13 @@ function parseArgs(argv) {
|
|
|
43
48
|
else if (arg === '--json') {
|
|
44
49
|
args.json = true
|
|
45
50
|
}
|
|
51
|
+
else if (arg === '--endpoint') {
|
|
52
|
+
args.endpoint = true
|
|
53
|
+
}
|
|
54
|
+
else if (arg === '--method') {
|
|
55
|
+
args.method = String(argv[index + 1] ?? '').toUpperCase()
|
|
56
|
+
index += 1
|
|
57
|
+
}
|
|
46
58
|
else if (arg === '--origin') {
|
|
47
59
|
args.origin = argv[index + 1]
|
|
48
60
|
index += 1
|
|
@@ -87,8 +99,26 @@ function uniqueEntries(entries, limit) {
|
|
|
87
99
|
.slice(0, Number.isFinite(limit) && limit > 0 ? limit : defaultLimit)
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
function documentBaseUrl(document, sourceUrl) {
|
|
103
|
+
if (typeof document.service_url === 'string') return document.service_url
|
|
104
|
+
if (typeof document.serviceUrl === 'string') return document.serviceUrl
|
|
105
|
+
if (typeof document.baseUrl === 'string') return document.baseUrl
|
|
106
|
+
if (typeof document.base_url === 'string') return document.base_url
|
|
107
|
+
return new URL('/', sourceUrl).toString()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function endpointUrl(rawPath, baseUrl, sourceUrl) {
|
|
111
|
+
const value = String(rawPath ?? '')
|
|
112
|
+
if (!value) return ''
|
|
113
|
+
if (/^https?:\/\//i.test(value)) return value
|
|
114
|
+
const resolvedBase = baseUrl || documentBaseUrl({}, sourceUrl)
|
|
115
|
+
const base = value.startsWith('/') ? resolvedBase : `${resolvedBase.replace(/\/?$/, '/')}`
|
|
116
|
+
return new URL(value, base).toString()
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
function endpointEntries(document, sourceUrl, limit) {
|
|
91
120
|
const entries = []
|
|
121
|
+
const baseUrl = documentBaseUrl(document, sourceUrl)
|
|
92
122
|
|
|
93
123
|
for (const [name, url] of Object.entries(document.x402Endpoints ?? {})) {
|
|
94
124
|
if (typeof url === 'string' && url.startsWith('http')) {
|
|
@@ -109,6 +139,31 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
109
139
|
}
|
|
110
140
|
}
|
|
111
141
|
|
|
142
|
+
if (Array.isArray(document.endpoints)) {
|
|
143
|
+
for (const endpoint of document.endpoints) {
|
|
144
|
+
const rawPath = endpoint?.url ?? endpoint?.endpoint ?? endpoint?.path
|
|
145
|
+
if (!rawPath) continue
|
|
146
|
+
entries.push({
|
|
147
|
+
name: endpoint.id ?? endpoint.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
|
|
148
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
149
|
+
method: String(endpoint.method ?? 'POST').toUpperCase(),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Array.isArray(document.items)) {
|
|
155
|
+
for (const item of document.items) {
|
|
156
|
+
if (item?.type && item.type !== 'http') continue
|
|
157
|
+
const rawPath = item?.resource ?? item?.url ?? item?.endpoint ?? item?.path
|
|
158
|
+
if (!rawPath) continue
|
|
159
|
+
entries.push({
|
|
160
|
+
name: item.metadata?.name ?? item.id ?? item.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
|
|
161
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
162
|
+
method: String(item.method ?? 'GET').toUpperCase(),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
112
167
|
if (document.openapi && document.paths && typeof document.paths === 'object') {
|
|
113
168
|
const baseUrl = document.servers?.find(server => typeof server?.url === 'string')?.url
|
|
114
169
|
?? sourceUrl
|
|
@@ -173,6 +228,42 @@ function parseEncodedChallenge(value) {
|
|
|
173
228
|
}
|
|
174
229
|
}
|
|
175
230
|
|
|
231
|
+
function parsePaymentAuthenticate(value) {
|
|
232
|
+
if (!value || !/^Payment\s+/i.test(value)) return null
|
|
233
|
+
const params = {}
|
|
234
|
+
const pattern = /([a-zA-Z][\w-]*)="([^"]*)"/g
|
|
235
|
+
let match = pattern.exec(value)
|
|
236
|
+
|
|
237
|
+
while (match) {
|
|
238
|
+
params[match[1]] = match[2]
|
|
239
|
+
match = pattern.exec(value)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const request = parseEncodedChallenge(params.request)
|
|
243
|
+
if (!request) return null
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
protocol: 'mpp',
|
|
247
|
+
resource: { url: '' },
|
|
248
|
+
accepts: [{
|
|
249
|
+
scheme: 'mpp',
|
|
250
|
+
network: request.methodDetails?.network ?? params.method ?? '',
|
|
251
|
+
amount: request.amount ?? '',
|
|
252
|
+
asset: request.currency ?? '',
|
|
253
|
+
payTo: request.recipient ?? '',
|
|
254
|
+
resource: '',
|
|
255
|
+
maxTimeoutSeconds: '',
|
|
256
|
+
extra: {
|
|
257
|
+
description: request.description ?? '',
|
|
258
|
+
expires: params.expires ?? '',
|
|
259
|
+
id: params.id ?? '',
|
|
260
|
+
intent: params.intent ?? '',
|
|
261
|
+
method: params.method ?? '',
|
|
262
|
+
},
|
|
263
|
+
}],
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
176
267
|
async function fetchDocument(url) {
|
|
177
268
|
const response = await fetch(url, {
|
|
178
269
|
headers: {
|
|
@@ -205,9 +296,17 @@ async function probeEndpoint(entry) {
|
|
|
205
296
|
const headerChallenge = parseEncodedChallenge(
|
|
206
297
|
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
|
|
207
298
|
)
|
|
299
|
+
const paymentChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
|
|
208
300
|
|
|
209
|
-
if (
|
|
210
|
-
|
|
301
|
+
if (!body.json?.accepts?.length) {
|
|
302
|
+
if (headerChallenge) {
|
|
303
|
+
body.json = headerChallenge
|
|
304
|
+
}
|
|
305
|
+
else if (paymentChallenge) {
|
|
306
|
+
paymentChallenge.resource.url = entry.url
|
|
307
|
+
paymentChallenge.accepts[0].resource = entry.url
|
|
308
|
+
body.json = paymentChallenge
|
|
309
|
+
}
|
|
211
310
|
}
|
|
212
311
|
|
|
213
312
|
return {
|
|
@@ -260,6 +359,7 @@ function challengeSummary(result) {
|
|
|
260
359
|
|
|
261
360
|
return {
|
|
262
361
|
status: result.status,
|
|
362
|
+
protocol: challenge?.protocol ?? (firstAccept.scheme === 'mpp' ? 'mpp' : 'x402'),
|
|
263
363
|
resourceUrl,
|
|
264
364
|
network: firstAccept.network ?? '',
|
|
265
365
|
amount,
|
|
@@ -298,7 +398,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
298
398
|
}
|
|
299
399
|
|
|
300
400
|
if (entries.length === 0) {
|
|
301
|
-
findings.push('P1 - Document does not expose any manifest, OpenAPI, category, or resource endpoints for no-payment probes.')
|
|
401
|
+
findings.push('P1 - Document does not expose any manifest, OpenAPI, item, category, or resource endpoints for no-payment probes.')
|
|
302
402
|
}
|
|
303
403
|
|
|
304
404
|
for (const result of challengeResults) {
|
|
@@ -329,7 +429,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
329
429
|
|
|
330
430
|
for (const result of preflightResults) {
|
|
331
431
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
332
|
-
if (!/x-payment/i.test(allowed)) {
|
|
432
|
+
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
333
433
|
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed allow headers: ${allowed || 'none'}.`)
|
|
334
434
|
}
|
|
335
435
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
@@ -353,7 +453,7 @@ function formatMarkdown(report) {
|
|
|
353
453
|
const document = report.document.body.json ?? {}
|
|
354
454
|
const challengeRows = report.challenges.map(result => {
|
|
355
455
|
const summary = challengeSummary(result)
|
|
356
|
-
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
456
|
+
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
357
457
|
})
|
|
358
458
|
const preflightRows = report.preflights.map(result => {
|
|
359
459
|
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${result.headers['access-control-allow-origin'] ?? '-'} | ${result.headers['access-control-allow-headers'] ?? '-'} | ${result.headers['access-control-allow-methods'] ?? '-'} |`
|
|
@@ -370,7 +470,7 @@ function formatMarkdown(report) {
|
|
|
370
470
|
'## Document',
|
|
371
471
|
'',
|
|
372
472
|
`- Status: ${report.document.status}`,
|
|
373
|
-
`- Type: ${document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'}`,
|
|
473
|
+
`- Type: ${report.directEndpoint ? 'direct endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document')}`,
|
|
374
474
|
`- Agent: ${document.agent?.name ?? '-'}`,
|
|
375
475
|
`- Wallet: ${document.agent?.wallet ?? '-'}`,
|
|
376
476
|
`- Facilitator: ${document.facilitator ?? '-'}`,
|
|
@@ -380,9 +480,9 @@ function formatMarkdown(report) {
|
|
|
380
480
|
'',
|
|
381
481
|
'## No-Payment Challenge Map',
|
|
382
482
|
'',
|
|
383
|
-
'| Endpoint | Method | HTTP | Price | Network | Resource URL |',
|
|
384
|
-
'| --- | --- | --- | --- | --- | --- |',
|
|
385
|
-
...(challengeRows.length ? challengeRows : ['| - | - | - | - | - | - |']),
|
|
483
|
+
'| Endpoint | Method | HTTP | Protocol | Price | Network | Resource URL |',
|
|
484
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
485
|
+
...(challengeRows.length ? challengeRows : ['| - | - | - | - | - | - | - |']),
|
|
386
486
|
'',
|
|
387
487
|
'## Browser Preflight Map',
|
|
388
488
|
'',
|
|
@@ -398,8 +498,18 @@ function formatMarkdown(report) {
|
|
|
398
498
|
}
|
|
399
499
|
|
|
400
500
|
async function runCheck(options) {
|
|
401
|
-
const document =
|
|
402
|
-
|
|
501
|
+
const document = options.endpoint
|
|
502
|
+
? {
|
|
503
|
+
status: 200,
|
|
504
|
+
ok: true,
|
|
505
|
+
headers: {},
|
|
506
|
+
url: options.url,
|
|
507
|
+
body: { text: '{}', json: {} },
|
|
508
|
+
}
|
|
509
|
+
: await fetchDocument(options.url)
|
|
510
|
+
const entries = options.endpoint
|
|
511
|
+
? [{ name: new URL(options.url).pathname.split('/').filter(Boolean).at(-1) ?? options.url, url: options.url, method: options.method || 'POST' }]
|
|
512
|
+
: (document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : [])
|
|
403
513
|
const origin = options.origin ?? new URL(document.url).origin
|
|
404
514
|
const challenges = []
|
|
405
515
|
const preflights = []
|
|
@@ -412,6 +522,7 @@ async function runCheck(options) {
|
|
|
412
522
|
const report = {
|
|
413
523
|
checkedAt: new Date().toISOString(),
|
|
414
524
|
document,
|
|
525
|
+
directEndpoint: options.endpoint,
|
|
415
526
|
entries,
|
|
416
527
|
findings: [],
|
|
417
528
|
origin,
|