@jrpool/kilotest 48.0.0 → 50.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/scheduled_tasks.lock +1 -0
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +23 -3
- package/api/requestFeature.ts +19 -11
- package/api/requestRetest.test.ts +39 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/docs/BACKLOG.md +232 -12
- package/docs/SERVICE.md +84 -3
- package/index.test.ts +443 -20
- package/index.ts +246 -27
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +31 -1
- package/mcp.ts +9 -0
- package/package.json +1 -1
- package/smokeTest.ts +16 -0
- package/test/dbFixture.ts +8 -0
- package/util.test.ts +295 -0
- package/util.ts +121 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +12 -8
- package/web/enqueue/index.ts +33 -39
- package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
- package/web/expungeReportsForm/index.html +1 -1
- package/web/expungeReportsForm/index.ts +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +24 -9
- package/web/hideReportForm/index.html +1 -1
- package/web/hideReportForm/index.ts +13 -8
- package/web/listTopIssues/index.ts +1 -1
- package/web/manage/index.html +2 -1
- package/web/metrics/index.html +19 -0
- package/web/metrics/index.ts +127 -0
- package/web/metrics/metrics.test.ts +211 -0
- package/web/pruneReportsForm/index.html +1 -1
- package/web/pruneReportsForm/index.ts +1 -1
- package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
- package/web/reannotate/index.ts +7 -3
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +6 -3
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +13 -7
- package/web/rewindReportsForm/index.html +1 -1
- package/web/rewindReportsForm/index.ts +1 -1
- package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
- package/web/showHiddenReportsForm/index.html +24 -0
- package/web/showHiddenReportsForm/index.ts +45 -0
- package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
- package/web/tutorialAI/index.ts +1 -1
- package/web/tutorialWeb/index.ts +1 -1
- package/web/unhideReportForm/index.html +1 -1
- package/web/unhideReportForm/index.ts +13 -8
- package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
package/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createLock,
|
|
12
12
|
deleteTestRequests,
|
|
13
13
|
errorMessage,
|
|
14
|
+
getExclusionCookieValue,
|
|
14
15
|
getJobNames,
|
|
15
16
|
getJSON,
|
|
16
17
|
getObject,
|
|
@@ -22,8 +23,12 @@ import {
|
|
|
22
23
|
isReportAvailable,
|
|
23
24
|
isTimeStamp,
|
|
24
25
|
isJobID,
|
|
26
|
+
isURL,
|
|
25
27
|
isUsableReport,
|
|
28
|
+
isValidAuthCode,
|
|
26
29
|
jobsPath,
|
|
30
|
+
metricsExclusionCookieName,
|
|
31
|
+
recordMetric,
|
|
27
32
|
reportsPath
|
|
28
33
|
} from './util.ts';
|
|
29
34
|
import {sendAlert} from './alerts.ts';
|
|
@@ -49,6 +54,7 @@ import {answer as listRules} from './web/listRules/index.ts';
|
|
|
49
54
|
import {answer as listTopIssues} from './web/listTopIssues/index.ts';
|
|
50
55
|
import {answer as listViolatorsPage} from './web/listViolators/index.ts';
|
|
51
56
|
import {answer as manage} from './web/manage/index.ts';
|
|
57
|
+
import {answer as metrics} from './web/metrics/index.ts';
|
|
52
58
|
import {answer as pruneReportsForm} from './web/pruneReportsForm/index.ts';
|
|
53
59
|
import {answer as reannotate} from './web/reannotate/index.ts';
|
|
54
60
|
import {answer as reannotateForm} from './web/reannotateForm/index.ts';
|
|
@@ -59,6 +65,7 @@ import {answer as requestRetestForm} from './web/requestRetestForm/index.ts';
|
|
|
59
65
|
import {answer as requestTestPage} from './web/requestTest/index.ts';
|
|
60
66
|
import {answer as requestTestForm} from './web/requestTestForm/index.ts';
|
|
61
67
|
import {answer as rewindReportsForm} from './web/rewindReportsForm/index.ts';
|
|
68
|
+
import {answer as showHiddenReportsForm} from './web/showHiddenReportsForm/index.ts';
|
|
62
69
|
import {answer as unhideReportForm} from './web/unhideReportForm/index.ts';
|
|
63
70
|
import {response as getReportAPI} from './api/getReport.ts';
|
|
64
71
|
import {response as listDiagnosesAPI} from './api/listDiagnoses.ts';
|
|
@@ -76,7 +83,15 @@ dotenv.config({quiet: true});
|
|
|
76
83
|
// CONSTANTS
|
|
77
84
|
|
|
78
85
|
// The data returned by a page-answering handler.
|
|
79
|
-
type AnswerData = {
|
|
86
|
+
type AnswerData = {
|
|
87
|
+
status: string;
|
|
88
|
+
message?: string;
|
|
89
|
+
answerPage?: string;
|
|
90
|
+
// A cookie an answer() handler wants set on the response. Generic, not metrics-specific:
|
|
91
|
+
// handlers return data and never touch the response object directly, so this is how one
|
|
92
|
+
// asks index.ts, which owns the response, to set a cookie on its behalf.
|
|
93
|
+
setCookie?: {name: string; value: string; maxAgeSeconds: number};
|
|
94
|
+
};
|
|
80
95
|
// A page-answering handler, whose parameters vary by topic.
|
|
81
96
|
type PageHandler = (...args: any[]) => Promise<AnswerData>;
|
|
82
97
|
|
|
@@ -93,6 +108,7 @@ const answer: {
|
|
|
93
108
|
listTopIssues: PageHandler;
|
|
94
109
|
listViolators: PageHandler;
|
|
95
110
|
manage: PageHandler;
|
|
111
|
+
metrics: PageHandler;
|
|
96
112
|
pruneReportsForm: PageHandler;
|
|
97
113
|
tutorialWeb: PageHandler;
|
|
98
114
|
tutorialAI: PageHandler;
|
|
@@ -105,6 +121,7 @@ const answer: {
|
|
|
105
121
|
requestTest: PageHandler;
|
|
106
122
|
requestTestForm: PageHandler;
|
|
107
123
|
rewindReportsForm: PageHandler;
|
|
124
|
+
showHiddenReportsForm: PageHandler;
|
|
108
125
|
unhideReportForm: PageHandler;
|
|
109
126
|
[key: string]: PageHandler | undefined;
|
|
110
127
|
} = {
|
|
@@ -120,6 +137,7 @@ const answer: {
|
|
|
120
137
|
listTopIssues,
|
|
121
138
|
listViolators: listViolatorsPage,
|
|
122
139
|
manage,
|
|
140
|
+
metrics,
|
|
123
141
|
pruneReportsForm,
|
|
124
142
|
tutorialWeb,
|
|
125
143
|
tutorialAI,
|
|
@@ -132,6 +150,7 @@ const answer: {
|
|
|
132
150
|
requestTest: requestTestPage,
|
|
133
151
|
requestTestForm,
|
|
134
152
|
rewindReportsForm,
|
|
153
|
+
showHiddenReportsForm,
|
|
135
154
|
unhideReportForm
|
|
136
155
|
};
|
|
137
156
|
// Response functions of the API services.
|
|
@@ -187,6 +206,13 @@ export const routes = {
|
|
|
187
206
|
POST: [
|
|
188
207
|
'/api/*',
|
|
189
208
|
'/mcp',
|
|
209
|
+
'/ai0BalanceForm.html',
|
|
210
|
+
'/expungeReportsForm.html',
|
|
211
|
+
'/hideReportForm.html',
|
|
212
|
+
'/metrics.html',
|
|
213
|
+
'/pruneReportsForm.html',
|
|
214
|
+
'/rewindReportsForm.html',
|
|
215
|
+
'/showHiddenReportsForm.html',
|
|
190
216
|
'/tutorialAIComment.html',
|
|
191
217
|
'/reannotate.html',
|
|
192
218
|
'/requestAction.html',
|
|
@@ -194,10 +220,59 @@ export const routes = {
|
|
|
194
220
|
'/requestRetest.html/*',
|
|
195
221
|
'/requestTest.html',
|
|
196
222
|
'/tutorialWebComment.html',
|
|
223
|
+
'/unhideReportForm.html',
|
|
197
224
|
'/worker/job',
|
|
198
225
|
'/worker/report'
|
|
199
226
|
]
|
|
200
227
|
};
|
|
228
|
+
// The set of page topics (answer{} keys) that are manager-only pages, linked from
|
|
229
|
+
// manage.html. Their views and submissions are recorded under the managerActivity
|
|
230
|
+
// metrics category instead of pageViews, since they are the maintainer operating
|
|
231
|
+
// Kilotest rather than the usage the observability plan's questions are about.
|
|
232
|
+
const managerPages = new Set([
|
|
233
|
+
'enqueueForm',
|
|
234
|
+
'reannotateForm',
|
|
235
|
+
'pruneReportsForm',
|
|
236
|
+
'rewindReportsForm',
|
|
237
|
+
'expungeReportsForm',
|
|
238
|
+
'hideReportForm',
|
|
239
|
+
'showHiddenReportsForm',
|
|
240
|
+
'unhideReportForm',
|
|
241
|
+
'ai0BalanceForm',
|
|
242
|
+
'renewWCAGForm',
|
|
243
|
+
'metrics'
|
|
244
|
+
]);
|
|
245
|
+
// The set of manager pages (a subset of managerPages) whose single answer() function both
|
|
246
|
+
// displays a form on GET and processes that form's own submission on POST, as opposed to
|
|
247
|
+
// enqueueForm/reannotateForm, whose submissions post to a separate action page. This
|
|
248
|
+
// governs POST dispatch for all of these pages, and GET dispatch for all except
|
|
249
|
+
// unhideReportForm (see noDirectGetPages below).
|
|
250
|
+
const selfSubmittingManagerPages = new Set([
|
|
251
|
+
'pruneReportsForm',
|
|
252
|
+
'rewindReportsForm',
|
|
253
|
+
'expungeReportsForm',
|
|
254
|
+
'hideReportForm',
|
|
255
|
+
'showHiddenReportsForm',
|
|
256
|
+
'unhideReportForm',
|
|
257
|
+
'ai0BalanceForm',
|
|
258
|
+
'metrics'
|
|
259
|
+
]);
|
|
260
|
+
// The subset of selfSubmittingManagerPages that must never be served on a direct GET
|
|
261
|
+
// request, only as the response to another page's successful submission (here,
|
|
262
|
+
// unhideReportForm is served only by showHiddenReportsForm, once a valid authorization code
|
|
263
|
+
// has been submitted), so that the names of hidden reports are never disclosed without one.
|
|
264
|
+
const noDirectGetPages = new Set(['unhideReportForm']);
|
|
265
|
+
// Old page names, from before a 2026-08-31 rename (see GitHub issue #3), that search
|
|
266
|
+
// engines and other crawlers are still requesting. Redirecting them lets crawlers update
|
|
267
|
+
// their own indexes, rather than leaving them to repeat the same dead request indefinitely.
|
|
268
|
+
// TEMPORARY: retire this redirect after 2027-04-01 once crawlers have re-indexed.
|
|
269
|
+
const renamedPagePrefixes: Record<string, string> = {
|
|
270
|
+
diagnoses: 'listDiagnoses',
|
|
271
|
+
reportIssue: 'listViolators',
|
|
272
|
+
reportIssues: 'listIssues',
|
|
273
|
+
rules: 'listRules',
|
|
274
|
+
targets: 'listReports'
|
|
275
|
+
};
|
|
201
276
|
const jobLock = createLock();
|
|
202
277
|
|
|
203
278
|
// FUNCTIONS
|
|
@@ -247,16 +322,22 @@ export const serveError = async (error: Record<string, unknown>, response: Serve
|
|
|
247
322
|
console.log('Cannot send error response because the response has ended.');
|
|
248
323
|
}
|
|
249
324
|
};
|
|
325
|
+
// Returns the IP address a request should be attributed to: the value of its
|
|
326
|
+
// x-forwarded-for header (Caddy runs in front of Kilotest, so a real client's address
|
|
327
|
+
// arrives this way, not as the socket's own remote address), else the socket's remote
|
|
328
|
+
// address, else 'unknown' if neither is available.
|
|
329
|
+
export const getRequestIP = (request: IncomingMessage): string | string[] => {
|
|
330
|
+
const forwardedFor = request.headers['x-forwarded-for'];
|
|
331
|
+
const remoteAddress = request.socket.remoteAddress;
|
|
332
|
+
return forwardedFor || remoteAddress || 'unknown';
|
|
333
|
+
};
|
|
250
334
|
// Creates an error object about a suspicious request.
|
|
251
335
|
export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
|
|
252
336
|
const {method, url, headers} = request;
|
|
253
|
-
const forwardedFor = headers['x-forwarded-for'];
|
|
254
|
-
const remoteAddress = request.socket.remoteAddress;
|
|
255
|
-
const ip = forwardedFor || remoteAddress || 'unknown';
|
|
256
337
|
return {
|
|
257
338
|
message: 'Invalid request',
|
|
258
339
|
reason,
|
|
259
|
-
'IP address':
|
|
340
|
+
'IP address': getRequestIP(request),
|
|
260
341
|
method,
|
|
261
342
|
URL: url,
|
|
262
343
|
'user agent': headers['user-agent'] || 'none',
|
|
@@ -264,6 +345,51 @@ export const getAbuseError = (request: IncomingMessage, reason: string | undefin
|
|
|
264
345
|
time: new Date().toISOString()
|
|
265
346
|
};
|
|
266
347
|
};
|
|
348
|
+
// Logs one line for every request, successful or not, once it finishes. Unlike
|
|
349
|
+
// getAbuseError (logged only for rejected requests, as a rare, human-read-in-the-moment
|
|
350
|
+
// dump), this is a terse, single-line-per-request JSON record, intended to be high-volume
|
|
351
|
+
// and machine-parseable, so that any kind of suspicious traffic pattern (not only the
|
|
352
|
+
// specific rejection reasons getAbuseError already covers) can be recognized later. It
|
|
353
|
+
// deliberately does not consult isMetricsExcluded: that exclusion exists to keep the
|
|
354
|
+
// maintainer's own manual testing out of usage counts, but abuse visibility should see
|
|
355
|
+
// all traffic, including the maintainer's own, or it recreates the same kind of blind
|
|
356
|
+
// spot this logging exists to close.
|
|
357
|
+
const logRequestOnFinish = (request: IncomingMessage, response: ServerResponse): void => {
|
|
358
|
+
const {method, headers} = request;
|
|
359
|
+
const ip = getRequestIP(request);
|
|
360
|
+
const userAgent = headers['user-agent'] || 'none';
|
|
361
|
+
const path = new URL(request.url as string, 'https://localhost:3000').pathname;
|
|
362
|
+
const originalEnd = response.end.bind(response);
|
|
363
|
+
// Reassign response.end so that every path through handleRequest logs exactly once,
|
|
364
|
+
// right before the response is actually sent, rather than requiring every dispatch
|
|
365
|
+
// branch to remember to log itself (the same class of gap that left successful
|
|
366
|
+
// requests unlogged in the first place). No query string is logged, so that a
|
|
367
|
+
// parameter such as authCode is never written to a log file.
|
|
368
|
+
response.end = ((...args: Parameters<typeof originalEnd>) => {
|
|
369
|
+
console.log(JSON.stringify({
|
|
370
|
+
type: 'request',
|
|
371
|
+
time: new Date().toISOString(),
|
|
372
|
+
ip,
|
|
373
|
+
method,
|
|
374
|
+
path,
|
|
375
|
+
status: response.statusCode,
|
|
376
|
+
userAgent
|
|
377
|
+
}));
|
|
378
|
+
return originalEnd(...args);
|
|
379
|
+
}) as typeof response.end;
|
|
380
|
+
};
|
|
381
|
+
// Returns whether a request carries the metrics-exclusion cookie with the value that
|
|
382
|
+
// proves it. A request without a valid AUTH_CODE configured can never match, since
|
|
383
|
+
// getExclusionCookieValue then hashes an empty string, which no cookie should equal.
|
|
384
|
+
const isMetricsExcluded = (request: IncomingMessage): boolean => {
|
|
385
|
+
const header = request.headers.cookie;
|
|
386
|
+
if (!header || !process.env.AUTH_CODE) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const cookies = header.split(';').map(pair => pair.trim().split('='));
|
|
390
|
+
const cookieValue = cookies.find(([name]) => name === metricsExclusionCookieName)?.[1];
|
|
391
|
+
return cookieValue === getExclusionCookieValue();
|
|
392
|
+
};
|
|
267
393
|
// Gets the ID and secret from a request's HTTP Basic Authorization header, or null if the header
|
|
268
394
|
// is absent or malformed.
|
|
269
395
|
const getBasicAuth = (request: IncomingMessage) => {
|
|
@@ -311,7 +437,21 @@ const processJobRequest = async (request: IncomingMessage, response: ServerRespo
|
|
|
311
437
|
const claimedJobNames = jobNames.claimed;
|
|
312
438
|
// For each claimed job:
|
|
313
439
|
for (const jobName of claimedJobNames) {
|
|
314
|
-
|
|
440
|
+
// A claimed-job file can disappear between the directory listing above and this
|
|
441
|
+
// read, because report submission (which deletes or reclassifies claimed jobs) is
|
|
442
|
+
// not serialized by jobLock. That race is a normal, recoverable condition, so the
|
|
443
|
+
// job is skipped rather than treated as defective; any other failure to read it
|
|
444
|
+
// should never occur and is left to propagate.
|
|
445
|
+
let job: unknown;
|
|
446
|
+
try {
|
|
447
|
+
job = await getObject(path.join(jobsPath(), 'claimed', jobName));
|
|
448
|
+
}
|
|
449
|
+
catch (error: unknown) {
|
|
450
|
+
if (error instanceof Error && (error.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
315
455
|
const {id, sources} = job as {id: string, sources: {worker: string}};
|
|
316
456
|
const {worker} = sources;
|
|
317
457
|
// If its assignee is the worker:
|
|
@@ -399,6 +539,24 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
399
539
|
response.end('{}');
|
|
400
540
|
return;
|
|
401
541
|
}
|
|
542
|
+
// Log this request once it finishes, for general abuse/usage visibility (see GitHub
|
|
543
|
+
// issue #3). Placed after the smoke-test short-circuit above, so CI's own smoke-test
|
|
544
|
+
// traffic is excluded from this log exactly as it is already excluded from metrics.
|
|
545
|
+
logRequestOnFinish(request, response);
|
|
546
|
+
// Records a pageViews or apiOperations metric, unless this request's browser has proven,
|
|
547
|
+
// via a valid authCode submitted earlier on metrics.html, that it belongs to the
|
|
548
|
+
// maintainer testing Kilotest manually rather than to real usage.
|
|
549
|
+
const recordPageMetric = (category: 'pageViews' | 'apiOperations', name: string): Promise<void> =>
|
|
550
|
+
isMetricsExcluded(request) ? Promise.resolve() : recordMetric(category, name);
|
|
551
|
+
// Applies a cookie an answer() handler asked to have set on the response.
|
|
552
|
+
const applySetCookie = (answerData: AnswerData): void => {
|
|
553
|
+
if (answerData.setCookie) {
|
|
554
|
+
const {name, value, maxAgeSeconds} = answerData.setCookie;
|
|
555
|
+
response.setHeader(
|
|
556
|
+
'Set-Cookie', `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
402
560
|
// If the request is a GET request:
|
|
403
561
|
if (method === 'GET') {
|
|
404
562
|
// If the path is not authorized for GET requests:
|
|
@@ -423,6 +581,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
423
581
|
}
|
|
424
582
|
// Otherwise, if it is for the home page:
|
|
425
583
|
else if (['/', '/index.html'].includes(pathname)) {
|
|
584
|
+
await recordPageMetric('pageViews', 'index');
|
|
426
585
|
// Get the home page.
|
|
427
586
|
const homePage = await fs.readFile('index.html', 'utf8');
|
|
428
587
|
// Serve it.
|
|
@@ -533,20 +692,46 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
533
692
|
// Otherwise, if it is for an HTML page other than the home page:
|
|
534
693
|
else if (pageName.endsWith('.html')) {
|
|
535
694
|
const topic = pageName.slice(0, -5);
|
|
536
|
-
// If
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
695
|
+
// If it is for a page's old, pre-rename name:
|
|
696
|
+
if (renamedPagePrefixes[topic]) {
|
|
697
|
+
// Redirect the client permanently to the current page name, preserving any
|
|
698
|
+
// further path segments and the query string.
|
|
699
|
+
const newLocation = `/${renamedPagePrefixes[topic]}.html${pathTail ? `/${pathTail}` : ''}${search}`;
|
|
700
|
+
response.writeHead(301, {Location: newLocation});
|
|
701
|
+
response.end();
|
|
702
|
+
}
|
|
703
|
+
// Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
|
|
704
|
+
// the '*.html*' GET pattern, but its handler expects POST's argument list and
|
|
705
|
+
// performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
|
|
706
|
+
// too, but its handler serves both methods the same way, so it is not excluded here,
|
|
707
|
+
// except for a page in noDirectGetPages, which stays excluded from GET even though
|
|
708
|
+
// it is self-submitting, since it must be reachable only via another page's POST):
|
|
709
|
+
else if (
|
|
710
|
+
answer[topic] &&
|
|
711
|
+
!noDirectGetPages.has(topic) &&
|
|
712
|
+
(!isPathAllowed('POST', pathname) || selfSubmittingManagerPages.has(topic))
|
|
713
|
+
) {
|
|
540
714
|
setHeaders('text/html', pathname, 'ultra');
|
|
541
|
-
// Get the answer data.
|
|
542
|
-
|
|
715
|
+
// Get the answer data. The method is passed so that a self-submitting manager
|
|
716
|
+
// page's handler can tell a form-display GET from its own submission POST.
|
|
717
|
+
const answerData = await answer[topic](pathTail, search, method);
|
|
543
718
|
// If they are valid:
|
|
544
719
|
if (answerData.status === 'ok') {
|
|
720
|
+
if (managerPages.has(topic)) {
|
|
721
|
+
await recordMetric('managerActivity', topic, 'ok');
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
await recordPageMetric('pageViews', topic);
|
|
725
|
+
}
|
|
726
|
+
applySetCookie(answerData);
|
|
545
727
|
// Serve the answer page.
|
|
546
728
|
response.end(answerData.answerPage);
|
|
547
729
|
}
|
|
548
730
|
// Otherwise, i.e. if they are invalid:
|
|
549
731
|
else {
|
|
732
|
+
if (managerPages.has(topic)) {
|
|
733
|
+
await recordMetric('managerActivity', topic, 'error');
|
|
734
|
+
}
|
|
550
735
|
// Report the error as suspected abuse.
|
|
551
736
|
await serveError(getAbuseError(request, answerData.message), response, true);
|
|
552
737
|
}
|
|
@@ -564,6 +749,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
564
749
|
if (service === 'listReports') {
|
|
565
750
|
// Get the response body.
|
|
566
751
|
const responseBody = await apiRespond.listReports([]);
|
|
752
|
+
await recordPageMetric('apiOperations', 'listReports');
|
|
567
753
|
// Send it.
|
|
568
754
|
setHeaders('application/json', null, 'ultra');
|
|
569
755
|
response.end(JSON.stringify(responseBody));
|
|
@@ -572,6 +758,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
572
758
|
else if (service === 'listIssues') {
|
|
573
759
|
// Get the response body.
|
|
574
760
|
const responseBody = await apiRespond.listIssues(specs);
|
|
761
|
+
await recordPageMetric('apiOperations', 'listIssues');
|
|
575
762
|
// Send it.
|
|
576
763
|
setHeaders('application/json', null, 'high');
|
|
577
764
|
response.end(JSON.stringify(responseBody));
|
|
@@ -580,6 +767,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
580
767
|
else if (service === 'listViolators') {
|
|
581
768
|
// Get the response body.
|
|
582
769
|
const responseBody = await apiRespond.listViolators(specs);
|
|
770
|
+
await recordPageMetric('apiOperations', 'listViolators');
|
|
583
771
|
// Send it.
|
|
584
772
|
setHeaders('application/json', null, 'high');
|
|
585
773
|
response.end(JSON.stringify(responseBody));
|
|
@@ -588,6 +776,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
588
776
|
else if (service === 'listDiagnoses') {
|
|
589
777
|
// Get the response body.
|
|
590
778
|
const responseBody = await apiRespond.listDiagnoses(specs);
|
|
779
|
+
await recordPageMetric('apiOperations', 'listDiagnoses');
|
|
591
780
|
// Send it.
|
|
592
781
|
setHeaders('application/json', null, 'high');
|
|
593
782
|
response.end(JSON.stringify(responseBody));
|
|
@@ -596,6 +785,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
596
785
|
else if (service === 'getReport') {
|
|
597
786
|
// Get the response body.
|
|
598
787
|
const responseBody = await apiRespond.getReport(specs);
|
|
788
|
+
await recordPageMetric('apiOperations', 'getReport');
|
|
599
789
|
// Send it.
|
|
600
790
|
setHeaders('application/json', null, 'low');
|
|
601
791
|
response.end(JSON.stringify(responseBody));
|
|
@@ -689,7 +879,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
689
879
|
else if (pageName === 'requestTest.html') {
|
|
690
880
|
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
691
881
|
// If the request is valid:
|
|
692
|
-
if (description && url
|
|
882
|
+
if (description && isURL(url) && why) {
|
|
693
883
|
// If a report on the page is already available:
|
|
694
884
|
if (await isReportAvailable(description, url)) {
|
|
695
885
|
// Report the error.
|
|
@@ -751,7 +941,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
751
941
|
const {target, authCode} = postData as {target: string; authCode?: string};
|
|
752
942
|
const [url, description] = target.split('\t') as [string, string];
|
|
753
943
|
// If the request is valid:
|
|
754
|
-
if (url
|
|
944
|
+
if (isURL(url) && isValidAuthCode(authCode)) {
|
|
755
945
|
// Set the non-location headers for a response.
|
|
756
946
|
setHeaders('text/html', null, 'ultra');
|
|
757
947
|
// If the request is an approval:
|
|
@@ -759,20 +949,14 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
759
949
|
// Set a location header for a response.
|
|
760
950
|
response.setHeader('content-location', pathname);
|
|
761
951
|
// Process the approval and get the answer data about the remaining requests.
|
|
762
|
-
const answerData = await answer.enqueue(url, description
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
response.end(answerData.answerPage);
|
|
767
|
-
}
|
|
768
|
-
// Otherwise, i.e. if they are invalid:
|
|
769
|
-
else {
|
|
770
|
-
// Report the error.
|
|
771
|
-
await serveError({message: answerData.message}, response, true);
|
|
772
|
-
}
|
|
952
|
+
const answerData = await answer.enqueue(url, description);
|
|
953
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
954
|
+
// Serve the test-order page with the remaining recommendations.
|
|
955
|
+
response.end(answerData.answerPage);
|
|
773
956
|
}
|
|
774
957
|
// Otherwise, i.e. if it is a rejection:
|
|
775
958
|
else {
|
|
959
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
776
960
|
// Delete the test requests for the URL.
|
|
777
961
|
await deleteTestRequests(url);
|
|
778
962
|
// Set a location header for a response.
|
|
@@ -785,8 +969,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
785
969
|
}
|
|
786
970
|
// Otherwise, i.e. if the request is invalid:
|
|
787
971
|
else {
|
|
788
|
-
|
|
789
|
-
|
|
972
|
+
await recordMetric('managerActivity', 'requestAction.html', 'error');
|
|
973
|
+
// Report the error, deliberately vague so as not to confirm to an attacker
|
|
974
|
+
// which part of the request (the URL, or the authorization code) was wrong.
|
|
975
|
+
await serveError({message: 'Invalid request'}, response, true);
|
|
790
976
|
}
|
|
791
977
|
}
|
|
792
978
|
// Otherwise, if it is a reannotation order:
|
|
@@ -798,11 +984,13 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
798
984
|
const answerData = await answer.reannotate(authCode);
|
|
799
985
|
// If the answer data are valid:
|
|
800
986
|
if (answerData.status === 'ok') {
|
|
987
|
+
await recordMetric('managerActivity', 'reannotate.html', 'ok');
|
|
801
988
|
// Serve the answer page.
|
|
802
989
|
response.end(answerData.answerPage);
|
|
803
990
|
}
|
|
804
991
|
// Otherwise, i.e. if they are invalid:
|
|
805
992
|
else {
|
|
993
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
806
994
|
// Report the error.
|
|
807
995
|
await serveError({message: answerData.message}, response, true);
|
|
808
996
|
}
|
|
@@ -816,11 +1004,39 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
816
1004
|
const answerData = await answer.renewWCAG(authCode);
|
|
817
1005
|
// If the answer data are valid:
|
|
818
1006
|
if (answerData.status === 'ok') {
|
|
1007
|
+
await recordMetric('managerActivity', 'renewWCAG.html', 'ok');
|
|
1008
|
+
// Serve the answer page.
|
|
1009
|
+
response.end(answerData.answerPage);
|
|
1010
|
+
}
|
|
1011
|
+
// Otherwise, i.e. if they are invalid:
|
|
1012
|
+
else {
|
|
1013
|
+
await recordMetric('managerActivity', 'renewWCAG.html', 'error');
|
|
1014
|
+
// Report the error.
|
|
1015
|
+
await serveError({message: answerData.message}, response, true);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Otherwise, if it is a self-submitting manager page (one page serves the form on
|
|
1019
|
+
// GET and processes its own submission on POST, unlike enqueueForm.html/reannotateForm.html,
|
|
1020
|
+
// whose submissions go to a separate action page):
|
|
1021
|
+
else if (pageName.endsWith('.html') && selfSubmittingManagerPages.has(pageName.slice(0, -5))) {
|
|
1022
|
+
const topic = pageName.slice(0, -5);
|
|
1023
|
+
setHeaders('text/html', pathname, 'ultra');
|
|
1024
|
+
// Reconstruct a query string from the POST body, so the page's answer() function
|
|
1025
|
+
// can read its submitted parameters the same way it reads a GET query string.
|
|
1026
|
+
const search = `?${new URLSearchParams(postData as Record<string, string>).toString()}`;
|
|
1027
|
+
// Get the answer data. The method is passed so the handler only processes a
|
|
1028
|
+
// submission for POST, never for GET, regardless of what its query string contains.
|
|
1029
|
+
const answerData = await answer[topic]!(pathTail, search, method);
|
|
1030
|
+
// If they are valid:
|
|
1031
|
+
if (answerData.status === 'ok') {
|
|
1032
|
+
await recordMetric('managerActivity', topic, 'ok');
|
|
1033
|
+
applySetCookie(answerData);
|
|
819
1034
|
// Serve the answer page.
|
|
820
1035
|
response.end(answerData.answerPage);
|
|
821
1036
|
}
|
|
822
1037
|
// Otherwise, i.e. if they are invalid:
|
|
823
1038
|
else {
|
|
1039
|
+
await recordMetric('managerActivity', topic, 'error');
|
|
824
1040
|
// Report the error.
|
|
825
1041
|
await serveError({message: answerData.message}, response, true);
|
|
826
1042
|
}
|
|
@@ -938,6 +1154,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
938
1154
|
const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
|
|
939
1155
|
// Get the response body.
|
|
940
1156
|
const responseBody = await apiRespond.requestTest([description, URL, reason]);
|
|
1157
|
+
await recordPageMetric('apiOperations', 'requestTest');
|
|
941
1158
|
// Send it.
|
|
942
1159
|
setHeaders('application/json', null, 'ultra');
|
|
943
1160
|
response.end(JSON.stringify(responseBody));
|
|
@@ -947,6 +1164,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
947
1164
|
const {reason} = postData as {reason: string};
|
|
948
1165
|
// Get the response body.
|
|
949
1166
|
const responseBody = await apiRespond.requestRetest(segments.slice(1).concat(reason));
|
|
1167
|
+
await recordPageMetric('apiOperations', 'requestRetest');
|
|
950
1168
|
// Send it.
|
|
951
1169
|
setHeaders('application/json', null, 'ultra');
|
|
952
1170
|
response.end(JSON.stringify(responseBody));
|
|
@@ -956,6 +1174,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
956
1174
|
const {feature} = postData as {feature: string};
|
|
957
1175
|
// Get the response body.
|
|
958
1176
|
const responseBody = await apiRespond.requestFeature([feature]);
|
|
1177
|
+
await recordPageMetric('apiOperations', 'requestFeature');
|
|
959
1178
|
// Send it.
|
|
960
1179
|
setHeaders('application/json', null, 'ultra');
|
|
961
1180
|
response.end(JSON.stringify(responseBody));
|
package/linkValidation.test.ts
CHANGED
package/mcp.test.ts
CHANGED
|
@@ -9,12 +9,26 @@ import {test} from 'node:test';
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import http from 'node:http';
|
|
11
11
|
import {mcpPath, createMCPServer, handleMCP} from './mcp.ts';
|
|
12
|
+
import {metricsPath} from './util.ts';
|
|
13
|
+
import fs from 'node:fs';
|
|
12
14
|
|
|
13
15
|
// CONSTANTS
|
|
14
16
|
|
|
15
17
|
// Set DB_DIR to the fixture database for all tests.
|
|
16
18
|
process.env.DB_DIR = (await import('./test/dbFixture.ts')).fixtureDBDir;
|
|
17
19
|
|
|
20
|
+
// FUNCTIONS
|
|
21
|
+
|
|
22
|
+
// Returns the current count for an MCP tool in the metrics file, or 0 if the file or the
|
|
23
|
+
// tool's entry is absent (the file does not exist until the first metric is recorded).
|
|
24
|
+
const getToolCallCount = (toolName: string): number => {
|
|
25
|
+
if (!fs.existsSync(metricsPath())) {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const metrics = JSON.parse(fs.readFileSync(metricsPath(), 'utf8'));
|
|
29
|
+
return metrics.mcpToolCalls[toolName] ?? 0;
|
|
30
|
+
};
|
|
31
|
+
|
|
18
32
|
// TESTS
|
|
19
33
|
|
|
20
34
|
test('mcpPath is /mcp', () => {
|
|
@@ -48,14 +62,17 @@ test('each tool has a description and a handler function', () => {
|
|
|
48
62
|
|
|
49
63
|
test('listReports handler returns content and structuredContent', async () => {
|
|
50
64
|
const server = createMCPServer();
|
|
65
|
+
const countBefore = getToolCallCount('listReports');
|
|
51
66
|
const result = await (server as any)._registeredTools.listReports.handler({});
|
|
52
67
|
assert.ok(result.content);
|
|
53
68
|
assert.equal(result.content[0].type, 'text');
|
|
54
69
|
assert.ok(result.structuredContent);
|
|
70
|
+
assert.equal(getToolCallCount('listReports'), countBefore + 1);
|
|
55
71
|
});
|
|
56
72
|
|
|
57
73
|
test('listIssues handler returns content and structuredContent for a valid report', async () => {
|
|
58
74
|
const server = createMCPServer();
|
|
75
|
+
const countBefore = getToolCallCount('listIssues');
|
|
59
76
|
const result = await (server as any)._registeredTools.listIssues.handler({
|
|
60
77
|
timeStamp: '260101T0000',
|
|
61
78
|
jobID: 'mix'
|
|
@@ -63,10 +80,12 @@ test('listIssues handler returns content and structuredContent for a valid repor
|
|
|
63
80
|
assert.ok(result.content);
|
|
64
81
|
assert.equal(result.content[0].type, 'text');
|
|
65
82
|
assert.ok(result.structuredContent);
|
|
83
|
+
assert.equal(getToolCallCount('listIssues'), countBefore + 1);
|
|
66
84
|
});
|
|
67
85
|
|
|
68
86
|
test('listViolators handler returns content and structuredContent for a valid issue', async () => {
|
|
69
87
|
const server = createMCPServer();
|
|
88
|
+
const countBefore = getToolCallCount('listViolators');
|
|
70
89
|
const result = await (server as any)._registeredTools.listViolators.handler({
|
|
71
90
|
issueID: 'linkNoText',
|
|
72
91
|
timeStamp: '260101T0000',
|
|
@@ -75,10 +94,12 @@ test('listViolators handler returns content and structuredContent for a valid is
|
|
|
75
94
|
assert.ok(result.content);
|
|
76
95
|
assert.equal(result.content[0].type, 'text');
|
|
77
96
|
assert.ok(result.structuredContent);
|
|
97
|
+
assert.equal(getToolCallCount('listViolators'), countBefore + 1);
|
|
78
98
|
});
|
|
79
99
|
|
|
80
100
|
test('listDiagnoses handler returns content and structuredContent for a valid diagnosis', async () => {
|
|
81
101
|
const server = createMCPServer();
|
|
102
|
+
const countBefore = getToolCallCount('listDiagnoses');
|
|
82
103
|
const result = await (server as any)._registeredTools.listDiagnoses.handler({
|
|
83
104
|
catalogIndex: '0',
|
|
84
105
|
issueID: 'linkNoText',
|
|
@@ -88,10 +109,12 @@ test('listDiagnoses handler returns content and structuredContent for a valid di
|
|
|
88
109
|
assert.ok(result.content);
|
|
89
110
|
assert.equal(result.content[0].type, 'text');
|
|
90
111
|
assert.ok(result.structuredContent);
|
|
112
|
+
assert.equal(getToolCallCount('listDiagnoses'), countBefore + 1);
|
|
91
113
|
});
|
|
92
114
|
|
|
93
115
|
test('getReport handler returns content and structuredContent for a valid report', async () => {
|
|
94
116
|
const server = createMCPServer();
|
|
117
|
+
const countBefore = getToolCallCount('getReport');
|
|
95
118
|
const result = await (server as any)._registeredTools.getReport.handler({
|
|
96
119
|
timeStamp: '260101T0000',
|
|
97
120
|
jobID: 'mix'
|
|
@@ -99,10 +122,12 @@ test('getReport handler returns content and structuredContent for a valid report
|
|
|
99
122
|
assert.ok(result.content);
|
|
100
123
|
assert.equal(result.content[0].type, 'text');
|
|
101
124
|
assert.ok(result.structuredContent);
|
|
125
|
+
assert.equal(getToolCallCount('getReport'), countBefore + 1);
|
|
102
126
|
});
|
|
103
127
|
|
|
104
128
|
test('requestTest handler returns content and structuredContent', async () => {
|
|
105
129
|
const server = createMCPServer();
|
|
130
|
+
const countBefore = getToolCallCount('requestTest');
|
|
106
131
|
const result = await (server as any)._registeredTools.requestTest.handler({
|
|
107
132
|
description: 'Test Page',
|
|
108
133
|
URL: 'https://example.com/test',
|
|
@@ -111,10 +136,12 @@ test('requestTest handler returns content and structuredContent', async () => {
|
|
|
111
136
|
assert.ok(result.content);
|
|
112
137
|
assert.equal(result.content[0].type, 'text');
|
|
113
138
|
assert.ok(result.structuredContent);
|
|
139
|
+
assert.equal(getToolCallCount('requestTest'), countBefore + 1);
|
|
114
140
|
});
|
|
115
141
|
|
|
116
142
|
test('requestRetest handler returns content and structuredContent for a valid report', async () => {
|
|
117
143
|
const server = createMCPServer();
|
|
144
|
+
const countBefore = getToolCallCount('requestRetest');
|
|
118
145
|
const result = await (server as any)._registeredTools.requestRetest.handler({
|
|
119
146
|
timeStamp: '260101T0000',
|
|
120
147
|
jobID: 'mix',
|
|
@@ -123,16 +150,19 @@ test('requestRetest handler returns content and structuredContent for a valid re
|
|
|
123
150
|
assert.ok(result.content);
|
|
124
151
|
assert.equal(result.content[0].type, 'text');
|
|
125
152
|
assert.ok(result.structuredContent);
|
|
153
|
+
assert.equal(getToolCallCount('requestRetest'), countBefore + 1);
|
|
126
154
|
});
|
|
127
155
|
|
|
128
156
|
test('requestFeature handler returns content and structuredContent', async () => {
|
|
129
157
|
const server = createMCPServer();
|
|
158
|
+
const countBefore = getToolCallCount('requestFeature');
|
|
130
159
|
const result = await (server as any)._registeredTools.requestFeature.handler({
|
|
131
|
-
feature: 'A new feature idea'
|
|
160
|
+
feature: 'A new feature idea worth considering'
|
|
132
161
|
});
|
|
133
162
|
assert.ok(result.content);
|
|
134
163
|
assert.equal(result.content[0].type, 'text');
|
|
135
164
|
assert.ok(result.structuredContent);
|
|
165
|
+
assert.equal(getToolCallCount('requestFeature'), countBefore + 1);
|
|
136
166
|
});
|
|
137
167
|
|
|
138
168
|
test('listIssues handler returns an error for a nonexistent report', async () => {
|