x402-surface-check 0.2.27 → 0.2.30

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
@@ -13,13 +13,14 @@ npx --yes x402-surface-check https://api.example.com/openapi.json report.md
13
13
  npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/eth
14
14
  npx --yes x402-surface-check --endpoint --method POST --body '{"prompt":"price CPI"}' https://api.example.com/paid-post
15
15
  npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
16
+ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
16
17
  ```
17
18
 
18
19
  ## What It Checks
19
20
 
20
- - Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
21
+ - Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
21
22
  - Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
22
- - Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
23
+ - Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links
23
24
  - OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
24
25
  - OpenAPI query/path examples, JSON request-body examples, nested request schemas, local `$ref` request schemas, and explicit direct-endpoint bodies for safer no-payment probes
25
26
  - OpenAPI paid-operation prioritization, so docs and discovery routes do not consume the probe limit before payment-bearing operations
@@ -36,6 +37,7 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
36
37
  - Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
37
38
  - Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
38
39
  - Cache-Control posture on no-payment challenge responses, with P1 warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
40
+ - Optional strict proof/idempotency posture: mutating paid routes that do not advertise payment-identifier idempotency, and payment challenges that do not advertise signed offer/receipt evidence
39
41
  - Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
40
42
  - Credential-like query params and URL userinfo inside public registry/discovery endpoint URLs, reported with redacted values so provider API keys and tokens are not repeated in scan output
41
43
  - Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
@@ -75,6 +77,7 @@ x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
75
77
  --origin <url> Origin to use for browser-style CORS preflight
76
78
  --limit <n> Maximum endpoints to probe, default 6
77
79
  --strict-cache Flag missing Cache-Control on no-payment 402 responses
80
+ --strict-proof Flag missing idempotency and signed offer/receipt extensions
78
81
  --json Print JSON instead of Markdown
79
82
  --help Show usage
80
83
  --version Show package version
@@ -86,6 +89,7 @@ Environment variables are also supported:
86
89
  X402_CHECK_ORIGIN=https://example.com x402-surface-check https://api.example.com/openapi.json
87
90
  X402_CHECK_LIMIT=12 x402-surface-check https://api.example.com/.well-known/x402
88
91
  X402_STRICT_CACHE=1 x402-surface-check https://api.example.com/.well-known/x402
92
+ X402_STRICT_PROOF=1 x402-surface-check https://api.example.com/.well-known/x402
89
93
  ```
90
94
 
91
95
  ## Scope
@@ -23,6 +23,7 @@ Options:
23
23
  --origin <url> Origin to use for browser-style CORS preflight
24
24
  --limit <n> Maximum endpoints to probe, default ${defaultLimit}
25
25
  --strict-cache Flag missing Cache-Control on no-payment 402 responses
26
+ --strict-proof Flag missing idempotency and signed offer/receipt extensions
26
27
  --json Print JSON instead of Markdown
27
28
  --help Show this help
28
29
  --version Show package version
@@ -40,6 +41,7 @@ function parseArgs(argv) {
40
41
  bodyFile: process.env.X402_CHECK_BODY_FILE,
41
42
  outputPath: '',
42
43
  strictCache: process.env.X402_STRICT_CACHE === '1',
44
+ strictProof: process.env.X402_STRICT_PROOF === '1',
43
45
  url: '',
44
46
  }
45
47
 
