x402-surface-check 0.1.0
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/LICENSE +21 -0
- package/README.md +57 -0
- package/bin/x402-surface-check.mjs +456 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tate Programs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# x402 Surface Check
|
|
2
|
+
|
|
3
|
+
No-payment CLI for checking x402 launch surfaces before a real agent spends.
|
|
4
|
+
|
|
5
|
+
It accepts an x402 manifest or OpenAPI URL, derives public endpoints, sends no-payment probes, checks browser preflight behavior, and returns a Markdown patch queue. It never sends `X-PAYMENT`, never signs, and never attempts a paid call.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx --yes x402-surface-check https://api.example.com/.well-known/x402
|
|
9
|
+
npx --yes x402-surface-check https://api.example.com/openapi.json report.md
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What It Checks
|
|
13
|
+
|
|
14
|
+
- Manifest or OpenAPI endpoint discovery
|
|
15
|
+
- No-payment HTTP 402 challenge shape
|
|
16
|
+
- x402 v1 and v2 price fields
|
|
17
|
+
- `amount` / `maxAmountRequired`, `asset`, `network`, and `payTo`
|
|
18
|
+
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
19
|
+
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
20
|
+
- HTTPS resource URLs and stable resource metadata
|
|
21
|
+
- Browser CORS allowance for `X-PAYMENT`
|
|
22
|
+
- Over-broad public method surfaces
|
|
23
|
+
|
|
24
|
+
## Options
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
x402-surface-check <manifest-or-openapi-url> [output.md]
|
|
28
|
+
|
|
29
|
+
--origin <url> Origin to use for browser-style CORS preflight
|
|
30
|
+
--limit <n> Maximum endpoints to probe, default 6
|
|
31
|
+
--json Print JSON instead of Markdown
|
|
32
|
+
--help Show usage
|
|
33
|
+
--version Show package version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Environment variables are also supported:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
X402_CHECK_ORIGIN=https://example.com x402-surface-check https://api.example.com/openapi.json
|
|
40
|
+
X402_CHECK_LIMIT=12 x402-surface-check https://api.example.com/.well-known/x402
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Scope
|
|
44
|
+
|
|
45
|
+
The checker is intentionally external and conservative:
|
|
46
|
+
|
|
47
|
+
- no wallet access
|
|
48
|
+
- no payment headers
|
|
49
|
+
- no paid calls
|
|
50
|
+
- no exploit attempts
|
|
51
|
+
- no private endpoint guessing
|
|
52
|
+
|
|
53
|
+
It is meant for launch-readiness review, spend-policy evidence, and pre-demo patch order.
|
|
54
|
+
|
|
55
|
+
## Web Version
|
|
56
|
+
|
|
57
|
+
Browser tool: https://tateprograms.com/x402-surface-check.html
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete']
|
|
5
|
+
const defaultLimit = 6
|
|
6
|
+
|
|
7
|
+
const packageJson = JSON.parse(
|
|
8
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
function usage() {
|
|
12
|
+
return `x402-surface-check ${packageJson.version}
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
x402-surface-check <manifest-or-openapi-url> [output.md]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--origin <url> Origin to use for browser-style CORS preflight
|
|
19
|
+
--limit <n> Maximum endpoints to probe, default ${defaultLimit}
|
|
20
|
+
--json Print JSON instead of Markdown
|
|
21
|
+
--help Show this help
|
|
22
|
+
--version Show package version
|
|
23
|
+
`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = {
|
|
28
|
+
json: false,
|
|
29
|
+
limit: Number(process.env.X402_CHECK_LIMIT ?? defaultLimit),
|
|
30
|
+
origin: process.env.X402_CHECK_ORIGIN,
|
|
31
|
+
outputPath: '',
|
|
32
|
+
url: '',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
36
|
+
const arg = argv[index]
|
|
37
|
+
if (arg === '--help' || arg === '-h') {
|
|
38
|
+
args.help = true
|
|
39
|
+
}
|
|
40
|
+
else if (arg === '--version' || arg === '-v') {
|
|
41
|
+
args.version = true
|
|
42
|
+
}
|
|
43
|
+
else if (arg === '--json') {
|
|
44
|
+
args.json = true
|
|
45
|
+
}
|
|
46
|
+
else if (arg === '--origin') {
|
|
47
|
+
args.origin = argv[index + 1]
|
|
48
|
+
index += 1
|
|
49
|
+
}
|
|
50
|
+
else if (arg === '--limit') {
|
|
51
|
+
args.limit = Number(argv[index + 1])
|
|
52
|
+
index += 1
|
|
53
|
+
}
|
|
54
|
+
else if (!args.url) {
|
|
55
|
+
args.url = arg
|
|
56
|
+
}
|
|
57
|
+
else if (!args.outputPath) {
|
|
58
|
+
args.outputPath = arg
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
throw new Error(`Unexpected argument: ${arg}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return args
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function moneyFromAtomic(amount, decimals = 6) {
|
|
69
|
+
const numeric = Number(amount)
|
|
70
|
+
if (!Number.isFinite(numeric)) return String(amount ?? '')
|
|
71
|
+
const value = numeric / (10 ** decimals)
|
|
72
|
+
return `$${value.toLocaleString(undefined, {
|
|
73
|
+
maximumFractionDigits: 6,
|
|
74
|
+
minimumFractionDigits: value < 0.01 ? 3 : 2,
|
|
75
|
+
})}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function uniqueEntries(entries, limit) {
|
|
79
|
+
const seen = new Set()
|
|
80
|
+
return entries
|
|
81
|
+
.filter(entry => {
|
|
82
|
+
const key = `${entry.method}:${entry.url}`
|
|
83
|
+
if (seen.has(key)) return false
|
|
84
|
+
seen.add(key)
|
|
85
|
+
return true
|
|
86
|
+
})
|
|
87
|
+
.slice(0, Number.isFinite(limit) && limit > 0 ? limit : defaultLimit)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function endpointEntries(document, sourceUrl, limit) {
|
|
91
|
+
const entries = []
|
|
92
|
+
|
|
93
|
+
for (const [name, url] of Object.entries(document.x402Endpoints ?? {})) {
|
|
94
|
+
if (typeof url === 'string' && url.startsWith('http')) {
|
|
95
|
+
entries.push({ name, url, method: 'POST' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const [category, items] of Object.entries(document.categories ?? {})) {
|
|
100
|
+
if (!Array.isArray(items)) continue
|
|
101
|
+
for (const item of items) {
|
|
102
|
+
if (typeof item?.endpoint === 'string' && item.endpoint.startsWith('http')) {
|
|
103
|
+
entries.push({
|
|
104
|
+
name: item.id ?? item.name ?? category,
|
|
105
|
+
url: item.endpoint,
|
|
106
|
+
method: item.method ?? 'POST',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (document.openapi && document.paths && typeof document.paths === 'object') {
|
|
113
|
+
const baseUrl = document.servers?.find(server => typeof server?.url === 'string')?.url
|
|
114
|
+
?? sourceUrl
|
|
115
|
+
|
|
116
|
+
for (const [path, operations] of Object.entries(document.paths)) {
|
|
117
|
+
if (!operations || typeof operations !== 'object') continue
|
|
118
|
+
for (const method of methods) {
|
|
119
|
+
const operation = operations[method]
|
|
120
|
+
if (!operation || typeof operation !== 'object') continue
|
|
121
|
+
const url = path.startsWith('http') ? path : new URL(path, baseUrl).toString()
|
|
122
|
+
entries.push({
|
|
123
|
+
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
124
|
+
url,
|
|
125
|
+
method: method.toUpperCase(),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const resource of document.resources ?? []) {
|
|
132
|
+
if (typeof resource !== 'string') continue
|
|
133
|
+
const match = resource.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
|
|
134
|
+
if (!match) continue
|
|
135
|
+
const [, method, rawPath] = match
|
|
136
|
+
const url = rawPath.startsWith('http')
|
|
137
|
+
? rawPath
|
|
138
|
+
: new URL(rawPath, document.baseUrl ?? sourceUrl).toString()
|
|
139
|
+
entries.push({
|
|
140
|
+
name: rawPath.split('/').filter(Boolean).at(-1) ?? rawPath,
|
|
141
|
+
url,
|
|
142
|
+
method: method.toUpperCase(),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return uniqueEntries(entries, limit)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readText(response) {
|
|
150
|
+
const text = await response.text()
|
|
151
|
+
try {
|
|
152
|
+
return { text, json: JSON.parse(text) }
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return { text, json: null }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseEncodedChallenge(value) {
|
|
160
|
+
if (!value) return null
|
|
161
|
+
try {
|
|
162
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
163
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
164
|
+
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(value)
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fetchDocument(url) {
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
headers: {
|
|
179
|
+
'user-agent': `x402-surface-check/${packageJson.version}`,
|
|
180
|
+
accept: 'application/json',
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
const body = await readText(response)
|
|
184
|
+
return {
|
|
185
|
+
status: response.status,
|
|
186
|
+
ok: response.ok,
|
|
187
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
188
|
+
url: response.url,
|
|
189
|
+
body,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function probeEndpoint(entry) {
|
|
194
|
+
const method = entry.method ?? 'POST'
|
|
195
|
+
const response = await fetch(entry.url, {
|
|
196
|
+
method,
|
|
197
|
+
headers: {
|
|
198
|
+
'user-agent': `x402-surface-check/${packageJson.version}`,
|
|
199
|
+
accept: 'application/json',
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
},
|
|
202
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : '{}',
|
|
203
|
+
})
|
|
204
|
+
const body = await readText(response)
|
|
205
|
+
const headerChallenge = parseEncodedChallenge(
|
|
206
|
+
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if (headerChallenge && !body.json?.accepts?.length) {
|
|
210
|
+
body.json = headerChallenge
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
...entry,
|
|
215
|
+
status: response.status,
|
|
216
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
217
|
+
body,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function probePreflight(entry, origin) {
|
|
222
|
+
const response = await fetch(entry.url, {
|
|
223
|
+
method: 'OPTIONS',
|
|
224
|
+
headers: {
|
|
225
|
+
origin,
|
|
226
|
+
'access-control-request-method': entry.method ?? 'POST',
|
|
227
|
+
'access-control-request-headers': 'content-type,x-payment',
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...entry,
|
|
233
|
+
status: response.status,
|
|
234
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function valueList(value) {
|
|
239
|
+
if (Array.isArray(value)) return value.map(String)
|
|
240
|
+
if (value && typeof value === 'object') return Object.keys(value)
|
|
241
|
+
if (typeof value === 'string') return [value]
|
|
242
|
+
return []
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function capabilityList(value) {
|
|
246
|
+
if (!Array.isArray(value)) return []
|
|
247
|
+
return value.map(item => item?.id ?? item?.name ?? item).filter(Boolean).map(String)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function challengeAccepts(result) {
|
|
251
|
+
return Array.isArray(result.body.json?.accepts) ? result.body.json.accepts : []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function challengeSummary(result) {
|
|
255
|
+
const challenge = result.body.json
|
|
256
|
+
const firstAccept = challenge?.accepts?.[0] ?? {}
|
|
257
|
+
const amount = firstAccept.amount ?? firstAccept.maxAmountRequired ?? firstAccept.maxAmount ?? ''
|
|
258
|
+
const resourceUrl = challenge?.resource?.url ?? firstAccept.resource ?? ''
|
|
259
|
+
const extraResource = firstAccept.extra?.resource ?? firstAccept.resource ?? ''
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
status: result.status,
|
|
263
|
+
resourceUrl,
|
|
264
|
+
network: firstAccept.network ?? '',
|
|
265
|
+
amount,
|
|
266
|
+
price: moneyFromAtomic(amount),
|
|
267
|
+
payTo: firstAccept.payTo ?? '',
|
|
268
|
+
asset: firstAccept.asset ?? '',
|
|
269
|
+
timeout: firstAccept.maxTimeoutSeconds ?? '',
|
|
270
|
+
extraResource,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function looksLikeStagingNetwork(network) {
|
|
275
|
+
return /devnet|testnet|sepolia|local|eip155:84532|solana:EtWTRAB/i.test(String(network ?? ''))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function looksLikePlaceholderPayTo(payTo) {
|
|
279
|
+
const value = String(payTo ?? '')
|
|
280
|
+
if (!value) return false
|
|
281
|
+
if (/^0x0{36,}0?1?$/i.test(value)) return true
|
|
282
|
+
if (/^1{24,}$/.test(value)) return true
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function findingList(documentResult, challengeResults, preflightResults, entries) {
|
|
287
|
+
const document = documentResult.body.json ?? {}
|
|
288
|
+
const findings = []
|
|
289
|
+
const networks = valueList(document.networks)
|
|
290
|
+
const challengeNetworks = new Set()
|
|
291
|
+
|
|
292
|
+
if (documentResult.status < 200 || documentResult.status >= 300) {
|
|
293
|
+
findings.push(`P1 - Document returned HTTP ${documentResult.status}; expected a successful JSON response.`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!documentResult.body.json) {
|
|
297
|
+
findings.push(`P1 - Document did not return parseable JSON; content begins: ${documentResult.body.text.slice(0, 80).replace(/\s+/g, ' ')}.`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (entries.length === 0) {
|
|
301
|
+
findings.push('P1 - Document does not expose any manifest, OpenAPI, category, or resource endpoints for no-payment probes.')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const result of challengeResults) {
|
|
305
|
+
const summary = challengeSummary(result)
|
|
306
|
+
if (summary.network) challengeNetworks.add(summary.network)
|
|
307
|
+
|
|
308
|
+
if (result.status !== 402) {
|
|
309
|
+
findings.push(`P1 - ${result.name} returned ${result.status}, not 402, for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
310
|
+
}
|
|
311
|
+
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
312
|
+
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
313
|
+
}
|
|
314
|
+
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
315
|
+
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
316
|
+
}
|
|
317
|
+
for (const accept of challengeAccepts(result)) {
|
|
318
|
+
if (looksLikePlaceholderPayTo(accept.payTo)) {
|
|
319
|
+
findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
|
|
320
|
+
}
|
|
321
|
+
if (looksLikeStagingNetwork(accept.network)) {
|
|
322
|
+
findings.push(`P2 - ${result.name} challenge advertises staging/test network ${accept.network}; document this as demo-only until live-value payment rails are active.`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!summary.resourceUrl || !summary.extraResource) {
|
|
326
|
+
findings.push(`P2 - ${result.name} challenge does not repeat the resource URL in both resource.url and accepts[0].extra.resource/resource.`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const result of preflightResults) {
|
|
331
|
+
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
332
|
+
if (!/x-payment/i.test(allowed)) {
|
|
333
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed allow headers: ${allowed || 'none'}.`)
|
|
334
|
+
}
|
|
335
|
+
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
336
|
+
if (/delete|put|patch/i.test(allowedMethods)) {
|
|
337
|
+
findings.push(`P2 - ${result.name} CORS allow-methods is broader than a narrow public x402 contract: ${allowedMethods}.`)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (networks.length > 1 && challengeNetworks.size === 1) {
|
|
342
|
+
findings.push(`P2 - Document lists ${networks.length} networks, while observed 402 challenges exposed one network: ${[...challengeNetworks].join(', ')}.`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (document.x402Endpoint && document.x402Endpoints) {
|
|
346
|
+
findings.push(`P3 - Document includes both x402Endpoint (${document.x402Endpoint}) and x402Endpoints; clarify which path clients should prefer.`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return findings
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function formatMarkdown(report) {
|
|
353
|
+
const document = report.document.body.json ?? {}
|
|
354
|
+
const challengeRows = report.challenges.map(result => {
|
|
355
|
+
const summary = challengeSummary(result)
|
|
356
|
+
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
357
|
+
})
|
|
358
|
+
const preflightRows = report.preflights.map(result => {
|
|
359
|
+
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'] ?? '-'} |`
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
return [
|
|
363
|
+
'# x402 Public Surface Check',
|
|
364
|
+
'',
|
|
365
|
+
`Document: ${report.document.url}`,
|
|
366
|
+
`Checked: ${report.checkedAt}`,
|
|
367
|
+
'Scope: manifest/OpenAPI parsing, no-payment endpoint probes, and browser-style CORS preflight. No payment headers or paid calls.',
|
|
368
|
+
`Preflight origin: ${report.origin}`,
|
|
369
|
+
'',
|
|
370
|
+
'## Document',
|
|
371
|
+
'',
|
|
372
|
+
`- Status: ${report.document.status}`,
|
|
373
|
+
`- Type: ${document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'}`,
|
|
374
|
+
`- Agent: ${document.agent?.name ?? '-'}`,
|
|
375
|
+
`- Wallet: ${document.agent?.wallet ?? '-'}`,
|
|
376
|
+
`- Facilitator: ${document.facilitator ?? '-'}`,
|
|
377
|
+
`- Networks: ${valueList(document.networks).join(', ') || '-'}`,
|
|
378
|
+
`- Capabilities: ${capabilityList(document.capabilities).join(', ') || '-'}`,
|
|
379
|
+
`- Probed endpoints: ${report.entries.length}`,
|
|
380
|
+
'',
|
|
381
|
+
'## No-Payment Challenge Map',
|
|
382
|
+
'',
|
|
383
|
+
'| Endpoint | Method | HTTP | Price | Network | Resource URL |',
|
|
384
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
385
|
+
...(challengeRows.length ? challengeRows : ['| - | - | - | - | - | - |']),
|
|
386
|
+
'',
|
|
387
|
+
'## Browser Preflight Map',
|
|
388
|
+
'',
|
|
389
|
+
'| Endpoint | Method | HTTP | Allow-Origin | Allow-Headers | Allow-Methods |',
|
|
390
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
391
|
+
...(preflightRows.length ? preflightRows : ['| - | - | - | - | - | - |']),
|
|
392
|
+
'',
|
|
393
|
+
'## Findings',
|
|
394
|
+
'',
|
|
395
|
+
...(report.findings.length ? report.findings.map(item => `- ${item}`) : ['- No obvious launch-readiness findings from the public no-payment probes.']),
|
|
396
|
+
'',
|
|
397
|
+
].join('\n')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function runCheck(options) {
|
|
401
|
+
const document = await fetchDocument(options.url)
|
|
402
|
+
const entries = document.body.json ? endpointEntries(document.body.json, document.url, options.limit) : []
|
|
403
|
+
const origin = options.origin ?? new URL(document.url).origin
|
|
404
|
+
const challenges = []
|
|
405
|
+
const preflights = []
|
|
406
|
+
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
challenges.push(await probeEndpoint(entry))
|
|
409
|
+
preflights.push(await probePreflight(entry, origin))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const report = {
|
|
413
|
+
checkedAt: new Date().toISOString(),
|
|
414
|
+
document,
|
|
415
|
+
entries,
|
|
416
|
+
findings: [],
|
|
417
|
+
origin,
|
|
418
|
+
challenges,
|
|
419
|
+
preflights,
|
|
420
|
+
}
|
|
421
|
+
report.findings = findingList(document, challenges, preflights, entries)
|
|
422
|
+
return report
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function main() {
|
|
426
|
+
const options = parseArgs(process.argv.slice(2))
|
|
427
|
+
if (options.help) {
|
|
428
|
+
console.log(usage())
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (options.version) {
|
|
432
|
+
console.log(packageJson.version)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (!options.url) {
|
|
436
|
+
console.error(usage())
|
|
437
|
+
process.exitCode = 2
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const report = await runCheck(options)
|
|
442
|
+
const output = options.json
|
|
443
|
+
? `${JSON.stringify(report, null, 2)}\n`
|
|
444
|
+
: `${formatMarkdown(report)}\n`
|
|
445
|
+
|
|
446
|
+
if (options.outputPath) {
|
|
447
|
+
await writeFile(options.outputPath, output)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
process.stdout.write(output)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
main().catch(error => {
|
|
454
|
+
console.error(`x402-surface-check: ${error.message}`)
|
|
455
|
+
process.exitCode = 1
|
|
456
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "x402-surface-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"x402-surface-check": "bin/x402-surface-check.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --check bin/x402-surface-check.mjs && node test/smoke.mjs",
|
|
16
|
+
"pack:dry": "npm pack --dry-run"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"x402",
|
|
20
|
+
"agent-payments",
|
|
21
|
+
"pay-sh",
|
|
22
|
+
"usdc",
|
|
23
|
+
"solana",
|
|
24
|
+
"mcp",
|
|
25
|
+
"security",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://tateprograms.com/x402-surface-check.html",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/TateLyman/x402-surface-check.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/TateLyman/x402-surface-check/issues"
|
|
35
|
+
},
|
|
36
|
+
"author": "Tate Programs <hello@tateprograms.com>",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
}
|
|
41
|
+
}
|