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