x402-surface-check 0.2.16 → 0.2.18
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 -1
- package/bin/x402-surface-check.mjs +101 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ npm: https://www.npmjs.com/package/x402-surface-check
|
|
|
10
10
|
npx --yes x402-surface-check https://api.example.com/.well-known/x402
|
|
11
11
|
npx --yes x402-surface-check https://api.example.com/openapi.json report.md
|
|
12
12
|
npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/eth
|
|
13
|
+
npx --yes x402-surface-check --endpoint --method POST --body '{"prompt":"price CPI"}' https://api.example.com/paid-post
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
## What It Checks
|
|
@@ -17,7 +18,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
17
18
|
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
19
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
19
20
|
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
20
|
-
- OpenAPI query/path examples, JSON request-body examples,
|
|
21
|
+
- 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
|
|
21
22
|
- OpenAPI paid-operation prioritization, so docs and discovery routes do not consume the probe limit before payment-bearing operations
|
|
22
23
|
- No-payment HTTP 402 challenge shape
|
|
23
24
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
@@ -29,6 +30,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
29
30
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
30
31
|
- HTTPS resource URLs and stable resource metadata
|
|
31
32
|
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
33
|
+
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
32
34
|
- Over-broad public method surfaces
|
|
33
35
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
34
36
|
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
@@ -58,6 +60,8 @@ x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
|
|
|
58
60
|
|
|
59
61
|
--endpoint Treat the URL as one paid endpoint instead of a discovery document
|
|
60
62
|
--method <verb> HTTP method for direct endpoint mode, default POST
|
|
63
|
+
--body <json> JSON request body for direct endpoint mode
|
|
64
|
+
--body-file <p> Read JSON request body for direct endpoint mode from a file
|
|
61
65
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
62
66
|
--limit <n> Maximum endpoints to probe, default 6
|
|
63
67
|
--json Print JSON instead of Markdown
|
|
@@ -18,6 +18,8 @@ Usage:
|
|
|
18
18
|
Options:
|
|
19
19
|
--endpoint Treat the URL as one paid endpoint instead of a discovery document
|
|
20
20
|
--method <verb> HTTP method for direct endpoint mode, default POST
|
|
21
|
+
--body <json> JSON request body for direct endpoint mode
|
|
22
|
+
--body-file <p> Read JSON request body for direct endpoint mode from a file
|
|
21
23
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
22
24
|
--limit <n> Maximum endpoints to probe, default ${defaultLimit}
|
|
23
25
|
--json Print JSON instead of Markdown
|
|
@@ -33,6 +35,8 @@ function parseArgs(argv) {
|
|
|
33
35
|
limit: Number(process.env.X402_CHECK_LIMIT ?? defaultLimit),
|
|
34
36
|
method: 'POST',
|
|
35
37
|
origin: process.env.X402_CHECK_ORIGIN,
|
|
38
|
+
body: process.env.X402_CHECK_BODY,
|
|
39
|
+
bodyFile: process.env.X402_CHECK_BODY_FILE,
|
|
36
40
|
outputPath: '',
|
|
37
41
|
url: '',
|
|
38
42
|
}
|
|
@@ -55,6 +59,14 @@ function parseArgs(argv) {
|
|
|
55
59
|
args.method = String(argv[index + 1] ?? '').toUpperCase()
|
|
56
60
|
index += 1
|
|
57
61
|
}
|
|
62
|
+
else if (arg === '--body') {
|
|
63
|
+
args.body = argv[index + 1]
|
|
64
|
+
index += 1
|
|
65
|
+
}
|
|
66
|
+
else if (arg === '--body-file') {
|
|
67
|
+
args.bodyFile = argv[index + 1]
|
|
68
|
+
index += 1
|
|
69
|
+
}
|
|
58
70
|
else if (arg === '--origin') {
|
|
59
71
|
args.origin = argv[index + 1]
|
|
60
72
|
index += 1
|
|
@@ -77,6 +89,23 @@ function parseArgs(argv) {
|
|
|
77
89
|
return args
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
async function directEndpointRequestBody(options) {
|
|
93
|
+
if (!options.endpoint) return undefined
|
|
94
|
+
if (options.body && options.bodyFile) {
|
|
95
|
+
throw new Error('Use either --body or --body-file, not both.')
|
|
96
|
+
}
|
|
97
|
+
const raw = options.bodyFile
|
|
98
|
+
? await readFile(options.bodyFile, 'utf8')
|
|
99
|
+
: options.body
|
|
100
|
+
if (!raw) return undefined
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(raw)
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new Error(`Request body must be valid JSON: ${error.message}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
function moneyFromAtomic(amount, decimals = 6) {
|
|
81
110
|
if (amount === '' || amount === null || amount === undefined) return ''
|
|
82
111
|
const numeric = Number(amount)
|
|
@@ -176,9 +205,13 @@ function resolveSchema(schema, document, seen = new Set()) {
|
|
|
176
205
|
return resolveSchema(resolved, document, seen)
|
|
177
206
|
}
|
|
178
207
|
|
|
179
|
-
function exampleValue(schemaOrParameter, document) {
|
|
208
|
+
function exampleValue(schemaOrParameter, document, depth = 0) {
|
|
180
209
|
if (!schemaOrParameter || typeof schemaOrParameter !== 'object') return undefined
|
|
181
210
|
const schema = resolveSchema(schemaOrParameter.schema ?? schemaOrParameter, document)
|
|
211
|
+
const composite = schema.oneOf ?? schema.anyOf ?? schema.allOf
|
|
212
|
+
if (Array.isArray(composite) && composite.length > 0) {
|
|
213
|
+
return exampleValue(composite[0], document, depth + 1)
|
|
214
|
+
}
|
|
182
215
|
const value = schemaOrParameter.example
|
|
183
216
|
?? schema.const
|
|
184
217
|
?? schema.example
|
|
@@ -195,6 +228,25 @@ function exampleValue(schemaOrParameter, document) {
|
|
|
195
228
|
if (schema.type === 'integer') return Number.isFinite(Number(schema.minimum)) ? Number(schema.minimum) : 1
|
|
196
229
|
if (schema.type === 'number') return Number.isFinite(Number(schema.minimum)) ? Number(schema.minimum) : 1
|
|
197
230
|
if (schema.type === 'boolean') return false
|
|
231
|
+
if (schema.type === 'array') {
|
|
232
|
+
if (depth > 4) return []
|
|
233
|
+
const item = exampleValue(schema.items ?? {}, document, depth + 1)
|
|
234
|
+
return item === undefined ? [] : [item]
|
|
235
|
+
}
|
|
236
|
+
if (schema.type === 'object') {
|
|
237
|
+
if (depth > 4) return {}
|
|
238
|
+
const properties = schema.properties && typeof schema.properties === 'object'
|
|
239
|
+
? schema.properties
|
|
240
|
+
: {}
|
|
241
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : Object.keys(properties))
|
|
242
|
+
const result = {}
|
|
243
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
244
|
+
if (!required.has(key)) continue
|
|
245
|
+
const nestedValue = exampleValue(property, document, depth + 1)
|
|
246
|
+
if (nestedValue !== undefined) result[key] = nestedValue
|
|
247
|
+
}
|
|
248
|
+
return result
|
|
249
|
+
}
|
|
198
250
|
return undefined
|
|
199
251
|
}
|
|
200
252
|
|
|
@@ -762,6 +814,45 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
762
814
|
return findings
|
|
763
815
|
}
|
|
764
816
|
|
|
817
|
+
function groupedFindingLabel(finding) {
|
|
818
|
+
if (/402 challenge response does not allow the requesting origin/.test(finding)) {
|
|
819
|
+
return 'P1 - Actual 402 challenge responses do not allow the requesting origin; browser clients cannot read payment requirements.'
|
|
820
|
+
}
|
|
821
|
+
if (/CORS preflight does not allow the requesting origin/.test(finding)) {
|
|
822
|
+
return 'P1 - CORS preflight does not allow the requesting origin.'
|
|
823
|
+
}
|
|
824
|
+
if (/CORS preflight does not allow X-PAYMENT/.test(finding)) {
|
|
825
|
+
return 'P1 - CORS preflight does not allow X-PAYMENT.'
|
|
826
|
+
}
|
|
827
|
+
if (/challenge does not repeat the resource URL/.test(finding)) {
|
|
828
|
+
return 'P2 - Challenge accept legs do not repeat the resource URL for reconciliation.'
|
|
829
|
+
}
|
|
830
|
+
if (/returned validation HTTP \d+ before a payment challenge/.test(finding)) {
|
|
831
|
+
return 'P1 - Routes return validation before a payment challenge.'
|
|
832
|
+
}
|
|
833
|
+
if (/returned auth HTTP \d+ before a payment challenge/.test(finding)) {
|
|
834
|
+
return 'P2 - Routes return auth before a payment challenge.'
|
|
835
|
+
}
|
|
836
|
+
if (/challenge advertises staging\/test network/.test(finding)) {
|
|
837
|
+
return 'P2 - Challenges advertise staging or test networks.'
|
|
838
|
+
}
|
|
839
|
+
if (/challenge advertises placeholder-looking payTo/.test(finding)) {
|
|
840
|
+
return 'P1 - Challenges advertise placeholder-looking payTo recipients.'
|
|
841
|
+
}
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function groupedFindingSummary(findings) {
|
|
846
|
+
const counts = new Map()
|
|
847
|
+
for (const finding of findings) {
|
|
848
|
+
const label = groupedFindingLabel(finding)
|
|
849
|
+
if (label) counts.set(label, (counts.get(label) ?? 0) + 1)
|
|
850
|
+
}
|
|
851
|
+
return [...counts.entries()]
|
|
852
|
+
.filter(([, count]) => count > 1)
|
|
853
|
+
.map(([label, count]) => `- ${count} endpoints: ${label}`)
|
|
854
|
+
}
|
|
855
|
+
|
|
765
856
|
function formatMarkdown(report) {
|
|
766
857
|
const document = report.document.body.json ?? {}
|
|
767
858
|
const challengeRows = report.challenges.map(result => {
|
|
@@ -771,6 +862,7 @@ function formatMarkdown(report) {
|
|
|
771
862
|
const preflightRows = report.preflights.map(result => {
|
|
772
863
|
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${result.headers['access-control-allow-origin'] ?? '-'} | ${result.headers['access-control-allow-headers'] ?? '-'} | ${result.headers['access-control-allow-methods'] ?? '-'} |`
|
|
773
864
|
})
|
|
865
|
+
const findingSummary = groupedFindingSummary(report.findings)
|
|
774
866
|
|
|
775
867
|
return [
|
|
776
868
|
'# x402 Public Surface Check',
|
|
@@ -804,6 +896,12 @@ function formatMarkdown(report) {
|
|
|
804
896
|
'| --- | --- | --- | --- | --- | --- |',
|
|
805
897
|
...(preflightRows.length ? preflightRows : ['| - | - | - | - | - | - |']),
|
|
806
898
|
'',
|
|
899
|
+
...(findingSummary.length ? [
|
|
900
|
+
'## Finding Summary',
|
|
901
|
+
'',
|
|
902
|
+
...findingSummary,
|
|
903
|
+
'',
|
|
904
|
+
] : []),
|
|
807
905
|
'## Findings',
|
|
808
906
|
'',
|
|
809
907
|
...(report.findings.length ? report.findings.map(item => `- ${item}`) : ['- No obvious launch-readiness findings from the public no-payment probes.']),
|
|
@@ -812,6 +910,7 @@ function formatMarkdown(report) {
|
|
|
812
910
|
}
|
|
813
911
|
|
|
814
912
|
async function runCheck(options) {
|
|
913
|
+
const directRequestBody = await directEndpointRequestBody(options)
|
|
815
914
|
let sourceDocument = null
|
|
816
915
|
let document = options.endpoint
|
|
817
916
|
? {
|
|
@@ -823,7 +922,7 @@ async function runCheck(options) {
|
|
|
823
922
|
}
|
|
824
923
|
: await fetchDocument(options.url)
|
|
825
924
|
let entries = options.endpoint
|
|
826
|
-
? [{ name: new URL(options.url).pathname.split('/').filter(Boolean).at(-1) ?? options.url, url: options.url, method: options.method || 'POST' }]
|
|
925
|
+
? [{ name: new URL(options.url).pathname.split('/').filter(Boolean).at(-1) ?? options.url, url: options.url, method: options.method || 'POST', requestBody: directRequestBody }]
|
|
827
926
|
: (document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : [])
|
|
828
927
|
|
|
829
928
|
if (!options.endpoint && entries.length === 0 && document.body.json) {
|