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 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 `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 (headerChallenge && !body.json?.accepts?.length) {
210
- body.json = headerChallenge
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 = await fetchDocument(options.url)
402
- const entries = document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : []
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,
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.1",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {