agentgrade-cli 1.0.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/cli.mjs +337 -0
  4. package/lib/probe.mjs +1383 -0
  5. package/package.json +32 -0
package/lib/probe.mjs ADDED
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * agentgrade probe — core scanning logic
3
+ *
4
+ * Pure functions that return structured results. No console output.
5
+ * Used by cli.mjs (terminal) and server.js (web API).
6
+ */
7
+
8
+ import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
9
+
10
+ // Generate a temp EIP-191 Bearer token for services that require wallet auth before payment
11
+ async function makeEIP191Token(domain) {
12
+ const key = generatePrivateKey();
13
+ const account = privateKeyToAccount(key);
14
+ const ts = Math.floor(Date.now() / 1000);
15
+ const sig = await account.signMessage({ message: `${domain}:${ts}` });
16
+ return `${ts}.${sig.slice(2)}`;
17
+ }
18
+
19
+ function smartParamValue(name) {
20
+ const n = name.toLowerCase();
21
+ if (/id$/.test(n)) return '1';
22
+ if (/uuid|guid/.test(n)) return '00000000-0000-0000-0000-000000000000';
23
+ if (/domain|hostname|host/.test(n)) return 'example.com';
24
+ if (/version|ver/.test(n)) return 'v1';
25
+ if (/email/.test(n)) return 'test@example.com';
26
+ if (/page|limit|offset|count|num/.test(n)) return '1';
27
+ return 'test';
28
+ }
29
+
30
+ const STUB = { string: 'probe', number: 1, boolean: true };
31
+ function stubBody(service) {
32
+ const fields = service.outputSchema?.input?.bodyFields
33
+ || service.inputSchema?.properties
34
+ || service.inputSchema?.body;
35
+ if (!fields) return {};
36
+ const body = {};
37
+ for (const [k, v] of Object.entries(fields)) body[k] = STUB[v.type] ?? 'probe';
38
+ return body;
39
+ }
40
+
41
+ function tryParseBase64(str) {
42
+ try { return JSON.parse(Buffer.from(str, 'base64').toString()); } catch { return null; }
43
+ }
44
+
45
+ const UA = 'agentgrade/0.2 (+https://agentgrade.com/kb/about-scanning)';
46
+
47
+ async function fetchSafe(url, opts = {}) {
48
+ try {
49
+ const controller = new AbortController();
50
+ const timeout = setTimeout(() => controller.abort(), 5000);
51
+ const headers = { 'User-Agent': UA, ...opts.headers };
52
+ const res = await fetch(url, { redirect: 'follow', signal: controller.signal, ...opts, headers });
53
+ clearTimeout(timeout);
54
+ const ct = res.headers.get('content-type') || '';
55
+ let text;
56
+ if (ct.includes('text/event-stream') && res.body) {
57
+ // SSE: read chunks until we get a data: line, then stop
58
+ const reader = res.body.getReader();
59
+ const decoder = new TextDecoder();
60
+ let buf = '';
61
+ try {
62
+ while (buf.length < 524288) {
63
+ const { done, value } = await Promise.race([
64
+ reader.read(),
65
+ new Promise((_, reject) => setTimeout(() => reject(new Error('SSE read timeout')), 3000)),
66
+ ]);
67
+ if (done) break;
68
+ buf += decoder.decode(value, { stream: true });
69
+ if ((buf.includes('\ndata: ') || buf.startsWith('data: ')) && buf.includes('\n\n')) break;
70
+ }
71
+ } catch {} finally { try { reader.cancel(); } catch {} controller.abort(); }
72
+ text = buf;
73
+ } else {
74
+ text = await res.text();
75
+ }
76
+ let json;
77
+ try { json = JSON.parse(text); } catch {}
78
+ if (!json) { const m = text.match(/^data: (.+)$/m); if (m) try { json = JSON.parse(m[1]); } catch {} }
79
+ const cfBlocked = res.status === 403 && res.headers.get('cf-mitigated') === 'challenge';
80
+ const rateLimited = res.status === 429;
81
+ const retryAfter = rateLimited ? (res.headers.get('retry-after') || null) : null;
82
+ return { status: res.status, text, json, headers: res.headers, cfBlocked, rateLimited, retryAfter };
83
+ } catch (e) {
84
+ return { status: 0, error: e.message };
85
+ }
86
+ }
87
+
88
+ async function firstMatch(bases, paths, opts = {}, test = r => r.status === 200) {
89
+ if (typeof bases === 'string') bases = [bases];
90
+ const combos = bases.flatMap(b => paths.map(p => ({ base: b, path: p })));
91
+ const results = await Promise.all(combos.map(c => fetchSafe(`${c.base}${c.path}`, opts).then(r => ({ ...c, r }))));
92
+ const hits = bases.filter(b => results.some(({ base, r }) => base === b && test(r)));
93
+ for (const b of bases) {
94
+ const hit = results.find(({ base, r }) => base === b && test(r));
95
+ if (hit) return { path: new URL(`${hit.base}${hit.path}`).pathname, r: hit.r, allCfBlocked: false, hitBases: hits };
96
+ }
97
+ return results.some(({ r }) => r.cfBlocked) ? { path: null, r: null, allCfBlocked: true, hitBases: [] } : null;
98
+ }
99
+
100
+ // ─── 402 Probe ──────────────────────────────────────────────────────────────
101
+
102
+ export async function probe402(url, method = 'POST', body = undefined) {
103
+ const result = { x402: null, mpp: null, spt: null, l402: null, otherHeaders: [], bodyMentions: [] };
104
+
105
+ const opts = {
106
+ method,
107
+ headers: { 'Content-Type': 'application/json', 'X-Agent-Id': 'agentgrade' },
108
+ };
109
+ if (body && method !== 'GET') opts.body = body;
110
+
111
+ const r = await fetchSafe(url, opts);
112
+ result.status = r.status;
113
+ result.cfBlocked = r.cfBlocked || false;
114
+ result.rateLimited = r.rateLimited || false;
115
+ if (r.rateLimited) { result.retryAfter = r.retryAfter; return result; }
116
+ if (r.error) { result.error = r.error; return result; }
117
+
118
+ if (r.status !== 402) {
119
+ // Scan body for payment protocol mentions
120
+ if (r.text) {
121
+ if (/\bx402\b|Payment-Required/i.test(r.text)) result.bodyMentions.push('x402');
122
+ if (/\bMPP\b.*payment|payment.*\bMPP\b|machine.payment.protocol|WWW-Authenticate:\s*Payment/i.test(r.text)) result.bodyMentions.push('MPP');
123
+ if (/\bL402\b|\bLSAT\b|\bmacaroon\b.{0,50}\binvoice\b/i.test(r.text)) result.bodyMentions.push('L402');
124
+ if (/\bHTTP 402\b|402 payment/i.test(r.text)) result.bodyMentions.push('402 (generic)');
125
+ }
126
+ return result;
127
+ }
128
+
129
+ // ── x402 ──
130
+ const prHeader = r.headers.get('payment-required');
131
+ if (prHeader) {
132
+ const parsed = tryParseBase64(prHeader);
133
+ if (parsed) {
134
+ const options = parsed.accepts || (Array.isArray(parsed) ? parsed : [parsed]);
135
+ result.x402 = {
136
+ version: parsed.x402Version || 1,
137
+ description: parsed.resource?.description,
138
+ options: options.map(opt => ({
139
+ network: opt.network || 'unknown',
140
+ amount: opt.amount || opt.maxAmountRequired || '?',
141
+ asset: opt.asset || '?',
142
+ assetName: opt.extra?.name,
143
+ payTo: opt.payTo || '?',
144
+ scheme: opt.scheme,
145
+ maxTimeoutSeconds: opt.maxTimeoutSeconds,
146
+ })),
147
+ extensions: parsed.extensions || null,
148
+ bazaar: parsed.extensions?.bazaar || null,
149
+ };
150
+ } else {
151
+ result.x402 = { raw: prHeader.slice(0, 200), decodeFailed: true };
152
+ }
153
+ }
154
+
155
+ // ── MPP ──
156
+ const wwwAuth = r.headers.get('www-authenticate');
157
+ if (wwwAuth && /payment/i.test(wwwAuth)) {
158
+ // Split concatenated WWW-Authenticate headers into individual Payment challenges
159
+ const challenges = wwwAuth.split(/,?\s*Payment\s+/i).filter(Boolean);
160
+ const allFields = [];
161
+ for (const ch of challenges) {
162
+ const pairs = {};
163
+ for (const m of ch.matchAll(/(\w+)="([^"]*)"/g)) pairs[m[1]] = m[2];
164
+ const parts = ch.split(',').map(s => s.trim());
165
+ for (const part of parts) {
166
+ const eqIdx = part.indexOf('=');
167
+ if (eqIdx > 0 && !part.includes('"')) pairs[part.slice(0, eqIdx).trim()] = part.slice(eqIdx + 1).trim();
168
+ }
169
+ allFields.push(pairs);
170
+ }
171
+ result.mpp = { header: wwwAuth, fields: allFields[0] || {}, challenges: allFields };
172
+ const stripeCh = allFields.find(p => p.method === 'stripe');
173
+ if (stripeCh) {
174
+ let request = null;
175
+ if (stripeCh.request) {
176
+ try { request = JSON.parse(Buffer.from(stripeCh.request, 'base64url').toString()); } catch {}
177
+ }
178
+ result.spt = {
179
+ method: 'stripe', intent: stripeCh.intent || null,
180
+ amount: request?.amount || stripeCh.amount || null,
181
+ currency: request?.currency || null,
182
+ decimals: request?.decimals ?? null,
183
+ realm: stripeCh.realm || null,
184
+ };
185
+ }
186
+ }
187
+
188
+ // ── L402 ──
189
+ if (wwwAuth && /l402|lsat/i.test(wwwAuth)) {
190
+ result.l402 = {
191
+ header: wwwAuth.slice(0, 200),
192
+ macaroon: wwwAuth.match(/macaroon="([^"]*)"/)?.[1] || null,
193
+ invoice: wwwAuth.match(/invoice="([^"]*)"/)?.[1] || null,
194
+ };
195
+ }
196
+
197
+ // ── Other payment headers ──
198
+ if (r.headers) {
199
+ for (const [k, v] of r.headers.entries()) {
200
+ if (/payment|invoice|receipt|macaroon|x-mpp|x-402/i.test(k) && k !== 'payment-required' && k !== 'www-authenticate') {
201
+ result.otherHeaders.push({ name: k, value: v });
202
+ }
203
+ }
204
+ }
205
+
206
+ return result;
207
+ }
208
+
209
+ // ─── Discovery ──────────────────────────────────────────────────────────────
210
+
211
+ export async function probeDiscovery(origin) {
212
+ const results = [];
213
+ const rails = [];
214
+
215
+ const wellKnownPaths = [
216
+ '/.well-known/agentnews.json',
217
+ '/.well-known/x402.json',
218
+ '/.well-known/mpp.json',
219
+ '/.well-known/x402-manifest.json',
220
+ '/.well-known/pay',
221
+ '/health',
222
+ '/openapi.json',
223
+ '/skill.md',
224
+ '/llms.txt',
225
+ '/agents.txt',
226
+ '/api-docs',
227
+ '/docs',
228
+ ];
229
+
230
+ for (const path of wellKnownPaths) {
231
+ const r = await fetchSafe(`${origin}${path}`, { headers: { Accept: 'application/json' } });
232
+ if (r.status !== 200) continue;
233
+
234
+ const entry = { path, status: r.status, hasPayment: false, mentions: [] };
235
+
236
+ if (r.json) {
237
+ const serialized = JSON.stringify(r.json);
238
+ entry.hasPayment = /settlement_rails|payment|402|mpp|x402|tempo|lightning|l402|usdc|stripe|spt/i.test(serialized);
239
+ if (r.json.settlement_rails) {
240
+ const entries = Array.isArray(r.json.settlement_rails)
241
+ ? r.json.settlement_rails.map(s => [s.method || s.name || '?', s])
242
+ : Object.entries(r.json.settlement_rails);
243
+ entry.settlementRails = entries.map(([key, details]) => ({
244
+ name: details.method || details.name || key,
245
+ protocol: details.protocol || '?',
246
+ currency: details.currency || '?',
247
+ status: details.status || 'available',
248
+ }));
249
+ for (const rail of entry.settlementRails) {
250
+ if (rail.protocol && !rails.includes(rail.protocol)) rails.push(rail.protocol);
251
+ }
252
+ }
253
+ if (r.json.payment_mode) entry.paymentMode = r.json.payment_mode;
254
+ if (r.json.payment_info) entry.paymentInfo = r.json.payment_info;
255
+ } else {
256
+ if (/\bx402\b|Payment-Required/i.test(r.text)) entry.mentions.push('x402');
257
+ if (/\bMPP\b.*payment|payment.*\bMPP\b|machine.payment.protocol/i.test(r.text)) entry.mentions.push('MPP');
258
+ if (/\bL402\b|\bLSAT\b/i.test(r.text)) entry.mentions.push('L402');
259
+ if (/\bUSDC\b/i.test(r.text)) entry.mentions.push('USDC');
260
+ if (/\blightning\b.*\b(invoice|network|payment)\b/i.test(r.text)) entry.mentions.push('Lightning');
261
+ if (/\bHTTP 402\b|quota.*exceeded/i.test(r.text)) entry.mentions.push('402 flow');
262
+ }
263
+
264
+ results.push(entry);
265
+ }
266
+
267
+ return { endpoints: results, rails };
268
+ }
269
+
270
+ // ─── Bazaar Discovery ───────────────────────────────────────────────────────
271
+
272
+ export async function probeBazaar(origin) {
273
+ const result = { x402Json: null, x402Probe: null, linkHeader: null, bazaarServices: 0 };
274
+
275
+ // /.well-known/x402.json
276
+ const r1 = await fetchSafe(`${origin}/.well-known/x402.json`, { headers: { Accept: 'application/json' } });
277
+ if (r1.status === 200 && r1.json) {
278
+ const raw = r1.json;
279
+ const rawKeys = Object.keys(raw);
280
+ // Diagnose expected top-level fields
281
+ const fieldDiag = (key, label) => {
282
+ if (key in raw && raw[key]) return { status: 'ok', value: raw[key] };
283
+ if (key in raw) return { status: 'empty', hint: `"${key}" is present but empty` };
284
+ // Check for common misspellings / wrong casing
285
+ const alt = rawKeys.find(k => k.toLowerCase() === key.toLowerCase() || (key === 'name' && k.toLowerCase() === 'provider'));
286
+ if (alt) return { status: 'misnamed', hint: `found "${alt}" — expected "${key}"`, value: raw[alt] };
287
+ return { status: 'missing', hint: `add "${key}" to /.well-known/x402.json (${label})` };
288
+ };
289
+ result.x402Json = {
290
+ x402Version: raw.x402Version || 1,
291
+ name: raw.name,
292
+ network: raw.network,
293
+ facilitator: raw.facilitator,
294
+ payTo: raw.payTo,
295
+ testnet: raw.testnet || false,
296
+ rawKeys,
297
+ fields: {
298
+ name: fieldDiag('name', 'your service name'),
299
+ network: fieldDiag('network', 'e.g. base-sepolia or base'),
300
+ facilitator: fieldDiag('facilitator', 'facilitator URL for payment verification'),
301
+ payTo: fieldDiag('payTo', 'wallet address to receive payments'),
302
+ },
303
+ services: (r1.json.services || []).flatMap(svc => {
304
+ const bazaar = svc.extensions?.bazaar || null;
305
+ // Format A (AgentNews): method/path at service level
306
+ if (svc.method && svc.path) {
307
+ return [{ method: svc.method, path: svc.path, description: svc.description, amount: svc.humanAmount || svc.amount, bazaar, outputSchema: svc.outputSchema, inputSchema: svc.inputSchema }];
308
+ }
309
+ // Format B (CloneRepo/SconeRepo): nested endpoints[]
310
+ if (svc.endpoints) {
311
+ return svc.endpoints.map(ep => ({
312
+ method: ep.method, path: ep.path, description: ep.description || svc.description,
313
+ amount: svc.payment?.[0]?.amount || svc.amount, bazaar, inputSchema: ep.inputSchema || svc.inputSchema,
314
+ }));
315
+ }
316
+ // Format C: "endpoint" string like "POST /:domain"
317
+ if (svc.endpoint && typeof svc.endpoint === 'string') {
318
+ const parts = svc.endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(.+)$/i);
319
+ if (parts) {
320
+ return [{ method: parts[1].toUpperCase(), path: parts[2].trim(), description: svc.description || svc.name, amount: svc.price?.amount || svc.amount, bazaar, inputSchema: svc.inputSchema }];
321
+ }
322
+ }
323
+ // Fallback: service with name/description only
324
+ return [{ method: null, path: null, description: svc.description || svc.name, amount: svc.amount, bazaar }];
325
+ }),
326
+ freeEndpoints: r1.json.freeEndpoints || [],
327
+ // Capture auth info from services (e.g. { type: "EIP-191", domain: "clonerepo.com" })
328
+ auth: r1.json.services?.[0]?.auth || r1.json.auth || null,
329
+ };
330
+ result.bazaarServices += result.x402Json.services.filter(s => s.bazaar?.discoverable).length;
331
+ }
332
+
333
+ // Parse WWW-Authenticate: Payment headers into { mpp, spt } (handles multiple challenges)
334
+ function parsePaymentAuth(headers) {
335
+ const wwwAuth = headers?.get('www-authenticate');
336
+ if (!wwwAuth || !/payment/i.test(wwwAuth)) return null;
337
+ const challenges = wwwAuth.split(/,?\s*Payment\s+/i).filter(Boolean);
338
+ const allFields = [];
339
+ for (const ch of challenges) {
340
+ const pairs = {};
341
+ for (const m of ch.matchAll(/(\w+)="([^"]*)"/g)) pairs[m[1]] = m[2];
342
+ allFields.push(pairs);
343
+ }
344
+ const mpp = allFields.length > 0;
345
+ const stripeCh = allFields.find(p => p.method === 'stripe');
346
+ let spt = null;
347
+ if (stripeCh) {
348
+ let request = null;
349
+ if (stripeCh.request) try { request = JSON.parse(Buffer.from(stripeCh.request, 'base64url').toString()); } catch {}
350
+ spt = { method: 'stripe', intent: stripeCh.intent || null, amount: request?.amount || null, currency: request?.currency || null };
351
+ }
352
+ return { mpp, spt };
353
+ }
354
+
355
+ // Live 402 probe: try hitting a declared service endpoint to trigger a real 402
356
+ result.live402 = null;
357
+ if (result.x402Json) {
358
+ const targets = result.x402Json.services
359
+ .filter(s => s.method && s.path)
360
+ .slice(0, 3);
361
+ if (targets.length === 0) targets.push({ method: 'POST', path: '/' });
362
+
363
+ for (const t of targets) {
364
+ const probePath = t.path.replace(/:([a-zA-Z_]+)/g, (_, p) => smartParamValue(p)).replace(/\{([^}]+)\}/g, (_, p) => smartParamValue(p));
365
+ const opts = { method: t.method, headers: { 'Content-Type': 'application/json', 'X-Agent-Id': 'agentgrade-probe' } };
366
+ if (t.method !== 'GET' && t.method !== 'HEAD') opts.body = JSON.stringify(stubBody(t));
367
+ const lr = await fetchSafe(`${origin}${probePath}`, opts);
368
+ if (lr.status === 402) {
369
+ const prHeader = lr.headers?.get('payment-required');
370
+ const parsed = prHeader ? tryParseBase64(prHeader) : null;
371
+ const auth = parsePaymentAuth(lr.headers);
372
+ result.live402 = {
373
+ method: t.method, path: t.path, status: 402,
374
+ version: parsed?.x402Version, options: parsed?.accepts || null,
375
+ extensions: parsed?.extensions || null,
376
+ bazaar: parsed?.extensions?.bazaar || null,
377
+ mpp: auth?.mpp || false, spt: auth?.spt || null,
378
+ };
379
+ break;
380
+ } else if (lr.status === 401 || lr.status === 403) {
381
+ // If EIP-191 auth is declared, retry with a temp wallet
382
+ const authInfo = result.x402Json?.auth;
383
+ if (authInfo?.type === 'EIP-191' && authInfo.domain) {
384
+ const token = await makeEIP191Token(authInfo.domain);
385
+ const retryOpts = { ...opts, headers: { ...opts.headers, 'Authorization': `Bearer ${token}` } };
386
+ const retryR = await fetchSafe(`${origin}${probePath}`, retryOpts);
387
+ if (retryR.status === 402) {
388
+ const prHeader = retryR.headers?.get('payment-required');
389
+ const parsed = prHeader ? tryParseBase64(prHeader) : null;
390
+ const retryAuth = parsePaymentAuth(retryR.headers);
391
+ result.live402 = {
392
+ method: t.method, path: t.path, status: 402,
393
+ version: parsed?.x402Version, options: parsed?.accepts || null,
394
+ extensions: parsed?.extensions || null,
395
+ bazaar: parsed?.extensions?.bazaar || null,
396
+ mpp: retryAuth?.mpp || false, spt: retryAuth?.spt || null,
397
+ authUsed: 'EIP-191',
398
+ };
399
+ break;
400
+ }
401
+ }
402
+ result.live402 = { method: t.method, path: t.path, status: lr.status, authRequired: true };
403
+ break;
404
+ } else if (lr.rateLimited) {
405
+ result.live402 = { method: t.method, path: t.path, status: 429, rateLimited: true };
406
+ break;
407
+ }
408
+ }
409
+ }
410
+
411
+ // /.well-known/x402-probe
412
+ const r2 = await fetchSafe(`${origin}/.well-known/x402-probe`, { headers: { Accept: 'application/json' } });
413
+ if (r2.status === 402) {
414
+ const prHeader = r2.headers?.get('payment-required');
415
+ const parsed = prHeader ? tryParseBase64(prHeader) : null;
416
+ result.x402Probe = {
417
+ version: parsed?.x402Version,
418
+ headerBazaar: parsed?.extensions?.bazaar || null,
419
+ challenges: [],
420
+ };
421
+ if (r2.json?.challenges) {
422
+ result.x402Probe.challenges = r2.json.challenges.map(ch => ({
423
+ endpoint: ch.endpoint,
424
+ amount: ch.accepts?.[0]?.amount,
425
+ assetName: ch.accepts?.[0]?.extra?.name,
426
+ bazaar: ch.extensions?.bazaar?.discoverable || false,
427
+ }));
428
+ result.bazaarServices += result.x402Probe.challenges.filter(c => c.bazaar).length;
429
+ }
430
+ }
431
+
432
+ // Link header
433
+ const r3 = await fetchSafe(origin, { method: 'HEAD' });
434
+ const link = r3.headers?.get('link');
435
+ if (link && /x402/i.test(link)) {
436
+ result.linkHeader = link;
437
+ }
438
+
439
+ return result;
440
+ }
441
+
442
+ // ─── Homepage Scan ──────────────────────────────────────────────────────────
443
+
444
+ export async function probeHomepage(origin) {
445
+ const r = await fetchSafe(origin, { headers: { Accept: 'text/html' } });
446
+ if (!r.text || r.status !== 200) return { protocols: {}, cfBlocked: r.cfBlocked || false };
447
+
448
+ const text = r.text;
449
+ const protocols = {};
450
+
451
+ if (/\bx402\b/i.test(text)) protocols.x402 = [];
452
+ if (/Payment-Required/i.test(text)) (protocols.x402 = protocols.x402 || []).push('Payment-Required header');
453
+ if (/\bUSDC\b/i.test(text)) (protocols.x402 = protocols.x402 || []).push('USDC');
454
+ if (/eip155:/i.test(text)) (protocols.x402 = protocols.x402 || []).push('EIP-155 network');
455
+ if (/eip155:8453|\bBase\s+(chain|mainnet|sepolia)\b/i.test(text)) (protocols.x402 = protocols.x402 || []).push('Base chain');
456
+
457
+ if (/\bMPP\b.*payment|payment.*\bMPP\b|machine.payment.protocol/i.test(text)) protocols.MPP = [];
458
+ if (/WWW-Authenticate:\s*Payment/i.test(text)) (protocols.MPP = protocols.MPP || []).push('WWW-Authenticate: Payment');
459
+ if (/\bpathUSD\b|\btempo\b.*\bchain\b|\bmppx\b/i.test(text)) (protocols.MPP = protocols.MPP || []).push('Tempo/pathUSD');
460
+
461
+ if (/\bL402\b/i.test(text)) protocols.L402 = [];
462
+ if (/\bLSAT\b/i.test(text)) (protocols.L402 = protocols.L402 || []).push('LSAT');
463
+ if (/\bmacaroon\b/i.test(text)) (protocols.L402 = protocols.L402 || []).push('macaroon');
464
+ if (/lightning.{0,50}invoice|invoice.{0,50}lightning/i.test(text)) (protocols.L402 = protocols.L402 || []).push('Lightning invoice');
465
+
466
+ if (/\bSPT\b|\bstripe\b.*\bpayment.token\b|\bshared.payment.token/i.test(text)) protocols.SPT = [];
467
+ if (/method="stripe"|method=["']?stripe/i.test(text)) (protocols.SPT = protocols.SPT || []).push('Stripe method');
468
+
469
+ if (/\bHTTP 402\b|402 payment/i.test(text)) protocols['402 flow'] = [];
470
+ if (/quota.*exceeded|storage.*limit.*exceeded/i.test(text)) (protocols['402 flow'] = protocols['402 flow'] || []).push('quota-based');
471
+
472
+ // Check for <link rel="alternate"> pointing to llms.txt
473
+ const llmsLink = /<link[^>]+rel=["']alternate["'][^>]+href=["'][^"']*llms[^"']*["'][^>]*>/i.test(text)
474
+ || /<link[^>]+href=["'][^"']*llms[^"']*["'][^>]+rel=["']alternate["'][^>]*>/i.test(text);
475
+
476
+ // Check for JSON-LD structured data
477
+ const jsonLdMatch = text.match(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i);
478
+ let jsonLd = null;
479
+ if (jsonLdMatch) {
480
+ try { const parsed = JSON.parse(jsonLdMatch[1]); jsonLd = { type: parsed['@type'] || (Array.isArray(parsed) ? 'array' : 'object') }; } catch {}
481
+ }
482
+
483
+ // Extract og:image URL
484
+ const ogImageMatch = text.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
485
+ || text.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
486
+ const ogImageUrl = ogImageMatch?.[1] || null;
487
+ const ogImage = !!ogImageUrl;
488
+
489
+ // twitter:image and twitter:card
490
+ const twitterImage = /<meta[^>]+(?:name|property)=["']twitter:image["'][^>]+content=["'][^"']+["']/i.test(text)
491
+ || /<meta[^>]+content=["'][^"']+["'][^>]+(?:name|property)=["']twitter:image["']/i.test(text);
492
+ const twitterCardMatch = text.match(/<meta[^>]+(?:name|property)=["']twitter:card["'][^>]+content=["']([^"']+)["']/i)
493
+ || text.match(/<meta[^>]+content=["']([^"']+)["'][^>]+(?:name|property)=["']twitter:card["']/i);
494
+ const twitterCard = twitterCardMatch?.[1] || null;
495
+
496
+ // Favicon: SVG and PNG from link tags
497
+ const faviconSvg = /<link[^>]+type=["']image\/svg\+xml["'][^>]+href=["']([^"']+)["']/i.exec(text)
498
+ || /<link[^>]+href=["']([^"']+)["'][^>]+type=["']image\/svg\+xml["']/i.exec(text);
499
+ const faviconPng = /<link[^>]+type=["']image\/png["'][^>]+href=["']([^"']+)["']/i.exec(text)
500
+ || /<link[^>]+href=["']([^"']+)["'][^>]+type=["']image\/png["']/i.exec(text);
501
+
502
+ // Verify reachability of og:image and favicons (parallel)
503
+ const resolve = (href) => href ? (href.startsWith('http') ? href : `${origin}${href.startsWith('/') ? '' : '/'}${href}`) : null;
504
+ const [ogReach, svgReach, pngReach, pngFallback] = await Promise.all([
505
+ ogImageUrl ? fetchSafe(resolve(ogImageUrl), { headers: { Accept: 'image/*' } }) : null,
506
+ faviconSvg ? fetchSafe(resolve(faviconSvg[1]), { headers: { Accept: 'image/svg+xml' } }) : null,
507
+ faviconPng ? fetchSafe(resolve(faviconPng[1]), { headers: { Accept: 'image/png' } }) : null,
508
+ !faviconPng ? fetchSafe(`${origin}/favicon.png`, { headers: { Accept: 'image/png' } }) : null,
509
+ ]);
510
+ const ogImageReachable = ogReach?.status === 200;
511
+ const faviconSvgOk = svgReach?.status === 200;
512
+ const faviconPngOk = (pngReach?.status === 200) || (pngFallback?.status === 200);
513
+
514
+ return {
515
+ protocols, llmsLink, jsonLd,
516
+ ogImage, ogImageReachable, twitterImage, twitterCard,
517
+ faviconSvg: faviconSvgOk, faviconPng: faviconPngOk,
518
+ };
519
+ }
520
+
521
+ // ─── Agent Capabilities ─────────────────────────────────────────────────────
522
+
523
+ export async function probeCapabilities(origin, base) {
524
+ if (!base || base === origin) return _runProbes(origin, origin);
525
+
526
+ // Subpath scan: run full checks independently at both locations, then merge
527
+ const [atSubpath, atOrigin] = await Promise.all([
528
+ _runProbes(origin, base),
529
+ _runProbes(origin, origin),
530
+ ]);
531
+
532
+ // Merge by type: one entry per type, annotate where found
533
+ const merged = [];
534
+ const types = [...new Set([...atSubpath, ...atOrigin].map(c => c.type))];
535
+ for (const type of types) {
536
+ const subs = atSubpath.filter(c => c.type === type);
537
+ const origs = atOrigin.filter(c => c.type === type);
538
+ for (let i = 0; i < Math.max(subs.length, origs.length); i++) {
539
+ const sub = subs[i], orig = origs[i];
540
+ if (sub && orig) merged.push({ ...sub, foundAt: 'both' });
541
+ else if (sub) merged.push({ ...sub, foundAt: 'subpath' });
542
+ else merged.push({ ...orig, foundAt: 'origin' });
543
+ }
544
+ }
545
+ return merged;
546
+ }
547
+
548
+ async function _runProbes(origin, base) {
549
+ const bases = [base];
550
+ const prefix = base.slice(origin.length);
551
+
552
+ async function probeMCP() {
553
+ const mcpPaths = ['/mcp', '/api/mcp', '/.well-known/mcp.json', '/mcp/docs'];
554
+ const hit = await firstMatch(bases, mcpPaths, { headers: { Accept: 'application/json' } },
555
+ r => r.status === 405 || r.status === 406 || (r.status === 200 && (r.json || /mcp|model.context|tool/i.test(r.text))));
556
+ if (hit?.allCfBlocked) return [{ type: 'MCP', cfBlocked: true }];
557
+ if (hit) {
558
+ const { path, r } = hit;
559
+ const cap = {
560
+ type: 'MCP', path, verified: false,
561
+ name: r.json?.name, description: r.json?.description,
562
+ tools: r.json?.tools?.length, transport: r.json?.transport,
563
+ detail: r.status === 405 ? 'responds 405 (expects POST/SSE)' : r.status === 406 ? 'responds 406 (Streamable HTTP)' : (!r.json ? 'MCP-related content' : undefined),
564
+ sseSupported: false, cors: false, protocolVersion: null,
565
+ streamableHttp: r.status === 406,
566
+ };
567
+ const rpcPost = (method, params) => fetchSafe(`${origin}${path}`, {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
570
+ body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
571
+ });
572
+ const ir = await rpcPost('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'agentgrade', version: '1.0' } });
573
+ cap.protocolVersion = ir.json?.result?.protocolVersion || null;
574
+ cap.name = ir.json?.result?.serverInfo?.name || cap.name;
575
+ cap.description = ir.json?.result?.serverInfo?.description || cap.description;
576
+ const tr = await rpcPost('tools/list');
577
+ const tools = tr.json?.result?.tools;
578
+ if (tools?.length > 0) {
579
+ const isPaid = (t) => !!(t._meta?.['agents-x402/paymentRequired'] || t._meta?.paymentRequired);
580
+ const requiredParams = (t) => (t.inputSchema?.required || []).length;
581
+ const toolMeta = tools.map(t => ({ name: t.name, paid: isPaid(t), requiredParams: requiredParams(t) }));
582
+ cap.tools = tools.length;
583
+ cap.toolNames = toolMeta.map(t => t.name);
584
+ cap.paidTools = toolMeta.filter(t => t.paid).map(t => t.name);
585
+ cap.freeTools = toolMeta.filter(t => !t.paid).map(t => t.name);
586
+ const free = tools.filter(t => !isPaid(t));
587
+ const candidates = free.length > 0 ? free : tools;
588
+ const probe = candidates.sort((a, b) => requiredParams(a) - requiredParams(b))[0];
589
+ const cr = await rpcPost('tools/call', { name: probe.name, arguments: {} });
590
+ cap.verified = !!(cr.json?.result || cr.json?.error);
591
+ cap.probeToolUsed = probe.name;
592
+ cap.probeToolPaid = isPaid(probe);
593
+ }
594
+ const [sse, cr2] = await Promise.all([
595
+ fetchSafe(`${origin}${path}`, { headers: { Accept: 'text/event-stream' } }),
596
+ fetchSafe(`${origin}${path}`, { method: 'OPTIONS', headers: { Origin: 'https://example.com', 'Access-Control-Request-Method': 'POST' } }),
597
+ ]);
598
+ cap.sseSupported = (sse.headers?.get?.('content-type') || '').includes('text/event-stream');
599
+ cap.cors = !!(cr2.headers?.get?.('access-control-allow-origin'));
600
+ return [cap];
601
+ }
602
+ return [];
603
+ }
604
+
605
+ async function probeClaude() {
606
+ const claudePaths = ['/.claude-plugin/manifest.json', '/.claude/plugin.json', '/claude.json', '/.well-known/claude.json', '/.well-known/claude-plugin.json'];
607
+ const hit = await firstMatch(bases, claudePaths, { headers: { Accept: 'application/json' } }, r => r.status === 200 && r.json);
608
+ if (hit?.allCfBlocked) return [{ type: 'Claude Plugin', cfBlocked: true }];
609
+ if (hit) {
610
+ const { path, r } = hit;
611
+ const toolCount = r.json.tools ? (Array.isArray(r.json.tools) ? r.json.tools.length : Object.keys(r.json.tools).length) : undefined;
612
+ const rawMcp = r.json.mcp || r.json.mcp_url || r.json.api?.mcp;
613
+ const mcpUrl = typeof rawMcp === 'string' ? rawMcp : rawMcp?.endpoint || rawMcp?.url;
614
+ const apiUrl = r.json.api?.url || r.json.openapi;
615
+ const cap = {
616
+ type: 'Claude Plugin', path, verified: false,
617
+ name: r.json.name, description: r.json.description, version: r.json.version,
618
+ skills: r.json.skills?.length, tools: toolCount,
619
+ declaredMcp: mcpUrl || null, declaredApi: apiUrl || null,
620
+ };
621
+ if (mcpUrl) {
622
+ const url = mcpUrl.startsWith('http') ? mcpUrl : `${origin}${mcpUrl}`;
623
+ const mr = await fetchSafe(url, {
624
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 }),
626
+ });
627
+ cap.verified = !!(mr.json?.result?.tools);
628
+ } else if (apiUrl) {
629
+ const url = apiUrl.startsWith('http') ? apiUrl : `${origin}${apiUrl}`;
630
+ const ar = await fetchSafe(url, { headers: { Accept: 'application/json' } });
631
+ cap.verified = ar.status === 200 && !!(ar.json?.openapi || ar.json?.swagger || ar.json?.info);
632
+ } else {
633
+ const hasTools = toolCount > 0 && r.json.tools?.some?.(t => t && (t.name || t.description));
634
+ const hasSkills = r.json.skills?.length > 0 && r.json.skills.some(s => s && (s.name || s.description));
635
+ cap.verified = !!(r.json.name && r.json.description && (hasTools || hasSkills));
636
+ }
637
+ return [cap];
638
+ }
639
+ return [];
640
+ }
641
+
642
+ async function probeAIPlugin() {
643
+ const aiPluginPaths = ['/.well-known/ai-plugin.json', '/ai-plugin.json'];
644
+ const hit = await firstMatch(origin, aiPluginPaths, { headers: { Accept: 'application/json' } }, r => r.status === 200 && r.json);
645
+ if (hit?.allCfBlocked) return [{ type: 'AI Plugin', cfBlocked: true }];
646
+ if (hit) {
647
+ const { path, r } = hit;
648
+ const j = r.json;
649
+ const validApiType = !j.api?.type || j.api.type === 'openapi';
650
+ const cap = {
651
+ type: 'AI Plugin', path,
652
+ name: j.name_for_human, modelName: j.name_for_model,
653
+ description: j.description_for_human,
654
+ apiUrl: j.api?.url, apiType: j.api?.type, auth: j.auth?.type,
655
+ apiSpecReachable: false,
656
+ verified: !!(j.schema_version && j.name_for_human && j.name_for_model && j.description_for_human && j.api && j.auth && validApiType),
657
+ };
658
+ if (cap.apiUrl) {
659
+ const specUrl = cap.apiUrl.startsWith('http') ? cap.apiUrl : `${origin}${cap.apiUrl}`;
660
+ const sr = await fetchSafe(specUrl, { headers: { Accept: 'application/json' } });
661
+ cap.apiSpecReachable = sr.status === 200 && !!(sr.json?.openapi || sr.json?.swagger || sr.json?.info);
662
+ if (cap.apiSpecReachable && sr.json?.paths) {
663
+ const getEntries = Object.entries(sr.json.paths).filter(([, m]) => m.get);
664
+ if (getEntries.length > 0) {
665
+ const [gPath, gMethods] = getEntries[0];
666
+ const gr = await fetchSafe(`${origin}${gPath}`, { headers: { Accept: 'application/json' } });
667
+ const ct = gr.headers?.get?.('content-type') || '';
668
+ const specProduces = Object.keys(gMethods.get?.responses?.['200']?.content || {});
669
+ const ctMatches = specProduces.length === 0 || specProduces.some(t => ct.includes(t.split('/')[1]));
670
+ cap.verified = cap.verified && gr.status && gr.status !== 404 && ctMatches;
671
+ } else {
672
+ cap.verified = false;
673
+ }
674
+ }
675
+ }
676
+ if (cap.verified && !cap.apiSpecReachable) cap.verified = false;
677
+ return [cap];
678
+ }
679
+ return [];
680
+ }
681
+
682
+ async function probeSkills() {
683
+ const skillPaths = ['/skill.md', '/skills.json', '/.well-known/skills.json'];
684
+ const hit = await firstMatch(bases, skillPaths, { headers: { Accept: 'application/json' } }, r => r.status === 200);
685
+ if (hit?.allCfBlocked) return [{ type: 'Skills', cfBlocked: true }];
686
+ if (hit) {
687
+ const { path, r } = hit;
688
+ if (r.json) {
689
+ const skills = Array.isArray(r.json) ? r.json : [];
690
+ const wellFormed = skills.every(s => s.name && s.path);
691
+ let endpointsReachable = 0;
692
+ let endpointsRespond = 0;
693
+ for (const sk of skills.slice(0, 3)) {
694
+ if (!sk.path) continue;
695
+ const method = (sk.method || 'GET').toUpperCase();
696
+ const sr = await fetchSafe(`${origin}${sk.path}`, { method, headers: { Accept: 'application/json' } });
697
+ if (sr.status && sr.status !== 404) endpointsReachable++;
698
+ if (method !== 'GET' && sr.status && sr.status !== 404) {
699
+ const vr = await fetchSafe(`${origin}${sk.path}`, {
700
+ method, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
701
+ body: '{}',
702
+ });
703
+ if (vr.json && (vr.status === 200 || vr.status === 400 || vr.status === 402 || vr.status === 422)) endpointsRespond++;
704
+ } else if (method === 'GET' && sr.status && sr.status !== 404 && sr.json) {
705
+ endpointsRespond++;
706
+ }
707
+ }
708
+ return [{
709
+ type: 'Skills', path, count: skills.length, endpointsReachable,
710
+ verified: wellFormed && skills.length > 0 && endpointsRespond > 0,
711
+ }];
712
+ } else if (r.text.length > 10) {
713
+ return [{ type: 'Skills', path, bytes: r.text.length, verified: false }];
714
+ }
715
+ }
716
+ return [];
717
+ }
718
+
719
+ async function probeOpenAPI() {
720
+ const oapiPaths = ['/openapi.json', '/openapi.yaml', '/swagger.json', '/api-docs', '/v1/openapi.json'];
721
+ const hit = await firstMatch(bases, oapiPaths, { headers: { Accept: 'application/json' } },
722
+ r => r.status === 200 && r.json && (r.json.openapi || r.json.swagger || r.json.info));
723
+ if (hit?.allCfBlocked) return [{ type: 'OpenAPI', cfBlocked: true }];
724
+ if (hit) {
725
+ const { path, r } = hit;
726
+ {
727
+ const declaredPaths = r.json.paths ? Object.keys(r.json.paths) : [];
728
+ let pathsReachable = 0;
729
+ let pathsRespond = 0;
730
+ for (const p of declaredPaths.slice(0, 3)) {
731
+ const pathDef = r.json.paths[p] || {};
732
+ const methods = Object.keys(pathDef).filter(m => m !== 'parameters');
733
+ const method = (methods[0] || 'get').toUpperCase();
734
+ const sr = await fetchSafe(`${origin}${p}`, { method, headers: { Accept: 'application/json' } });
735
+ if (sr.status && sr.status !== 404) pathsReachable++;
736
+ if (sr.json && sr.status >= 200 && sr.status < 300) {
737
+ const ct = sr.headers?.get?.('content-type') || '';
738
+ const specProduces = Object.keys(pathDef[methods[0]]?.responses?.['200']?.content || {});
739
+ const ctOk = specProduces.length === 0 || specProduces.some(t => ct.includes(t.split('/')[1]));
740
+ if (ctOk) pathsRespond++;
741
+ }
742
+ }
743
+ const paidOps = declaredPaths.filter(p => {
744
+ const ops = r.json.paths[p] || {};
745
+ return Object.values(ops).some(op => op?.['x-payment-info']);
746
+ });
747
+ return [{
748
+ type: 'OpenAPI', path,
749
+ version: r.json.openapi || r.json.swagger,
750
+ title: r.json.info?.title,
751
+ paths: declaredPaths.length,
752
+ pathsReachable,
753
+ verified: declaredPaths.length > 0 && pathsRespond > 0,
754
+ xPaymentInfo: paidOps.length > 0 ? { paths: paidOps.length } : null,
755
+ }];
756
+ }
757
+ }
758
+ return [];
759
+ }
760
+
761
+ async function probeTextFiles() {
762
+ const results = [];
763
+ const textOpts = { headers: { Accept: 'text/plain' } };
764
+ const [llms1, llms2, agentsTxt, robotsTxt] = await Promise.all([
765
+ fetchSafe(`${base}/llms.txt`, textOpts),
766
+ fetchSafe(`${base}/llms-full.txt`, textOpts),
767
+ fetchSafe(`${base}/agents.txt`, textOpts),
768
+ fetchSafe(`${origin}/robots.txt`, textOpts),
769
+ ]);
770
+ if (llms1.cfBlocked) results.push({ type: 'llms.txt', cfBlocked: true });
771
+ if (agentsTxt.cfBlocked) results.push({ type: 'agents.txt', cfBlocked: true });
772
+ if (robotsTxt.cfBlocked) results.push({ type: 'robots.txt (agent-aware)', cfBlocked: true });
773
+ for (const r of [llms1, llms2]) {
774
+ if (r.status !== 200 || !r.text || r.text.length < 20) continue;
775
+ const lines = r.text.split('\n').filter(l => l.trim()).length;
776
+ const firstLine = r.text.split('\n').find(l => l.trim())?.trim().slice(0, 120);
777
+ const verified = /^#\s+\S/.test(firstLine || '');
778
+ results.push({ type: 'llms.txt', path: `${prefix}${r === llms1 ? '/llms.txt' : '/llms-full.txt'}`, lines, preview: firstLine, verified });
779
+ }
780
+ if (agentsTxt.status === 200 && agentsTxt.text?.length > 10) {
781
+ results.push({ type: 'agents.txt', path: `${prefix}/agents.txt`, lines: agentsTxt.text.split('\n').filter(l => l.trim()).length });
782
+ }
783
+ if (robotsTxt.status === 200 && robotsTxt.text) {
784
+ const agentMentions = robotsTxt.text.match(/^#.*\b(agents?|AI|GPT|Claude|bots?)\b.*$/gim) || [];
785
+ const agentUAs = robotsTxt.text.match(/^User-agent:.*\b(GPTBot|ChatGPT|Claude|Anthropic|Google-Extended|Applebot|PerplexityBot)\b.*$/gim) || [];
786
+ if (agentMentions.length > 0 || agentUAs.length > 0) {
787
+ // Check if Google-Extended is blocked (Disallow: / after User-agent: Google-Extended)
788
+ const googleExtBlocked = /User-agent:\s*Google-Extended[\s\S]*?Disallow:\s*\//im.test(robotsTxt.text);
789
+ results.push({
790
+ type: 'robots.txt (agent-aware)', path: '/robots.txt',
791
+ directives: [...agentUAs, ...agentMentions].slice(0, 5).map(l => l.trim()),
792
+ googleExtBlocked,
793
+ });
794
+ }
795
+ }
796
+ return results;
797
+ }
798
+
799
+ async function probeIdentity() {
800
+ const agentDiscovery = [
801
+ ['/.well-known/webfinger', 'WebFinger', (r) => ({ verified: typeof r.json?.subject === 'string', subject: r.json?.subject })],
802
+ ['/.well-known/did.json', 'DID Document', (r) => ({ verified: typeof r.json?.id === 'string' && r.json.id.startsWith('did:'), id: r.json?.id })],
803
+ ['/.well-known/nostr.json', 'Nostr NIP-05', (r) => ({ verified: !!(r.json?.names && typeof r.json.names === 'object' && Object.keys(r.json.names).length > 0) })],
804
+ ['/.well-known/atproto-did', 'AT Protocol DID', (r) => ({ verified: /^did:(plc|web):/.test(r.text?.trim() || ''), did: r.text?.trim() })],
805
+ ['/.well-known/apple-app-site-association', 'Apple App Links', null],
806
+ ['/.well-known/assetlinks.json', 'Android Asset Links', null],
807
+ ];
808
+ const responses = await Promise.all(agentDiscovery.map(([path]) => fetchSafe(`${origin}${path}`, { headers: { Accept: 'application/json' } })));
809
+ const results = [];
810
+ responses.forEach((r, i) => {
811
+ const [path, name, validate] = agentDiscovery[i];
812
+ if (r.cfBlocked) {
813
+ results.push({ type: name, cfBlocked: true });
814
+ } else if (r.status === 200 && (r.json || r.text?.length > 5)) {
815
+ const extra = validate ? validate(r) : {};
816
+ results.push({ type: name, path, ...extra });
817
+ }
818
+ });
819
+ return results;
820
+ }
821
+
822
+ async function probeContentNegotiation() {
823
+ const agentUAs = ['ClaudeBot/1.0', 'ChatGPT-User', 'GPTBot/1.0', 'PerplexityBot'];
824
+ const results = [];
825
+ // 1. Agent UA → non-HTML response?
826
+ const uaR = await fetchSafe(`${base}/`, { headers: { 'User-Agent': agentUAs[0], Accept: '*/*' } });
827
+ if (uaR.cfBlocked) return [{ type: 'Content Negotiation', cfBlocked: true }];
828
+ const uaCt = (uaR.headers?.get?.('content-type') || '').toLowerCase();
829
+ const uaNonHtml = uaR.status === 200 && uaCt && !uaCt.includes('text/html') && (uaR.text?.length || 0) > 20;
830
+ // 2. Accept: application/json → JSON?
831
+ const jsonR = await fetchSafe(`${base}/`, { headers: { Accept: 'application/json' } });
832
+ const jsonOk = jsonR.status === 200 && !!jsonR.json && !jsonR.json.error;
833
+ // 3. Accept: text/plain → text/markdown?
834
+ const textR = await fetchSafe(`${base}/`, { headers: { Accept: 'text/plain' } });
835
+ const textCt = (textR.headers?.get?.('content-type') || '').toLowerCase();
836
+ const textOk = textR.status === 200 && (textCt.includes('text/plain') || textCt.includes('text/markdown')) && (textR.text?.length || 0) > 20;
837
+
838
+ if (uaNonHtml || jsonOk || textOk) {
839
+ results.push({
840
+ type: 'Content Negotiation', path: `${prefix}/`,
841
+ agentUA: uaNonHtml ? { contentType: uaCt.split(';')[0].trim() } : null,
842
+ acceptJson: jsonOk,
843
+ acceptText: textOk ? { contentType: textCt.split(';')[0].trim() } : null,
844
+ verified: uaNonHtml || jsonOk || textOk,
845
+ });
846
+ }
847
+ return results;
848
+ }
849
+
850
+ async function probeWebBotAuth() {
851
+ const r = await fetchSafe(`${origin}/.well-known/http-message-signatures-directory`, { headers: { Accept: 'application/json' } });
852
+ if (r.cfBlocked) return [{ type: 'Web Bot Auth', cfBlocked: true }];
853
+ if (r.status !== 200 || !r.json) return [];
854
+ const members = Array.isArray(r.json.members) ? r.json.members : [];
855
+ const entities = members.map(m => m.name || m.entity).filter(Boolean);
856
+ const keyUrls = members.map(m => m.publicKeyUrl || m.keys).filter(Boolean);
857
+ return [{
858
+ type: 'Web Bot Auth', path: '/.well-known/http-message-signatures-directory',
859
+ members: members.length,
860
+ entities: entities.slice(0, 10),
861
+ hasKeys: keyUrls.length > 0,
862
+ verified: members.length > 0 && keyUrls.length > 0,
863
+ }];
864
+ }
865
+
866
+ const groups = await Promise.all([probeMCP(), probeClaude(), probeAIPlugin(), probeSkills(), probeOpenAPI(), probeTextFiles(), probeIdentity(), probeContentNegotiation(), probeWebBotAuth()]);
867
+ return groups.flat();
868
+ }
869
+
870
+ // ─── Consistency Checks ────────────────────────────────────────────────────
871
+
872
+ export function checkConsistency(capabilities, bazaar, discovery) {
873
+ const warnings = [];
874
+
875
+ // Extract data from capabilities
876
+ const mcpCap = capabilities.find(c => c.type === 'MCP');
877
+ const skillsCap = capabilities.find(c => c.type === 'Skills');
878
+ const oapiCap = capabilities.find(c => c.type === 'OpenAPI');
879
+
880
+ // We need the raw data — fetch it in fullScan and pass it here
881
+ // For now, work with what we have in the capability objects
882
+ return warnings;
883
+ }
884
+
885
+ // Deeper consistency check that fetches raw endpoint data
886
+ export async function probeConsistency(origin, capabilities, bazaar, extra = {}) {
887
+ const warnings = [];
888
+
889
+ // Gather raw data from endpoints we know exist
890
+ let mcpTools = null;
891
+ const mcpCap = capabilities.find(c => c.type === 'MCP');
892
+ if (mcpCap) {
893
+ const r = await fetchSafe(`${origin}${mcpCap.path}`, {
894
+ method: 'POST',
895
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
896
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 }),
897
+ });
898
+ if (r.json?.result?.tools) mcpTools = r.json.result.tools;
899
+ }
900
+
901
+ let skills = null;
902
+ const skillsCap = capabilities.find(c => c.type === 'Skills');
903
+ if (skillsCap) {
904
+ const r = await fetchSafe(`${origin}${skillsCap.path}`, { headers: { Accept: 'application/json' } });
905
+ if (r.json && Array.isArray(r.json)) skills = r.json;
906
+ }
907
+
908
+ let oapiPaths = null;
909
+ const oapiCap = capabilities.find(c => c.type === 'OpenAPI');
910
+ if (oapiCap) {
911
+ const r = await fetchSafe(`${origin}${oapiCap.path}`, { headers: { Accept: 'application/json' } });
912
+ if (r.json?.paths) oapiPaths = Object.entries(r.json.paths).flatMap(([p, methods]) =>
913
+ Object.keys(methods).filter(m => m !== 'parameters').map(m => ({ method: m.toUpperCase(), path: p }))
914
+ );
915
+ }
916
+
917
+ const x402Services = bazaar?.x402Json?.services || [];
918
+ const x402Free = bazaar?.x402Json?.freeEndpoints || [];
919
+
920
+ // ── MCP vs Skills consistency ──
921
+ if (mcpTools && skills) {
922
+ const mcpNames = new Set(mcpTools.map(t => t.name));
923
+ const skillNames = new Set(skills.map(s => s.name));
924
+ const inMcpNotSkills = [...mcpNames].filter(n => !skillNames.has(n));
925
+ const inSkillsNotMcp = [...skillNames].filter(n => !mcpNames.has(n));
926
+ if (inMcpNotSkills.length > 0) {
927
+ warnings.push({
928
+ type: 'mcp_skills_mismatch',
929
+ severity: 'warn',
930
+ message: `MCP exposes ${mcpTools.length} tools but skills.json lists ${skills.length}`,
931
+ detail: `In MCP but not skills.json: ${inMcpNotSkills.join(', ')}`,
932
+ });
933
+ }
934
+ if (inSkillsNotMcp.length > 0) {
935
+ warnings.push({
936
+ type: 'skills_mcp_mismatch',
937
+ severity: 'warn',
938
+ message: `skills.json lists actions not in MCP`,
939
+ detail: `In skills.json but not MCP: ${inSkillsNotMcp.join(', ')}`,
940
+ });
941
+ }
942
+ }
943
+
944
+ // ── MCP paid tools vs x402 services ──
945
+ if (mcpTools && x402Services.length > 0) {
946
+ // Check if MCP paid tools have matching x402 service entries
947
+ const x402Paths = new Set(x402Services.map(s => s.path));
948
+ const x402FreePaths = new Set(x402Free.map(e => e.path));
949
+ // We can't directly map MCP tool names to paths without the skills.json bridge
950
+ // But if skills.json exists, use it
951
+ if (skills) {
952
+ const paidSkills = skills.filter(s => s.auth && s.auth !== 'none');
953
+ const freeSkills = skills.filter(s => !s.auth || s.auth === 'none');
954
+ for (const sk of paidSkills) {
955
+ if (!sk.path) continue;
956
+ const normalized = sk.path.replace(/:\w+/g, ':id');
957
+ const inX402 = x402Services.some(s => s.path.replace(/:\w+/g, ':id') === normalized);
958
+ if (!inX402) {
959
+ warnings.push({
960
+ type: 'paid_not_in_x402',
961
+ severity: 'warn',
962
+ message: `Paid skill "${sk.name}" (${sk.path}) not listed in x402.json services`,
963
+ detail: 'Agents won\'t discover this paid endpoint via x402 service catalog',
964
+ });
965
+ }
966
+ }
967
+ for (const sk of freeSkills) {
968
+ if (!sk.path) continue;
969
+ const normalized = sk.path.replace(/:\w+/g, ':id').replace(/\?.*$/, '');
970
+ const inFree = x402Free.some(e => e.path.replace(/:\w+/g, ':id').replace(/\?.*$/, '') === normalized);
971
+ if (!inFree && x402Free.length > 0) {
972
+ warnings.push({
973
+ type: 'free_not_in_x402',
974
+ severity: 'info',
975
+ message: `Free skill "${sk.name}" (${sk.path}) not listed in x402.json freeEndpoints`,
976
+ detail: 'Consider adding it so agents discover all available actions',
977
+ });
978
+ }
979
+ }
980
+ }
981
+ }
982
+
983
+ // ── MCP tool count vs OpenAPI path count ──
984
+ if (mcpTools && oapiPaths) {
985
+ // Check that MCP tools have corresponding OpenAPI paths
986
+ if (skills) {
987
+ for (const sk of skills) {
988
+ if (!sk.path) continue;
989
+ const normalized = sk.path.replace(/:\w+/g, '{id}').replace(/\?.*$/, '');
990
+ const inOapi = oapiPaths.some(p => p.path === normalized && p.method === sk.method);
991
+ if (!inOapi) {
992
+ warnings.push({
993
+ type: 'skill_not_in_openapi',
994
+ severity: 'info',
995
+ message: `Skill "${sk.name}" (${sk.method} ${sk.path}) not found in OpenAPI spec`,
996
+ detail: 'Agents using OpenAPI for discovery won\'t find this endpoint',
997
+ });
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // ── x402 payment gating on MCP ──
1004
+ if (mcpCap && x402Services.length > 0) {
1005
+ // Test if MCP gates paid actions — try calling a paid tool without payment
1006
+ const paidTool = mcpTools?.find(t => {
1007
+ if (!skills) return false;
1008
+ const sk = skills.find(s => s.name === t.name);
1009
+ return sk && sk.auth && sk.auth !== 'none';
1010
+ });
1011
+ if (paidTool) {
1012
+ const r = await fetchSafe(`${origin}${mcpCap.path}`, {
1013
+ method: 'POST',
1014
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'X-Agent-Id': 'agentgrade-consistency-check' },
1015
+ body: JSON.stringify({
1016
+ jsonrpc: '2.0', method: 'tools/call', id: 99,
1017
+ params: { name: paidTool.name, arguments: { title: '__agentgrade_probe__', url: 'https://example.com' } },
1018
+ }),
1019
+ });
1020
+ if (r.status === 402) {
1021
+ warnings.push({
1022
+ type: 'mcp_402_confirmed',
1023
+ severity: 'pass',
1024
+ message: `MCP correctly gates paid tool "${paidTool.name}" behind 402`,
1025
+ detail: 'Payment required before execution — agents won\'t get free access to paid actions',
1026
+ });
1027
+ } else if (r.status === 200) {
1028
+ warnings.push({
1029
+ type: 'mcp_402_missing',
1030
+ severity: 'error',
1031
+ message: `MCP tool "${paidTool.name}" should require payment but returned 200`,
1032
+ detail: 'Agents can call this paid action for free via MCP. Add payment gating to MCP tool calls.',
1033
+ });
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ // ── Skills auth field consistency ──
1039
+ if (skills && x402Services.length === 0 && bazaar?.x402Json === null) {
1040
+ const paidSkills = skills.filter(s => s.auth && s.auth !== 'none');
1041
+ if (paidSkills.length > 0) {
1042
+ warnings.push({
1043
+ type: 'skills_claim_payment_no_x402',
1044
+ severity: 'warn',
1045
+ message: `skills.json claims ${paidSkills.length} paid action(s) but no x402.json found`,
1046
+ detail: 'Publish /.well-known/x402.json so agents can discover payment requirements',
1047
+ });
1048
+ }
1049
+ }
1050
+
1051
+ // ── Homepage claims x402 but scan couldn't confirm it ──
1052
+ const { homepage, probe } = extra;
1053
+ const homepageClaims402 = homepage?.protocols?.x402 || homepage?.protocols?.MPP || homepage?.protocols?.L402;
1054
+ const probeFound402 = probe?.x402 || probe?.mpp || probe?.spt || probe?.l402;
1055
+ const bazaarFound402 = bazaar?.live402?.status === 402 || bazaar?.x402Probe;
1056
+ if (homepageClaims402 && !probeFound402 && !bazaarFound402) {
1057
+ const claimed = Object.keys(homepage.protocols).filter(k => homepage.protocols[k]).join(', ');
1058
+ const hasX402Json = !!bazaar?.x402Json;
1059
+ const probeStatus = probe?.status;
1060
+ if (!hasX402Json) {
1061
+ warnings.push({
1062
+ type: 'homepage_claims_payment_unverifiable',
1063
+ severity: 'warn',
1064
+ message: `Homepage mentions ${claimed} but no live 402 was detected`,
1065
+ detail: `POST to the root returned ${probeStatus || 'unknown'} (not 402), and no /.well-known/x402.json was found. Without a service catalog, the scanner cannot discover which endpoints require payment. Publish /.well-known/x402.json with required fields: x402Version, name, network, facilitator, payTo, and services[]. See https://agentgrade.com/kb/bazaar for the expected format and a minimal passing example.`,
1066
+ });
1067
+ } else {
1068
+ warnings.push({
1069
+ type: 'homepage_claims_payment_no_live_402',
1070
+ severity: 'info',
1071
+ message: `Homepage mentions ${claimed} and x402.json exists, but no live 402 response was observed`,
1072
+ detail: `The declared service endpoints did not return 402 when probed. Check that the paths in x402.json match your actual payment-gated routes.`,
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ return warnings;
1078
+ }
1079
+
1080
+ // ─── Domain Advisory ─────────────────────────────────────────────────────────
1081
+
1082
+ const VENTURE_SIGNALS = [
1083
+ /venture\s*os/i, /ventureos/i, /venture-os/i,
1084
+ /domain.*for\s*sale/i, /this\s*domain.*available/i,
1085
+ /buy\s*this\s*domain/i, /make\s*an?\s*offer/i,
1086
+ /parked.*domain/i, /domain\s*parking/i,
1087
+ /hugedomains/i, /dan\.com/i, /sedo\.com/i, /afternic/i, /godaddy\s*auctions/i,
1088
+ ];
1089
+
1090
+ async function checkDomainAdvisory(hostname) {
1091
+ const parts = hostname.split('.');
1092
+ if (parts.length < 2) return null;
1093
+ const tld = parts[parts.length - 1];
1094
+ if (tld === 'com') return null;
1095
+
1096
+ // Check both forms: base.com and basetld.com
1097
+ // e.g. agent.news → check agent.com AND agentnews.com
1098
+ const baseName = parts.slice(0, -1).join('.');
1099
+ const candidates = [`${baseName}.com`];
1100
+ if (parts.length === 2) candidates.push(`${baseName}${tld}.com`);
1101
+
1102
+ for (const comDomain of candidates) {
1103
+ const result = await checkComDomain(comDomain);
1104
+ if (result) return result;
1105
+ }
1106
+ return null;
1107
+ }
1108
+
1109
+ async function checkComDomain(comDomain) {
1110
+ let r;
1111
+ try {
1112
+ r = await fetchSafe(`https://${comDomain}`, { headers: { Accept: 'text/html, */*' } });
1113
+ } catch { return null; }
1114
+
1115
+ if (!r.text && !r.status) return null;
1116
+
1117
+ const body = (r.text || '').slice(0, 50000);
1118
+ const matched = VENTURE_SIGNALS.filter(re => re.test(body));
1119
+ if (matched.length === 0) return null;
1120
+
1121
+ const isVentureOS = matched.some(re => /venture/i.test(re.source));
1122
+
1123
+ return {
1124
+ comDomain,
1125
+ isVentureOS,
1126
+ signals: matched.map(re => body.match(re)?.[0]).filter(Boolean),
1127
+ message: isVentureOS
1128
+ ? `${comDomain} appears to be held by VentureOS, a portfolio associated with copycat activity. Consider securing the .com before it causes brand confusion.`
1129
+ : `${comDomain} appears to be a parked or for-sale domain. Consider acquiring it to protect your brand.`,
1130
+ };
1131
+ }
1132
+
1133
+ // ─── Full Scan ──────────────────────────────────────────────────────────────
1134
+
1135
+ export async function fullScan(targetUrl, method = 'POST', body = undefined) {
1136
+ const t0 = performance.now();
1137
+ const parsed = new URL(targetUrl);
1138
+ const origin = parsed.origin;
1139
+ const base = origin + parsed.pathname.replace(/\/$/, '');
1140
+ const isRoot = parsed.pathname === '/' || parsed.pathname === '';
1141
+ const subpath = base !== origin ? parsed.pathname.replace(/\/$/, '') : null;
1142
+
1143
+ const timings = {};
1144
+ const timed = (name, fn) => { const s = performance.now(); return fn().then(r => { timings[name] = Math.round(performance.now() - s); return r; }); };
1145
+
1146
+ const [probeResult, discovery, bazaar, rawCapabilities] = await Promise.all([
1147
+ timed('probe402', () => probe402(targetUrl, method, body)),
1148
+ timed('discovery', () => probeDiscovery(origin)),
1149
+ timed('bazaar', () => probeBazaar(origin)),
1150
+ timed('capabilities', () => probeCapabilities(origin, base)),
1151
+ ]);
1152
+ const capabilities = rawCapabilities.filter(c => !c.cfBlocked);
1153
+ const cfBlockedProbes = rawCapabilities.filter(c => c.cfBlocked).map(c => c.type);
1154
+
1155
+ let homepage = null;
1156
+ if (isRoot) {
1157
+ homepage = await timed('homepage', () => probeHomepage(origin));
1158
+ }
1159
+
1160
+ // Subpath discoverability: does the domain root advertise this service?
1161
+ let subpathDiscoverable = null;
1162
+ if (subpath) {
1163
+ const rootR = await fetchSafe(origin, { headers: { Accept: 'application/json, text/html, */*' } });
1164
+ subpathDiscoverable = rootR.text && rootR.text.includes(subpath) ? 'referenced' : 'not_found';
1165
+ }
1166
+
1167
+ // Cross-reference consistency
1168
+ const consistency = await timed('consistency', () => probeConsistency(origin, capabilities, bazaar, { homepage, probe: probeResult }));
1169
+
1170
+ // Build summary
1171
+ const rails = [...discovery.rails];
1172
+ if (probeResult.x402 && !rails.includes('x402')) rails.push('x402');
1173
+ if (probeResult.mpp && !rails.includes('MPP')) rails.push('MPP');
1174
+ if (probeResult.spt && !rails.includes('SPT')) rails.push('SPT');
1175
+ if (probeResult.l402 && !rails.includes('L402')) rails.push('L402');
1176
+
1177
+ // Live 402 confirms x402 — actual payment gating observed
1178
+ if (bazaar.live402?.status === 402 && !rails.includes('x402')) rails.push('x402');
1179
+ if (bazaar.x402Probe && !rails.includes('x402')) rails.push('x402');
1180
+ // x402.json declares support but isn't live confirmation
1181
+ if (bazaar.x402Json && !rails.includes('x402') && !rails.includes('x402 (documented)')) rails.push('x402 (documented)');
1182
+
1183
+ const confirmed = rails.filter(r => !r.includes('(documented)'));
1184
+ const documented = rails.filter(r => r.includes('(documented)'));
1185
+ const capabilityTypes = [...new Set(capabilities.map(c => c.type))];
1186
+ const cfBlocked = probeResult.cfBlocked || homepage?.cfBlocked || false;
1187
+ const rateLimited = probeResult.rateLimited || bazaar.live402?.rateLimited || false;
1188
+ timings.total = Math.round(performance.now() - t0);
1189
+
1190
+ const result = {
1191
+ url: targetUrl,
1192
+ origin,
1193
+ base,
1194
+ subpath,
1195
+ method,
1196
+ probe: probeResult,
1197
+ discovery,
1198
+ bazaar,
1199
+ homepage,
1200
+ capabilities,
1201
+ consistency,
1202
+ subpathDiscoverable,
1203
+ timings,
1204
+ summary: {
1205
+ confirmedRails: confirmed,
1206
+ documentedRails: documented,
1207
+ bazaarServices: bazaar.bazaarServices,
1208
+ capabilities: capabilityTypes,
1209
+ cfBlocked,
1210
+ cfBlockedProbes,
1211
+ rateLimited,
1212
+ subpath,
1213
+ subpathDiscoverable,
1214
+ },
1215
+ };
1216
+ result.score = computeScore(result);
1217
+ result.domainAdvisory = await timed('domainAdvisory', () => checkDomainAdvisory(parsed.hostname));
1218
+ return result;
1219
+ }
1220
+
1221
+ // ─── Score ───────────────────────────────────────────────────────────────────
1222
+
1223
+ export function computeScore(data) {
1224
+ const capMap = {};
1225
+ (data.capabilities || []).forEach(c => { capMap[c.type] = capMap[c.type] || c; });
1226
+ const bz = data.bazaar || {};
1227
+ const disc = (path) => (data.discovery?.endpoints || []).find(e => e.path === path);
1228
+ const hasX402Json = disc('/.well-known/x402.json');
1229
+ const x402Opt = data.probe?.x402?.options?.[0] || bz.live402?.options?.[0];
1230
+ const hasAnyPayment = !!data.probe?.x402 || bz.live402?.status === 402 || !!bz.x402Probe || !!data.probe?.mpp || !!data.probe?.spt || !!data.probe?.l402;
1231
+ const sm = data.summary || {};
1232
+
1233
+ const groups = [
1234
+ { key: 'payments', label: 'Machine Payments', weight: 3, checks: [
1235
+ { label: 'Live 402 response', passed: hasAnyPayment },
1236
+ { label: 'x402', passed: !!data.probe?.x402 || bz.live402?.status === 402 || !!bz.x402Probe, optional: hasAnyPayment },
1237
+ { label: 'MPP', passed: !!data.probe?.mpp || !!bz.live402?.mpp, optional: hasAnyPayment },
1238
+ { label: 'SPT (Stripe)', passed: !!data.probe?.spt || !!bz.live402?.spt, optional: hasAnyPayment },
1239
+ { label: 'L402', passed: !!data.probe?.l402, optional: hasAnyPayment },
1240
+ { label: 'Payment header decodable', passed: (!!data.probe?.x402 && !data.probe.x402.decodeFailed) || !!bz.live402?.version || (data.probe?.mpp?.fields && Object.keys(data.probe.mpp.fields).length > 0) || !!data.probe?.l402 },
1241
+ { label: 'Recipient specified', passed: !!(x402Opt?.payTo && x402Opt.payTo !== '?') || !!bz.x402Json?.payTo || !!data.probe?.mpp?.fields?.recipient },
1242
+ { label: 'Payment terms specified', passed: !!(x402Opt?.amount || data.probe?.mpp?.fields?.amount || data.probe?.l402?.invoice) },
1243
+ { label: 'Discovery published', passed: !!hasX402Json || !!bz.x402Json || !!disc('/.well-known/mpp.json') },
1244
+ ]},
1245
+ { key: 'Bazaar', label: 'Bazaar discovery', weight: 3, checks: [
1246
+ { label: 'Bazaar in live 402 header', passed: !!bz.live402?.bazaar },
1247
+ { label: '/.well-known/x402.json exists', passed: !!bz.x402Json },
1248
+ { label: 'Discoverable service declared', passed: sm.bazaarServices > 0 },
1249
+ { label: 'Provider (name) specified', passed: bz.x402Json?.fields?.name?.status === 'ok' },
1250
+ { label: 'Facilitator specified', passed: bz.x402Json?.fields?.facilitator?.status === 'ok' },
1251
+ { label: 'payTo specified', passed: bz.x402Json?.fields?.payTo?.status === 'ok' },
1252
+ ]},
1253
+ { key: 'MCP', label: 'MCP endpoint', weight: 2, checks: [
1254
+ { label: 'MCP endpoint responds', passed: !!capMap['MCP'] },
1255
+ { label: 'Initialize handshake', passed: !!capMap['MCP']?.protocolVersion },
1256
+ { label: 'Tools listed', passed: capMap['MCP']?.tools > 0 },
1257
+ { label: 'tools/call responds', passed: !!capMap['MCP']?.verified },
1258
+ { label: 'Server name present', passed: !!capMap['MCP']?.name },
1259
+ { label: 'SSE transport', passed: !!capMap['MCP']?.sseSupported, optional: true },
1260
+ { label: 'CORS enabled', passed: !!capMap['MCP']?.cors, optional: true },
1261
+ ]},
1262
+ { key: 'OpenAPI', label: 'OpenAPI spec', weight: 2, checks: [
1263
+ { label: 'OpenAPI spec found', passed: !!capMap['OpenAPI'] },
1264
+ { label: 'Paths defined', passed: capMap['OpenAPI']?.paths > 0 },
1265
+ { label: 'Paths reachable', passed: (capMap['OpenAPI']?.pathsReachable || 0) > 0 },
1266
+ { label: 'Response matches spec', passed: !!capMap['OpenAPI']?.verified },
1267
+ { label: 'Version identified', passed: !!capMap['OpenAPI']?.version },
1268
+ { label: 'Title present', passed: !!capMap['OpenAPI']?.title },
1269
+ { label: 'x-payment-info declared', passed: !!capMap['OpenAPI']?.xPaymentInfo, optional: true },
1270
+ ]},
1271
+ { key: 'llms.txt', label: 'llms.txt', weight: 2, checks: [
1272
+ { label: 'llms.txt found', passed: !!capMap['llms.txt'] },
1273
+ { label: 'Meaningful content', passed: (capMap['llms.txt']?.lines || 0) > 3 },
1274
+ ]},
1275
+ { key: 'Homepage & Meta', label: 'Homepage & Meta', weight: 1, checks: [
1276
+ { label: 'Linked from HTML', passed: !!data.homepage?.llmsLink, optional: true },
1277
+ { label: 'JSON-LD structured data', passed: !!data.homepage?.jsonLd, optional: true },
1278
+ { label: 'og:image declared', passed: !!data.homepage?.ogImage, optional: true },
1279
+ { label: 'og:image reachable', passed: !!data.homepage?.ogImageReachable, optional: true },
1280
+ { label: 'twitter:card set', passed: !!data.homepage?.twitterCard, optional: true },
1281
+ { label: 'Favicon SVG', passed: !!data.homepage?.faviconSvg, optional: true },
1282
+ { label: 'Favicon PNG', passed: !!data.homepage?.faviconPng, optional: true },
1283
+ ]},
1284
+ { key: 'AI Plugin', label: 'AI plugin', weight: 1, checks: [
1285
+ { label: 'ai-plugin.json found', passed: !!capMap['AI Plugin'] },
1286
+ { label: 'name_for_model present', passed: !!capMap['AI Plugin']?.modelName },
1287
+ { label: 'api.type is openapi', passed: !!capMap['AI Plugin'] && (!capMap['AI Plugin'].apiType || capMap['AI Plugin'].apiType === 'openapi') },
1288
+ { label: 'API spec URL present', passed: !!capMap['AI Plugin']?.apiUrl },
1289
+ { label: 'API spec reachable', passed: !!capMap['AI Plugin']?.apiSpecReachable },
1290
+ { label: 'API endpoint responds', passed: !!capMap['AI Plugin']?.verified },
1291
+ ]},
1292
+ { key: 'Claude Plugin', label: 'Claude plugin', weight: 1, checks: [
1293
+ { label: 'Plugin manifest found', passed: !!capMap['Claude Plugin'] },
1294
+ { label: 'Name present', passed: !!capMap['Claude Plugin']?.name },
1295
+ { label: 'Description present', passed: !!capMap['Claude Plugin']?.description },
1296
+ { label: 'Declared endpoint responds', passed: !!capMap['Claude Plugin']?.verified },
1297
+ ]},
1298
+ { key: 'Skills', label: 'Skills manifest', weight: 1, checks: [
1299
+ { label: 'Skills file found', passed: !!capMap['Skills'] },
1300
+ { label: 'Skills defined', passed: (capMap['Skills']?.count || 0) > 0 },
1301
+ { label: 'Skill endpoints reachable', passed: (capMap['Skills']?.endpointsReachable || 0) > 0 },
1302
+ ]},
1303
+ { key: 'agents.txt', label: 'agents.txt', weight: 1, checks: [
1304
+ { label: 'agents.txt found', passed: !!capMap['agents.txt'] },
1305
+ ]},
1306
+ { key: 'robots.txt', label: 'robots.txt directives', weight: 1, checks: [
1307
+ { label: 'robots.txt found with agent directives', passed: !!capMap['robots.txt (agent-aware)'] },
1308
+ { label: 'Google-Extended not blocked', passed: !!capMap['robots.txt (agent-aware)'] && !capMap['robots.txt (agent-aware)'].googleExtBlocked, optional: true },
1309
+ ]},
1310
+ { key: 'Content Negotiation', label: 'Content negotiation', weight: 1, checks: [
1311
+ { label: 'Agent UA gets non-HTML', passed: !!capMap['Content Negotiation']?.agentUA },
1312
+ { label: 'Accept: JSON returns JSON', passed: !!capMap['Content Negotiation']?.acceptJson },
1313
+ { label: 'Accept: text returns text', passed: !!capMap['Content Negotiation']?.acceptText },
1314
+ ]},
1315
+ { key: 'Web Bot Auth', label: 'Bot authentication', weight: 0, checks: [
1316
+ { label: 'Signatures directory published', passed: !!capMap['Web Bot Auth'], optional: true },
1317
+ { label: 'Members declared', passed: (capMap['Web Bot Auth']?.members || 0) > 0, optional: true },
1318
+ { label: 'Public keys available', passed: !!capMap['Web Bot Auth']?.hasKeys, optional: true },
1319
+ ]},
1320
+ { key: 'Identity', label: 'Identity', weight: 0, checks: [
1321
+ { label: 'WebFinger', passed: !!capMap['WebFinger'], optional: true },
1322
+ { label: 'DID Document', passed: !!capMap['DID Document'], optional: true },
1323
+ { label: 'Nostr NIP-05', passed: !!capMap['Nostr NIP-05'], optional: true },
1324
+ { label: 'AT Protocol DID', passed: !!capMap['AT Protocol DID'], optional: true },
1325
+ { label: 'Apple App Links', passed: !!capMap['Apple App Links'], optional: true },
1326
+ { label: 'Android Asset Links', passed: !!capMap['Android Asset Links'], optional: true },
1327
+ ]},
1328
+ ];
1329
+
1330
+ const allChecks = groups.flatMap(g => g.checks.map(ch => ({ ...ch, weight: g.weight })));
1331
+ const scoredChecks = allChecks.filter(ch => ch.passed || !ch.optional);
1332
+ const passedChecks = allChecks.filter(ch => ch.passed).length;
1333
+ const totalChecks = scoredChecks.length;
1334
+ const pct = totalChecks > 0 ? Math.round((passedChecks / totalChecks) * 100) : 0;
1335
+
1336
+ return { passedChecks, totalChecks, pct, groups: groups.map(g => ({ key: g.key, label: g.label, passed: g.checks.filter(c => c.passed).length, total: g.checks.filter(c => c.passed || !c.optional).length })) };
1337
+ }
1338
+
1339
+ // ─── Scan Delta ──────────────────────────────────────────────────────────────
1340
+
1341
+ export function computeDelta(current, previous) {
1342
+ if (!current || !previous) return null;
1343
+ const cr = current.confirmedRails || [], pr = previous.confirmedRails || [];
1344
+ const cc = current.capabilities || [], pc = previous.capabilities || [];
1345
+ const addedRails = cr.filter(r => !pr.includes(r));
1346
+ const removedRails = pr.filter(r => !cr.includes(r));
1347
+ const addedCapabilities = cc.filter(c => !pc.includes(c));
1348
+ const removedCapabilities = pc.filter(c => !cc.includes(c));
1349
+ const bazaarDelta = (current.bazaarServices || 0) - (previous.bazaarServices || 0);
1350
+ return {
1351
+ addedRails, removedRails, addedCapabilities, removedCapabilities, bazaarDelta,
1352
+ changed: addedRails.length + removedRails.length + addedCapabilities.length + removedCapabilities.length > 0 || bazaarDelta !== 0,
1353
+ };
1354
+ }
1355
+
1356
+ // ─── x402.json Validator ─────────────────────────────────────────────────────
1357
+
1358
+ export function validateX402Json(raw) {
1359
+ const errors = [], warnings = [], suggestions = [];
1360
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { valid: false, errors: ['Input must be a JSON object'], warnings, suggestions };
1361
+
1362
+ if (!raw.name) errors.push('Missing required field: "name"');
1363
+ if (!raw.payTo) errors.push('Missing required field: "payTo"');
1364
+ else if (!/^0x[a-fA-F0-9]{40}$/.test(raw.payTo)) warnings.push('"payTo" does not look like a valid Ethereum address');
1365
+ if (!raw.network) errors.push('Missing required field: "network"');
1366
+
1367
+ if (raw.x402Version && raw.x402Version !== 1 && raw.x402Version !== 2) warnings.push(`Unexpected x402Version: ${raw.x402Version}`);
1368
+ if (!raw.x402Version) suggestions.push('Add "x402Version": 2 to declare protocol version');
1369
+
1370
+ if (!raw.services || !Array.isArray(raw.services)) {
1371
+ errors.push('Missing or invalid "services" array');
1372
+ } else {
1373
+ raw.services.forEach((svc, i) => {
1374
+ if (!svc.method && !svc.endpoints) warnings.push(`services[${i}]: missing "method" or "endpoints"`);
1375
+ if (!svc.path && !svc.endpoints) warnings.push(`services[${i}]: missing "path" or "endpoints"`);
1376
+ if (!svc.description) suggestions.push(`services[${i}]: add a "description" for agent discoverability`);
1377
+ });
1378
+ }
1379
+
1380
+ if (!raw.facilitator) suggestions.push('Add "facilitator" for payment verification');
1381
+
1382
+ return { valid: errors.length === 0, errors, warnings, suggestions };
1383
+ }