mppx 0.8.2 → 0.8.4

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +2 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  6. package/dist/cli/plugins/stripe.js +32 -12
  7. package/dist/cli/plugins/stripe.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +1 -0
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/cli/utils.d.ts +2 -2
  12. package/dist/cli/utils.d.ts.map +1 -1
  13. package/dist/cli/utils.js +8 -2
  14. package/dist/cli/utils.js.map +1 -1
  15. package/dist/cli/validate/challenge.d.ts +14 -0
  16. package/dist/cli/validate/challenge.d.ts.map +1 -0
  17. package/dist/cli/validate/challenge.js +211 -0
  18. package/dist/cli/validate/challenge.js.map +1 -0
  19. package/dist/cli/validate/discovery.d.ts +10 -0
  20. package/dist/cli/validate/discovery.d.ts.map +1 -0
  21. package/dist/cli/validate/discovery.js +128 -0
  22. package/dist/cli/validate/discovery.js.map +1 -0
  23. package/dist/cli/validate/helpers.d.ts +33 -0
  24. package/dist/cli/validate/helpers.d.ts.map +1 -0
  25. package/dist/cli/validate/helpers.js +136 -0
  26. package/dist/cli/validate/helpers.js.map +1 -0
  27. package/dist/cli/validate/index.d.ts +17 -0
  28. package/dist/cli/validate/index.d.ts.map +1 -0
  29. package/dist/cli/validate/index.js +224 -0
  30. package/dist/cli/validate/index.js.map +1 -0
  31. package/dist/cli/validate/payment.d.ts +7 -0
  32. package/dist/cli/validate/payment.d.ts.map +1 -0
  33. package/dist/cli/validate/payment.js +381 -0
  34. package/dist/cli/validate/payment.js.map +1 -0
  35. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  36. package/dist/stripe/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/client/Charge.d.ts.map +1 -1
  38. package/dist/tempo/client/Charge.js +7 -1
  39. package/dist/tempo/client/Charge.js.map +1 -1
  40. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  41. package/dist/tempo/internal/auto-swap.js +21 -6
  42. package/dist/tempo/internal/auto-swap.js.map +1 -1
  43. package/dist/tempo/internal/fee-token.js +1 -1
  44. package/dist/tempo/internal/fee-token.js.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.js +1 -1
  48. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/cli/cli.test.ts +134 -29
  51. package/src/cli/cli.ts +2 -0
  52. package/src/cli/mcp.test.ts +1 -0
  53. package/src/cli/plugins/stripe.ts +31 -12
  54. package/src/cli/plugins/tempo.ts +1 -0
  55. package/src/cli/utils.test.ts +36 -2
  56. package/src/cli/utils.ts +16 -3
  57. package/src/cli/validate/challenge.ts +335 -0
  58. package/src/cli/validate/discovery.ts +120 -0
  59. package/src/cli/validate/helpers.ts +154 -0
  60. package/src/cli/validate/index.ts +348 -0
  61. package/src/cli/validate/payment.ts +488 -0
  62. package/src/cli/validate.test.ts +539 -0
  63. package/src/mcp/client/McpClient.integration.test.ts +12 -1
  64. package/src/stripe/server/internal/html.gen.ts +1 -1
  65. package/src/tempo/client/Charge.test.ts +56 -0
  66. package/src/tempo/client/Charge.ts +20 -2
  67. package/src/tempo/internal/auto-swap.test.ts +85 -1
  68. package/src/tempo/internal/auto-swap.ts +52 -6
  69. package/src/tempo/internal/fee-token.ts +1 -1
  70. package/src/tempo/server/Charge.test.ts +31 -16
  71. package/src/tempo/server/internal/html/package.json +1 -1
  72. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -0,0 +1,335 @@
