x402-surface-check 0.1.0 → 0.2.1
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 +108 -10
- 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 `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,18 @@ 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
|
+
|
|
112
154
|
if (document.openapi && document.paths && typeof document.paths === 'object') {
|
|
113
155
|
const baseUrl = document.servers?.find(server => typeof server?.url === 'string')?.url
|
|
114
156
|
?? sourceUrl
|
|
@@ -173,6 +215,42 @@ function parseEncodedChallenge(value) {
|
|
|
173
215
|
}
|
|
174
216
|
}
|
|
175
217
|
|
|
218
|
+
function parsePaymentAuthenticate(value) {
|
|
219
|
+
if (!value || !/^Payment\s+/i.test(value)) return null
|
|
220
|
+
const params = {}
|
|
221
|
+
const pattern = /([a-zA-Z][\w-]*)="([^"]*)"/g
|
|
222
|
+
let match = pattern.exec(value)
|
|
223
|
+
|
|
224
|
+
while (match) {
|
|
225
|
+
params[match[1]] = match[2]
|
|
226
|
+
match = pattern.exec(value)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const request = parseEncodedChallenge(params.request)
|
|
230
|
+
if (!request) return null
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
protocol: 'mpp',
|
|
234
|
+
resource: { url: '' },
|
|
235
|
+
accepts: [{
|
|
236
|
+
scheme: 'mpp',
|
|
237
|
+
network: request.methodDetails?.network ?? params.method ?? '',
|
|
238
|
+
amount: request.amount ?? '',
|
|
239
|
+
asset: request.currency ?? '',
|
|
240
|
+
payTo: request.recipient ?? '',
|
|
241
|
+
resource: '',
|
|
242
|
+
maxTimeoutSeconds: '',
|
|
243
|
+
extra: {
|
|
244
|
+
description: request.description ?? '',
|
|
245
|
+
expires: params.expires ?? '',
|
|
246
|
+
id: params.id ?? '',
|
|
247
|
+
intent: params.intent ?? '',
|
|
248
|
+
method: params.method ?? '',
|
|
249
|
+
},
|
|
250
|
+
}],
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
176
254
|
async function fetchDocument(url) {
|
|
177
255
|
const response = await fetch(url, {
|
|
178
256
|
headers: {
|
|
@@ -205,9 +283,17 @@ async function probeEndpoint(entry) {
|
|
|
205
283
|
const headerChallenge = parseEncodedChallenge(
|
|
206
284
|
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
|
|
207
285
|
)
|
|
286
|
+
const paymentChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
|
|
208
287
|
|
|
209
|
-
if (
|
|
210
|
-
|
|
288
|
+
if (!body.json?.accepts?.length) {
|
|
289
|
+
if (headerChallenge) {
|
|
290
|
+
body.json = headerChallenge
|
|
291
|
+
}
|
|
292
|
+
else if (paymentChallenge) {
|
|
293
|
+
paymentChallenge.resource.url = entry.url
|
|
294
|
+
paymentChallenge.accepts[0].resource = entry.url
|
|
295
|
+
body.json = paymentChallenge
|
|
296
|
+
}
|
|
211
297
|
}
|
|
212
298
|
|
|
213
299
|
return {
|
|
@@ -260,6 +346,7 @@ function challengeSummary(result) {
|
|
|
260
346
|
|
|
261
347
|
return {
|
|
262
348
|
status: result.status,
|
|
349
|
+
protocol: challenge?.protocol ?? (firstAccept.scheme === 'mpp' ? 'mpp' : 'x402'),
|
|
263
350
|
resourceUrl,
|
|
264
351
|
network: firstAccept.network ?? '',
|
|
265
352
|
amount,
|
|
@@ -329,7 +416,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
329
416
|
|
|
330
417
|
for (const result of preflightResults) {
|
|
331
418
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
332
|
-
if (!/x-payment/i.test(allowed)) {
|
|
419
|
+
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
333
420
|
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed allow headers: ${allowed || 'none'}.`)
|
|
334
421
|
}
|
|
335
422
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
@@ -353,7 +440,7 @@ function formatMarkdown(report) {
|
|
|
353
440
|
const document = report.document.body.json ?? {}
|
|
354
441
|
const challengeRows = report.challenges.map(result => {
|
|
355
442
|
const summary = challengeSummary(result)
|
|
356
|
-
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
443
|
+
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
357
444
|
})
|
|
358
445
|
const preflightRows = report.preflights.map(result => {
|
|
359
446
|
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 +457,7 @@ function formatMarkdown(report) {
|
|
|
370
457
|
'## Document',
|
|
371
458
|
'',
|
|
372
459
|
`- Status: ${report.document.status}`,
|
|
373
|
-
`- Type: ${document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'}`,
|
|
460
|
+
`- Type: ${report.directEndpoint ? 'direct endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document')}`,
|
|
374
461
|
`- Agent: ${document.agent?.name ?? '-'}`,
|
|
375
462
|
`- Wallet: ${document.agent?.wallet ?? '-'}`,
|
|
376
463
|
`- Facilitator: ${document.facilitator ?? '-'}`,
|
|
@@ -380,9 +467,9 @@ function formatMarkdown(report) {
|
|
|
380
467
|
'',
|
|
381
468
|
'## No-Payment Challenge Map',
|
|
382
469
|
'',
|
|
383
|
-
'| Endpoint | Method | HTTP | Price | Network | Resource URL |',
|
|
384
|
-
'| --- | --- | --- | --- | --- | --- |',
|
|
385
|
-
...(challengeRows.length ? challengeRows : ['| - | - | - | - | - | - |']),
|
|
470
|
+
'| Endpoint | Method | HTTP | Protocol | Price | Network | Resource URL |',
|
|
471
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
472
|
+
...(challengeRows.length ? challengeRows : ['| - | - | - | - | - | - | - |']),
|
|
386
473
|
'',
|
|
387
474
|
'## Browser Preflight Map',
|
|
388
475
|
'',
|
|
@@ -398,8 +485,18 @@ function formatMarkdown(report) {
|
|
|
398
485
|
}
|
|
399
486
|
|
|
400
487
|
async function runCheck(options) {
|
|
401
|
-
const document =
|
|
402
|
-
|
|
488
|
+
const document = options.endpoint
|
|
489
|
+
? {
|
|
490
|
+
status: 200,
|
|
491
|
+
ok: true,
|
|
492
|
+
headers: {},
|
|
493
|
+
url: options.url,
|
|
494
|
+
body: { text: '{}', json: {} },
|
|
495
|
+
}
|
|
496
|
+
: await fetchDocument(options.url)
|
|
497
|
+
const entries = options.endpoint
|
|
498
|
+
? [{ name: new URL(options.url).pathname.split('/').filter(Boolean).at(-1) ?? options.url, url: options.url, method: options.method || 'POST' }]
|
|
499
|
+
: (document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : [])
|
|
403
500
|
const origin = options.origin ?? new URL(document.url).origin
|
|
404
501
|
const challenges = []
|
|
405
502
|
const preflights = []
|
|
@@ -412,6 +509,7 @@ async function runCheck(options) {
|
|
|
412
509
|
const report = {
|
|
413
510
|
checkedAt: new Date().toISOString(),
|
|
414
511
|
document,
|
|
512
|
+
directEndpoint: options.endpoint,
|
|
415
513
|
entries,
|
|
416
514
|
findings: [],
|
|
417
515
|
origin,
|