@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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/getReport.ts +3 -1
  3. package/api/requestFeature.test.ts +23 -3
  4. package/api/requestFeature.ts +19 -11
  5. package/api/requestRetest.test.ts +39 -13
  6. package/api/requestRetest.ts +11 -5
  7. package/api/requestTest.ts +6 -6
  8. package/api/schemas.ts +2 -2
  9. package/docs/BACKLOG.md +232 -12
  10. package/docs/SERVICE.md +84 -3
  11. package/index.test.ts +443 -20
  12. package/index.ts +246 -27
  13. package/linkValidation.test.ts +1 -0
  14. package/mcp.test.ts +31 -1
  15. package/mcp.ts +9 -0
  16. package/package.json +1 -1
  17. package/smokeTest.ts +16 -0
  18. package/test/dbFixture.ts +8 -0
  19. package/util.test.ts +295 -0
  20. package/util.ts +121 -0
  21. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  22. package/web/ai0BalanceForm/index.html +1 -1
  23. package/web/ai0BalanceForm/index.ts +12 -8
  24. package/web/enqueue/index.ts +33 -39
  25. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  26. package/web/expungeReportsForm/index.html +1 -1
  27. package/web/expungeReportsForm/index.ts +1 -1
  28. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  29. package/web/hideReportForm/index.html +1 -1
  30. package/web/hideReportForm/index.ts +13 -8
  31. package/web/listTopIssues/index.ts +1 -1
  32. package/web/manage/index.html +2 -1
  33. package/web/metrics/index.html +19 -0
  34. package/web/metrics/index.ts +127 -0
  35. package/web/metrics/metrics.test.ts +211 -0
  36. package/web/pruneReportsForm/index.html +1 -1
  37. package/web/pruneReportsForm/index.ts +1 -1
  38. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  39. package/web/reannotate/index.ts +7 -3
  40. package/web/reannotate/reannotate.test.ts +1 -1
  41. package/web/renewWCAG/index.ts +6 -3
  42. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  43. package/web/reportDeletion.ts +13 -7
  44. package/web/rewindReportsForm/index.html +1 -1
  45. package/web/rewindReportsForm/index.ts +1 -1
  46. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  47. package/web/showHiddenReportsForm/index.html +24 -0
  48. package/web/showHiddenReportsForm/index.ts +45 -0
  49. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  50. package/web/tutorialAI/index.ts +1 -1
  51. package/web/tutorialWeb/index.ts +1 -1
  52. package/web/unhideReportForm/index.html +1 -1
  53. package/web/unhideReportForm/index.ts +13 -8
  54. 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 = {status: string; message?: string; answerPage?: string};
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': ip,
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
- const job = await getObject(path.join(jobsPath(), 'claimed', jobName));
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 the page can be generated and is not POST-only (a POST-only path also
537
- // matches the '*.html*' GET pattern, but its handler expects POST's argument
538
- // list and performs no GET-appropriate rendering):
539
- if (answer[topic] && !isPathAllowed('POST', pathname)) {
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
- const answerData = await answer[topic](pathTail, search);
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.startsWith('https://') && why) {
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.startsWith('https://') && authCode === process.env.AUTH_CODE) {
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, authCode);
763
- // If the answer data are valid:
764
- if (answerData.status === 'ok') {
765
- // Serve the test-order page with the remaining recommendations.
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
- // Report the error.
789
- await serveError({message: 'ERROR: Invalid test order'}, response, true);
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));
@@ -55,6 +55,7 @@ const htmlRoutes = [
55
55
  '/reannotate.html',
56
56
  '/reannotateForm.html',
57
57
  '/hideReportForm.html',
58
+ '/showHiddenReportsForm.html',
58
59
  '/unhideReportForm.html',
59
60
  '/expungeReportsForm.html',
60
61
  '/pruneReportsForm.html',
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 () => {