x402-surface-check 0.2.10 → 0.2.12
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 +5 -0
- package/bin/x402-surface-check.mjs +49 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
15
15
|
## What It Checks
|
|
16
16
|
|
|
17
17
|
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
|
+
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, or `resourcesUrl`
|
|
18
19
|
- No-payment HTTP 402 challenge shape
|
|
19
20
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
20
21
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
@@ -27,6 +28,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
27
28
|
- Browser CORS allowance for `X-PAYMENT`
|
|
28
29
|
- Over-broad public method surfaces
|
|
29
30
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
31
|
+
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
30
32
|
- Object-valued document metadata such as facilitator objects, without `[object Object]` report artifacts
|
|
31
33
|
|
|
32
34
|
## Public Proof
|
|
@@ -36,6 +38,9 @@ Recent public no-payment checks have found and verified real launch fixes:
|
|
|
36
38
|
- TensorFeed: parameter-required premium routes moved behind canonical x402 V2 challenges, then verified clean. https://github.com/solana-foundation/pay-skills/pull/68#issuecomment-4455360068
|
|
37
39
|
- x402jp: weather routes that returned 500 now return structured Base x402 challenges. https://github.com/solana-foundation/pay-skills/pull/58#issuecomment-4455401355
|
|
38
40
|
- Spraay: resource echo and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/60#issuecomment-4455519760
|
|
41
|
+
- UZPROOF: schemes-style Solana x402 challenge and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/28#issuecomment-4455613892
|
|
42
|
+
- HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
|
|
43
|
+
- 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
|
|
39
44
|
- Agent Trust Bench: live discovery URL and browser-compatibility notes for adversarial agent-payment resources. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4455484414
|
|
40
45
|
|
|
41
46
|
Field notes and browser version: https://tateprograms.com/x402-surface-check.html
|
|
@@ -132,6 +132,15 @@ function endpointUrl(rawPath, baseUrl, sourceUrl) {
|
|
|
132
132
|
return new URL(value, base).toString()
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function linkedDiscoveryUrl(document, sourceUrl) {
|
|
136
|
+
const rawUrl = document?.discovery_url
|
|
137
|
+
?? document?.discoveryUrl
|
|
138
|
+
?? document?.resources_url
|
|
139
|
+
?? document?.resourcesUrl
|
|
140
|
+
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
|
|
141
|
+
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
|
|
142
|
+
}
|
|
143
|
+
|
|
135
144
|
function operationExpectedPrice(operation) {
|
|
136
145
|
const price = operation?.['x-payment-info']?.price
|
|
137
146
|
?? operation?.['x-payment']?.price
|
|
@@ -501,11 +510,21 @@ function looksLikePlaceholderPayTo(payTo) {
|
|
|
501
510
|
return false
|
|
502
511
|
}
|
|
503
512
|
|
|
513
|
+
function entryKey(entry) {
|
|
514
|
+
return `${entry.method ?? 'POST'} ${entry.url}`
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function looksLikeOperationalHealthEndpoint(result) {
|
|
518
|
+
const value = `${result.name ?? ''} ${new URL(result.url).pathname}`.toLowerCase()
|
|
519
|
+
return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
|
|
520
|
+
}
|
|
521
|
+
|
|
504
522
|
function findingList(documentResult, challengeResults, preflightResults, entries) {
|
|
505
523
|
const document = documentResult.body.json ?? {}
|
|
506
524
|
const findings = []
|
|
507
525
|
const networks = valueList(document.networks)
|
|
508
526
|
const challengeNetworks = new Set()
|
|
527
|
+
const challengesByEntry = new Map(challengeResults.map(result => [entryKey(result), result]))
|
|
509
528
|
|
|
510
529
|
if (documentResult.status < 200 || documentResult.status >= 300) {
|
|
511
530
|
findings.push(`P1 - Document returned HTTP ${documentResult.status}; expected a successful JSON response.`)
|
|
@@ -526,7 +545,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
526
545
|
|
|
527
546
|
if (result.status !== 402) {
|
|
528
547
|
if (result.status >= 200 && result.status < 300) {
|
|
529
|
-
|
|
548
|
+
if (!looksLikeOperationalHealthEndpoint(result)) {
|
|
549
|
+
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.`)
|
|
550
|
+
}
|
|
530
551
|
}
|
|
531
552
|
else if (result.status === 400 || result.status === 422) {
|
|
532
553
|
findings.push(`P1 - ${result.name} returned validation HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
@@ -569,9 +590,14 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
569
590
|
}
|
|
570
591
|
|
|
571
592
|
for (const result of preflightResults) {
|
|
593
|
+
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
594
|
+
if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
|
|
572
595
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
573
596
|
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
574
|
-
|
|
597
|
+
const observed = result.status >= 400
|
|
598
|
+
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
599
|
+
: `allow headers: ${allowed || 'none'}`
|
|
600
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed ${observed}.`)
|
|
575
601
|
}
|
|
576
602
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
577
603
|
if (/delete|put|patch/i.test(allowedMethods)) {
|
|
@@ -603,6 +629,7 @@ function formatMarkdown(report) {
|
|
|
603
629
|
return [
|
|
604
630
|
'# x402 Public Surface Check',
|
|
605
631
|
'',
|
|
632
|
+
...(report.sourceDocument ? [`Source: ${report.sourceDocument.url}`] : []),
|
|
606
633
|
`Document: ${report.document.url}`,
|
|
607
634
|
`Checked: ${report.checkedAt}`,
|
|
608
635
|
'Scope: manifest/OpenAPI parsing, no-payment endpoint probes, and browser-style CORS preflight. No payment headers or paid calls.',
|
|
@@ -639,7 +666,8 @@ function formatMarkdown(report) {
|
|
|
639
666
|
}
|
|
640
667
|
|
|
641
668
|
async function runCheck(options) {
|
|
642
|
-
|
|
669
|
+
let sourceDocument = null
|
|
670
|
+
let document = options.endpoint
|
|
643
671
|
? {
|
|
644
672
|
status: 200,
|
|
645
673
|
ok: true,
|
|
@@ -648,9 +676,25 @@ async function runCheck(options) {
|
|
|
648
676
|
body: { text: '{}', json: {} },
|
|
649
677
|
}
|
|
650
678
|
: await fetchDocument(options.url)
|
|
651
|
-
|
|
679
|
+
let entries = options.endpoint
|
|
652
680
|
? [{ name: new URL(options.url).pathname.split('/').filter(Boolean).at(-1) ?? options.url, url: options.url, method: options.method || 'POST' }]
|
|
653
681
|
: (document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : [])
|
|
682
|
+
|
|
683
|
+
if (!options.endpoint && entries.length === 0 && document.body.json) {
|
|
684
|
+
const discoveryUrl = linkedDiscoveryUrl(document.body.json, document.url)
|
|
685
|
+
if (discoveryUrl) {
|
|
686
|
+
const followedDocument = await fetchDocument(discoveryUrl)
|
|
687
|
+
const followedEntries = followedDocument.body.json
|
|
688
|
+
? endpointEntries(followedDocument.body.json, followedDocument.url, options.limit)
|
|
689
|
+
: []
|
|
690
|
+
if (followedEntries.length > 0) {
|
|
691
|
+
sourceDocument = document
|
|
692
|
+
document = followedDocument
|
|
693
|
+
entries = followedEntries
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
654
698
|
const origin = options.origin ?? new URL(document.url).origin
|
|
655
699
|
const challenges = []
|
|
656
700
|
const preflights = []
|
|
@@ -669,6 +713,7 @@ async function runCheck(options) {
|
|
|
669
713
|
origin,
|
|
670
714
|
challenges,
|
|
671
715
|
preflights,
|
|
716
|
+
sourceDocument,
|
|
672
717
|
}
|
|
673
718
|
report.findings = findingList(document, challenges, preflights, entries)
|
|
674
719
|
return report
|