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 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 query/path examples and JSON request-body examples for safer no-payment probes
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 exampleValue(schemaOrParameter) {
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') return ''
163
- if (schema.type === 'number' || schema.type === 'integer') return 0
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 = path.startsWith('http') ? new URL(resolvedPath) : new URL(resolvedPath, baseUrl)
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.servers?.find(server => typeof server?.url === 'string')?.url
277
- ?? sourceUrl
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
- entries.push({
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {