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 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 or OpenAPI endpoint discovery
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 (headerChallenge && !body.json?.accepts?.length) {
210
- body.json = headerChallenge
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 = await fetchDocument(options.url)
402
- const entries = document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : []
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {