x402-surface-check 0.2.23 → 0.2.25

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
@@ -33,6 +34,7 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
33
34
  - HTTPS resource URLs and stable resource metadata
34
35
  - Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
35
36
  - Cache-Control posture on no-payment challenge responses, with warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
37
+ - Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
36
38
  - Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
37
39
  - Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
38
40
  - Over-broad public method surfaces
@@ -51,6 +53,7 @@ Recent public no-payment checks have found and verified real launch fixes:
51
53
  - HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
52
54
  - anchor-x402: multi-rail x402 challenges verified, with browser preflight blockers isolated before merge. https://github.com/solana-foundation/pay-skills/pull/47#issuecomment-4455678163
53
55
  - Agent Trust Bench: three no-payment passes converged on zero findings after discovery, browser preflight, cache, and resource-echo fixes. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4467597309
56
+ - SolSentry: x402 stats endpoint flagged for returning `200` content while headers advertised payment enforcement and browser preflight omitted `X-PAYMENT`. https://github.com/solsentry/solsentry-app/issues/2
54
57
  - Solrouter: private LLM inference route verified with HTTPS resource-binding and price-alignment notes. https://github.com/solana-foundation/pay-skills/pull/39#issuecomment-4455800060
55
58
  - Tetrac: Solana market-data payment gates verified, with browser payment-header preflight blocker isolated. https://github.com/solana-foundation/pay-skills/pull/32#issuecomment-4455923744
56
59
 
@@ -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) {
@@ -710,6 +769,24 @@ function cachePolicy(headers = {}) {
710
769
  return headers['cache-control'] ?? headers.cacheControl ?? ''
711
770
  }
712
771
 
772
+ function paymentSignalHeaders(headers = {}) {
773
+ return [
774
+ 'x-payment-required',
775
+ 'x-payment-enforce',
776
+ 'x-price-usdc',
777
+ 'x-payment-address',
778
+ 'x-payment-network',
779
+ 'x-payment-token',
780
+ 'x-payment-protocol',
781
+ ].filter(name => headers[name] !== undefined && headers[name] !== '')
782
+ }
783
+
784
+ function advertisesPaymentEnforcement(headers = {}) {
785
+ const required = String(headers['x-payment-required'] ?? '').toLowerCase() === 'true'
786
+ const enforced = String(headers['x-payment-enforce'] ?? '').toLowerCase() === 'true'
787
+ return required || enforced || (required && paymentSignalHeaders(headers).length > 1)
788
+ }
789
+
713
790
  function looksExplicitlyCacheable(headers = {}) {
714
791
  const policy = cachePolicy(headers)
715
792
  if (!policy) return false
@@ -750,11 +827,21 @@ function findingList(documentResult, challengeResults, preflightResults, entries
750
827
  if (summary.network) challengeNetworks.add(summary.network)
751
828
  const hasChallenge = hasPaymentChallenge(result)
752
829
 
830
+ if (result.publicDiscovery && !hasChallenge) {
831
+ if (result.status < 200 || result.status >= 300) {
832
+ 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.`)
833
+ }
834
+ continue
835
+ }
836
+
753
837
  if (result.status !== 402) {
754
838
  if (result.status >= 200 && result.status < 300) {
755
839
  if (!looksLikeOperationalHealthEndpoint(result)) {
756
840
  findings.push(`P3 - ${result.name} returned ${result.status} without a payment challenge for a no-payment ${result.method ?? 'POST'} probe; document this as free/trial access or move the 402 challenge before content.`)
757
841
  }
842
+ if (advertisesPaymentEnforcement(result.headers)) {
843
+ findings.push(`P2 - ${result.name} returned ${result.status} content while payment headers advertise enforcement (${paymentSignalHeaders(result.headers).join(', ')}); either return a 402 before content or document this endpoint as public telemetry.`)
844
+ }
758
845
  }
759
846
  else if (result.status === 400 || result.status === 422) {
760
847
  findings.push(`P1 - ${result.name} returned validation HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe.`)
@@ -807,7 +894,7 @@ function findingList(documentResult, challengeResults, preflightResults, entries
807
894
 
808
895
  for (const result of preflightResults) {
809
896
  const challengeResult = challengesByEntry.get(entryKey(result))
810
- if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
897
+ if (!challengeResult || (!hasPaymentChallenge(challengeResult) && !advertisesPaymentEnforcement(challengeResult.headers))) continue
811
898
  const allowedOrigin = result.headers['access-control-allow-origin'] ?? ''
812
899
  if (!allowedOrigin) {
813
900
  findings.push(`P1 - ${result.name} CORS preflight does not allow the requesting origin; observed allow-origin: none.`)
@@ -864,6 +951,9 @@ function groupedFindingLabel(finding) {
864
951
  if (/payment challenge response did not expose Cache-Control/.test(finding)) {
865
952
  return 'P3 - Payment challenge responses do not expose Cache-Control in strict cache mode.'
866
953
  }
954
+ if (/content while payment headers advertise enforcement/.test(finding)) {
955
+ return 'P2 - Payment headers advertise enforcement on a 200 response.'
956
+ }
867
957
  return null
868
958
  }
869
959
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {