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 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
+ }