x402-surface-check 0.2.27 → 0.2.29

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,28 @@ 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') {
423
+ endpointMaps.push(document.tools)
424
+ }
425
+ for (const endpointMap of endpointMaps) {
426
+ for (const [key, endpoint] of Object.entries(endpointMap)) {
427
+ if (typeof endpoint === 'string') {
428
+ const methodMatch = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
429
+ const rawPath = methodMatch?.[2] ?? key
430
+ entries.push({
431
+ name: String(rawPath).split('/').filter(Boolean).at(-1) ?? key,
432
+ url: endpointUrl(rawPath, baseUrl, sourceUrl),
433
+ method: String(methodMatch?.[1] ?? 'GET').toUpperCase(),
434
+ })
435
+ continue
436
+ }
402
437
  if (!endpoint || typeof endpoint !== 'object') continue
403
- const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
438
+ const keyPath = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)?.[2] ?? key
439
+ const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path ?? keyPath
404
440
  if (!rawPath) continue
405
441
  const method = String(endpoint.method ?? 'POST').toUpperCase()
406
442
  const paymentSignal = manifestEndpointPaymentSignal(endpoint)
@@ -685,6 +721,23 @@ function challengeAccepts(result) {
685
721
  return []
686
722
  }
687
723
 
724
+ function extensionKeys(container) {
725
+ const extensions = container?.extensions
726
+ if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return []
727
+ return Object.keys(extensions)
728
+ }
729
+
730
+ function challengeExtensionKeys(result) {
731
+ return new Set([
732
+ ...extensionKeys(result.body.json),
733
+ ...challengeAccepts(result).flatMap(accept => extensionKeys(accept)),
734
+ ].map(key => key.toLowerCase()))
735
+ }
736
+
737
+ function hasChallengeExtension(result, pattern) {
738
+ return [...challengeExtensionKeys(result)].some(key => pattern.test(key))
739
+ }
740
+
688
741
  function acceptAmountValue(accept) {
689
742
  return accept.maxAmountRequired ?? accept.maxAmount ?? accept.amount ?? ''
690
743
  }
@@ -898,6 +951,10 @@ function looksLikeOperationalHealthEndpoint(result) {
898
951
  return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
899
952
  }
900
953
 
954
+ function isMutatingMethod(method) {
955
+ return !['GET', 'HEAD', 'OPTIONS'].includes(String(method ?? 'POST').toUpperCase())
956
+ }
957
+
901
958
  function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
902
959
  const document = documentResult.body.json ?? {}
903
960
  const findings = []
@@ -1005,6 +1062,15 @@ function findingList(documentResult, challengeResults, preflightResults, entries
1005
1062
  else if (options.strictCache && !cachePolicy(result.headers)) {
1006
1063
  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
1064
  }
1065
+
1066
+ if (options.strictProof) {
1067
+ if (isMutatingMethod(result.method) && !hasChallengeExtension(result, /payment[-_]?identifier|idempotenc/)) {
1068
+ 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.`)
1069
+ }
1070
+ if (!hasChallengeExtension(result, /offer[-_]?receipt|signed[-_]?(offer|receipt)/)) {
1071
+ 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.`)
1072
+ }
1073
+ }
1008
1074
  }
1009
1075
 
1010
1076
  for (const result of preflightResults) {
@@ -1072,6 +1138,12 @@ function groupedFindingLabel(finding) {
1072
1138
  if (/payment challenge response is explicitly cacheable/.test(finding)) {
1073
1139
  return 'P1 - Payment challenge responses are explicitly cacheable.'
1074
1140
  }
1141
+ if (/does not advertise payment-identifier idempotency/.test(finding)) {
1142
+ return 'P2 - Mutating paid routes do not advertise payment-identifier idempotency.'
1143
+ }
1144
+ if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
1145
+ return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
1146
+ }
1075
1147
  if (/content while payment headers advertise enforcement/.test(finding)) {
1076
1148
  return 'P2 - Payment headers advertise enforcement on a 200 response.'
1077
1149
  }
@@ -1111,6 +1183,11 @@ function referenceGuides(findings) {
1111
1183
  add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
1112
1184
  add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
1113
1185
  }
1186
+ if (/payment-identifier|idempotency|signed offer|signed offer\/receipt|service delivery|audits|disputes/i.test(text)) {
1187
+ add('x402 Payment-Identifier', 'https://docs.x402.org/extensions/payment-identifier')
1188
+ add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
1189
+ add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
1190
+ }
1114
1191
  if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
1115
1192
  add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
1116
1193
  add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
@@ -1243,6 +1320,7 @@ async function runCheck(options) {
1243
1320
  }
1244
1321
  report.findings = findingList(document, challenges, preflights, entries, {
1245
1322
  strictCache: options.strictCache,
1323
+ strictProof: options.strictProof,
1246
1324
  })
1247
1325
  return report
1248
1326
  }
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.29",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {