agent-json-validate 1.2.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 +435 -0
- package/package.json +36 -0
- package/schema.json +587 -0
- package/validate.js +863 -0
package/validate.js
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agent.json validator
|
|
5
|
+
*
|
|
6
|
+
* Validates an agent.json manifest against the specification.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx agent-json-validate https://example.com/.well-known/agent.json
|
|
10
|
+
* npx agent-json-validate ./path/to/agent.json
|
|
11
|
+
* cat agent.json | npx agent-json-validate --stdin
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const https = require("https");
|
|
17
|
+
const http = require("http");
|
|
18
|
+
|
|
19
|
+
// --- Schema constraints (inlined to avoid runtime dependency on ajv) ---
|
|
20
|
+
|
|
21
|
+
const VALID_PARAM_TYPES = [
|
|
22
|
+
"string",
|
|
23
|
+
"integer",
|
|
24
|
+
"number",
|
|
25
|
+
"boolean",
|
|
26
|
+
"array",
|
|
27
|
+
"object",
|
|
28
|
+
];
|
|
29
|
+
const VALID_BOUNTY_TYPES = ["cpa"];
|
|
30
|
+
const VALID_CURRENCIES = ["USDC"];
|
|
31
|
+
const INTENT_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
32
|
+
const ORIGIN_PATTERN =
|
|
33
|
+
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
|
34
|
+
|
|
35
|
+
// --- Validation ---
|
|
36
|
+
|
|
37
|
+
function validate(manifest, sourceUrl) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
const warnings = [];
|
|
40
|
+
|
|
41
|
+
// Root required fields
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
43
|
+
errors.push("Manifest must be a JSON object");
|
|
44
|
+
return { valid: false, errors, warnings };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// version
|
|
48
|
+
if (!manifest.version) {
|
|
49
|
+
errors.push('Missing required field: "version"');
|
|
50
|
+
} else if (manifest.version !== "1.0" && manifest.version !== "1.1") {
|
|
51
|
+
errors.push(`Invalid version: "${manifest.version}". Expected "1.0" or "1.1"`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// origin
|
|
55
|
+
if (!manifest.origin) {
|
|
56
|
+
errors.push('Missing required field: "origin"');
|
|
57
|
+
} else if (typeof manifest.origin !== "string") {
|
|
58
|
+
errors.push('"origin" must be a string');
|
|
59
|
+
} else if (!ORIGIN_PATTERN.test(manifest.origin)) {
|
|
60
|
+
errors.push(
|
|
61
|
+
`Invalid origin: "${manifest.origin}". Must be a valid domain name.`
|
|
62
|
+
);
|
|
63
|
+
} else if (sourceUrl) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(sourceUrl);
|
|
66
|
+
const expectedOrigin = url.hostname;
|
|
67
|
+
if (
|
|
68
|
+
manifest.origin !== expectedOrigin &&
|
|
69
|
+
!expectedOrigin.endsWith("." + manifest.origin)
|
|
70
|
+
) {
|
|
71
|
+
errors.push(
|
|
72
|
+
`Origin mismatch: manifest says "${manifest.origin}" but served from "${expectedOrigin}"`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Can't parse URL, skip origin check
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// payout_address
|
|
81
|
+
if (!manifest.payout_address) {
|
|
82
|
+
errors.push('Missing required field: "payout_address"');
|
|
83
|
+
} else if (typeof manifest.payout_address !== "string") {
|
|
84
|
+
errors.push('"payout_address" must be a string');
|
|
85
|
+
} else if (manifest.payout_address.length === 0) {
|
|
86
|
+
errors.push('"payout_address" must not be empty');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// display_name (optional)
|
|
90
|
+
if (manifest.display_name !== undefined) {
|
|
91
|
+
if (typeof manifest.display_name !== "string") {
|
|
92
|
+
errors.push('"display_name" must be a string');
|
|
93
|
+
} else if (manifest.display_name.length > 100) {
|
|
94
|
+
warnings.push(
|
|
95
|
+
`"display_name" is ${manifest.display_name.length} characters (recommended max: 100)`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// description (optional)
|
|
101
|
+
if (manifest.description !== undefined) {
|
|
102
|
+
if (typeof manifest.description !== "string") {
|
|
103
|
+
errors.push('"description" must be a string');
|
|
104
|
+
} else if (manifest.description.length > 500) {
|
|
105
|
+
warnings.push(
|
|
106
|
+
`"description" is ${manifest.description.length} characters (recommended max: 500)`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (manifest.extensions !== undefined) {
|
|
112
|
+
if (typeof manifest.extensions !== "object" || manifest.extensions === null || Array.isArray(manifest.extensions)) {
|
|
113
|
+
errors.push('"extensions" must be an object');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// identity (optional)
|
|
118
|
+
if (manifest.identity !== undefined) {
|
|
119
|
+
validateIdentity(manifest.identity, errors, warnings);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// bounty (optional, manifest-level)
|
|
123
|
+
if (manifest.bounty !== undefined) {
|
|
124
|
+
validateBounty(manifest.bounty, "manifest-level", errors, warnings);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// incentive (optional, manifest-level)
|
|
128
|
+
if (manifest.incentive !== undefined) {
|
|
129
|
+
validateIncentive(manifest.incentive, "manifest-level", errors, warnings);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// x402 (optional, root-level)
|
|
133
|
+
if (manifest.x402 !== undefined) {
|
|
134
|
+
validateX402Root(manifest.x402, "x402", errors, warnings);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// intents (optional)
|
|
138
|
+
if (manifest.intents !== undefined) {
|
|
139
|
+
if (!Array.isArray(manifest.intents)) {
|
|
140
|
+
errors.push('"intents" must be an array');
|
|
141
|
+
} else {
|
|
142
|
+
const intentNames = new Set();
|
|
143
|
+
manifest.intents.forEach((intent, i) => {
|
|
144
|
+
validateIntent(intent, i, intentNames, errors, warnings);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
warnings.push(
|
|
149
|
+
"No intents declared. This is a Tier 1 (minimal) manifest. Declare intents for better agent routing."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Cross-reference validation: intent network_pricing networks should exist in root x402 config
|
|
154
|
+
if (manifest.x402 && typeof manifest.x402 === "object" && manifest.intents && Array.isArray(manifest.intents)) {
|
|
155
|
+
const rootNetworks = new Set();
|
|
156
|
+
if (Array.isArray(manifest.x402.networks)) {
|
|
157
|
+
manifest.x402.networks.forEach((entry) => {
|
|
158
|
+
if (entry && typeof entry.network === "string") {
|
|
159
|
+
rootNetworks.add(entry.network);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
} else if (typeof manifest.x402.network === "string") {
|
|
163
|
+
rootNetworks.add(manifest.x402.network);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (rootNetworks.size > 0) {
|
|
167
|
+
manifest.intents.forEach((intent, i) => {
|
|
168
|
+
if (intent && intent.x402 && Array.isArray(intent.x402.network_pricing)) {
|
|
169
|
+
intent.x402.network_pricing.forEach((entry, j) => {
|
|
170
|
+
if (entry && typeof entry.network === "string" && !rootNetworks.has(entry.network)) {
|
|
171
|
+
warnings.push(
|
|
172
|
+
`intents[${i}].x402.network_pricing[${j}]: network "${entry.network}" is not declared in the root x402 configuration. Agents will have no settlement details for this network.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for unknown top-level fields
|
|
182
|
+
const knownFields = [
|
|
183
|
+
"version",
|
|
184
|
+
"origin",
|
|
185
|
+
"payout_address",
|
|
186
|
+
"display_name",
|
|
187
|
+
"description",
|
|
188
|
+
"extensions",
|
|
189
|
+
"identity",
|
|
190
|
+
"intents",
|
|
191
|
+
"bounty",
|
|
192
|
+
"incentive",
|
|
193
|
+
"x402",
|
|
194
|
+
];
|
|
195
|
+
for (const key of Object.keys(manifest)) {
|
|
196
|
+
if (!knownFields.includes(key) && !key.startsWith("x-") && !key.startsWith("x_")) {
|
|
197
|
+
warnings.push(`Unknown top-level field: "${key}"`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
valid: errors.length === 0,
|
|
203
|
+
errors,
|
|
204
|
+
warnings,
|
|
205
|
+
tier: determineTier(manifest),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateIdentity(identity, errors, warnings) {
|
|
210
|
+
if (typeof identity !== "object" || identity === null) {
|
|
211
|
+
errors.push('"identity" must be an object');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (identity.did !== undefined) {
|
|
216
|
+
if (typeof identity.did !== "string") {
|
|
217
|
+
errors.push('"identity.did" must be a string');
|
|
218
|
+
} else if (!identity.did.startsWith("did:")) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`Invalid DID format: "${identity.did}". Must start with "did:"`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (identity.public_key !== undefined) {
|
|
226
|
+
if (typeof identity.public_key !== "string") {
|
|
227
|
+
errors.push('"identity.public_key" must be a string');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validateIntent(intent, index, nameSet, errors, warnings) {
|
|
233
|
+
const prefix = `intents[${index}]`;
|
|
234
|
+
|
|
235
|
+
if (typeof intent !== "object" || intent === null) {
|
|
236
|
+
errors.push(`${prefix}: must be an object`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// name
|
|
241
|
+
if (!intent.name) {
|
|
242
|
+
errors.push(`${prefix}: missing required field "name"`);
|
|
243
|
+
} else if (typeof intent.name !== "string") {
|
|
244
|
+
errors.push(`${prefix}: "name" must be a string`);
|
|
245
|
+
} else {
|
|
246
|
+
if (!INTENT_NAME_PATTERN.test(intent.name)) {
|
|
247
|
+
errors.push(
|
|
248
|
+
`${prefix}: intent name "${intent.name}" must be snake_case (lowercase letters, digits, underscores, starting with a letter)`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (intent.name.length > 64) {
|
|
252
|
+
errors.push(
|
|
253
|
+
`${prefix}: intent name "${intent.name}" exceeds 64 character limit`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (nameSet.has(intent.name)) {
|
|
257
|
+
errors.push(`${prefix}: duplicate intent name "${intent.name}"`);
|
|
258
|
+
}
|
|
259
|
+
nameSet.add(intent.name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// description
|
|
263
|
+
if (!intent.description) {
|
|
264
|
+
errors.push(`${prefix}: missing required field "description"`);
|
|
265
|
+
} else if (typeof intent.description !== "string") {
|
|
266
|
+
errors.push(`${prefix}: "description" must be a string`);
|
|
267
|
+
} else {
|
|
268
|
+
if (intent.description.length < 10) {
|
|
269
|
+
warnings.push(
|
|
270
|
+
`${prefix}: description is very short (${intent.description.length} chars). Longer descriptions improve routing accuracy.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (intent.description.length > 500) {
|
|
274
|
+
warnings.push(
|
|
275
|
+
`${prefix}: description is ${intent.description.length} chars (recommended max: 500)`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// endpoint (optional)
|
|
281
|
+
const VALID_METHODS = ["GET", "POST", "PUT", "DELETE"];
|
|
282
|
+
if (intent.endpoint !== undefined) {
|
|
283
|
+
if (typeof intent.endpoint !== "string") {
|
|
284
|
+
errors.push(`${prefix}: "endpoint" must be a string`);
|
|
285
|
+
} else if (intent.endpoint.length === 0) {
|
|
286
|
+
errors.push(`${prefix}: "endpoint" must not be empty`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// method (optional, but required when endpoint is present)
|
|
291
|
+
if (intent.method !== undefined) {
|
|
292
|
+
if (typeof intent.method !== "string") {
|
|
293
|
+
errors.push(`${prefix}: "method" must be a string`);
|
|
294
|
+
} else if (!VALID_METHODS.includes(intent.method)) {
|
|
295
|
+
errors.push(
|
|
296
|
+
`${prefix}: invalid method "${intent.method}". Must be one of: ${VALID_METHODS.join(", ")}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (intent.endpoint === undefined) {
|
|
300
|
+
warnings.push(
|
|
301
|
+
`${prefix}: "method" is set but "endpoint" is missing. Method is only used with direct API intents.`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else if (intent.endpoint !== undefined) {
|
|
305
|
+
errors.push(
|
|
306
|
+
`${prefix}: "endpoint" is set but "method" is missing. The public schema requires "method" whenever "endpoint" is declared.`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (intent.extensions !== undefined) {
|
|
311
|
+
if (typeof intent.extensions !== "object" || intent.extensions === null || Array.isArray(intent.extensions)) {
|
|
312
|
+
errors.push(`${prefix}: "extensions" must be an object`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// parameters (optional)
|
|
317
|
+
if (intent.parameters !== undefined) {
|
|
318
|
+
if (typeof intent.parameters !== "object" || intent.parameters === null) {
|
|
319
|
+
errors.push(`${prefix}: "parameters" must be an object`);
|
|
320
|
+
} else {
|
|
321
|
+
for (const [paramName, param] of Object.entries(intent.parameters)) {
|
|
322
|
+
validateParameter(param, `${prefix}.parameters.${paramName}`, errors, warnings);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// returns (optional)
|
|
328
|
+
if (intent.returns !== undefined) {
|
|
329
|
+
if (typeof intent.returns !== "object" || intent.returns === null) {
|
|
330
|
+
errors.push(`${prefix}: "returns" must be an object`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// price (optional)
|
|
335
|
+
if (intent.price !== undefined) {
|
|
336
|
+
validatePrice(intent.price, `${prefix}.price`, errors, warnings);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// bounty (optional, intent-level)
|
|
340
|
+
if (intent.bounty !== undefined) {
|
|
341
|
+
validateBounty(intent.bounty, `${prefix}.bounty`, errors, warnings);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// incentive (optional, intent-level)
|
|
345
|
+
if (intent.incentive !== undefined) {
|
|
346
|
+
validateIncentive(intent.incentive, `${prefix}.incentive`, errors, warnings);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// x402 (optional, intent-level)
|
|
350
|
+
if (intent.x402 !== undefined) {
|
|
351
|
+
validateX402Intent(intent.x402, `${prefix}.x402`, errors, warnings);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const knownFields = [
|
|
355
|
+
"name",
|
|
356
|
+
"description",
|
|
357
|
+
"extensions",
|
|
358
|
+
"endpoint",
|
|
359
|
+
"method",
|
|
360
|
+
"parameters",
|
|
361
|
+
"returns",
|
|
362
|
+
"price",
|
|
363
|
+
"bounty",
|
|
364
|
+
"incentive",
|
|
365
|
+
"x402",
|
|
366
|
+
];
|
|
367
|
+
for (const key of Object.keys(intent)) {
|
|
368
|
+
if (!knownFields.includes(key) && !key.startsWith("x-") && !key.startsWith("x_")) {
|
|
369
|
+
warnings.push(`${prefix}: unknown field "${key}"`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validatePrice(price, path, errors, warnings) {
|
|
375
|
+
if (typeof price !== "object" || price === null) {
|
|
376
|
+
errors.push(`${path}: must be an object`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (price.amount === undefined) {
|
|
381
|
+
errors.push(`${path}: missing required field "amount"`);
|
|
382
|
+
} else if (typeof price.amount !== "number" || price.amount < 0) {
|
|
383
|
+
errors.push(`${path}: "amount" must be a non-negative number`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const VALID_PRICE_CURRENCIES = ["USD", "USDC"];
|
|
387
|
+
if (!price.currency) {
|
|
388
|
+
errors.push(`${path}: missing required field "currency"`);
|
|
389
|
+
} else if (!VALID_PRICE_CURRENCIES.includes(price.currency)) {
|
|
390
|
+
errors.push(
|
|
391
|
+
`${path}: invalid currency "${price.currency}". Must be one of: ${VALID_PRICE_CURRENCIES.join(", ")}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const VALID_PRICE_MODELS = ["per_call", "per_unit", "flat"];
|
|
396
|
+
if (price.model !== undefined) {
|
|
397
|
+
if (!VALID_PRICE_MODELS.includes(price.model)) {
|
|
398
|
+
errors.push(
|
|
399
|
+
`${path}: invalid model "${price.model}". Must be one of: ${VALID_PRICE_MODELS.join(", ")}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (price.model === "per_unit" && !price.unit_param) {
|
|
403
|
+
errors.push(
|
|
404
|
+
`${path}: model is "per_unit" but "unit_param" is not set. Specify which parameter determines the unit count.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (price.unit_param !== undefined && typeof price.unit_param !== "string") {
|
|
410
|
+
errors.push(`${path}: "unit_param" must be a string`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (price.free_tier !== undefined) {
|
|
414
|
+
if (typeof price.free_tier !== "number" || price.free_tier < 0 || !Number.isInteger(price.free_tier)) {
|
|
415
|
+
errors.push(`${path}: "free_tier" must be a non-negative integer`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (price.network !== undefined) {
|
|
420
|
+
if (typeof price.network === "string") {
|
|
421
|
+
// valid — single network
|
|
422
|
+
} else if (Array.isArray(price.network)) {
|
|
423
|
+
if (price.network.length === 0) {
|
|
424
|
+
errors.push(`${path}: "network" array must not be empty`);
|
|
425
|
+
} else if (!price.network.every((n) => typeof n === "string")) {
|
|
426
|
+
errors.push(`${path}: "network" array must contain only strings`);
|
|
427
|
+
} else {
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
for (const n of price.network) {
|
|
430
|
+
if (seen.has(n)) {
|
|
431
|
+
warnings.push(`${path}: duplicate network "${n}" in network array`);
|
|
432
|
+
}
|
|
433
|
+
seen.add(n);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
errors.push(`${path}: "network" must be a string or array of strings`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (price.currency === "USD") {
|
|
441
|
+
warnings.push(
|
|
442
|
+
`${path}: "network" is set but currency is "USD". Network is typically used with on-chain currencies like "USDC".`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateX402Root(x402, path, errors, warnings) {
|
|
449
|
+
if (typeof x402 !== "object" || x402 === null) {
|
|
450
|
+
errors.push(`${path}: must be an object`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (x402.supported === undefined) {
|
|
455
|
+
errors.push(`${path}: missing required field "supported"`);
|
|
456
|
+
} else if (typeof x402.supported !== "boolean") {
|
|
457
|
+
errors.push(`${path}: "supported" must be a boolean`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (x402.recipient !== undefined && typeof x402.recipient !== "string") {
|
|
461
|
+
errors.push(`${path}: "recipient" must be a string`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (x402.networks !== undefined) {
|
|
465
|
+
// Multi-network mode
|
|
466
|
+
if (!Array.isArray(x402.networks)) {
|
|
467
|
+
errors.push(`${path}: "networks" must be an array`);
|
|
468
|
+
} else if (x402.networks.length === 0) {
|
|
469
|
+
errors.push(`${path}: "networks" array must not be empty`);
|
|
470
|
+
} else {
|
|
471
|
+
const seenNetworks = new Set();
|
|
472
|
+
x402.networks.forEach((entry, i) => {
|
|
473
|
+
const entryPath = `${path}.networks[${i}]`;
|
|
474
|
+
if (typeof entry !== "object" || entry === null) {
|
|
475
|
+
errors.push(`${entryPath}: must be an object`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!entry.network || typeof entry.network !== "string") {
|
|
479
|
+
errors.push(`${entryPath}: missing required field "network" (string)`);
|
|
480
|
+
} else {
|
|
481
|
+
if (seenNetworks.has(entry.network)) {
|
|
482
|
+
warnings.push(`${entryPath}: duplicate network "${entry.network}" in networks array`);
|
|
483
|
+
}
|
|
484
|
+
seenNetworks.add(entry.network);
|
|
485
|
+
}
|
|
486
|
+
if (!entry.asset || typeof entry.asset !== "string") {
|
|
487
|
+
errors.push(`${entryPath}: missing required field "asset" (string)`);
|
|
488
|
+
}
|
|
489
|
+
if (entry.contract !== undefined && typeof entry.contract !== "string") {
|
|
490
|
+
errors.push(`${entryPath}: "contract" must be a string`);
|
|
491
|
+
}
|
|
492
|
+
if (entry.facilitator !== undefined && typeof entry.facilitator !== "string") {
|
|
493
|
+
errors.push(`${entryPath}: "facilitator" must be a string`);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
// Single-network (flat) mode
|
|
499
|
+
if (x402.network !== undefined && typeof x402.network !== "string") {
|
|
500
|
+
errors.push(`${path}: "network" must be a string`);
|
|
501
|
+
}
|
|
502
|
+
if (x402.asset !== undefined && typeof x402.asset !== "string") {
|
|
503
|
+
errors.push(`${path}: "asset" must be a string`);
|
|
504
|
+
}
|
|
505
|
+
if (x402.contract !== undefined && typeof x402.contract !== "string") {
|
|
506
|
+
errors.push(`${path}: "contract" must be a string`);
|
|
507
|
+
}
|
|
508
|
+
if (x402.facilitator !== undefined && typeof x402.facilitator !== "string") {
|
|
509
|
+
errors.push(`${path}: "facilitator" must be a string`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (x402.supported === true && !x402.network && !x402.asset) {
|
|
513
|
+
warnings.push(
|
|
514
|
+
`${path}: x402 is supported but no network or asset is declared. Agents won't know how to pay.`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function validateX402Intent(x402, path, errors, warnings) {
|
|
521
|
+
if (typeof x402 !== "object" || x402 === null) {
|
|
522
|
+
errors.push(`${path}: must be an object`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (x402.supported !== undefined && typeof x402.supported !== "boolean") {
|
|
527
|
+
errors.push(`${path}: "supported" must be a boolean`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (x402.direct_price !== undefined) {
|
|
531
|
+
if (typeof x402.direct_price !== "number" || x402.direct_price < 0) {
|
|
532
|
+
errors.push(`${path}: "direct_price" must be a non-negative number`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (x402.ticket_price !== undefined) {
|
|
537
|
+
if (typeof x402.ticket_price !== "number" || x402.ticket_price < 0) {
|
|
538
|
+
errors.push(`${path}: "ticket_price" must be a non-negative number`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (x402.description !== undefined && typeof x402.description !== "string") {
|
|
543
|
+
errors.push(`${path}: "description" must be a string`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (
|
|
547
|
+
typeof x402.ticket_price === "number" &&
|
|
548
|
+
typeof x402.direct_price === "number" &&
|
|
549
|
+
x402.ticket_price > x402.direct_price
|
|
550
|
+
) {
|
|
551
|
+
warnings.push(
|
|
552
|
+
`${path}: "ticket_price" (${x402.ticket_price}) is greater than "direct_price" (${x402.direct_price}). Session tickets are typically discounted.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (x402.network_pricing !== undefined) {
|
|
557
|
+
if (!Array.isArray(x402.network_pricing)) {
|
|
558
|
+
errors.push(`${path}: "network_pricing" must be an array`);
|
|
559
|
+
} else if (x402.network_pricing.length === 0) {
|
|
560
|
+
errors.push(`${path}: "network_pricing" array must not be empty`);
|
|
561
|
+
} else {
|
|
562
|
+
const seenPricingNetworks = new Set();
|
|
563
|
+
x402.network_pricing.forEach((entry, i) => {
|
|
564
|
+
const entryPath = `${path}.network_pricing[${i}]`;
|
|
565
|
+
if (typeof entry !== "object" || entry === null) {
|
|
566
|
+
errors.push(`${entryPath}: must be an object`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!entry.network || typeof entry.network !== "string") {
|
|
570
|
+
errors.push(`${entryPath}: missing required field "network" (string)`);
|
|
571
|
+
} else {
|
|
572
|
+
if (seenPricingNetworks.has(entry.network)) {
|
|
573
|
+
warnings.push(`${entryPath}: duplicate network "${entry.network}" in network_pricing array`);
|
|
574
|
+
}
|
|
575
|
+
seenPricingNetworks.add(entry.network);
|
|
576
|
+
}
|
|
577
|
+
if (entry.direct_price !== undefined) {
|
|
578
|
+
if (typeof entry.direct_price !== "number" || entry.direct_price < 0) {
|
|
579
|
+
errors.push(`${entryPath}: "direct_price" must be a non-negative number`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (entry.ticket_price !== undefined) {
|
|
583
|
+
if (typeof entry.ticket_price !== "number" || entry.ticket_price < 0) {
|
|
584
|
+
errors.push(`${entryPath}: "ticket_price" must be a non-negative number`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (
|
|
588
|
+
typeof entry.ticket_price === "number" &&
|
|
589
|
+
typeof entry.direct_price === "number" &&
|
|
590
|
+
entry.ticket_price > entry.direct_price
|
|
591
|
+
) {
|
|
592
|
+
warnings.push(
|
|
593
|
+
`${entryPath}: "ticket_price" (${entry.ticket_price}) is greater than "direct_price" (${entry.direct_price}). Session tickets are typically discounted.`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validateParameter(param, path, errors, warnings) {
|
|
602
|
+
if (typeof param !== "object" || param === null) {
|
|
603
|
+
errors.push(`${path}: must be an object`);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!param.type) {
|
|
608
|
+
errors.push(`${path}: missing required field "type"`);
|
|
609
|
+
} else if (!VALID_PARAM_TYPES.includes(param.type)) {
|
|
610
|
+
errors.push(
|
|
611
|
+
`${path}: invalid type "${param.type}". Must be one of: ${VALID_PARAM_TYPES.join(", ")}`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (param.required !== undefined && typeof param.required !== "boolean") {
|
|
616
|
+
errors.push(`${path}: "required" must be a boolean`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (param.enum !== undefined) {
|
|
620
|
+
if (!Array.isArray(param.enum) || param.enum.length === 0) {
|
|
621
|
+
errors.push(`${path}: "enum" must be a non-empty array`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function validateBounty(bounty, path, errors, warnings) {
|
|
627
|
+
if (typeof bounty !== "object" || bounty === null) {
|
|
628
|
+
errors.push(`${path}: must be an object`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!bounty.type) {
|
|
633
|
+
errors.push(`${path}: missing required field "type"`);
|
|
634
|
+
} else if (!VALID_BOUNTY_TYPES.includes(bounty.type)) {
|
|
635
|
+
errors.push(
|
|
636
|
+
`${path}: invalid bounty type "${bounty.type}". Must be one of: ${VALID_BOUNTY_TYPES.join(", ")}`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (bounty.rate === undefined) {
|
|
641
|
+
errors.push(`${path}: missing required field "rate"`);
|
|
642
|
+
} else if (typeof bounty.rate !== "number" || bounty.rate < 0) {
|
|
643
|
+
errors.push(`${path}: "rate" must be a non-negative number`);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!bounty.currency) {
|
|
647
|
+
errors.push(`${path}: missing required field "currency"`);
|
|
648
|
+
} else if (!VALID_CURRENCIES.includes(bounty.currency)) {
|
|
649
|
+
errors.push(
|
|
650
|
+
`${path}: invalid currency "${bounty.currency}". Must be one of: ${VALID_CURRENCIES.join(", ")}`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (bounty.splits !== undefined) {
|
|
655
|
+
validateSplits(bounty.splits, `${path}.splits`, errors, warnings);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function validateSplits(splits, path, errors, warnings) {
|
|
660
|
+
if (typeof splits !== "object" || splits === null) {
|
|
661
|
+
errors.push(`${path}: must be an object`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const validKeys = ["orchestrator", "platform", "referrer"];
|
|
666
|
+
let sum = 0;
|
|
667
|
+
|
|
668
|
+
for (const [key, value] of Object.entries(splits)) {
|
|
669
|
+
if (!validKeys.includes(key)) {
|
|
670
|
+
if (!key.startsWith("x-") && !key.startsWith("x_")) {
|
|
671
|
+
warnings.push(`${path}: unknown split key "${key}"`);
|
|
672
|
+
}
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (typeof value !== "number" || value < 0 || value > 1) {
|
|
676
|
+
errors.push(`${path}.${key}: must be a number between 0 and 1`);
|
|
677
|
+
} else {
|
|
678
|
+
sum += value;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (Math.abs(sum - 1.0) > 0.001) {
|
|
683
|
+
errors.push(
|
|
684
|
+
`${path}: splits must sum to 1.0 (currently ${sum.toFixed(3)})`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function validateIncentive(incentive, path, errors, warnings) {
|
|
690
|
+
if (typeof incentive !== "object" || incentive === null) {
|
|
691
|
+
errors.push(`${path}: must be an object`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!incentive.type) {
|
|
696
|
+
errors.push(`${path}: missing required field "type"`);
|
|
697
|
+
} else if (!VALID_BOUNTY_TYPES.includes(incentive.type)) {
|
|
698
|
+
errors.push(
|
|
699
|
+
`${path}: invalid incentive type "${incentive.type}". Must be one of: ${VALID_BOUNTY_TYPES.join(", ")}`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (incentive.rate === undefined) {
|
|
704
|
+
errors.push(`${path}: missing required field "rate"`);
|
|
705
|
+
} else if (typeof incentive.rate !== "number" || incentive.rate < 0) {
|
|
706
|
+
errors.push(`${path}: "rate" must be a non-negative number`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!incentive.currency) {
|
|
710
|
+
errors.push(`${path}: missing required field "currency"`);
|
|
711
|
+
} else if (!VALID_CURRENCIES.includes(incentive.currency)) {
|
|
712
|
+
errors.push(
|
|
713
|
+
`${path}: invalid currency "${incentive.currency}". Must be one of: ${VALID_CURRENCIES.join(", ")}`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function determineTier(manifest) {
|
|
719
|
+
if (manifest.identity && (manifest.identity.did || manifest.identity.public_key)) {
|
|
720
|
+
return 3;
|
|
721
|
+
}
|
|
722
|
+
if (manifest.intents && manifest.intents.length > 0) {
|
|
723
|
+
return 2;
|
|
724
|
+
}
|
|
725
|
+
return 1;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// --- Fetching ---
|
|
729
|
+
|
|
730
|
+
function fetchUrl(url) {
|
|
731
|
+
return new Promise((resolve, reject) => {
|
|
732
|
+
const client = url.startsWith("https") ? https : http;
|
|
733
|
+
client
|
|
734
|
+
.get(url, (res) => {
|
|
735
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
736
|
+
const location = res.headers.location;
|
|
737
|
+
if (location) {
|
|
738
|
+
fetchUrl(location).then(resolve).catch(reject);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (res.statusCode !== 200) {
|
|
743
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
let data = "";
|
|
747
|
+
res.on("data", (chunk) => (data += chunk));
|
|
748
|
+
res.on("end", () => resolve(data));
|
|
749
|
+
})
|
|
750
|
+
.on("error", reject);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function loadManifest(input) {
|
|
755
|
+
// URL
|
|
756
|
+
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
757
|
+
const body = await fetchUrl(input);
|
|
758
|
+
return { manifest: JSON.parse(body), sourceUrl: input };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Local file
|
|
762
|
+
const resolved = path.resolve(input);
|
|
763
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
764
|
+
return { manifest: JSON.parse(content), sourceUrl: null };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function loadFromStdin() {
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
let data = "";
|
|
770
|
+
process.stdin.setEncoding("utf-8");
|
|
771
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
772
|
+
process.stdin.on("end", () => {
|
|
773
|
+
resolve({ manifest: JSON.parse(data), sourceUrl: null });
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// --- Output ---
|
|
779
|
+
|
|
780
|
+
function printResult(result) {
|
|
781
|
+
const tierLabel = `Tier ${result.tier}`;
|
|
782
|
+
|
|
783
|
+
if (result.valid) {
|
|
784
|
+
console.log(`\n ✓ Valid agent.json (${tierLabel})\n`);
|
|
785
|
+
} else {
|
|
786
|
+
console.log(`\n ✗ Invalid agent.json\n`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (result.errors.length > 0) {
|
|
790
|
+
console.log(" Errors:");
|
|
791
|
+
result.errors.forEach((e) => console.log(` ✗ ${e}`));
|
|
792
|
+
console.log();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (result.warnings.length > 0) {
|
|
796
|
+
console.log(" Warnings:");
|
|
797
|
+
result.warnings.forEach((w) => console.log(` ⚠ ${w}`));
|
|
798
|
+
console.log();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (result.valid && result.warnings.length === 0) {
|
|
802
|
+
console.log(" No issues found.\n");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// --- CLI ---
|
|
807
|
+
|
|
808
|
+
async function main() {
|
|
809
|
+
const args = process.argv.slice(2);
|
|
810
|
+
|
|
811
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
812
|
+
console.log(`
|
|
813
|
+
agent.json validator
|
|
814
|
+
|
|
815
|
+
Usage:
|
|
816
|
+
npx agent-json-validate <url-or-path>
|
|
817
|
+
npx agent-json-validate https://example.com/.well-known/agent.json
|
|
818
|
+
npx agent-json-validate ./agent.json
|
|
819
|
+
cat agent.json | npx agent-json-validate --stdin
|
|
820
|
+
|
|
821
|
+
Options:
|
|
822
|
+
--stdin Read manifest from stdin
|
|
823
|
+
--json Output results as JSON
|
|
824
|
+
--help Show this help
|
|
825
|
+
`);
|
|
826
|
+
process.exit(0);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const jsonOutput = args.includes("--json");
|
|
830
|
+
const useStdin = args.includes("--stdin");
|
|
831
|
+
const input = args.find((a) => !a.startsWith("--"));
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const { manifest, sourceUrl } = useStdin
|
|
835
|
+
? await loadFromStdin()
|
|
836
|
+
: await loadManifest(input);
|
|
837
|
+
|
|
838
|
+
const result = validate(manifest, sourceUrl);
|
|
839
|
+
|
|
840
|
+
if (jsonOutput) {
|
|
841
|
+
console.log(JSON.stringify(result, null, 2));
|
|
842
|
+
} else {
|
|
843
|
+
printResult(result);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
process.exit(result.valid ? 0 : 1);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
if (err instanceof SyntaxError) {
|
|
849
|
+
console.error("\n ✗ Invalid JSON:", err.message, "\n");
|
|
850
|
+
} else {
|
|
851
|
+
console.error("\n ✗ Error:", err.message, "\n");
|
|
852
|
+
}
|
|
853
|
+
process.exit(1);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Export for programmatic use
|
|
858
|
+
module.exports = { validate };
|
|
859
|
+
|
|
860
|
+
// Run CLI if invoked directly
|
|
861
|
+
if (require.main === module) {
|
|
862
|
+
main();
|
|
863
|
+
}
|