@@ -60,6 +62,9 @@ function parseArgs(argv) {
60
62
  else if (arg === '--strict-cache') {
61
63
  args.strictCache = true
62
64
  }
65
+ else if (arg === '--strict-proof') {
66
+ args.strictProof = true
67
+ }
63
68
  else if (arg === '--method') {
64
69
  args.method = String(argv[index + 1] ?? '').toUpperCase()
65
70
  index += 1
@@ -173,10 +178,22 @@ function openApiServerBaseUrl(document, sourceUrl) {
173
178
  }
174
179
 
175
180
  function linkedDiscoveryUrl(document, sourceUrl) {
181
+ const discovery = document?.discovery && typeof document.discovery === 'object'
182
+ ? document.discovery
183
+ : {}
176
184
  const rawUrl = document?.discovery_url
177
185
  ?? document?.discoveryUrl
178
186
  ?? document?.resources_url
179
187
  ?? document?.resourcesUrl
188
+ ?? (typeof document?.discovery === 'string' ? document.discovery : undefined)
189
+ ?? discovery.x402_json
190
+ ?? discovery.x402Json
191
+ ?? discovery.resources_json
192
+ ?? discovery.resourcesJson
193
+ ?? discovery.resources
194
+ ?? discovery.openapi
195
+ ?? discovery.openapi_url
196
+ ?? discovery.openapiUrl
180
197
  ?? (/^(https?:\/\/|\/)/i.test(String(document?.openapi ?? '')) ? document.openapi : '')
181
198
  if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
182
199
  return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
@@ -326,6 +343,7 @@ function manifestEndpointPaymentSignal(endpoint) {
326
343
  if (!endpoint || typeof endpoint !== 'object') return 0
327
344
  if (Number(endpoint.phase1_response?.status) === 402) return 2
328
345
  if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
346
+ if (/^\$?\d+(\.\d+)?/.test(String(endpoint.price ?? endpoint.cost ?? endpoint.amount ?? ''))) return 1
329
347
  if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
330
348
  if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
331
349
  return 0
@@ -397,10 +415,46 @@ function endpointEntries(document, sourceUrl, limit) {
397
415
  })
398
416
  }
399
417
  }
400
- else if (document.endpoints && typeof document.endpoints === 'object') {
401
- for (const [key, endpoint] of Object.entries(document.endpoints)) {
418
+ const endpointMaps = []
419
+ if (!Array.isArray(document.endpoints) && document.endpoints && typeof document.endpoints === 'object') {
420
+ endpointMaps.push(document.endpoints)
421
+ }
422
+ if (document.tools && typeof document.tools === 'object' && !Array.isArray(document.tools)) {
423
+ endpointMaps.push(document.tools)
424
+ }
425
+ if (Array.isArray(document.tools)) {
426
+ for (const tool of document.tools) {
427
+ if (!tool || typeof tool !== 'object') continue
428
+ const rawPath = tool.url ?? tool.endpoint ?? tool.path
429
+ if (!rawPath) continue
430
+ const method = String(tool.method ?? 'POST').toUpperCase()
431
+ const paymentSignal = manifestEndpointPaymentSignal(tool)
432
+ const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
433
+ if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
434
+ entries.push({
435
+ name: tool.id ?? tool.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
436
+ url: manifestEndpointUrl(rawPath, tool, baseUrl, sourceUrl),
437
+ method,
438
+ requestBody: manifestEndpointBody(tool, document),
439
+ publicDiscovery: paymentSignal === 0,
440
+ })
441
+ }
442
+ }
443
+ for (const endpointMap of endpointMaps) {
444
+ for (const [key, endpoint] of Object.entries(endpointMap)) {
445
+ if (typeof endpoint === 'string') {
446
+ const methodMatch = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
447
+ const rawPath = methodMatch?.[2] ?? key
448
+ entries.push({
449
+ name: String(rawPath).split('/').filter(Boolean).at(-1) ?? key,
450
+ url: endpointUrl(rawPath, baseUrl, sourceUrl),
451
+ method: String(methodMatch?.[1] ?? 'GET').toUpperCase(),
452
+ })
453
+ continue
454
+ }
402
455
  if (!endpoint || typeof endpoint !== 'object') continue
403
- const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
456
+ const keyPath = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)?.[2] ?? key
457
+ const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path ?? keyPath
404
458
  if (!rawPath) continue
405
459
  const method = String(endpoint.method ?? 'POST').toUpperCase()
406
460
  const paymentSignal = manifestEndpointPaymentSignal(endpoint)
@@ -685,6 +739,23 @@ function challengeAccepts(result) {
685
739
  return []
686
740
  }
687
741
 
742
+ function extensionKeys(container) {
743
+ const extensions = container?.extensions
744
+ if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return []
745
+ return Object.keys(extensions)
746
+ }
747
+
748
+ function challengeExtensionKeys(result) {
749
+ return new Set([
750
+ ...extensionKeys(result.body.json),
751
+ ...challengeAccepts(result).flatMap(accept => extensionKeys(accept)),
752
+ ].map(key => key.toLowerCase()))
753
+ }
754
+
755
+ function hasChallengeExtension(result, pattern) {
756
+ return [...challengeExtensionKeys(result)].some(key => pattern.test(key))
757
+ }
758
+
688
759
  function acceptAmountValue(accept) {
689
760
  return accept.maxAmountRequired ?? accept.maxAmount ?? accept.amount ?? ''
690
761
  }
@@ -898,6 +969,10 @@ function looksLikeOperationalHealthEndpoint(result) {
898
969
  return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
899
970
  }
900
971
 
972
+ function isMutatingMethod(method) {
973
+ return !['GET', 'HEAD', 'OPTIONS'].includes(String(method ?? 'POST').toUpperCase())
974
+ }
975
+
901
976
  function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
902
977
  const document = documentResult.body.json ?? {}
903
978
  const findings = []
@@ -1005,6 +1080,15 @@ function findingList(documentResult, challengeResults, preflightResults, entries
1005
1080
  else if (options.strictCache && !cachePolicy(result.headers)) {
1006
1081
  findings.push(`P3 - ${result.name} payment challenge response did not expose Cache-Control; for payment-gated routes, document or send no-store/private cache policy and confirm paid 200 responses are never shared-cacheable.`)
1007
1082
  }
1083
+
1084
+ if (options.strictProof) {
1085
+ if (isMutatingMethod(result.method) && !hasChallengeExtension(result, /payment[-_]?identifier|idempotenc/)) {
1086
+ findings.push(`P2 - ${result.name} is a mutating paid route but does not advertise payment-identifier idempotency; retries after network or client failures can duplicate charges or lose paid responses without a shared payment ID.`)
1087
+ }
1088
+ if (!hasChallengeExtension(result, /offer[-_]?receipt|signed[-_]?(offer|receipt)/)) {
1089
+ findings.push(`P3 - ${result.name} challenge does not advertise signed offer/receipt metadata; clients have weaker proof of committed terms and service delivery for audits or disputes.`)
1090
+ }
1091
+ }
1008
1092
  }
1009
1093
 
1010
1094
  for (const result of preflightResults) {
@@ -1072,6 +1156,12 @@ function groupedFindingLabel(finding) {
1072
1156
  if (/payment challenge response is explicitly cacheable/.test(finding)) {
1073
1157
  return 'P1 - Payment challenge responses are explicitly cacheable.'
1074
1158
  }
1159
+ if (/does not advertise payment-identifier idempotency/.test(finding)) {
1160
+ return 'P2 - Mutating paid routes do not advertise payment-identifier idempotency.'
1161
+ }
1162
+ if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
1163
+ return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
1164
+ }
1075
1165
  if (/content while payment headers advertise enforcement/.test(finding)) {
1076
1166
  return 'P2 - Payment headers advertise enforcement on a 200 response.'
1077
1167
  }
@@ -1111,6 +1201,11 @@ function referenceGuides(findings) {
1111
1201
  add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
1112
1202
  add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
1113
1203
  }
1204
+ if (/payment-identifier|idempotency|signed offer|signed offer\/receipt|service delivery|audits|disputes/i.test(text)) {
1205
+ add('x402 Payment-Identifier', 'https://docs.x402.org/extensions/payment-identifier')
1206
+ add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
1207
+ add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
1208
+ }
1114
1209
  if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
1115
1210
  add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
1116
1211
  add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
@@ -1243,6 +1338,7 @@ async function runCheck(options) {
1243
1338
  }
1244
1339
  report.findings = findingList(document, challenges, preflights, entries, {
1245
1340
  strictCache: options.strictCache,
1341
+ strictProof: options.strictProof,
1246
1342
  })
1247
1343
  return report
1248
1344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.27",
3
+ "version": "0.2.30",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {