x402-surface-check 0.2.24 → 0.2.26

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
@@ -17,7 +17,8 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
17
17
 
18
18
  ## What It Checks
19
19
 
20
- - Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
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
+ - 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
21
22
  - Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
22
23
  - OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
23
24
  - 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
@@ -31,8 +32,10 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
31
32
  - Placeholder recipients such as zero addresses and Solana system-program values
32
33
  - Testnet or staging rails such as Base Sepolia and Solana devnet
33
34
  - HTTPS resource URLs and stable resource metadata
35
+ - Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
36
+ - Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
34
37
  - Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
35
- - Cache-Control posture on no-payment challenge responses, with warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
38
+ - 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
36
39
  - Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
37
40
  - Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
38
41
  - Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
@@ -322,6 +322,47 @@ function openApiProbeUrl(path, operation, baseUrl, document) {
322
322
  return url.toString()
323
323
  }
324
324
 
325
+ function manifestEndpointPaymentSignal(endpoint) {
326
+ if (!endpoint || typeof endpoint !== 'object') return 0
327
+ if (Number(endpoint.phase1_response?.status) === 402) return 2
328
+ if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
329
+ if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
330
+ if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
331
+ return 0
332
+ }
333
+
334
+ function manifestEndpointBody(endpoint, document) {
335
+ const body = endpoint?.request_body ?? endpoint?.requestBody
336
+ if (!body || typeof body !== 'object') return undefined
337
+ if (body.example !== undefined) return body.example
338
+ if (body.safe_example !== undefined) return body.safe_example
339
+ if (body.safeExample !== undefined) return body.safeExample
340
+ return exampleValue(body, document)
341
+ }
342
+
343
+ function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
344
+ const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
345
+ const parameters = endpoint?.parameters
346
+ if (!parameters || typeof parameters !== 'object') return url.toString()
347
+
348
+ for (const [name, parameter] of Object.entries(parameters)) {
349
+ if (url.pathname.includes(`{${name}}`)) {
350
+ const pathValue = parameter?.example ?? parameter?.default
351
+ if (pathValue !== undefined && pathValue !== '') {
352
+ url.pathname = url.pathname.replaceAll(`{${name}}`, encodeURIComponent(String(pathValue)))
353
+ }
354
+ continue
355
+ }
356
+
357
+ const value = parameter?.example ?? parameter?.default
358
+ if (value !== undefined && value !== '') {
359
+ url.searchParams.set(name, String(value))
360
+ }
361
+ }
362
+
363
+ return url.toString()
364
+ }
365
+
325
366
  function endpointEntries(document, sourceUrl, limit) {
326
367
  const entries = []
327
368
  const baseUrl = documentBaseUrl(document, sourceUrl)
@@ -356,6 +397,24 @@ function endpointEntries(document, sourceUrl, limit) {
356
397
  })
357
398
  }
358
399
  }
400
+ else if (document.endpoints && typeof document.endpoints === 'object') {
401
+ for (const [key, endpoint] of Object.entries(document.endpoints)) {
402
+ if (!endpoint || typeof endpoint !== 'object') continue
403
+ const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path
404
+ if (!rawPath) continue
405
+ const method = String(endpoint.method ?? 'POST').toUpperCase()
406
+ const paymentSignal = manifestEndpointPaymentSignal(endpoint)
407
+ const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
408
+ if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
409
+ entries.push({
410
+ name: endpoint.id ?? endpoint.name ?? key,
411
+ url: manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl),
412
+ method,
413
+ requestBody: manifestEndpointBody(endpoint, document),
414
+ publicDiscovery: paymentSignal === 0,
415
+ })
416
+ }
417
+ }
359
418
 
360
419
  if (Array.isArray(document.items)) {
361
420
  for (const item of document.items) {
@@ -640,6 +699,37 @@ function acceptDecimals(accept) {
640
699
  return Number.isFinite(numeric) ? numeric : 6
641
700
  }
642
701
 
702
+ function acceptResourceValue(accept) {
703
+ return accept.resource
704
+ ?? accept.extra?.resource
705
+ ?? accept.resourceUrl
706
+ ?? accept.extra?.resourceUrl
707
+ ?? ''
708
+ }
709
+
710
+ function challengeResourceValue(challenge) {
711
+ return challenge?.resource?.url
712
+ ?? challenge?.resourceUrl
713
+ ?? ''
714
+ }
715
+
716
+ function hasFreshnessMetadata(challenge, accept) {
717
+ return [
718
+ challenge?.expires,
719
+ challenge?.expiresAt,
720
+ challenge?.validBefore,
721
+ challenge?.maxTimeoutSeconds,
722
+ accept?.maxTimeoutSeconds,
723
+ accept?.maxTimeout,
724
+ accept?.timeout,
725
+ accept?.expires,
726
+ accept?.expiresAt,
727
+ accept?.validBefore,
728
+ accept?.extra?.expires,
729
+ accept?.extra?.validBefore,
730
+ ].some(value => value !== undefined && value !== null && value !== '')
731
+ }
732
+
643
733
  function usesDecimalAmount(accept, result) {
644
734
  const rawAmount = acceptAmountValue(accept)
645
735
  if (rawAmount === undefined || rawAmount === null || rawAmount === '') return false
@@ -675,8 +765,8 @@ function challengeSummary(result) {
675
765
  const firstAccept = challengeAccepts(result)[0] ?? {}
676
766
  const hasChallenge = hasPaymentChallenge(result)
677
767
  const amount = acceptAmountValue(firstAccept)
678
- const resourceUrl = challenge?.resource?.url ?? firstAccept.resource ?? ''
679
- const extraResource = firstAccept.extra?.resource ?? firstAccept.resource ?? ''
768
+ const resourceUrl = challengeResourceValue(challenge) || acceptResourceValue(firstAccept)
769
+ const extraResource = acceptResourceValue(firstAccept)
680
770
 
681
771
  return {
682
772
  status: result.status,
@@ -706,6 +796,21 @@ function looksLikePlaceholderPayTo(payTo) {
706
796
  return false
707
797
  }
708
798
 
799
+ function looksLikeLocalResourceUrl(value) {
800
+ if (!/^https?:\/\//i.test(String(value ?? ''))) return false
801
+ try {
802
+ const host = new URL(value).hostname.toLowerCase()
803
+ return host === 'localhost'
804
+ || host === '0.0.0.0'
805
+ || host === '127.0.0.1'
806
+ || host === '::1'
807
+ || host.endsWith('.local')
808
+ }
809
+ catch {
810
+ return false
811
+ }
812
+ }
813
+
709
814
  function cachePolicy(headers = {}) {
710
815
  return headers['cache-control'] ?? headers.cacheControl ?? ''
711
816
  }
@@ -768,6 +873,13 @@ function findingList(documentResult, challengeResults, preflightResults, entries
768
873
  if (summary.network) challengeNetworks.add(summary.network)
769
874
  const hasChallenge = hasPaymentChallenge(result)
770
875
 
876
+ if (result.publicDiscovery && !hasChallenge) {
877
+ if (result.status < 200 || result.status >= 300) {
878
+ findings.push(`P2 - ${result.name} is documented as a public discovery route but returned HTTP ${result.status}; check the manifest example parameters or route availability.`)
879
+ }
880
+ continue
881
+ }
882
+
771
883
  if (result.status !== 402) {
772
884
  if (result.status >= 200 && result.status < 300) {
773
885
  if (!looksLikeOperationalHealthEndpoint(result)) {
@@ -798,6 +910,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
798
910
  if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
799
911
  findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
800
912
  }
913
+ if (looksLikeLocalResourceUrl(summary.resourceUrl) || looksLikeLocalResourceUrl(summary.extraResource)) {
914
+ findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${summary.resourceUrl || summary.extraResource}.`)
915
+ }
801
916
  if (!summary.amount || !summary.payTo || !summary.asset) {
802
917
  findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
803
918
  }
@@ -807,7 +922,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
807
922
  findings.push(`P1 - ${result.name} documented price ${moneyFromDecimal(summary.expectedPriceUsd)} does not match live 402 challenge price ${moneyFromDecimal(summary.priceUsd)}.`)
808
923
  }
809
924
  }
810
- for (const accept of challengeAccepts(result)) {
925
+ const accepts = challengeAccepts(result)
926
+ const topResource = challengeResourceValue(result.body.json)
927
+ const acceptResources = accepts.map(acceptResourceValue)
928
+ const populatedAcceptResources = acceptResources.filter(Boolean)
929
+
930
+ for (const accept of accepts) {
811
931
  if (looksLikePlaceholderPayTo(accept.payTo)) {
812
932
  findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
813
933
  }
@@ -815,11 +935,20 @@ function findingList(documentResult, challengeResults, preflightResults, entries
815
935
  findings.push(`P2 - ${result.name} challenge advertises staging/test network ${accept.network}; document this as demo-only until live-value payment rails are active.`)
816
936
  }
817
937
  }
818
- if (!summary.resourceUrl || !summary.extraResource) {
819
- findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in both resource.url and accepts[0].extra.resource/resource.`)
938
+ if (!topResource && populatedAcceptResources.length === 0) {
939
+ findings.push(`P2 - ${result.name} challenge does not expose a signed/intended resource URL at the top level or in any accept leg.`)
940
+ }
941
+ else if (accepts.length > 0 && populatedAcceptResources.length < accepts.length) {
942
+ findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in every accept leg for spend-map and replay binding.`)
943
+ }
944
+ if (topResource && populatedAcceptResources.some(resource => resource !== topResource)) {
945
+ findings.push(`P2 - ${result.name} challenge resource URL differs between resource.url and one or more accept legs; agents may bind the payment to inconsistent resources.`)
946
+ }
947
+ if (accepts.length > 0 && !accepts.some(accept => hasFreshnessMetadata(result.body.json, accept))) {
948
+ findings.push(`P2 - ${result.name} challenge does not expose timeout/expiry metadata; bounded freshness helps reduce replay windows for payment capabilities.`)
820
949
  }
821
950
  if (looksExplicitlyCacheable(result.headers)) {
822
- findings.push(`P2 - ${result.name} payment challenge response is explicitly cacheable (${cachePolicy(result.headers)}); paid routes should use no-store/private cache policy or bypass shared caches.`)
951
+ findings.push(`P1 - ${result.name} payment challenge response is explicitly cacheable (${cachePolicy(result.headers)}); paid routes should use no-store/private cache policy or bypass shared caches.`)
823
952
  }
824
953
  else if (options.strictCache && !cachePolicy(result.headers)) {
825
954
  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.`)
@@ -867,8 +996,8 @@ function groupedFindingLabel(finding) {
867
996
  if (/CORS preflight does not allow X-PAYMENT/.test(finding)) {
868
997
  return 'P1 - CORS preflight does not allow X-PAYMENT.'
869
998
  }
870
- if (/challenge does not repeat the resource URL/.test(finding)) {
871
- return 'P2 - Challenge accept legs do not repeat the resource URL for reconciliation.'
999
+ if (/challenge does not expose a signed\/intended resource URL|challenge does not repeat the resource URL|challenge resource URL differs/.test(finding)) {
1000
+ return 'P2 - Challenges have incomplete or inconsistent resource binding.'
872
1001
  }
873
1002
  if (/returned validation HTTP \d+ before a payment challenge/.test(finding)) {
874
1003
  return 'P1 - Routes return validation before a payment challenge.'
@@ -882,9 +1011,15 @@ function groupedFindingLabel(finding) {
882
1011
  if (/challenge advertises placeholder-looking payTo/.test(finding)) {
883
1012
  return 'P1 - Challenges advertise placeholder-looking payTo recipients.'
884
1013
  }
1014
+ if (/challenge does not expose timeout\/expiry metadata/.test(finding)) {
1015
+ return 'P2 - Challenges do not expose timeout or expiry metadata for replay-window control.'
1016
+ }
885
1017
  if (/payment challenge response did not expose Cache-Control/.test(finding)) {
886
1018
  return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
887
1019
  }
1020
+ if (/payment challenge response is explicitly cacheable/.test(finding)) {
1021
+ return 'P1 - Payment challenge responses are explicitly cacheable.'
1022
+ }
888
1023
  if (/content while payment headers advertise enforcement/.test(finding)) {
889
1024
  return 'P2 - Payment headers advertise enforcement on a 200 response.'
890
1025
  }
@@ -916,11 +1051,13 @@ function referenceGuides(findings) {
916
1051
  add('Cloudflare x402 Worker Starter', 'https://tateprograms.com/cloudflare-x402-worker.html')
917
1052
  add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
918
1053
  }
919
- if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency/i.test(text)) {
1054
+ if (/validation HTTP \d+ before a payment challenge|auth HTTP \d+ before a payment challenge|replay|idempotency|timeout\/expiry|freshness/i.test(text)) {
920
1055
  add('x402 Launch Checklist', 'https://tateprograms.com/x402-launch-checklist.html')
1056
+ add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
921
1057
  }
922
- if (/resource URL|resource echo|accepts\[0\]\.extra\.resource/i.test(text)) {
1058
+ if (/resource URL|resource echo|resource binding|accept leg|accepts\[0\]\.extra\.resource/i.test(text)) {
923
1059
  add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
1060
+ add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
924
1061
  }
925
1062
  return guides.map(guide => `- ${guide.label}: ${guide.url}`)
926
1063
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {