x402-surface-check 0.2.14 → 0.2.16
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 +4 -2
- package/bin/x402-surface-check.mjs +69 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,9 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
16
16
|
|
|
17
17
|
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `resources[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
|
|
18
18
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
19
|
-
- OpenAPI
|
|
19
|
+
- 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, and local `$ref` request schemas for safer no-payment probes
|
|
21
|
+
- OpenAPI paid-operation prioritization, so docs and discovery routes do not consume the probe limit before payment-bearing operations
|
|
20
22
|
- No-payment HTTP 402 challenge shape
|
|
21
23
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
22
24
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
@@ -26,7 +28,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
26
28
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
27
29
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
28
30
|
- HTTPS resource URLs and stable resource metadata
|
|
29
|
-
- Browser CORS allowance for the requesting origin and `X-PAYMENT
|
|
31
|
+
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
30
32
|
- Over-broad public method surfaces
|
|
31
33
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
32
34
|
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
@@ -132,6 +132,12 @@ function endpointUrl(rawPath, baseUrl, sourceUrl) {
|
|
|
132
132
|
return new URL(value, base).toString()
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function openApiServerBaseUrl(document, sourceUrl) {
|
|
136
|
+
const rawUrl = document.servers?.find(server => typeof server?.url === 'string')?.url
|
|
137
|
+
if (!rawUrl) return documentBaseUrl(document, sourceUrl)
|
|
138
|
+
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
function linkedDiscoveryUrl(document, sourceUrl) {
|
|
136
142
|
const rawUrl = document?.discovery_url
|
|
137
143
|
?? document?.discoveryUrl
|
|
@@ -151,21 +157,48 @@ function operationExpectedPrice(operation) {
|
|
|
151
157
|
return numeric === null ? null : numeric
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
function
|
|
160
|
+
function resolveLocalRef(ref, document) {
|
|
161
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined
|
|
162
|
+
return ref
|
|
163
|
+
.slice(2)
|
|
164
|
+
.split('/')
|
|
165
|
+
.map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
166
|
+
.reduce((value, part) => value?.[part], document)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveSchema(schema, document, seen = new Set()) {
|
|
170
|
+
if (!schema || typeof schema !== 'object') return schema
|
|
171
|
+
if (!schema.$ref) return schema
|
|
172
|
+
if (seen.has(schema.$ref)) return schema
|
|
173
|
+
const resolved = resolveLocalRef(schema.$ref, document)
|
|
174
|
+
if (!resolved) return schema
|
|
175
|
+
seen.add(schema.$ref)
|
|
176
|
+
return resolveSchema(resolved, document, seen)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function exampleValue(schemaOrParameter, document) {
|
|
155
180
|
if (!schemaOrParameter || typeof schemaOrParameter !== 'object') return undefined
|
|
156
|
-
const schema = schemaOrParameter.schema ?? schemaOrParameter
|
|
181
|
+
const schema = resolveSchema(schemaOrParameter.schema ?? schemaOrParameter, document)
|
|
157
182
|
const value = schemaOrParameter.example
|
|
183
|
+
?? schema.const
|
|
158
184
|
?? schema.example
|
|
159
185
|
?? schema.default
|
|
160
186
|
?? (Array.isArray(schema.enum) ? schema.enum[0] : undefined)
|
|
161
187
|
if (value !== undefined) return value
|
|
162
|
-
if (schema.type === 'string')
|
|
163
|
-
|
|
188
|
+
if (schema.type === 'string') {
|
|
189
|
+
if (schema.format === 'uri') return 'https://example.com'
|
|
190
|
+
if (schema.format === 'date-time') return '2026-01-01T00:00:00.000Z'
|
|
191
|
+
if (schema.format === 'date') return '2026-01-01'
|
|
192
|
+
if (Number(schema.minLength) > 0) return 'example'
|
|
193
|
+
return ''
|
|
194
|
+
}
|
|
195
|
+
if (schema.type === 'integer') return Number.isFinite(Number(schema.minimum)) ? Number(schema.minimum) : 1
|
|
196
|
+
if (schema.type === 'number') return Number.isFinite(Number(schema.minimum)) ? Number(schema.minimum) : 1
|
|
164
197
|
if (schema.type === 'boolean') return false
|
|
165
198
|
return undefined
|
|
166
199
|
}
|
|
167
200
|
|
|
168
|
-
function mediaExample(media) {
|
|
201
|
+
function mediaExample(media, document) {
|
|
169
202
|
if (!media || typeof media !== 'object') return undefined
|
|
170
203
|
if (media.example !== undefined) return media.example
|
|
171
204
|
const examples = media.examples && typeof media.examples === 'object'
|
|
@@ -175,7 +208,7 @@ function mediaExample(media) {
|
|
|
175
208
|
if (firstExample?.value !== undefined) return firstExample.value
|
|
176
209
|
if (firstExample?.externalValue) return undefined
|
|
177
210
|
|
|
178
|
-
const schema = media.schema
|
|
211
|
+
const schema = resolveSchema(media.schema, document)
|
|
179
212
|
if (!schema || typeof schema !== 'object' || schema.type !== 'object') return undefined
|
|
180
213
|
const body = {}
|
|
181
214
|
const properties = schema.properties && typeof schema.properties === 'object'
|
|
@@ -185,29 +218,35 @@ function mediaExample(media) {
|
|
|
185
218
|
|
|
186
219
|
for (const [name, property] of Object.entries(properties)) {
|
|
187
220
|
if (!required.has(name)) continue
|
|
188
|
-
const value = exampleValue(property)
|
|
221
|
+
const value = exampleValue(property, document)
|
|
189
222
|
if (value !== undefined) body[name] = value
|
|
190
223
|
}
|
|
191
224
|
|
|
192
225
|
return Object.keys(body).length ? body : undefined
|
|
193
226
|
}
|
|
194
227
|
|
|
195
|
-
function operationRequestBody(operation) {
|
|
228
|
+
function operationRequestBody(operation, document) {
|
|
196
229
|
const content = operation?.requestBody?.content
|
|
197
230
|
if (!content || typeof content !== 'object') return undefined
|
|
198
231
|
const media = content['application/json']
|
|
199
232
|
?? content['application/*+json']
|
|
200
233
|
?? Object.entries(content).find(([type]) => /json/i.test(type))?.[1]
|
|
201
|
-
return mediaExample(media)
|
|
234
|
+
return mediaExample(media, document)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function operationPaymentSignal(operation) {
|
|
238
|
+
if (operation?.['x-payment-info'] || operation?.['x-payment'] || operation?.['x-x402'] || operation?.payment) return 2
|
|
239
|
+
if (operation?.responses && Object.hasOwn(operation.responses, '402')) return 1
|
|
240
|
+
return 0
|
|
202
241
|
}
|
|
203
242
|
|
|
204
|
-
function openApiProbeUrl(path, operation, baseUrl) {
|
|
243
|
+
function openApiProbeUrl(path, operation, baseUrl, document) {
|
|
205
244
|
const parameters = Array.isArray(operation?.parameters) ? operation.parameters : []
|
|
206
245
|
let resolvedPath = path
|
|
207
246
|
const searchParams = new URLSearchParams()
|
|
208
247
|
|
|
209
248
|
for (const parameter of parameters) {
|
|
210
|
-
const value = exampleValue(parameter)
|
|
249
|
+
const value = exampleValue(parameter, document)
|
|
211
250
|
if (value === undefined || value === '') continue
|
|
212
251
|
if (parameter.in === 'path') {
|
|
213
252
|
resolvedPath = resolvedPath.replaceAll(`{${parameter.name}}`, encodeURIComponent(String(value)))
|
|
@@ -217,7 +256,9 @@ function openApiProbeUrl(path, operation, baseUrl) {
|
|
|
217
256
|
}
|
|
218
257
|
}
|
|
219
258
|
|
|
220
|
-
const url =
|
|
259
|
+
const url = /^https?:\/\//i.test(String(resolvedPath))
|
|
260
|
+
? new URL(resolvedPath)
|
|
261
|
+
: new URL(String(resolvedPath).replace(/^\/+/, ''), `${baseUrl.replace(/\/?$/, '/')}`)
|
|
221
262
|
for (const [name, value] of searchParams.entries()) {
|
|
222
263
|
url.searchParams.set(name, value)
|
|
223
264
|
}
|
|
@@ -273,24 +314,29 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
273
314
|
}
|
|
274
315
|
|
|
275
316
|
if (document.openapi && document.paths && typeof document.paths === 'object') {
|
|
276
|
-
const baseUrl = document
|
|
277
|
-
|
|
317
|
+
const baseUrl = openApiServerBaseUrl(document, sourceUrl)
|
|
318
|
+
const openApiEntries = []
|
|
278
319
|
|
|
279
320
|
for (const [path, operations] of Object.entries(document.paths)) {
|
|
280
321
|
if (!operations || typeof operations !== 'object') continue
|
|
281
322
|
for (const method of methods) {
|
|
282
323
|
const operation = operations[method]
|
|
283
324
|
if (!operation || typeof operation !== 'object') continue
|
|
284
|
-
const url = openApiProbeUrl(path, operation, baseUrl)
|
|
285
|
-
|
|
325
|
+
const url = openApiProbeUrl(path, operation, baseUrl, document)
|
|
326
|
+
openApiEntries.push({
|
|
286
327
|
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
287
328
|
url,
|
|
288
329
|
method: method.toUpperCase(),
|
|
289
330
|
expectedPriceUsd: operationExpectedPrice(operation),
|
|
290
|
-
requestBody: operationRequestBody(operation),
|
|
331
|
+
requestBody: operationRequestBody(operation, document),
|
|
332
|
+
paymentSignal: operationPaymentSignal(operation),
|
|
291
333
|
})
|
|
292
334
|
}
|
|
293
335
|
}
|
|
336
|
+
|
|
337
|
+
entries.push(...openApiEntries
|
|
338
|
+
.sort((a, b) => b.paymentSignal - a.paymentSignal)
|
|
339
|
+
.map(({ paymentSignal, ...entry }) => entry))
|
|
294
340
|
}
|
|
295
341
|
|
|
296
342
|
for (const resource of document.resources ?? []) {
|
|
@@ -428,7 +474,7 @@ async function fetchDocument(url) {
|
|
|
428
474
|
}
|
|
429
475
|
}
|
|
430
476
|
|
|
431
|
-
async function probeEndpoint(entry) {
|
|
477
|
+
async function probeEndpoint(entry, origin) {
|
|
432
478
|
const method = entry.method ?? 'POST'
|
|
433
479
|
const response = await fetch(entry.url, {
|
|
434
480
|
method,
|
|
@@ -436,6 +482,7 @@ async function probeEndpoint(entry) {
|
|
|
436
482
|
'user-agent': `x402-surface-check/${packageJson.version}`,
|
|
437
483
|
accept: 'application/json',
|
|
438
484
|
'content-type': 'application/json',
|
|
485
|
+
...(origin ? { origin } : {}),
|
|
439
486
|
},
|
|
440
487
|
body: method === 'GET' || method === 'HEAD'
|
|
441
488
|
? undefined
|
|
@@ -656,6 +703,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
656
703
|
continue
|
|
657
704
|
}
|
|
658
705
|
|
|
706
|
+
if (!result.headers?.['access-control-allow-origin']) {
|
|
707
|
+
findings.push(`P1 - ${result.name} 402 challenge response does not allow the requesting origin; browser agents cannot read the payment requirements even if preflight succeeds.`)
|
|
708
|
+
}
|
|
659
709
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
660
710
|
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
661
711
|
}
|
|
@@ -796,7 +846,7 @@ async function runCheck(options) {
|
|
|
796
846
|
const preflights = []
|
|
797
847
|
|
|
798
848
|
for (const entry of entries) {
|
|
799
|
-
challenges.push(await probeEndpoint(entry))
|
|
849
|
+
challenges.push(await probeEndpoint(entry, origin))
|
|
800
850
|
preflights.push(await probePreflight(entry, origin))
|
|
801
851
|
}
|
|
802
852
|
|