1
+ import * as Challenge from '../../Challenge.js'
2
+ import * as Constants from '../../Constants.js'
3
+ import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
4
+ import { pc } from '../utils.js'
5
+ import { extractRequestBodyFromDiscovery } from './discovery.js'
6
+ import type { CheckResult, EndpointSpec } from './helpers.js'
7
+ import {
8
+ buildUrl,
9
+ check,
10
+ fail,
11
+ fetchWithTimeout,
12
+ isValidAddress,
13
+ isValidIntegerAmount,
14
+ skip,
15
+ warn,
16
+ } from './helpers.js'
17
+
18
+ function detectTestnet(challenge: Challenge.Challenge): boolean {
19
+ const request = challenge.request as Record<string, unknown>
20
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
21
+ if (typeof methodDetails?.chainId === 'number') {
22
+ return methodDetails.chainId !== tempoChainIds.mainnet
23
+ }
24
+ return false
25
+ }
26
+
27
+ export async function validateChallenge(
28
+ baseUrl: string,
29
+ endpoint: EndpointSpec,
30
+ verbose: boolean,
31
+ options?: {
32
+ body?: string | undefined
33
+ query?: string[] | undefined
34
+ discoveryDoc?: Record<string, unknown> | undefined
35
+ },
36
+ ): Promise<{ results: CheckResult[]; resolvedBody?: string | undefined }> {
37
+ const results: CheckResult[] = []
38
+ const url = buildUrl(baseUrl, endpoint, options?.query)
39
+ const fetchHeaders: Record<string, string> = {}
40
+ let fetchBody: string | undefined
41
+
42
+ // Make bare unauthenticated request first (no body)
43
+ let response: Response
44
+ try {
45
+ response = await fetchWithTimeout(url, { method: endpoint.method })
46
+ } catch (error) {
47
+ results.push(fail('Request failed', (error as Error).message))
48
+ return { results }
49
+ }
50
+
51
+ // If we got 400, retry with body (explicit --body or auto-generated from schema)
52
+ if (response.status === 400) {
53
+ const bodyToTry =
54
+ options?.body ??
55
+ (options?.discoveryDoc
56
+ ? extractRequestBodyFromDiscovery(options.discoveryDoc, endpoint)
57
+ : undefined)
58
+ if (bodyToTry) {
59
+ if (verbose) console.log(pc.dim(` Retrying with body: ${bodyToTry}`))
60
+ fetchBody = bodyToTry
61
+ fetchHeaders['content-type'] = 'application/json'
62
+ try {
63
+ response = await fetchWithTimeout(url, {
64
+ method: endpoint.method,
65
+ headers: fetchHeaders,
66
+ body: fetchBody,
67
+ })
68
+ } catch (error) {
69
+ results.push(fail('Request failed', (error as Error).message))
70
+ return { results }
71
+ }
72
+ }
73
+ }
74
+
75
+ // Check 402
76
+ if (response.status !== 402) {
77
+ if (response.status === 200) {
78
+ results.push(
79
+ skip(
80
+ 'Returns 402 without credentials',
81
+ 'Got 200 (endpoint may not require payment in all cases)',
82
+ ),
83
+ )
84
+ } else if (response.status === 401 || response.status === 403) {
85
+ results.push(
86
+ skip(
87
+ 'Returns 402 without credentials',
88
+ `Got ${response.status} (endpoint requires auth before payment gate)`,
89
+ ),
90
+ )
91
+ } else {
92
+ results.push(
93
+ fail(
94
+ 'Returns 402 without credentials',
95
+ `Got ${response.status} instead`,
96
+ response.status === 400
97
+ ? 'Server requires a valid request body before returning 402. Add a requestBody schema with examples to your OpenAPI doc, or use --body.'
98
+ : 'Endpoints that require payment must return HTTP 402 with a WWW-Authenticate: Payment header when no valid credential is provided.',
99
+ ),
100
+ )
101
+ }
102
+ return { results }
103
+ }
104
+ results.push(check('Returns 402 without credentials'))
105
+
106
+ // Check WWW-Authenticate header
107
+ const wwwAuth = response.headers.get(Constants.Headers.wwwAuthenticate)
108
+ if (!wwwAuth) {
109
+ results.push(
110
+ skip('Not an MPP endpoint', 'No WWW-Authenticate header (may be x402 or other protocol)'),
111
+ )
112
+ return { results }
113
+ }
114
+ if (!wwwAuth.startsWith(`${Constants.Schemes.payment} `)) {
115
+ results.push(skip('Not an MPP endpoint', `WWW-Authenticate scheme is not Payment`))
116
+ return { results }
117
+ }
118
+ results.push(check('WWW-Authenticate header present', 'Payment scheme'))
119
+
120
+ // Parse challenge
121
+ let challenge: Challenge.Challenge
122
+ try {
123
+ challenge = Challenge.fromResponse(response)
124
+ } catch (error) {
125
+ results.push(fail('Challenge parseable', (error as Error).message))
126
+ return { results }
127
+ }
128
+ results.push(check('Challenge parseable', `${challenge.method}/${challenge.intent}`))
129
+
130
+ // Validate required fields
131
+ if (!challenge.id)
132
+ results.push(
133
+ fail(
134
+ 'Challenge has id',
135
+ undefined,
136
+ 'Every challenge must include a unique id field. Generate a random string or hash per challenge.',
137
+ ),
138
+ )
139
+ else results.push(check('Challenge has id'))
140
+
141
+ if (!challenge.realm)
142
+ results.push(
143
+ fail(
144
+ 'Challenge has realm',
145
+ undefined,
146
+ "Set realm to your server's hostname. It tells clients who they are paying.",
147
+ ),
148
+ )
149
+ else results.push(check('Challenge has realm'))
150
+
151
+ if (!challenge.method)
152
+ results.push(
153
+ fail(
154
+ 'Challenge has method',
155
+ undefined,
156
+ 'Set method to the payment method (e.g. "tempo", "stripe").',
157
+ ),
158
+ )
159
+ if (!challenge.intent)
160
+ results.push(
161
+ fail(
162
+ 'Challenge has intent',
163
+ undefined,
164
+ 'Set intent to the payment type (e.g. "charge", "session").',
165
+ ),
166
+ )
167
+
168
+ // Semantic checks
169
+ if (challenge.expires) {
170
+ const expiresDate = new Date(challenge.expires)
171
+ const now = new Date()
172
+ if (expiresDate <= now) {
173
+ results.push(
174
+ fail(
175
+ 'Challenge expires in the future',
176
+ `Expired at ${challenge.expires}`,
177
+ 'The expires timestamp must be in the future when the challenge is issued. Use a 5-10 minute window from the current time.',
178
+ ),
179
+ )
180
+ } else {
181
+ const diffMs = expiresDate.getTime() - now.getTime()
182
+ const diffMin = Math.round(diffMs / 60000)
183
+ results.push(check('Challenge expires in the future', `${diffMin}m from now`))
184
+ }
185
+ } else {
186
+ results.push(
187
+ warn(
188
+ 'Challenge has expiration',
189
+ 'No expires field set',
190
+ 'Add an expires field (ISO 8601 datetime) to prevent replay attacks. Recommended: 5 minutes from issuance.',
191
+ ),
192
+ )
193
+ }
194
+
195
+ // Realm check (allow subdomain matches)
196
+ try {
197
+ const serverHost = new URL(baseUrl).hostname
198
+ const realm = challenge.realm ?? ''
199
+ const matches = serverHost === realm || serverHost.endsWith(`.${realm}`)
200
+ if (matches) {
201
+ results.push(check('Realm matches server hostname'))
202
+ } else {
203
+ results.push(
204
+ warn(
205
+ 'Realm matches server hostname',
206
+ `realm="${realm}" vs host="${serverHost}"`,
207
+ 'Set the realm to your production hostname (or base domain) in the challenge. Clients use realm to verify they are paying the right server.',
208
+ ),
209
+ )
210
+ }
211
+ } catch {}
212
+
213
+ // Method-specific validation
214
+ const request = challenge.request as Record<string, unknown>
215
+ if (challenge.method === Constants.Methods.tempo) {
216
+ if (isValidAddress(request.recipient)) {
217
+ results.push(check('Valid recipient address'))
218
+ } else {
219
+ results.push(
220
+ fail(
221
+ 'Valid recipient address',
222
+ `Got: ${String(request.recipient)}`,
223
+ 'Set request.recipient to a valid 0x-prefixed 40-hex-char address. This is where payment will be sent.',
224
+ ),
225
+ )
226
+ }
227
+
228
+ if (isValidAddress(request.currency)) {
229
+ const isTestnet = detectTestnet(challenge)
230
+ const network = isTestnet ? 'testnet' : 'mainnet'
231
+ results.push(check('Valid currency address', `${network}`))
232
+ } else {
233
+ results.push(
234
+ fail(
235
+ 'Valid currency address',
236
+ `Got: ${String(request.currency)}`,
237
+ 'Set request.currency to a valid token address. Common: "0x20c0000000000000000000000000000000000000" (PathUSD).',
238
+ ),
239
+ )
240
+ }
241
+
242
+ if (isValidIntegerAmount(request.amount)) {
243
+ results.push(check('Amount is valid integer string'))
244
+ } else if (request.amount === undefined || request.amount === null) {
245
+ results.push(
246
+ warn(
247
+ 'Amount is valid integer string',
248
+ 'No amount (dynamic pricing?)',
249
+ 'Set request.amount to a string of digits in the token\'s smallest unit (e.g. "10000" = $0.01 for 6-decimal tokens).',
250
+ ),
251
+ )
252
+ } else {
253
+ results.push(
254
+ fail(
255
+ 'Amount is valid integer string',
256
+ `Got: ${String(request.amount)}`,
257
+ 'request.amount must be a string of digits (no decimals, no prefix). Example: "10000" for $0.01 with 6-decimal tokens.',
258
+ ),
259
+ )
260
+ }
261
+ } else if (challenge.method === Constants.Methods.stripe) {
262
+ if (request.amount !== undefined) {
263
+ results.push(check('Stripe challenge has amount'))
264
+ } else {
265
+ results.push(warn('Stripe challenge has amount', 'No amount field'))
266
+ }
267
+ }
268
+
269
+ if (verbose) {
270
+ console.log(pc.dim(` Challenge: ${JSON.stringify(challenge, null, 2)}`))
271
+ }
272
+
273
+ return { results, resolvedBody: fetchBody }
274
+ }
275
+
276
+ export async function validateErrorHandling(
277
+ baseUrl: string,
278
+ endpoint: EndpointSpec,
279
+ options?: { body?: string | undefined; query?: string[] | undefined },
280
+ ): Promise<CheckResult[]> {
281
+ const results: CheckResult[] = []
282
+ const url = buildUrl(baseUrl, endpoint, options?.query)
283
+ const fetchHeaders: Record<string, string> = {
284
+ [Constants.Headers.authorization]: `${Constants.Schemes.payment} dGhpcyBpcyBnYXJiYWdl`,
285
+ }
286
+ let fetchBody: string | undefined
287
+ if (options?.body) {
288
+ fetchBody = options.body
289
+ fetchHeaders['content-type'] = 'application/json'
290
+ }
291
+
292
+ try {
293
+ const response = await fetchWithTimeout(url, {
294
+ method: endpoint.method,
295
+ headers: fetchHeaders,
296
+ body: fetchBody ?? null,
297
+ })
298
+
299
+ if (response.status === 402) {
300
+ results.push(check('Malformed credential returns 402', 'not 500'))
301
+ const wwwAuth = response.headers.get(Constants.Headers.wwwAuthenticate)
302
+ if (wwwAuth?.startsWith(`${Constants.Schemes.payment} `)) {
303
+ results.push(check('Error response includes fresh challenge'))
304
+ } else {
305
+ results.push(
306
+ warn(
307
+ 'Error response includes fresh challenge',
308
+ 'No WWW-Authenticate header',
309
+ 'When rejecting an invalid credential, respond with 402 and include a fresh WWW-Authenticate: Payment challenge so the client can retry.',
310
+ ),
311
+ )
312
+ }
313
+ } else if (response.status >= 500) {
314
+ results.push(
315
+ fail(
316
+ 'Malformed credential returns 402',
317
+ `Got ${response.status} (server error)`,
318
+ 'When the Authorization header contains an invalid credential, respond with 402 (not 500). Catch credential validation errors and return a fresh challenge.',
319
+ ),
320
+ )
321
+ } else {
322
+ results.push(
323
+ warn(
324
+ 'Malformed credential returns 402',
325
+ `Got ${response.status}`,
326
+ `When the Authorization header contains an invalid Payment credential, respond with 402 and a fresh WWW-Authenticate challenge. Returning ${response.status} prevents the client from retrying with a valid payment.`,
327
+ ),
328
+ )
329
+ }
330
+ } catch (error) {
331
+ results.push(fail('Error handling test', (error as Error).message))
332
+ }
333
+
334
+ return results
335
+ }
@@ -0,0 +1,120 @@
1
+ import type { EndpointSpec } from './helpers.js'
2
+ import { fetchWithTimeout, HTTP_METHODS } from './helpers.js'
3
+
4
+ export async function fetchDiscoveryDoc(
5
+ baseUrl: string,
6
+ ): Promise<{ doc: unknown; raw: string } | { error: string }> {
7
+ const url = new URL('/openapi.json', baseUrl).href
8
+ try {
9
+ const response = await fetchWithTimeout(url, {})
10
+ if (!response.ok) return { error: `HTTP ${response.status}` }
11
+ const raw = await response.text()
12
+ const doc = JSON.parse(raw)
13
+ return { doc, raw }
14
+ } catch (error) {
15
+ if (error instanceof DOMException && error.name === 'AbortError')
16
+ return { error: 'Request timed out after 15s' }
17
+ if (error instanceof SyntaxError) return { error: 'Invalid JSON' }
18
+ return { error: (error as Error).message }
19
+ }
20
+ }
21
+
22
+ // Extracts testable endpoints from an OpenAPI doc. Prefers endpoints with
23
+ // explicit x-payment-info (the server declares them as paid). Falls back to
24
+ // endpoints that list a 402 response (weaker signal, but still worth testing).
25
+ export function extractEndpointsFromDiscovery(doc: Record<string, unknown>): EndpointSpec[] {
26
+ const withPaymentInfo: EndpointSpec[] = []
27
+ const with402Response: EndpointSpec[] = []
28
+ const paths = doc.paths as Record<string, Record<string, unknown>> | undefined
29
+ if (!paths) return []
30
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
31
+ for (const [method, operation] of Object.entries(pathItem)) {
32
+ if (!HTTP_METHODS.has(method)) continue
33
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) continue
34
+ const op = operation as Record<string, unknown>
35
+ if (op['x-payment-info']) {
36
+ const payInfo = op['x-payment-info'] as Record<string, unknown>
37
+ withPaymentInfo.push({
38
+ method: method.toUpperCase(),
39
+ path: pathKey,
40
+ amount: payInfo.amount as string | undefined,
41
+ })
42
+ } else {
43
+ const responses = op.responses as Record<string, unknown> | undefined
44
+ if (responses && '402' in responses) {
45
+ with402Response.push({ method: method.toUpperCase(), path: pathKey })
46
+ }
47
+ }
48
+ }
49
+ }
50
+ return withPaymentInfo.length > 0 ? withPaymentInfo : with402Response
51
+ }
52
+
53
+ // Recursively generates a minimal valid value from a JSON Schema definition.
54
+ // Fills only required fields, preferring const > example > default > synthetic.
55
+ function generateValueFromSchema(schema: Record<string, unknown>): unknown {
56
+ if (schema.const !== undefined) return schema.const
57
+ if (schema.example !== undefined) return schema.example
58
+ if (schema.default !== undefined) return schema.default
59
+
60
+ switch (schema.type) {
61
+ case 'string': {
62
+ if (schema.format === 'email') return 'test@example.com'
63
+ if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
64
+ if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com'
65
+ if (schema.enum && Array.isArray(schema.enum)) return schema.enum[0]
66
+ return 'test'
67
+ }
68
+ case 'number':
69
+ case 'integer':
70
+ return schema.minimum ?? 1
71
+ case 'boolean':
72
+ return true
73
+ case 'array':
74
+ return []
75
+ case 'object': {
76
+ const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
77
+ if (!properties) return {}
78
+ const required = (schema.required as string[]) || []
79
+ const obj: Record<string, unknown> = {}
80
+ for (const key of required) {
81
+ const prop = properties[key]
82
+ if (prop) obj[key] = generateValueFromSchema(prop)
83
+ }
84
+ return obj
85
+ }
86
+ default:
87
+ return null
88
+ }
89
+ }
90
+
91
+ // Looks up the requestBody schema for an endpoint in the OpenAPI doc and
92
+ // returns a JSON string suitable for the request. Uses the doc's example
93
+ // if one exists, otherwise generates a minimal body from the schema.
94
+ export function extractRequestBodyFromDiscovery(
95
+ doc: Record<string, unknown>,
96
+ endpoint: EndpointSpec,
97
+ ): string | undefined {
98
+ const paths = doc.paths as Record<string, Record<string, unknown>> | undefined
99
+ if (!paths) return undefined
100
+ const pathItem = paths[endpoint.path]
101
+ if (!pathItem) return undefined
102
+ const op = pathItem[endpoint.method.toLowerCase()] as Record<string, unknown> | undefined
103
+ if (!op?.requestBody) return undefined
104
+
105
+ const rb = op.requestBody as Record<string, unknown>
106
+ const content = rb.content as Record<string, unknown> | undefined
107
+ const jsonContent = content?.['application/json'] as Record<string, unknown> | undefined
108
+ if (!jsonContent) return undefined
109
+
110
+ if (jsonContent.example) return JSON.stringify(jsonContent.example)
111
+
112
+ const schema = jsonContent.schema as Record<string, unknown> | undefined
113
+ if (!schema || schema.type !== 'object') return undefined
114
+
115
+ const result = generateValueFromSchema(schema)
116
+ if (result && typeof result === 'object' && Object.keys(result as object).length > 0) {
117
+ return JSON.stringify(result)
118
+ }
119
+ return undefined
120
+ }
@@ -0,0 +1,154 @@
1
+ import { pc } from '../utils.js'
2
+
3
+ export type CheckResult = {
4
+ label: string
5
+ detail?: string | undefined
6
+ hint?: string | undefined
7
+ severity: 'pass' | 'fail' | 'warn' | 'skip'
8
+ }
9
+
10
+ export type EndpointSpec = {
11
+ method: string
12
+ path: string
13
+ amount?: string | undefined
14
+ }
15
+
16
+ export function check(label: string, detail?: string): CheckResult {
17
+ return detail ? { label, detail, severity: 'pass' } : { label, severity: 'pass' }
18
+ }
19
+
20
+ export function fail(label: string, detail?: string, hint?: string): CheckResult {
21
+ const r: CheckResult = { label, severity: 'fail' }
22
+ if (detail) r.detail = detail
23
+ if (hint) r.hint = hint
24
+ return r
25
+ }
26
+
27
+ export function warn(label: string, detail?: string, hint?: string): CheckResult {
28
+ const r: CheckResult = { label, severity: 'warn' }
29
+ if (detail) r.detail = detail
30
+ if (hint) r.hint = hint
31
+ return r
32
+ }
33
+
34
+ export function skip(label: string, detail?: string, hint?: string): CheckResult {
35
+ const r: CheckResult = detail ? { label, detail, severity: 'skip' } : { label, severity: 'skip' }
36
+ if (hint) r.hint = hint
37
+ return r
38
+ }
39
+
40
+ const SEVERITY_ICONS = {
41
+ pass: pc.green('✓'),
42
+ fail: pc.red('✗'),
43
+ warn: pc.yellow('⚠'),
44
+ skip: pc.dim('○'),
45
+ } as const
46
+
47
+ export function printCheck(result: CheckResult) {
48
+ const icon = SEVERITY_ICONS[result.severity]
49
+ const text = result.detail ? `${result.label} (${result.detail})` : result.label
50
+ console.log(` ${icon} ${text}`)
51
+ if (result.hint && result.severity !== 'pass') {
52
+ console.log(pc.dim(` → ${result.hint}`))
53
+ }
54
+ }
55
+
56
+ export function printSection(title: string) {
57
+ console.log(`\n${pc.bold(title)}`)
58
+ }
59
+
60
+ export type Counts = { passed: number; failed: number; warnings: number; skipped: number }
61
+
62
+ export function printResults(results: CheckResult[], counts: Counts) {
63
+ for (const result of results) {
64
+ printCheck(result)
65
+ if (result.severity === 'pass') counts.passed++
66
+ else if (result.severity === 'fail') counts.failed++
67
+ else if (result.severity === 'warn') counts.warnings++
68
+ else if (result.severity === 'skip') counts.skipped++
69
+ }
70
+ }
71
+
72
+ export async function fetchWithTimeout(
73
+ url: string,
74
+ init: RequestInit,
75
+ timeoutMs = 15_000,
76
+ ): Promise<Response> {
77
+ const controller = new AbortController()
78
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
79
+ try {
80
+ return await fetch(url, { ...init, signal: controller.signal })
81
+ } finally {
82
+ clearTimeout(timeout)
83
+ }
84
+ }
85
+
86
+ export function buildUrl(baseUrl: string, endpoint: EndpointSpec, query?: string[]): string {
87
+ let url = new URL(endpoint.path, baseUrl).href
88
+ if (query) {
89
+ const u = new URL(url)
90
+ for (const q of query) {
91
+ const [key, ...rest] = q.split('=')
92
+ if (key) u.searchParams.set(key, rest.join('='))
93
+ }
94
+ url = u.href
95
+ }
96
+ return url
97
+ }
98
+
99
+ export function formatBytes(bytes: number): string {
100
+ if (bytes < 1024) return `${bytes}B`
101
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
102
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
103
+ }
104
+
105
+ export const HTTP_METHODS = new Set([
106
+ 'get',
107
+ 'post',
108
+ 'put',
109
+ 'patch',
110
+ 'delete',
111
+ 'head',
112
+ 'options',
113
+ 'trace',
114
+ ])
115
+
116
+ export function isValidAddress(addr: unknown): boolean {
117
+ return typeof addr === 'string' && /^0x[0-9a-fA-F]{40}$/.test(addr)
118
+ }
119
+
120
+ export function isValidIntegerAmount(amount: unknown): boolean {
121
+ return typeof amount === 'string' && /^(0|[1-9][0-9]*)$/.test(amount)
122
+ }
123
+
124
+ export function parseEndpointArg(input: string): EndpointSpec | null {
125
+ const sep = input.indexOf(':')
126
+ if (sep < 1) return null
127
+ const method = input.slice(0, sep).toLowerCase()
128
+ if (!HTTP_METHODS.has(method)) return null
129
+ const path = input.slice(sep + 1)
130
+ if (!path) return null
131
+ const normalizedPath = path.startsWith('/') ? path : '/' + path
132
+ return { method: method.toUpperCase(), path: normalizedPath }
133
+ }
134
+
135
+ // Resolves --body input: if JSON with all keys starting with /, it's a
136
+ // per-path mapping. Otherwise it's a global body for all endpoints.
137
+ export function resolveBodyForEndpoint(
138
+ rawBody: string | undefined,
139
+ endpointPath: string,
140
+ ): string | undefined {
141
+ if (!rawBody) return undefined
142
+ try {
143
+ const parsed = JSON.parse(rawBody)
144
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
145
+ const keys = Object.keys(parsed)
146
+ if (keys.length > 0 && keys.every((k) => k.startsWith('/'))) {
147
+ const value = parsed[endpointPath]
148
+ if (value === undefined) return undefined
149
+ return typeof value === 'string' ? value : JSON.stringify(value)
150
+ }
151
+ }
152
+ } catch {}
153
+ return rawBody
154
+ }