@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
@@ -15,7 +15,7 @@
15
15
  <h1><a href="/">Kilotest</a>: Record AI service 0 balance</h1>
16
16
  <p>__oldBalance__ recorded AI service 0 balance.</p>
17
17
  <p>The current AI service 0 balance is available on the <a href="https://platform.claude.com/settings/billing">Claude Platform Billing page</a>.</p>
18
- <form action="/ai0BalanceForm.html">
18
+ <form action="/ai0BalanceForm.html" method="post">
19
19
  <p><label>
20
20
  Balance (in $):
21
21
  <input type="number" size="5" min="0" max="99.99" step="0.01" name="newBalance" required>
@@ -7,20 +7,22 @@
7
7
 
8
8
  import fs from 'node:fs/promises';
9
9
  import {balancePath} from '../../balances.ts';
10
- import {getJSON, populateTemplate} from '../../util.ts';
10
+ import {getJSON, isValidAuthCode, populateTemplate} from '../../util.ts';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
- // Returns a form for recording the AI service 0 balance.
15
- export const answer = async (_: any, search: string) => {
14
+ // Returns a form for recording the AI service 0 balance. A GET request never processes a
15
+ // submission, regardless of its query string; only a POST request (the form's own
16
+ // submission) does.
17
+ export const answer = async (_: any, search: string, method: string) => {
16
18
  const searchParams = new URLSearchParams(search);
17
19
  const authCode = searchParams?.get('authCode');
18
20
  const newBalanceString = searchParams?.get('newBalance');
19
21
  let oldBalance: any;
20
- // If the form displayed itself:
21
- if (newBalanceString) {
22
+ // If the form has been submitted:
23
+ if (method === 'POST' && newBalanceString) {
22
24
  // If the authorization code is valid:
23
- if (authCode === process.env.AUTH_CODE) {
25
+ if (isValidAuthCode(authCode)) {
24
26
  const newBalance = Number.parseFloat(newBalanceString);
25
27
  // If the new balance is valid:
26
28
  if (
@@ -40,10 +42,12 @@ export const answer = async (_: any, search: string) => {
40
42
  }
41
43
  // Otherwise, i.e. if the authorization code is invalid:
42
44
  else {
43
- // Report the error.
45
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
46
+ // authorization code specifically (as opposed to some other part of the request) is
47
+ // what was wrong.
44
48
  return {
45
49
  status: 'error',
46
- message: 'Invalid authorization code'
50
+ message: 'Invalid request'
47
51
  }
48
52
  }
49
53
  }
@@ -6,51 +6,45 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {
9
- deleteTestRequests, getJSON, getNowStamp, getRandomString, isURL, jobsPath, populateTemplate
9
+ deleteTestRequests, getJSON, getNowStamp, getRandomString, jobsPath, populateTemplate
10
10
  } from '../../util.ts';
11
11
  import fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
 
14
14
  // FUNCTIONS
15
15
 
16
- // Implements a test request approval and returns a revised request page.
17
- export const answer = async (url: string, description: string, authCode: string) => {
18
- // If the arguments are valid:
19
- if (isURL(url) && description && authCode === process.env.AUTH_CODE) {
20
- // Get the job template.
21
- const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
22
- const job = JSON.parse(jobTemplateJSON);
23
- const nowStamp = getNowStamp();
24
- // Populate the template with job properties.
25
- const jobIDSuffix = getRandomString(3);
26
- const jobName = `${nowStamp}-${jobIDSuffix}`;
27
- job.id = jobName;
28
- job.creationTimeStamp = nowStamp;
29
- job.executionTimeStamp = nowStamp;
30
- job.target.what = description;
31
- job.target.url = url;
32
- const query: Record<string, string> = {
33
- target: description,
34
- jobName
35
- };
36
- // Save the job in the queue.
37
- await fs.writeFile(
38
- path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
39
- );
40
- console.log(`Retest queued for ${description} as job ${jobName}`);
41
- // Delete the test requests for the target.
42
- await deleteTestRequests(url);
43
- // Get the populated answer template.
44
- const answerPage = await populateTemplate(import.meta.dirname, query);
45
- // Return the populated page.
46
- return {
47
- status: 'ok',
48
- answerPage
49
- };
50
- }
51
- // Otherwise, i.e. if the authorization code is invalid, return an error page.
16
+ // Implements a test request approval and returns a revised request page. The caller
17
+ // (index.ts, this function's only caller) has already validated the URL and
18
+ // authorization code before invoking this function.
19
+ export const answer = async (url: string, description: string) => {
20
+ // Get the job template.
21
+ const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
22
+ const job = JSON.parse(jobTemplateJSON);
23
+ const nowStamp = getNowStamp();
24
+ // Populate the template with job properties.
25
+ const jobIDSuffix = getRandomString(3);
26
+ const jobName = `${nowStamp}-${jobIDSuffix}`;
27
+ job.id = jobName;
28
+ job.creationTimeStamp = nowStamp;
29
+ job.executionTimeStamp = nowStamp;
30
+ job.target.what = description;
31
+ job.target.url = url;
32
+ const query: Record<string, string> = {
33
+ target: description,
34
+ jobName
35
+ };
36
+ // Save the job in the queue.
37
+ await fs.writeFile(
38
+ path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
39
+ );
40
+ console.log(`Retest queued for ${description} as job ${jobName}`);
41
+ // Delete the test requests for the target.
42
+ await deleteTestRequests(url);
43
+ // Get the populated answer template.
44
+ const answerPage = await populateTemplate(import.meta.dirname, query);
45
+ // Return the populated page.
52
46
  return {
53
- status: 'error',
54
- message: 'Invalid authorization code'
47
+ status: 'ok',
48
+ answerPage
55
49
  };
56
50
  };
@@ -35,32 +35,40 @@ after(async () => {
35
35
 
36
36
  // TESTS
37
37
 
38
- test('expungeReportsForm displays a list of reports when no submission', async () => {
39
- const result: any = await answer(null, '');
38
+ test('expungeReportsForm displays a list of reports on a GET request', async () => {
39
+ const result: any = await answer(null, '', 'GET');
40
40
  assert.equal(result.status, 'ok');
41
41
  assert.ok(result.answerPage);
42
42
  const html = parse(result.answerPage);
43
43
  assert.ok(html.querySelector('title'));
44
44
  });
45
45
 
46
- test('expungeReportsForm returns an error for an invalid auth code', async () => {
47
- const result: any = await answer(null, 'authCode=wrong&report=260101T0001-ct');
46
+ test('expungeReportsForm ignores a report query string on a GET request', async () => {
47
+ const reportPath = path.join(reportsPath(), '260101T0001-ct.json');
48
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct', 'GET');
49
+ assert.equal(result.status, 'ok');
50
+ // The report should NOT have been deleted by a mere GET request.
51
+ await fs.access(reportPath);
52
+ });
53
+
54
+ test('expungeReportsForm returns an error for an invalid auth code on a POST request', async () => {
55
+ const result: any = await answer(null, 'authCode=wrong&report=260101T0001-ct', 'POST');
48
56
  assert.equal(result.status, 'error');
49
- assert.equal(result.message, 'Invalid authorization code');
57
+ assert.equal(result.message, 'Invalid request');
50
58
  });
51
59
 
52
- test('expungeReportsForm returns an error when deleting a nonexistent report', async () => {
53
- const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
60
+ test('expungeReportsForm returns an error when deleting a nonexistent report on a POST request', async () => {
61
+ const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
54
62
  assert.equal(result.status, 'error');
55
63
  assert.ok(result.message.includes('Deleting sole reports'));
56
64
  });
57
65
 
58
- test('expungeReportsForm deletes a sole report with valid auth code', async () => {
66
+ test('expungeReportsForm deletes a sole report with valid auth code on a POST request', async () => {
59
67
  // 260101T0001-ct is the only report for its URL (canttell).
60
68
  const reportPath = path.join(reportsPath(), '260101T0001-ct.json');
61
69
  const backup = await fs.readFile(reportPath, 'utf8');
62
70
  try {
63
- const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct');
71
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0001-ct', 'POST');
64
72
  assert.equal(result.status, 'ok');
65
73
  await fs.access(reportPath).then(
66
74
  () => {throw new Error('Report should have been deleted');},
@@ -77,7 +85,7 @@ test('expungeReportsForm returns an error when a report file is corrupt', async
77
85
  const backup = await fs.readFile(reportPath, 'utf8');
78
86
  try {
79
87
  await fs.writeFile(reportPath, 'not valid json');
80
- const result: any = await answer(null, '');
88
+ const result: any = await answer(null, '', 'GET');
81
89
  assert.equal(result.status, 'error');
82
90
  }
83
91
  finally {
@@ -100,7 +108,7 @@ test('expungeReportsForm shows no-deletable message when every URL has at least
100
108
  process.env.DB_DIR = tmpDir;
101
109
  try {
102
110
  const {answer} = await import('./index.ts');
103
- const result: any = await answer(null, '');
111
+ const result: any = await answer(null, '', 'GET');
104
112
  assert.equal(result.status, 'ok');
105
113
  assert.ok(result.answerPage.includes('no reports to delete'));
106
114
  assert.ok(result.answerPage.includes('disabled'));
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Expunge reports</h1>
16
16
  <p>__intro__</p>
17
- <form action="/expungeReportsForm.html">
17
+ <form action="/expungeReportsForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -10,7 +10,7 @@ import {reportDeletionForm} from '../reportDeletion.ts';
10
10
  // FUNCTIONS
11
11
 
12
12
  // Returns a form for deleting sole reports.
13
- export const answer = async (_: any, search: string) => reportDeletionForm(import.meta.dirname, search, {
13
+ export const answer = async (_: any, search: string, method: string) => reportDeletionForm(import.meta.dirname, search, method, {
14
14
  intro: 'Choose the sole reports to delete.',
15
15
  emptyIntro: 'Each target has at least 2 reports, so there are no reports to delete.',
16
16
  failurePrefix: 'Deleting sole reports failed',
@@ -44,8 +44,8 @@ after(async () => {
44
44
 
45
45
  // TESTS
46
46
 
47
- test('hideReportForm displays a list of reports when no submission', async () => {
48
- const result: any = await answer(null, '');
47
+ test('hideReportForm displays a list of reports on a GET request', async () => {
48
+ const result: any = await answer(null, '', 'GET');
49
49
  assert.equal(result.status, 'ok');
50
50
  assert.ok(result.answerPage);
51
51
  const html = parse(result.answerPage);
@@ -53,13 +53,28 @@ test('hideReportForm displays a list of reports when no submission', async () =>
53
53
  assert.ok(radios.length > 0);
54
54
  });
55
55
 
56
- test('hideReportForm returns an error for an invalid auth code', async () => {
57
- const result: any = await answer(null, 'authCode=wrong&report=260101T0009-brd');
56
+ test('hideReportForm ignores a report query string on a GET request', async () => {
57
+ const reportPath = path.join(reportsPath(), '260101T0009-brd.json');
58
+ const hiddenPath = path.join(hiddenReportsPath(), '260101T0009-brd.json');
59
+ try {
60
+ await fs.rename(hiddenPath, reportPath);
61
+ }
62
+ catch {
63
+ // Report may already be in reports.
64
+ }
65
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd', 'GET');
66
+ assert.equal(result.status, 'ok');
67
+ // The report should NOT have been hidden by a mere GET request.
68
+ await fs.access(reportPath);
69
+ });
70
+
71
+ test('hideReportForm returns an error for an invalid auth code on a POST request', async () => {
72
+ const result: any = await answer(null, 'authCode=wrong&report=260101T0009-brd', 'POST');
58
73
  assert.equal(result.status, 'error');
59
- assert.equal(result.message, 'Invalid authorization code');
74
+ assert.equal(result.message, 'Invalid request');
60
75
  });
61
76
 
62
- test('hideReportForm hides a report with valid auth code', async () => {
77
+ test('hideReportForm hides a report with valid auth code on a POST request', async () => {
63
78
  const reportPath = path.join(reportsPath(), '260101T0009-brd.json');
64
79
  const hiddenPath = path.join(hiddenReportsPath(), '260101T0009-brd.json');
65
80
  // Ensure the report exists in reports and not in hidden.
@@ -69,14 +84,14 @@ test('hideReportForm hides a report with valid auth code', async () => {
69
84
  catch {
70
85
  // Report may already be in reports.
71
86
  }
72
- const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd');
87
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0009-brd', 'POST');
73
88
  assert.equal(result.status, 'ok');
74
89
  // The report should now be in the hidden directory.
75
90
  await fs.access(hiddenPath);
76
91
  });
77
92
 
78
- test('hideReportForm returns an error when hiding a nonexistent report', async () => {
79
- const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
93
+ test('hideReportForm returns an error when hiding a nonexistent report on a POST request', async () => {
94
+ const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
80
95
  assert.equal(result.status, 'error');
81
96
  assert.ok(result.message.includes('Hiding report'));
82
97
  });
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Classify report as experimental</h1>
16
16
  <p>Choose a report to classify as experimental.</p>
17
- <form action="/hideReportForm.html">
17
+ <form action="/hideReportForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -5,21 +5,24 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {getReportExtracts, hiddenReportsPath, populateTemplate, reportsPath} from '../../util.ts';
8
+ import {
9
+ getReportExtracts, hiddenReportsPath, isValidAuthCode, populateTemplate, reportsPath
10
+ } from '../../util.ts';
9
11
  import fs from 'node:fs/promises';
10
12
  import path from 'node:path';
11
13
 
12
14
  // FUNCTIONS
13
15
 
14
- // Returns a form for hiding a report.
15
- export const answer = async (_: any, search: string) => {
16
+ // Returns a form for hiding a report. A GET request never processes a submission,
17
+ // regardless of its query string; only a POST request (the form's own submission) does.
18
+ export const answer = async (_: any, search: string, method: string) => {
16
19
  const searchParams = new URLSearchParams(search);
17
20
  const authCode = searchParams?.get('authCode');
18
21
  const jobName = searchParams?.get('report');
19
- // If the form has been displayed by itself after a submission and a report is to be hidden:
20
- if (jobName) {
22
+ // If the form has been submitted and a report is to be hidden:
23
+ if (method === 'POST' && jobName) {
21
24
  // If the authorization code is valid:
22
- if (authCode === process.env.AUTH_CODE) {
25
+ if (isValidAuthCode(authCode)) {
23
26
  const fileName = `${jobName}.json`;
24
27
  try {
25
28
  // Move the specified report to the directory of hidden reports.
@@ -36,10 +39,12 @@ export const answer = async (_: any, search: string) => {
36
39
  }
37
40
  // Otherwise, i.e. if the authorization code is invalid:
38
41
  else {
39
- // Report the error.
42
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
43
+ // authorization code specifically (as opposed to some other part of the request) is
44
+ // what was wrong.
40
45
  return {
41
46
  status: 'error',
42
- message: 'Invalid authorization code'
47
+ message: 'Invalid request'
43
48
  }
44
49
  }
45
50
  }
@@ -79,7 +79,7 @@ const getIssuesSummary = async () => {
79
79
  // Report this.
80
80
  console.log(`ERROR: Annotations obsolete for issue ${issueID}; reannotate`);
81
81
  // Notify a manager.
82
- sendAlert('Annotations obsolete', `Annotations for issue ${issueID} obsolete; reannotate.`);
82
+ sendAlert('Kilotest: Annotations obsolete', `Annotations for issue ${issueID} obsolete; reannotate.`);
83
83
  }
84
84
  });
85
85
  // For each summarized issue:
@@ -23,9 +23,10 @@
23
23
  <li><a href="/rewindReportsForm.html">Delete latest superseding reports</a></li>
24
24
  <li><a href="/expungeReportsForm.html">Delete sole reports</a></li>
25
25
  <li><a href="/hideReportForm.html">Classify a report as experimental</a></li>
26
- <li><a href="/unhideReportForm.html">Declassify a report as experimental</a></li>
26
+ <li><a href="/showHiddenReportsForm.html">View the list of experimental reports</a></li>
27
27
  <li><a href="/ai0BalanceForm.html">Record the current balance of AI service 0</a></li>
28
28
  <li><a href="/renewWCAGForm.html">Renew the map of WCAG numeric to text identifiers</a></li>
29
+ <li><a href="/metrics.html">View usage metrics</a></li>
29
30
  </ul>
30
31
  </main>
31
32
  </body>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="publisher" content="Jonathan Robert Pool">
7
+ <meta name="creator" content="Jonathan Robert Pool">
8
+ <meta name="keywords" content="report,accessibility,a11y">
9
+ <title>Usage metrics | Kilotest</title>
10
+ <link rel="icon" href="/favicon.ico">
11
+ <link rel="stylesheet" href="/style.css">
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <h1><a href="/">Kilotest</a>: Usage metrics</h1>
16
+ __body__
17
+ </main>
18
+ </body>
19
+ </html>
@@ -0,0 +1,127 @@
1
+ /*
2
+ index.ts
3
+ Serves usage metrics for the maintainer: web page views, MCP tool calls, and API
4
+ operation calls, each broken down by name, recorded since a given date and time.
5
+ */
6
+
7
+ // IMPORTS
8
+
9
+ import {
10
+ clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, isValidAuthCode,
11
+ metricsExclusionCookieName, populateTemplate
12
+ } from '../../util.ts';
13
+
14
+ // FUNCTIONS
15
+
16
+ // Returns an HTML table of the counts in a metrics category, sorted by descending count.
17
+ const getCategoryTable = (counts: Record<string, number>): string => {
18
+ const margin = ' '.repeat(6);
19
+ const names = Object.keys(counts).sort((a, b) => counts[b]! - counts[a]!);
20
+ if (!names.length) {
21
+ return `${margin}<p>None yet.</p>`;
22
+ }
23
+ const rows = names.map(name => `${margin} <tr><td>${name}</td><td>${counts[name]}</td></tr>`);
24
+ return [
25
+ `${margin}<table class="allBorder">`,
26
+ `${margin} <thead>`,
27
+ `${margin} <tr><th>Name</th><th>Count</th></tr>`,
28
+ `${margin} </thead>`,
29
+ `${margin} <tbody>`,
30
+ ...rows,
31
+ `${margin} </tbody>`,
32
+ `${margin}</table>`
33
+ ].join('\n');
34
+ };
35
+ // Returns an HTML table of manager-page activity, sorted by descending failure count,
36
+ // since repeated failures are the signal of suspected abuse most worth surfacing first.
37
+ const getManagerActivityTable = (activity: Record<string, {ok: number; error: number}>): string => {
38
+ const margin = ' '.repeat(6);
39
+ const names = Object.keys(activity).sort((a, b) => activity[b]!.error - activity[a]!.error);
40
+ if (!names.length) {
41
+ return `${margin}<p>None yet.</p>`;
42
+ }
43
+ const rows = names.map(name => {
44
+ const {ok, error} = activity[name]!;
45
+ return `${margin} <tr><td>${name}</td><td>${ok}</td><td>${error}</td></tr>`;
46
+ });
47
+ return [
48
+ `${margin}<table class="allBorder">`,
49
+ `${margin} <thead>`,
50
+ `${margin} <tr><th>Name</th><th>Successful</th><th>Failed</th></tr>`,
51
+ `${margin} </thead>`,
52
+ `${margin} <tbody>`,
53
+ ...rows,
54
+ `${margin} </tbody>`,
55
+ `${margin}</table>`
56
+ ].join('\n');
57
+ };
58
+ // Returns a page reporting usage metrics, or, if the form has not been submitted (a POST
59
+ // request) yet, a form requesting an authorization code. A GET request, regardless of its
60
+ // query string, never processes a submission, so the action cannot be triggered by GET.
61
+ export const answer = async (_: any, search: string, method: string) => {
62
+ const searchParams = new URLSearchParams(search);
63
+ const authCode = searchParams?.get('authCode');
64
+ let query: Record<string, string>;
65
+ // If the form has been submitted:
66
+ if (method === 'POST') {
67
+ // If the authorization code is invalid:
68
+ if (!isValidAuthCode(authCode)) {
69
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
70
+ // authorization code specifically (as opposed to some other part of the request) is
71
+ // what was wrong.
72
+ return {
73
+ status: 'error',
74
+ message: 'Invalid request'
75
+ };
76
+ }
77
+ // If clearing the counts was requested:
78
+ const clearCounts = searchParams.has('clearCounts');
79
+ const metrics = clearCounts ? await clearMetrics() : await getMetrics();
80
+ query = {
81
+ body: [
82
+ ...(clearCounts ? ['<p>Counts cleared.</p>'] : []),
83
+ `<p>Counts recorded since ${getDateTimeString(metrics.since)}.</p>`,
84
+ '<h2>Web page views</h2>',
85
+ getCategoryTable(metrics.pageViews),
86
+ '<h2>MCP tool calls</h2>',
87
+ getCategoryTable(metrics.mcpToolCalls),
88
+ '<h2>API operation calls</h2>',
89
+ getCategoryTable(metrics.apiOperations),
90
+ '<h2>Manager page activity</h2>',
91
+ '<p>Manager-only pages (linked from <a href="/manage.html">management</a>), excluded from the web page views above. A high failed count for a page may indicate a suspected attack, such as repeated guessing of the authorization code.</p>',
92
+ getManagerActivityTable(metrics.managerActivity)
93
+ ].join('\n')
94
+ };
95
+ }
96
+ // Otherwise, i.e. if the form has not been submitted yet:
97
+ else {
98
+ query = {
99
+ body: [
100
+ '<form action="/metrics.html" method="post">',
101
+ ' <p><label>',
102
+ ' Authorization code: <input size="3" minlength="3" maxlength="3" name="authCode" required>',
103
+ ' </label></p>',
104
+ ' <p><label><input type="checkbox" name="clearCounts"> Clear counts</label></p>',
105
+ ' <p><button type="submit">Submit</button></p>',
106
+ '</form>'
107
+ ].join('\n')
108
+ };
109
+ }
110
+ // Get the populated template.
111
+ const answerPage = await populateTemplate(import.meta.dirname, query);
112
+ // Return the populated page. Submitting a valid authCode also sets a cookie that
113
+ // excludes this browser's future page views and API calls from usage metrics, on the
114
+ // premise that a maintainer who has just proven their identity here is very likely the
115
+ // same person about to manually browse and test the rest of Kilotest.
116
+ return {
117
+ status: 'ok',
118
+ answerPage,
119
+ ...(method === 'POST' && {
120
+ setCookie: {
121
+ name: metricsExclusionCookieName,
122
+ value: getExclusionCookieValue(),
123
+ maxAgeSeconds: 30 * 24 * 60 * 60
124
+ }
125
+ })
126
+ };
127
+ };