botvisibility 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.
@@ -0,0 +1,868 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeUrl = normalizeUrl;
4
+ exports.parseOpenApiSpec = parseOpenApiSpec;
5
+ exports.fetchOpenApiSpec = fetchOpenApiSpec;
6
+ exports.checkLlmsTxt = checkLlmsTxt;
7
+ exports.checkAgentCard = checkAgentCard;
8
+ exports.checkOpenApiSpecFromParsed = checkOpenApiSpecFromParsed;
9
+ exports.checkRobotsTxt = checkRobotsTxt;
10
+ exports.checkStructuredData = checkStructuredData;
11
+ exports.checkCorsHeaders = checkCorsHeaders;
12
+ exports.checkAiMetaTags = checkAiMetaTags;
13
+ exports.checkSkillFile = checkSkillFile;
14
+ exports.checkAiSiteProfile = checkAiSiteProfile;
15
+ exports.checkSkillsIndex = checkSkillsIndex;
16
+ exports.checkLinkHeaders = checkLinkHeaders;
17
+ exports.checkApiReadOps = checkApiReadOps;
18
+ exports.checkApiWriteOps = checkApiWriteOps;
19
+ exports.checkApiPrimaryAction = checkApiPrimaryAction;
20
+ exports.checkApiKeyAuth = checkApiKeyAuth;
21
+ exports.checkScopedApiKeys = checkScopedApiKeys;
22
+ exports.checkOpenIdConfig = checkOpenIdConfig;
23
+ exports.checkStructuredErrors = checkStructuredErrors;
24
+ exports.checkAsyncOps = checkAsyncOps;
25
+ exports.checkIdempotency = checkIdempotency;
26
+ exports.checkSparseFields = checkSparseFields;
27
+ exports.checkCursorPagination = checkCursorPagination;
28
+ exports.checkSearchFiltering = checkSearchFiltering;
29
+ exports.checkBulkOps = checkBulkOps;
30
+ exports.checkRateLimitHeaders = checkRateLimitHeaders;
31
+ exports.checkCachingHeaders = checkCachingHeaders;
32
+ exports.runAllChecks = runAllChecks;
33
+ const FETCH_TIMEOUT = 10000;
34
+ // Validate URL format and normalize
35
+ function normalizeUrl(input) {
36
+ let url = input.trim();
37
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
38
+ url = 'https://' + url;
39
+ }
40
+ const parsed = new URL(url);
41
+ return parsed.origin;
42
+ }
43
+ // --- OpenAPI Spec Mining ---
44
+ function parseOpenApiSpec(spec) {
45
+ const paths = (spec.paths ?? {});
46
+ let hasGetEndpoints = false;
47
+ let hasWriteEndpoints = false;
48
+ let hasNonGetEndpoints = false;
49
+ let hasAsyncPatterns = false;
50
+ let hasIdempotencyKey = false;
51
+ let hasSparseFields = false;
52
+ let hasCursorPagination = false;
53
+ let hasSearchFiltering = false;
54
+ let hasBulkOperations = false;
55
+ const specStr = JSON.stringify(spec).toLowerCase();
56
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
57
+ if (!pathItem || typeof pathItem !== 'object')
58
+ continue;
59
+ const methods = Object.keys(pathItem);
60
+ for (const method of methods) {
61
+ const m = method.toLowerCase();
62
+ if (m === 'get')
63
+ hasGetEndpoints = true;
64
+ if (m === 'post' || m === 'put' || m === 'patch' || m === 'delete') {
65
+ hasWriteEndpoints = true;
66
+ hasNonGetEndpoints = true;
67
+ }
68
+ if (m === 'options' || m === 'head')
69
+ hasNonGetEndpoints = true;
70
+ const operation = pathItem[method];
71
+ if (operation && typeof operation === 'object') {
72
+ const opStr = JSON.stringify(operation).toLowerCase();
73
+ if (opStr.includes('callback') || opStr.includes('webhook') || opStr.includes('"202"') || opStr.includes('async')) {
74
+ hasAsyncPatterns = true;
75
+ }
76
+ if (opStr.includes('idempotency') || opStr.includes('idempotency-key') || opStr.includes('idempotencykey')) {
77
+ hasIdempotencyKey = true;
78
+ }
79
+ if (opStr.includes('"fields"') || opStr.includes('sparse') || opStr.includes('fieldset')) {
80
+ hasSparseFields = true;
81
+ }
82
+ if (opStr.includes('cursor') || opStr.includes('page_token') || opStr.includes('next_token') || opStr.includes('pagetoken')) {
83
+ hasCursorPagination = true;
84
+ }
85
+ if (opStr.includes('"filter"') || opStr.includes('"search"') || opStr.includes('"query"') || opStr.includes('"q"')) {
86
+ hasSearchFiltering = true;
87
+ }
88
+ if (opStr.includes('bulk') || opStr.includes('batch')) {
89
+ hasBulkOperations = true;
90
+ }
91
+ }
92
+ }
93
+ const pathLower = pathKey.toLowerCase();
94
+ if (pathLower.includes('bulk') || pathLower.includes('batch')) {
95
+ hasBulkOperations = true;
96
+ }
97
+ if (pathLower.includes('search')) {
98
+ hasSearchFiltering = true;
99
+ }
100
+ }
101
+ if (spec.webhooks || specStr.includes('"callbacks"')) {
102
+ hasAsyncPatterns = true;
103
+ }
104
+ const components = (spec.components ?? spec.securityDefinitions ?? {});
105
+ const securitySchemes = (components?.securitySchemes ?? spec.securityDefinitions ?? {});
106
+ let hasApiKeyAuth = false;
107
+ let hasScopedAuth = false;
108
+ for (const [, scheme] of Object.entries(securitySchemes)) {
109
+ if (scheme && typeof scheme === 'object') {
110
+ const s = scheme;
111
+ if (s.type === 'apiKey' || s.type === 'http') {
112
+ hasApiKeyAuth = true;
113
+ }
114
+ if (s.type === 'oauth2' || s.type === 'openIdConnect') {
115
+ hasScopedAuth = true;
116
+ hasApiKeyAuth = true;
117
+ }
118
+ if (s.flows && typeof s.flows === 'object') {
119
+ const flows = s.flows;
120
+ for (const flow of Object.values(flows)) {
121
+ if (flow && typeof flow === 'object' && flow.scopes) {
122
+ hasScopedAuth = true;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ return {
129
+ raw: spec,
130
+ paths,
131
+ hasGetEndpoints,
132
+ hasWriteEndpoints,
133
+ hasNonGetEndpoints,
134
+ securitySchemes,
135
+ hasApiKeyAuth,
136
+ hasScopedAuth,
137
+ hasAsyncPatterns,
138
+ hasIdempotencyKey,
139
+ hasSparseFields,
140
+ hasCursorPagination,
141
+ hasSearchFiltering,
142
+ hasBulkOperations,
143
+ };
144
+ }
145
+ async function fetchOpenApiSpec(baseUrl) {
146
+ const paths = [
147
+ '/openapi.json',
148
+ '/openapi.yaml',
149
+ '/swagger.json',
150
+ '/api-docs',
151
+ '/api/openapi.json',
152
+ '/docs/openapi.json',
153
+ '/v1/openapi.json',
154
+ ];
155
+ for (const specPath of paths) {
156
+ try {
157
+ const url = `${baseUrl}${specPath}`;
158
+ const controller = new AbortController();
159
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
160
+ const res = await fetch(url, {
161
+ headers: { 'Accept': 'application/json, application/yaml, text/yaml' },
162
+ signal: controller.signal,
163
+ });
164
+ clearTimeout(timeout);
165
+ if (res.ok) {
166
+ const text = await res.text();
167
+ const isOpenApi = text.includes('openapi') || text.includes('swagger') || text.includes('paths');
168
+ if (isOpenApi) {
169
+ try {
170
+ const json = JSON.parse(text);
171
+ return parseOpenApiSpec(json);
172
+ }
173
+ catch {
174
+ // Not valid JSON, skip
175
+ }
176
+ }
177
+ }
178
+ }
179
+ catch {
180
+ // Continue to next path
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ // --- Helper for fetch with timeout ---
186
+ async function timedFetch(url, opts = {}) {
187
+ const controller = new AbortController();
188
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
189
+ try {
190
+ const res = await fetch(url, { ...opts, signal: controller.signal });
191
+ return res;
192
+ }
193
+ finally {
194
+ clearTimeout(timeout);
195
+ }
196
+ }
197
+ // --- Homepage HTML helper (fetch once, reuse for checks 1.5, 1.7, 1.11) ---
198
+ async function fetchHomepageHtml(baseUrl) {
199
+ try {
200
+ const res = await timedFetch(baseUrl, { headers: { 'Accept': 'text/html' } });
201
+ if (res.ok)
202
+ return await res.text();
203
+ return null;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ // --- Level 1 Check Functions ---
210
+ async function checkLlmsTxt(baseUrl) {
211
+ const paths = ['/llms.txt', '/llms-full.txt', '/.well-known/llms.txt'];
212
+ for (const p of paths) {
213
+ const url = `${baseUrl}${p}`;
214
+ try {
215
+ const res = await timedFetch(url, {
216
+ method: 'GET',
217
+ headers: { 'Accept': 'text/plain' },
218
+ });
219
+ if (res.ok) {
220
+ const text = await res.text();
221
+ const isHtml = text.trimStart().startsWith('<!') || text.trimStart().startsWith('<html');
222
+ if (isHtml)
223
+ continue;
224
+ const hasContent = text.length > 50;
225
+ const hasMarkdownStyle = text.includes('#') || text.includes('##');
226
+ const hasLinks = text.includes('http://') || text.includes('https://');
227
+ if (hasContent && (hasMarkdownStyle || hasLinks)) {
228
+ return {
229
+ id: '1.1', name: 'llms.txt', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
230
+ message: 'llms.txt exists with valid content',
231
+ details: `Found at ${p} (${text.length} chars)`,
232
+ foundAt: url
233
+ };
234
+ }
235
+ else if (hasContent) {
236
+ return {
237
+ id: '1.1', name: 'llms.txt', passed: true, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true,
238
+ message: 'llms.txt exists but could be improved',
239
+ details: `Found at ${p} but missing markdown structure or links`,
240
+ recommendation: 'Add app description, API links, and documentation references. See llmstxt.org for format.',
241
+ foundAt: url
242
+ };
243
+ }
244
+ }
245
+ }
246
+ catch {
247
+ // Try next path
248
+ }
249
+ }
250
+ return {
251
+ id: '1.1', name: 'llms.txt', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
252
+ message: 'No llms.txt found',
253
+ recommendation: 'Create /llms.txt with app description, capabilities, and API documentation links. See llmstxt.org'
254
+ };
255
+ }
256
+ async function checkAgentCard(baseUrl) {
257
+ const url = `${baseUrl}/.well-known/agent-card.json`;
258
+ try {
259
+ const res = await timedFetch(url, {
260
+ headers: { 'Accept': 'application/json' }
261
+ });
262
+ if (res.ok) {
263
+ const text = await res.text();
264
+ const isHtml = text.trimStart().startsWith('<!') || text.trimStart().startsWith('<html');
265
+ if (isHtml) {
266
+ return {
267
+ id: '1.2', name: 'Agent Card', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
268
+ message: 'No agent-card.json found',
269
+ recommendation: 'Create /.well-known/agent-card.json with name, description, url, api spec URL, and auth info.'
270
+ };
271
+ }
272
+ try {
273
+ const json = JSON.parse(text);
274
+ const requiredFields = ['name', 'description', 'url'];
275
+ const hasRequired = requiredFields.every(f => f in json);
276
+ if (hasRequired) {
277
+ return {
278
+ id: '1.2', name: 'Agent Card', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
279
+ message: 'Valid agent-card.json found',
280
+ foundAt: url
281
+ };
282
+ }
283
+ else {
284
+ const missing = requiredFields.filter(f => !(f in json));
285
+ return {
286
+ id: '1.2', name: 'Agent Card', passed: false, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true,
287
+ message: 'Agent card exists but missing required fields',
288
+ details: `Missing: ${missing.join(', ')}`,
289
+ recommendation: `Add missing fields: ${missing.join(', ')}`,
290
+ foundAt: url
291
+ };
292
+ }
293
+ }
294
+ catch {
295
+ return {
296
+ id: '1.2', name: 'Agent Card', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
297
+ message: 'agent-card.json exists but is invalid JSON',
298
+ recommendation: 'Fix JSON syntax errors in /.well-known/agent-card.json',
299
+ foundAt: url
300
+ };
301
+ }
302
+ }
303
+ return {
304
+ id: '1.2', name: 'Agent Card', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
305
+ message: 'No agent-card.json found',
306
+ recommendation: 'Create /.well-known/agent-card.json with name, description, url, api spec URL, and auth info.'
307
+ };
308
+ }
309
+ catch (e) {
310
+ return {
311
+ id: '1.2', name: 'Agent Card', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
312
+ message: 'Failed to check agent-card.json',
313
+ details: e instanceof Error ? e.message : 'Network error'
314
+ };
315
+ }
316
+ }
317
+ function checkOpenApiSpecFromParsed(spec) {
318
+ if (spec) {
319
+ return {
320
+ id: '1.3', name: 'OpenAPI Spec', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
321
+ message: 'OpenAPI/Swagger spec found',
322
+ details: `${Object.keys(spec.paths).length} paths detected`,
323
+ };
324
+ }
325
+ return {
326
+ id: '1.3', name: 'OpenAPI Spec', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
327
+ message: 'No OpenAPI/Swagger spec found',
328
+ recommendation: 'Publish an OpenAPI 3.x spec at /openapi.json'
329
+ };
330
+ }
331
+ async function checkRobotsTxt(baseUrl) {
332
+ const url = `${baseUrl}/robots.txt`;
333
+ try {
334
+ const res = await timedFetch(url, {
335
+ headers: { 'Accept': 'text/plain' }
336
+ });
337
+ if (res.ok) {
338
+ const text = await res.text().then(t => t.toLowerCase());
339
+ const aiAgents = ['gptbot', 'claudebot', 'googlebot-extended', 'anthropic', 'openai'];
340
+ const blockedAgents = aiAgents.filter(agent => {
341
+ const regex = new RegExp(`user-agent:\\s*${agent}[\\s\\S]*?disallow:\\s*/`, 'i');
342
+ return regex.test(text);
343
+ });
344
+ const allowsApi = !text.includes('disallow: /api');
345
+ if (blockedAgents.length === 0 && allowsApi) {
346
+ return {
347
+ id: '1.4', name: 'robots.txt AI Policy', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
348
+ message: 'robots.txt allows AI crawlers',
349
+ foundAt: url
350
+ };
351
+ }
352
+ else if (blockedAgents.length > 0) {
353
+ return {
354
+ id: '1.4', name: 'robots.txt AI Policy', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
355
+ message: 'robots.txt blocks AI crawlers',
356
+ details: `Blocked: ${blockedAgents.join(', ')}`,
357
+ recommendation: 'Remove Disallow rules for AI agents (GPTBot, ClaudeBot)'
358
+ };
359
+ }
360
+ else {
361
+ return {
362
+ id: '1.4', name: 'robots.txt AI Policy', passed: false, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true,
363
+ message: 'robots.txt may block API paths',
364
+ recommendation: 'Ensure /api and /docs paths are not blocked for AI crawlers'
365
+ };
366
+ }
367
+ }
368
+ return {
369
+ id: '1.4', name: 'robots.txt AI Policy', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
370
+ message: 'No robots.txt (nothing blocked)'
371
+ };
372
+ }
373
+ catch (e) {
374
+ return {
375
+ id: '1.4', name: 'robots.txt AI Policy', passed: false, status: 'na', level: 1, category: 'Discoverable', autoDetectable: true,
376
+ message: 'Could not check robots.txt',
377
+ details: e instanceof Error ? e.message : 'Network error'
378
+ };
379
+ }
380
+ }
381
+ function checkStructuredData(html) {
382
+ if (!html) {
383
+ return { id: '1.5', name: 'Documentation Accessibility', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not fetch homepage' };
384
+ }
385
+ // Extract JSON-LD blocks
386
+ const jsonLdRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
387
+ let match;
388
+ while ((match = jsonLdRegex.exec(html)) !== null) {
389
+ try {
390
+ const json = JSON.parse(match[1]);
391
+ const hasAction = JSON.stringify(json).includes('potentialAction');
392
+ if (hasAction) {
393
+ return { id: '1.5', name: 'Documentation Accessibility', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true, message: 'JSON-LD with potentialAction found', details: 'Structured data includes actionable entry points for agents' };
394
+ }
395
+ }
396
+ catch { /* skip invalid JSON-LD */ }
397
+ }
398
+ return { id: '1.5', name: 'Documentation Accessibility', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No JSON-LD with potentialAction found', recommendation: 'Add Schema.org JSON-LD with potentialAction to make your site actionable by agents' };
399
+ }
400
+ async function checkCorsHeaders(baseUrl) {
401
+ try {
402
+ let corsHeader = null;
403
+ try {
404
+ const optRes = await timedFetch(baseUrl, {
405
+ method: 'OPTIONS',
406
+ headers: {
407
+ 'Origin': 'https://example.com',
408
+ 'Access-Control-Request-Method': 'GET'
409
+ }
410
+ });
411
+ corsHeader = optRes.headers.get('access-control-allow-origin');
412
+ }
413
+ catch {
414
+ // OPTIONS might fail
415
+ }
416
+ if (!corsHeader) {
417
+ const getRes = await timedFetch(baseUrl, {
418
+ method: 'GET',
419
+ headers: { 'Origin': 'https://example.com' }
420
+ });
421
+ corsHeader = getRes.headers.get('access-control-allow-origin');
422
+ }
423
+ if (corsHeader) {
424
+ return {
425
+ id: '1.6', name: 'CORS Headers', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true,
426
+ message: 'CORS headers present',
427
+ details: `Allow-Origin: ${corsHeader}`
428
+ };
429
+ }
430
+ return {
431
+ id: '1.6', name: 'CORS Headers', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true,
432
+ message: 'No CORS headers detected',
433
+ recommendation: 'Add Access-Control-Allow-Origin headers to enable cross-origin API access.'
434
+ };
435
+ }
436
+ catch {
437
+ return {
438
+ id: '1.6', name: 'CORS Headers', passed: false, status: 'na', level: 1, category: 'Discoverable', autoDetectable: true,
439
+ message: 'Could not check CORS headers'
440
+ };
441
+ }
442
+ }
443
+ // --- New Level 1 Checks (1.7–1.11) ---
444
+ function checkAiMetaTags(html) {
445
+ if (!html) {
446
+ return { id: '1.7', name: 'AI Meta Tags', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not fetch homepage' };
447
+ }
448
+ const hasLlmsDesc = /meta\s+name=["']llms:description["']/i.test(html);
449
+ const hasLlmsUrl = /meta\s+name=["']llms:url["']/i.test(html);
450
+ const hasLlmsInstr = /meta\s+name=["']llms:instructions["']/i.test(html);
451
+ const count = [hasLlmsDesc, hasLlmsUrl, hasLlmsInstr].filter(Boolean).length;
452
+ if (hasLlmsDesc) {
453
+ return { id: '1.7', name: 'AI Meta Tags', passed: true, status: count >= 2 ? 'pass' : 'partial', level: 1, category: 'Discoverable', autoDetectable: true, message: `Found ${count} AI meta tag${count > 1 ? 's' : ''} (llms:description${hasLlmsUrl ? ', llms:url' : ''}${hasLlmsInstr ? ', llms:instructions' : ''})` };
454
+ }
455
+ return { id: '1.7', name: 'AI Meta Tags', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No AI meta tags found', recommendation: 'Add <meta name="llms:description" content="..."> to your <head> for AI agent discovery' };
456
+ }
457
+ async function checkSkillFile(baseUrl) {
458
+ try {
459
+ const res = await timedFetch(`${baseUrl}/skill.md`, { headers: { 'Accept': 'text/plain, text/markdown' } });
460
+ if (res.ok) {
461
+ const text = await res.text();
462
+ const isHtml = text.trimStart().startsWith('<!') || text.trimStart().startsWith('<html');
463
+ if (isHtml) {
464
+ return { id: '1.8', name: 'Skill File', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No skill.md found', recommendation: 'Create /skill.md with YAML frontmatter and step-by-step agent instructions' };
465
+ }
466
+ const hasContent = text.length > 50;
467
+ const hasFrontmatter = text.trimStart().startsWith('---');
468
+ const hasHeaders = text.includes('#');
469
+ if (hasContent && (hasFrontmatter || hasHeaders)) {
470
+ return { id: '1.8', name: 'Skill File', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true, message: 'skill.md found with valid content', details: `${text.length} chars${hasFrontmatter ? ', has YAML frontmatter' : ''}`, foundAt: `${baseUrl}/skill.md` };
471
+ }
472
+ return { id: '1.8', name: 'Skill File', passed: false, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true, message: 'skill.md exists but may be incomplete', recommendation: 'Add YAML frontmatter and structured instructions to skill.md' };
473
+ }
474
+ return { id: '1.8', name: 'Skill File', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No skill.md found', recommendation: 'Create /skill.md with YAML frontmatter and step-by-step agent instructions' };
475
+ }
476
+ catch {
477
+ return { id: '1.8', name: 'Skill File', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not check skill.md' };
478
+ }
479
+ }
480
+ async function checkAiSiteProfile(baseUrl) {
481
+ try {
482
+ const res = await timedFetch(`${baseUrl}/.well-known/ai.json`, { headers: { 'Accept': 'application/json' } });
483
+ if (res.ok) {
484
+ const text = await res.text();
485
+ const isHtml = text.trimStart().startsWith('<!') || text.trimStart().startsWith('<html');
486
+ if (isHtml) {
487
+ return { id: '1.9', name: 'AI Site Profile', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No ai.json found', recommendation: 'Create /.well-known/ai.json with name, capabilities, and links to skill files' };
488
+ }
489
+ try {
490
+ const json = JSON.parse(text);
491
+ const hasName = 'name' in json;
492
+ const hasCaps = 'capabilities' in json || 'skills' in json;
493
+ if (hasName && hasCaps) {
494
+ return { id: '1.9', name: 'AI Site Profile', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true, message: 'ai.json found with name and capabilities', foundAt: `${baseUrl}/.well-known/ai.json` };
495
+ }
496
+ return { id: '1.9', name: 'AI Site Profile', passed: false, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true, message: 'ai.json exists but missing required fields', recommendation: 'Add name and capabilities/skills fields to ai.json' };
497
+ }
498
+ catch {
499
+ return { id: '1.9', name: 'AI Site Profile', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'ai.json is invalid JSON' };
500
+ }
501
+ }
502
+ return { id: '1.9', name: 'AI Site Profile', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No ai.json found', recommendation: 'Create /.well-known/ai.json with name, capabilities, and links to skill files' };
503
+ }
504
+ catch {
505
+ return { id: '1.9', name: 'AI Site Profile', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not check ai.json' };
506
+ }
507
+ }
508
+ async function checkSkillsIndex(baseUrl) {
509
+ try {
510
+ const res = await timedFetch(`${baseUrl}/.well-known/skills/index.json`, { headers: { 'Accept': 'application/json' } });
511
+ if (res.ok) {
512
+ const text = await res.text();
513
+ const isHtml = text.trimStart().startsWith('<!') || text.trimStart().startsWith('<html');
514
+ if (isHtml) {
515
+ return { id: '1.10', name: 'Skills Index', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No skills index found', recommendation: 'Create /.well-known/skills/index.json listing all available agent skills' };
516
+ }
517
+ try {
518
+ const json = JSON.parse(text);
519
+ const arr = Array.isArray(json) ? json : (json.skills || json.items || []);
520
+ if (Array.isArray(arr) && arr.length > 0 && arr[0].id && arr[0].name) {
521
+ return { id: '1.10', name: 'Skills Index', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true, message: `Skills index found with ${arr.length} skill${arr.length > 1 ? 's' : ''}`, foundAt: `${baseUrl}/.well-known/skills/index.json` };
522
+ }
523
+ return { id: '1.10', name: 'Skills Index', passed: false, status: 'partial', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Skills index exists but entries missing id/name', recommendation: 'Each skill entry should have at least id and name fields' };
524
+ }
525
+ catch {
526
+ return { id: '1.10', name: 'Skills Index', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Skills index is invalid JSON' };
527
+ }
528
+ }
529
+ return { id: '1.10', name: 'Skills Index', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No skills index found', recommendation: 'Create /.well-known/skills/index.json listing all available agent skills' };
530
+ }
531
+ catch {
532
+ return { id: '1.10', name: 'Skills Index', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not check skills index' };
533
+ }
534
+ }
535
+ function checkLinkHeaders(html) {
536
+ if (!html) {
537
+ return { id: '1.11', name: 'Link Headers', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'Could not fetch homepage' };
538
+ }
539
+ const linkRegex = /<link\s[^>]*href=["']([^"']*(?:llms\.txt|ai\.json|agent-card\.json)[^"']*)["'][^>]*>/gi;
540
+ const found = [];
541
+ let match;
542
+ while ((match = linkRegex.exec(html)) !== null) {
543
+ found.push(match[1]);
544
+ }
545
+ if (found.length > 0) {
546
+ return { id: '1.11', name: 'Link Headers', passed: true, status: 'pass', level: 1, category: 'Discoverable', autoDetectable: true, message: `Found ${found.length} AI discovery link${found.length > 1 ? 's' : ''} in <head>`, details: found.join(', ') };
547
+ }
548
+ return { id: '1.11', name: 'Link Headers', passed: false, status: 'fail', level: 1, category: 'Discoverable', autoDetectable: true, message: 'No AI discovery links in <head>', recommendation: 'Add <link> elements pointing to llms.txt, ai.json, or agent-card.json' };
549
+ }
550
+ // --- Level 2 Check Functions ---
551
+ function checkApiReadOps(spec) {
552
+ if (!spec) {
553
+ return { id: '2.1', name: 'API Read Operations', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
554
+ }
555
+ return {
556
+ id: '2.1', name: 'API Read Operations', level: 2, category: 'Usable', autoDetectable: true,
557
+ passed: spec.hasGetEndpoints, status: spec.hasGetEndpoints ? 'pass' : 'fail',
558
+ message: spec.hasGetEndpoints ? 'GET endpoints found in API spec' : 'No GET endpoints found in API spec',
559
+ recommendation: spec.hasGetEndpoints ? undefined : 'Add GET endpoints for reading resources',
560
+ };
561
+ }
562
+ function checkApiWriteOps(spec) {
563
+ if (!spec) {
564
+ return { id: '2.2', name: 'API Write Operations', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
565
+ }
566
+ return {
567
+ id: '2.2', name: 'API Write Operations', level: 2, category: 'Usable', autoDetectable: true,
568
+ passed: spec.hasWriteEndpoints, status: spec.hasWriteEndpoints ? 'pass' : 'fail',
569
+ message: spec.hasWriteEndpoints ? 'Write endpoints (POST/PUT/PATCH/DELETE) found' : 'No write endpoints found in API spec',
570
+ recommendation: spec.hasWriteEndpoints ? undefined : 'Add POST/PUT/PATCH endpoints for creating and updating resources',
571
+ };
572
+ }
573
+ function checkApiPrimaryAction(spec) {
574
+ if (!spec) {
575
+ return { id: '2.3', name: 'API Primary Action', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
576
+ }
577
+ return {
578
+ id: '2.3', name: 'API Primary Action', level: 2, category: 'Usable', autoDetectable: true,
579
+ passed: spec.hasNonGetEndpoints, status: spec.hasNonGetEndpoints ? 'pass' : 'fail',
580
+ message: spec.hasNonGetEndpoints ? 'Non-GET endpoints found — primary actions available' : 'Only GET endpoints found',
581
+ recommendation: spec.hasNonGetEndpoints ? undefined : 'Add endpoints for primary actions (POST for creation, PUT/PATCH for updates)',
582
+ };
583
+ }
584
+ function checkApiKeyAuth(spec) {
585
+ if (!spec) {
586
+ return { id: '2.4', name: 'API Key Authentication', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
587
+ }
588
+ return {
589
+ id: '2.4', name: 'API Key Authentication', level: 2, category: 'Usable', autoDetectable: true,
590
+ passed: spec.hasApiKeyAuth, status: spec.hasApiKeyAuth ? 'pass' : 'fail',
591
+ message: spec.hasApiKeyAuth ? 'API key or HTTP auth scheme found' : 'No API key authentication found in spec',
592
+ recommendation: spec.hasApiKeyAuth ? undefined : 'Add securitySchemes with apiKey or http bearer auth',
593
+ };
594
+ }
595
+ function checkScopedApiKeys(spec) {
596
+ if (!spec) {
597
+ return { id: '2.5', name: 'Scoped API Keys', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
598
+ }
599
+ return {
600
+ id: '2.5', name: 'Scoped API Keys', level: 2, category: 'Usable', autoDetectable: true,
601
+ passed: spec.hasScopedAuth, status: spec.hasScopedAuth ? 'pass' : 'na',
602
+ message: spec.hasScopedAuth ? 'Scoped auth (OAuth2/OpenID) with defined scopes found' : 'No scoped auth detected — may not be needed',
603
+ };
604
+ }
605
+ async function checkOpenIdConfig(baseUrl) {
606
+ const url = `${baseUrl}/.well-known/openid-configuration`;
607
+ try {
608
+ const res = await timedFetch(url, {
609
+ headers: { 'Accept': 'application/json' }
610
+ });
611
+ if (res.ok) {
612
+ const json = await res.json();
613
+ const hasIssuer = 'issuer' in json;
614
+ const hasTokenEndpoint = 'token_endpoint' in json;
615
+ if (hasIssuer && hasTokenEndpoint) {
616
+ return {
617
+ id: '2.6', name: 'OpenID Configuration', passed: true, status: 'pass', level: 2, category: 'Usable', autoDetectable: true,
618
+ message: 'OpenID Connect discovery available',
619
+ details: `Issuer: ${json.issuer}`,
620
+ foundAt: url
621
+ };
622
+ }
623
+ }
624
+ return {
625
+ id: '2.6', name: 'OpenID Configuration', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true,
626
+ message: 'No OpenID configuration found — not required if using API keys',
627
+ };
628
+ }
629
+ catch {
630
+ return {
631
+ id: '2.6', name: 'OpenID Configuration', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true,
632
+ message: 'No OpenID configuration found — not required if using API keys',
633
+ };
634
+ }
635
+ }
636
+ async function checkStructuredErrors(baseUrl) {
637
+ try {
638
+ const probeUrl = `${baseUrl}/api/this-path-should-not-exist-botvisibility-probe`;
639
+ const res = await timedFetch(probeUrl, {
640
+ headers: { 'Accept': 'application/json' },
641
+ });
642
+ const contentType = res.headers.get('content-type') || '';
643
+ if (contentType.includes('application/json') || contentType.includes('application/problem+json')) {
644
+ try {
645
+ const text = await res.text();
646
+ const json = JSON.parse(text);
647
+ const hasErrorStructure = json.error || json.message || json.detail || json.title || json.status || json.code;
648
+ if (hasErrorStructure) {
649
+ return {
650
+ id: '2.7', name: 'Structured Error Responses', passed: true, status: 'pass', level: 2, category: 'Usable', autoDetectable: true,
651
+ message: 'API returns structured JSON errors',
652
+ details: `Error response has structured fields (${Object.keys(json).slice(0, 4).join(', ')})`,
653
+ };
654
+ }
655
+ return {
656
+ id: '2.7', name: 'Structured Error Responses', passed: true, status: 'partial', level: 2, category: 'Usable', autoDetectable: true,
657
+ message: 'API returns JSON on error but structure is unclear',
658
+ recommendation: 'Use a standard error format with error, message, and status fields',
659
+ };
660
+ }
661
+ catch {
662
+ // JSON parse failed
663
+ }
664
+ }
665
+ if (contentType.includes('text/html')) {
666
+ return {
667
+ id: '2.7', name: 'Structured Error Responses', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true,
668
+ message: 'No JSON API detected — not applicable',
669
+ };
670
+ }
671
+ return {
672
+ id: '2.7', name: 'Structured Error Responses', passed: false, status: 'fail', level: 2, category: 'Usable', autoDetectable: true,
673
+ message: 'API does not return structured JSON errors',
674
+ recommendation: 'Return JSON error responses with error, message, and status fields.',
675
+ };
676
+ }
677
+ catch {
678
+ return {
679
+ id: '2.7', name: 'Structured Error Responses', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true,
680
+ message: 'Could not probe API for error format',
681
+ };
682
+ }
683
+ }
684
+ function checkAsyncOps(spec) {
685
+ if (!spec) {
686
+ return { id: '2.8', name: 'Async Operations', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
687
+ }
688
+ return {
689
+ id: '2.8', name: 'Async Operations', level: 2, category: 'Usable', autoDetectable: true,
690
+ passed: spec.hasAsyncPatterns, status: spec.hasAsyncPatterns ? 'pass' : 'na',
691
+ message: spec.hasAsyncPatterns ? 'Async operation patterns detected (callbacks/webhooks/202)' : 'No async patterns detected — may not be needed',
692
+ };
693
+ }
694
+ function checkIdempotency(spec) {
695
+ if (!spec) {
696
+ return { id: '2.9', name: 'Idempotency Support', passed: false, status: 'na', level: 2, category: 'Usable', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
697
+ }
698
+ return {
699
+ id: '2.9', name: 'Idempotency Support', level: 2, category: 'Usable', autoDetectable: true,
700
+ passed: spec.hasIdempotencyKey, status: spec.hasIdempotencyKey ? 'pass' : 'na',
701
+ message: spec.hasIdempotencyKey ? 'Idempotency key support detected' : 'No idempotency key detected — may not be needed',
702
+ };
703
+ }
704
+ // --- Level 3 Check Functions ---
705
+ function checkSparseFields(spec) {
706
+ if (!spec) {
707
+ return { id: '3.1', name: 'Sparse Fields', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
708
+ }
709
+ return {
710
+ id: '3.1', name: 'Sparse Fields', level: 3, category: 'Optimized', autoDetectable: true,
711
+ passed: spec.hasSparseFields, status: spec.hasSparseFields ? 'pass' : 'na',
712
+ message: spec.hasSparseFields ? 'Sparse fieldset support detected (fields parameter)' : 'No sparse fieldset support detected — may not be needed',
713
+ recommendation: spec.hasSparseFields ? undefined : 'Add a fields query parameter to allow agents to request only needed data',
714
+ };
715
+ }
716
+ function checkCursorPagination(spec) {
717
+ if (!spec) {
718
+ return { id: '3.2', name: 'Cursor Pagination', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
719
+ }
720
+ return {
721
+ id: '3.2', name: 'Cursor Pagination', level: 3, category: 'Optimized', autoDetectable: true,
722
+ passed: spec.hasCursorPagination, status: spec.hasCursorPagination ? 'pass' : 'na',
723
+ message: spec.hasCursorPagination ? 'Cursor-based pagination detected' : 'No cursor pagination detected — may not be needed',
724
+ recommendation: spec.hasCursorPagination ? undefined : 'Add cursor-based pagination for efficient traversal of large collections',
725
+ };
726
+ }
727
+ function checkSearchFiltering(spec) {
728
+ if (!spec) {
729
+ return { id: '3.3', name: 'Search & Filtering', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
730
+ }
731
+ return {
732
+ id: '3.3', name: 'Search & Filtering', level: 3, category: 'Optimized', autoDetectable: true,
733
+ passed: spec.hasSearchFiltering, status: spec.hasSearchFiltering ? 'pass' : 'na',
734
+ message: spec.hasSearchFiltering ? 'Search/filter parameters detected' : 'No search or filter parameters detected — may not be needed',
735
+ recommendation: spec.hasSearchFiltering ? undefined : 'Add filter and search query parameters to help agents find resources efficiently',
736
+ };
737
+ }
738
+ function checkBulkOps(spec) {
739
+ if (!spec) {
740
+ return { id: '3.4', name: 'Bulk Operations', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true, message: 'No OpenAPI spec found — cannot evaluate' };
741
+ }
742
+ return {
743
+ id: '3.4', name: 'Bulk Operations', level: 3, category: 'Optimized', autoDetectable: true,
744
+ passed: spec.hasBulkOperations, status: spec.hasBulkOperations ? 'pass' : 'na',
745
+ message: spec.hasBulkOperations ? 'Bulk/batch operation endpoints detected' : 'No bulk operations detected — may not be needed',
746
+ recommendation: spec.hasBulkOperations ? undefined : 'Add bulk/batch endpoints for operations on multiple resources',
747
+ };
748
+ }
749
+ async function checkRateLimitHeaders(baseUrl) {
750
+ try {
751
+ const res = await timedFetch(baseUrl, { method: 'GET' });
752
+ const rateLimitHeaders = [
753
+ 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset',
754
+ 'ratelimit-limit', 'ratelimit-remaining', 'retry-after'
755
+ ];
756
+ const foundHeaders = [];
757
+ for (const header of rateLimitHeaders) {
758
+ if (res.headers.get(header)) {
759
+ foundHeaders.push(header);
760
+ }
761
+ }
762
+ if (foundHeaders.length >= 2) {
763
+ return {
764
+ id: '3.5', name: 'Rate Limit Headers', passed: true, status: 'pass', level: 3, category: 'Optimized', autoDetectable: true,
765
+ message: 'Rate limit headers present',
766
+ details: `Found: ${foundHeaders.join(', ')}`
767
+ };
768
+ }
769
+ else if (foundHeaders.length === 1) {
770
+ return {
771
+ id: '3.5', name: 'Rate Limit Headers', passed: false, status: 'partial', level: 3, category: 'Optimized', autoDetectable: true,
772
+ message: 'Partial rate limit headers',
773
+ details: `Found: ${foundHeaders.join(', ')}`,
774
+ recommendation: 'Add X-RateLimit-Remaining and X-RateLimit-Reset headers'
775
+ };
776
+ }
777
+ return {
778
+ id: '3.5', name: 'Rate Limit Headers', passed: false, status: 'fail', level: 3, category: 'Optimized', autoDetectable: true,
779
+ message: 'No rate limit headers',
780
+ recommendation: 'Add X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers.'
781
+ };
782
+ }
783
+ catch {
784
+ return {
785
+ id: '3.5', name: 'Rate Limit Headers', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true,
786
+ message: 'Could not check rate limit headers'
787
+ };
788
+ }
789
+ }
790
+ async function checkCachingHeaders(baseUrl) {
791
+ try {
792
+ const res = await timedFetch(baseUrl, { method: 'GET' });
793
+ const etag = res.headers.get('etag');
794
+ const lastModified = res.headers.get('last-modified');
795
+ const cacheControl = res.headers.get('cache-control');
796
+ const cachingSignals = [etag, lastModified, cacheControl].filter(Boolean);
797
+ if (cachingSignals.length >= 2) {
798
+ return {
799
+ id: '3.6', name: 'Caching Headers', passed: true, status: 'pass', level: 3, category: 'Optimized', autoDetectable: true,
800
+ message: 'Caching headers present',
801
+ details: `ETag: ${etag ? 'yes' : 'no'}, Cache-Control: ${cacheControl || 'no'}, Last-Modified: ${lastModified ? 'yes' : 'no'}`
802
+ };
803
+ }
804
+ else if (cachingSignals.length === 1) {
805
+ return {
806
+ id: '3.6', name: 'Caching Headers', passed: true, status: 'partial', level: 3, category: 'Optimized', autoDetectable: true,
807
+ message: 'Basic caching support',
808
+ recommendation: 'Add ETag headers alongside Cache-Control for conditional requests support.'
809
+ };
810
+ }
811
+ return {
812
+ id: '3.6', name: 'Caching Headers', passed: false, status: 'fail', level: 3, category: 'Optimized', autoDetectable: true,
813
+ message: 'No caching headers',
814
+ recommendation: 'Add ETag or Last-Modified headers to enable 304 responses and reduce token cost.'
815
+ };
816
+ }
817
+ catch {
818
+ return {
819
+ id: '3.6', name: 'Caching Headers', passed: false, status: 'na', level: 3, category: 'Optimized', autoDetectable: true,
820
+ message: 'Could not check caching headers'
821
+ };
822
+ }
823
+ }
824
+ // --- Run All Checks ---
825
+ async function runAllChecks(baseUrl) {
826
+ // First, fetch OpenAPI spec and homepage HTML (used by many checks)
827
+ const [spec, homepageHtml] = await Promise.all([
828
+ fetchOpenApiSpec(baseUrl),
829
+ fetchHomepageHtml(baseUrl),
830
+ ]);
831
+ // Level 1 checks (parallel) — 11 total
832
+ const level1 = await Promise.all([
833
+ checkLlmsTxt(baseUrl),
834
+ checkAgentCard(baseUrl),
835
+ Promise.resolve(checkOpenApiSpecFromParsed(spec)),
836
+ checkRobotsTxt(baseUrl),
837
+ Promise.resolve(checkStructuredData(homepageHtml)),
838
+ checkCorsHeaders(baseUrl),
839
+ Promise.resolve(checkAiMetaTags(homepageHtml)),
840
+ checkSkillFile(baseUrl),
841
+ checkAiSiteProfile(baseUrl),
842
+ checkSkillsIndex(baseUrl),
843
+ Promise.resolve(checkLinkHeaders(homepageHtml)),
844
+ ]);
845
+ // Level 2 checks (mix of sync spec-dependent + async network)
846
+ const level2 = await Promise.all([
847
+ Promise.resolve(checkApiReadOps(spec)),
848
+ Promise.resolve(checkApiWriteOps(spec)),
849
+ Promise.resolve(checkApiPrimaryAction(spec)),
850
+ Promise.resolve(checkApiKeyAuth(spec)),
851
+ Promise.resolve(checkScopedApiKeys(spec)),
852
+ checkOpenIdConfig(baseUrl),
853
+ checkStructuredErrors(baseUrl),
854
+ Promise.resolve(checkAsyncOps(spec)),
855
+ Promise.resolve(checkIdempotency(spec)),
856
+ ]);
857
+ // Level 3 checks (mix of sync spec-dependent + async network)
858
+ const level3 = await Promise.all([
859
+ Promise.resolve(checkSparseFields(spec)),
860
+ Promise.resolve(checkCursorPagination(spec)),
861
+ Promise.resolve(checkSearchFiltering(spec)),
862
+ Promise.resolve(checkBulkOps(spec)),
863
+ checkRateLimitHeaders(baseUrl),
864
+ checkCachingHeaders(baseUrl),
865
+ ]);
866
+ return [...level1, ...level2, ...level3];
867
+ }
868
+ //# sourceMappingURL=scanner.js.map