@jrpool/kilotest 49.0.0 → 51.1.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/docs/SERVICE.md CHANGED
@@ -64,7 +64,18 @@ pm2 start pm2.config.cjs
64
64
  pm2 save
65
65
  ```
66
66
 
67
- The management of the PM2 logs is [documented by PM2](https://pm2.keymetrics.io/docs/usage/log-management/).
67
+ ### PM2 log rotation
68
+
69
+ PM2's own log management is [documented by PM2](https://pm2.keymetrics.io/docs/usage/log-management/), but PM2 does not rotate its logs by default. Since the general request log (see “Request logging” above) writes one line per request rather than only for rejections, log volume is meaningfully higher than before that addition, so explicit rotation is configured via the [`pm2-logrotate`](https://github.com/keymetrics/pm2-logrotate) module:
70
+
71
+ ```text
72
+ pm2 install pm2-logrotate
73
+ pm2 set pm2-logrotate:max_size 20M
74
+ pm2 set pm2-logrotate:retain 14
75
+ pm2 set pm2-logrotate:compress true
76
+ ```
77
+
78
+ This rotates `~/.pm2/logs/kilotest-out-0.log` (and the error log) once it reaches 20MB, keeps 14 rotated files, and compresses rotated files with gzip.
68
79
 
69
80
  ### Process tuning
70
81
 
@@ -233,13 +244,30 @@ The Caddy configuration is maintained and tracked in `/etc/caddy/Caddyfile`. Lea
233
244
  # Docs: https://caddyserver.com/docs/caddyfile.
234
245
 
235
246
  kilotest.com {
247
+ # Log every request in structured JSON, rotated so it cannot grow unbounded. See
248
+ # "Request logging" below for why this exists alongside Kilotest's own request log.
249
+ log {
250
+ output file /var/log/caddy/kilotest-access.log {
251
+ roll_size 50mb
252
+ roll_keep 10
253
+ roll_keep_for 720h
254
+ }
255
+ format json
256
+ }
236
257
  # Enable Zstandard and Gzip compression of responses.
237
258
  encode zstd gzip
238
- # Specify the only paths of forwardable requests. UptimeRobet uses HEAD.
259
+ # Specify the only paths of forwardable requests.
239
260
  @allowedGET {
240
- method GET HEAD
261
+ method GET
241
262
  path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt /capability.md *.html* /fullReport.json/* /api/* /tutorialWeb/images/* /tutorialAI/images/* /qai /qai/comments /favicon.* /style.css /sitemap.xml
242
263
  }
264
+ # UptimeRobot's uptime check is the only known HEAD caller, and it targets only the site
265
+ # root (see "Health monitoring" below), so HEAD is forwarded only for that path, not the
266
+ # full GET allowlist above. index.ts's HEAD branch matches this same scope.
267
+ @allowedHEAD {
268
+ method HEAD
269
+ path / /index.html
270
+ }
243
271
  @allowedPOST {
244
272
  method POST
245
273
  path /mcp /requestRetest.html/* /requestTest.html /requestAction.html /reannotate.html /renewWCAG.html /ai0BalanceForm.html /expungeReportsForm.html /hideReportForm.html /metrics.html /pruneReportsForm.html /rewindReportsForm.html /showHiddenReportsForm.html /unhideReportForm.html /api/* /tutorialWebComment.html /tutorialAIComment.html /worker/job /worker/report
@@ -254,13 +282,19 @@ kilotest.com {
254
282
  }
255
283
  respond 204
256
284
  }
257
- # Forward any other GET, POST, or OPTIONS request, if allowed, to port 3000.
285
+ # Forward any other GET, HEAD, or POST request, if allowed, to port 3000.
258
286
  handle @allowedGET {
259
287
  reverse_proxy localhost:3000 {
260
288
  # Improve SSE latency.
261
289
  flush_interval -1
262
290
  }
263
291
  }
292
+ handle @allowedHEAD {
293
+ reverse_proxy localhost:3000 {
294
+ # Improve SSE latency.
295
+ flush_interval -1
296
+ }
297
+ }
264
298
  handle @allowedPOST {
265
299
  reverse_proxy localhost:3000 {
266
300
  # Improve SSE latency.
@@ -278,6 +312,15 @@ This configuration can be futher tightened if experience warrants.
278
312
 
279
313
  The `flush_interval` setting prevents granular reporting by Testaro workers from being buffered, so the updates reach the browser without delay.
280
314
 
315
+ ### Request logging
316
+
317
+ Two independent request logs exist, added as part of the abuse evaluation in [GitHub issue #3](https://github.com/jrpool/kilotest/issues/3), so that suspicious traffic can be recognized after the fact rather than only the narrower set of cases that already trigger a rejection:
318
+
319
+ - **Application-level, general request log** (`index.ts`): every request Kilotest itself receives, whether it succeeds or is rejected, is logged as one single-line JSON record via `console.log` (captured by PM2 like all of Kilotest's other console output), with the fields `type: 'request'`, `time`, `ip`, `method`, `path` (no query string, so a parameter such as `authCode` is never written to a log file), `status`, and `userAgent`. This is separate from, and does not replace, `getAbuseError`'s existing multi-line dump, which remains a rarer, human-read-in-the-moment record logged only for rejected requests. Smoke-test-tagged requests (`x-kilotest-smoke`) are excluded from this log, exactly as they are already excluded from usage metrics; the maintainer's own manual testing is deliberately *not* excluded (unlike usage metrics), since abuse visibility should see all real traffic.
320
+ - **Caddy access log**: enabled at the reverse-proxy layer for connection- and pre-dispatch-level visibility the application itself never sees (for example, requests to paths outside Kilotest's own allowlist, which Caddy rejects before ever forwarding them). See the `log` directive in the Caddyfile above.
321
+
322
+ Both logs are rotated; see “PM2 log rotation” above for the application log and the `log` directive’s own `roll_size`/`roll_keep` options for the Caddy log.
323
+
281
324
  ## Version management
282
325
 
283
326
  When a new version of the `kilotest` package has been published, the service can be updated as follows:
@@ -310,7 +353,7 @@ The smoke-test check is defined in `.github/workflows/smoke-test.yml` and runs `
310
353
  When a pull request adds a new route to `index.js`, the Caddyfile at `/etc/caddy/Caddyfile` on the server must be updated to forward the new path before the pull request can merge. The workflow to follow is:
311
354
 
312
355
  1. Add the new route to the `routes` constant and the dispatch chain in `index.js`.
313
- 1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path.
356
+ 1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path, then reload Caddy so the change takes effect: `sudo systemctl reload caddy` (a reload, not a restart, so in-flight connections are not dropped).
314
357
  1. Open or update the pull request.
315
358
  1. The smoke test workflow runs and verifies that all paths, including the new one, are forwarded by Caddy.
316
359
  1. If the Caddyfile was not updated, the smoke test reports a bare 404 for the new path, the required status check fails, and the pull request cannot merge.
@@ -332,7 +375,7 @@ The deployment is monitored for external health and availability using [UptimeRo
332
375
 
333
376
  UptimeRobot also sends a recovery message when the service recovers and a subsequent check succeeds after a prior failure.
334
377
 
335
- This monitoring service requires `Caddyfile` to permit `HEAD` requests, not only `GET` requests, to the root path.
378
+ This monitoring service requires both `Caddyfile` and Kilotest itself to permit `HEAD` requests, not only `GET` requests, to the root path: `Caddyfile`’s `@allowedHEAD` matcher forwards such a request to port 3000, and `index.ts`’s own dedicated `HEAD` branch in `handleRequest` answers it. Before that branch existed, Kilotest treated `HEAD` as an invalid method for every path (its dispatch recognized only `GET` and `POST`), so UptimeRobot’s hourly check received a `400` response and reported the service as down even while it was actually up.
336
379
 
337
380
  ## Alerting
338
381
 
@@ -356,7 +399,7 @@ Alerts are sent to the maintainer in the following conditions:
356
399
 
357
400
  7. **Test request received via API**: When a test request is submitted via the API, an alert is sent with subject `"Kilotest: new test request in the API"` (via the `requestTest` API operation) or `"Kilotest: new retest request in the API"` (via the `requestRetest` API operation), including the target URL, description, and the user's stated reason. A request is treated as a duplicate, and no alert is sent for it, only if a request already pending in `db/jobs/testRequests.json`, i.e., submitted but not yet manually approved by the maintainer into an actual job, for the same URL has the identical description; this check ignores the stated reason and the test-versus-retest distinction, and is not limited to a recent time window, it compares against every request still pending approval for that URL. Once the maintainer approves a pending request (turning it into a job via `/enqueue.html`), all pending requests for that URL are cleared, so this check cannot detect a duplicate against a request that has already been approved, is currently running, or has already completed; an identical request submitted after approval alerts again. The check and its dedup key are identical for the UI and API paths, since both funnel through the same underlying request-queuing function.
358
401
 
359
- 8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request.
402
+ 8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request, and the request is also appended to `db/featureRequests.json`, the same way tutorial comments are appended to their own files (see item 1), so that a manager can review or delete it later via `/deleteNotesForm.html`.
360
403
 
361
404
  9. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
362
405
 
package/index.test.ts CHANGED
@@ -15,9 +15,19 @@ process.env.DB_DIR = fixtureDBDir;
15
15
  process.env.AUTH_CODE = 'test-auth-code';
16
16
  process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
17
17
  process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
18
+ process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.json');
18
19
  process.env.TESTARO_WORKERS = JSON.stringify({
19
20
  worker1: {secret: 'secret1', name: 'Worker One'}
20
21
  });
22
+ // Blank the alert configuration unconditionally, so this file sends no real alert
23
+ // emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
24
+ // when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
25
+ // which normally guards against this by --require-ing test/setup.ts first. dotenv does
26
+ // not override an already-set env var, so setting these here, before index.ts is ever
27
+ // imported, prevents a real .env file's alert credentials from ever taking effect.
28
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
29
+ process.env[key] = '';
30
+ }
21
31
 
22
32
  // IMPORTS
23
33
 
@@ -58,6 +68,7 @@ beforeEach(async () => {
58
68
  await fs.mkdir(testCommentsDir, {recursive: true});
59
69
  await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
60
70
  await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
71
+ await fs.writeFile(path.join(testCommentsDir, 'featureRequests.json'), '[]\n');
61
72
  });
62
73
 
63
74
  after(async () => {
@@ -215,6 +226,30 @@ test('GET /index.html serves the home page', async () => {
215
226
  assert.ok(res.headers['content-type'].includes('text/html'));
216
227
  });
217
228
 
229
+ // TESTS: HEAD routes
230
+
231
+ test('HEAD / returns the same status and headers as GET /, with no body', async () => {
232
+ const getRes = await request('GET', '/');
233
+ const headRes = await request('HEAD', '/');
234
+ assert.equal(headRes.statusCode, getRes.statusCode);
235
+ assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
236
+ assert.equal(headRes.body, '');
237
+ });
238
+
239
+ test('HEAD /index.html returns the same status and headers as GET /, with no body', async () => {
240
+ const getRes = await request('GET', '/');
241
+ const headRes = await request('HEAD', '/index.html');
242
+ assert.equal(headRes.statusCode, getRes.statusCode);
243
+ assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
244
+ assert.equal(headRes.body, '');
245
+ });
246
+
247
+ test('HEAD /robots.txt returns an invalid HEAD request error, since only the home page supports HEAD', async () => {
248
+ const res = await request('HEAD', '/robots.txt');
249
+ assert.equal(res.statusCode, 400);
250
+ assert.equal(res.body, '');
251
+ });
252
+
218
253
  test('GET /robots.txt serves the robots file as text', async () => {
219
254
  const res = await request('GET', '/robots.txt');
220
255
  assert.equal(res.statusCode, 200);
@@ -826,6 +861,40 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
826
861
  assert.equal(res.headers['location'], '/tutorialAI.html');
827
862
  });
828
863
 
864
+ test('GET /diagnoses.html/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
865
+ const res = await request(
866
+ 'GET', '/diagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body', {}, {followRedirects: false}
867
+ );
868
+ assert.equal(res.statusCode, 301);
869
+ assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body');
870
+ });
871
+
872
+ test('GET /reportIssues.html/<timeStamp>/<jobID> redirects to the equivalent /listIssues.html/<timeStamp>/<jobID>', async () => {
873
+ const res = await request('GET', '/reportIssues.html/260101T0000/mix', {}, {followRedirects: false});
874
+ assert.equal(res.statusCode, 301);
875
+ assert.equal(res.headers['location'], '/listIssues.html/260101T0000/mix');
876
+ });
877
+
878
+ test('GET /reportIssue.html/<segments> redirects to the equivalent /listViolators.html/<segments>', async () => {
879
+ const res = await request(
880
+ 'GET', '/reportIssue.html/duplicateID/260101T0000/mix', {}, {followRedirects: false}
881
+ );
882
+ assert.equal(res.statusCode, 301);
883
+ assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000/mix');
884
+ });
885
+
886
+ test('GET /rules.html/<segment> redirects to the equivalent /listRules.html/<segment>', async () => {
887
+ const res = await request('GET', '/rules.html/allCaps', {}, {followRedirects: false});
888
+ assert.equal(res.statusCode, 301);
889
+ assert.equal(res.headers['location'], '/listRules.html/allCaps');
890
+ });
891
+
892
+ test('GET /targets.html redirects to /listReports.html', async () => {
893
+ const res = await request('GET', '/targets.html', {}, {followRedirects: false});
894
+ assert.equal(res.statusCode, 301);
895
+ assert.equal(res.headers['location'], '/listReports.html');
896
+ });
897
+
829
898
  test('POST /requestTest.html with non-https URL returns an error', async () => {
830
899
  const res = await formRequest('POST', '/requestTest.html', {
831
900
  description: 'Test Page',
@@ -1213,6 +1282,7 @@ const htmlPagePaths = [
1213
1282
  '/pruneReportsForm.html',
1214
1283
  '/rewindReportsForm.html',
1215
1284
  '/ai0BalanceForm.html',
1285
+ '/deleteNotesForm.html',
1216
1286
  '/metrics.html'
1217
1287
  ];
1218
1288
 
@@ -1390,6 +1460,32 @@ test('POST /rewindReportsForm.html with no selections shows the form and records
1390
1460
  assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
1391
1461
  });
1392
1462
 
1463
+ test('POST /deleteNotesForm.html deletes multiple selected notes across sources in one submission', async () => {
1464
+ await fs.writeFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, JSON.stringify([
1465
+ {timeStamp: '260101T0000', content: 'A web tutorial comment'}
1466
+ ]));
1467
+ await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify([
1468
+ {timeStamp: '260101T0001', content: 'A feature request'},
1469
+ {timeStamp: '260101T0001', content: 'A different feature request'}
1470
+ ]));
1471
+ // Reconstructing the POST body's query string uses querystring.stringify, which (unlike
1472
+ // the URLSearchParams object constructor) preserves multiple values for the same field
1473
+ // name as repeated key=value pairs rather than collapsing them into one comma-joined
1474
+ // value, so this exercises that multi-checkbox submission actually deletes every
1475
+ // selected note, not just the one whose value happens to survive the collapse.
1476
+ const res = await formRequest('POST', '/deleteNotesForm.html', [
1477
+ ['authCode', 'test-auth-code'],
1478
+ ['note', `tutorialWeb\t${encodeURIComponent('A web tutorial comment')}`],
1479
+ ['note', `featureRequest\t${encodeURIComponent('A feature request')}`]
1480
+ ]);
1481
+ assert.equal(res.statusCode, 200);
1482
+ const webComments = JSON.parse(await fs.readFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, 'utf8'));
1483
+ assert.equal(webComments.length, 0);
1484
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
1485
+ assert.equal(featureRequests.length, 1);
1486
+ assert.equal(featureRequests[0].content, 'A different feature request');
1487
+ });
1488
+
1393
1489
  test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
1394
1490
  const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
1395
1491
  const backup = await fs.readFile(reportPath, 'utf8');
@@ -1545,7 +1641,7 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
1545
1641
  assert.ok(res.body.includes('Test request duplicates an already submitted request'));
1546
1642
  });
1547
1643
 
1548
- test('POST /requestAction.html with valid auth code and approval of a duplicate returns an error', {timeout: 500}, async () => {
1644
+ test('POST /requestAction.html with valid auth code approves the same target twice without error', {timeout: 500}, async () => {
1549
1645
  await fs.writeFile(testRequestsPath, '{}\n');
1550
1646
  // Create a request.
1551
1647
  await formRequest('POST', '/requestTest.html', {
@@ -1559,20 +1655,19 @@ test('POST /requestAction.html with valid auth code and approval of a duplicate
1559
1655
  authCode: 'test-auth-code',
1560
1656
  what: 'yes'
1561
1657
  });
1562
- // Approve it again (the requests may have been cleared, so this may succeed or fail).
1563
- // This test covers the requestAction answer error branch.
1658
+ // Approve it again. enqueue.answer has no duplicate-detection logic of its own (that
1659
+ // lives in processTestRequest, upstream of approval), so a second approval of the same
1660
+ // target queues a second job rather than erroring.
1564
1661
  const res = await formRequest('POST', '/requestAction.html', {
1565
1662
  target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
1566
1663
  authCode: 'test-auth-code',
1567
1664
  what: 'yes'
1568
1665
  });
1569
- // Either it succeeds (200) or returns an error (400).
1570
- assert.ok(res.statusCode === 200 || res.statusCode === 400);
1666
+ assert.equal(res.statusCode, 200);
1571
1667
  });
1572
1668
 
1573
1669
  test('POST /requestAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
1574
- // A URL that starts with https:// but is not a valid URL causes
1575
- // enqueue.answer to return status error.
1670
+ // A URL that starts with https:// but is not a valid URL fails index.ts's own isURL check.
1576
1671
  const res = await formRequest('POST', '/requestAction.html', {
1577
1672
  target: 'https://\tTest Page',
1578
1673
  authCode: 'test-auth-code',
@@ -1646,6 +1741,75 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
1646
1741
  assert.equal(result.reason, 'test reason');
1647
1742
  });
1648
1743
 
1744
+ // TESTS FOR general per-request logging
1745
+
1746
+ test('a request logs a single-line JSON record with the expected fields', async () => {
1747
+ const logged: string[] = [];
1748
+ const originalLog = console.log;
1749
+ console.log = (line: string) => logged.push(line);
1750
+ try {
1751
+ await request('GET', '/listReports.html');
1752
+ }
1753
+ finally {
1754
+ console.log = originalLog;
1755
+ }
1756
+ const requestLines = logged.filter(line => {
1757
+ try {
1758
+ return JSON.parse(line).type === 'request';
1759
+ }
1760
+ catch {
1761
+ return false;
1762
+ }
1763
+ });
1764
+ assert.equal(requestLines.length, 1);
1765
+ const record = JSON.parse(requestLines[0]!);
1766
+ assert.equal(record.method, 'GET');
1767
+ assert.equal(record.path, '/listReports.html');
1768
+ assert.equal(record.status, 200);
1769
+ assert.ok(record.ip);
1770
+ assert.ok(record.userAgent);
1771
+ assert.ok(record.time);
1772
+ });
1773
+
1774
+ test('a request logs its path without its query string', async () => {
1775
+ const logged: string[] = [];
1776
+ const originalLog = console.log;
1777
+ console.log = (line: string) => logged.push(line);
1778
+ try {
1779
+ await request('GET', '/listIssues.html/260101T0000/mix?authCode=secret');
1780
+ }
1781
+ finally {
1782
+ console.log = originalLog;
1783
+ }
1784
+ const record = logged
1785
+ .map(line => { try {return JSON.parse(line);} catch {return null;} })
1786
+ .find(parsed => parsed?.type === 'request');
1787
+ assert.ok(record);
1788
+ assert.equal(record.path, '/listIssues.html/260101T0000/mix');
1789
+ assert.ok(!JSON.stringify(record).includes('secret'));
1790
+ });
1791
+
1792
+ test('a smoke-test-flagged request is not logged as a general request', async () => {
1793
+ const logged: string[] = [];
1794
+ const originalLog = console.log;
1795
+ console.log = (line: string) => logged.push(line);
1796
+ try {
1797
+ await request('GET', '/listReports.html', null, {'x-kilotest-smoke': '1'});
1798
+ }
1799
+ finally {
1800
+ console.log = originalLog;
1801
+ }
1802
+ const requestLines = logged.filter(line => {
1803
+ try {
1804
+ return JSON.parse(line).type === 'request';
1805
+ }
1806
+ catch {
1807
+ return false;
1808
+ }
1809
+ });
1810
+ assert.equal(requestLines.length, 0);
1811
+ });
1812
+
1649
1813
  // UNIT TESTS FOR startServer
1650
1814
 
1651
1815
  test('startServer starts an HTTP server when protocol is http', {timeout: 500}, async () => {
package/index.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  isReportAvailable,
24
24
  isTimeStamp,
25
25
  isJobID,
26
+ isURL,
26
27
  isUsableReport,
28
+ isValidAuthCode,
27
29
  jobsPath,
28
30
  metricsExclusionCookieName,
29
31
  recordMetric,
@@ -40,7 +42,9 @@ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './
40
42
  import http, {type IncomingMessage, type ServerResponse} from 'node:http';
41
43
  import https from 'node:https';
42
44
  import path from 'node:path';
45
+ import querystring from 'node:querystring';
43
46
  import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
47
+ import {answer as deleteNotesForm} from './web/deleteNotesForm/index.ts';
44
48
  import {answer as enqueue} from './web/enqueue/index.ts';
45
49
  import {answer as enqueueForm} from './web/enqueueForm/index.ts';
46
50
  import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
@@ -95,6 +99,7 @@ type PageHandler = (...args: any[]) => Promise<AnswerData>;
95
99
 
96
100
  const answer: {
97
101
  ai0BalanceForm: PageHandler;
102
+ deleteNotesForm: PageHandler;
98
103
  enqueue: PageHandler;
99
104
  enqueueForm: PageHandler;
100
105
  expungeReportsForm: PageHandler;
@@ -124,6 +129,7 @@ const answer: {
124
129
  [key: string]: PageHandler | undefined;
125
130
  } = {
126
131
  ai0BalanceForm,
132
+ deleteNotesForm,
127
133
  enqueue,
128
134
  enqueueForm,
129
135
  expungeReportsForm,
@@ -205,6 +211,7 @@ export const routes = {
205
211
  '/api/*',
206
212
  '/mcp',
207
213
  '/ai0BalanceForm.html',
214
+ '/deleteNotesForm.html',
208
215
  '/expungeReportsForm.html',
209
216
  '/hideReportForm.html',
210
217
  '/metrics.html',
@@ -238,6 +245,7 @@ const managerPages = new Set([
238
245
  'unhideReportForm',
239
246
  'ai0BalanceForm',
240
247
  'renewWCAGForm',
248
+ 'deleteNotesForm',
241
249
  'metrics'
242
250
  ]);
243
251
  // The set of manager pages (a subset of managerPages) whose single answer() function both
@@ -253,6 +261,7 @@ const selfSubmittingManagerPages = new Set([
253
261
  'showHiddenReportsForm',
254
262
  'unhideReportForm',
255
263
  'ai0BalanceForm',
264
+ 'deleteNotesForm',
256
265
  'metrics'
257
266
  ]);
258
267
  // The subset of selfSubmittingManagerPages that must never be served on a direct GET
@@ -260,6 +269,17 @@ const selfSubmittingManagerPages = new Set([
260
269
  // unhideReportForm is served only by showHiddenReportsForm, once a valid authorization code
261
270
  // has been submitted), so that the names of hidden reports are never disclosed without one.
262
271
  const noDirectGetPages = new Set(['unhideReportForm']);
272
+ // Old page names, from before a 2026-08-31 rename (see GitHub issue #3), that search
273
+ // engines and other crawlers are still requesting. Redirecting them lets crawlers update
274
+ // their own indexes, rather than leaving them to repeat the same dead request indefinitely.
275
+ // TEMPORARY: retire this redirect after 2027-04-01 once crawlers have re-indexed.
276
+ const renamedPagePrefixes: Record<string, string> = {
277
+ diagnoses: 'listDiagnoses',
278
+ reportIssue: 'listViolators',
279
+ reportIssues: 'listIssues',
280
+ rules: 'listRules',
281
+ targets: 'listReports'
282
+ };
263
283
  const jobLock = createLock();
264
284
 
265
285
  // FUNCTIONS
@@ -309,16 +329,22 @@ export const serveError = async (error: Record<string, unknown>, response: Serve
309
329
  console.log('Cannot send error response because the response has ended.');
310
330
  }
311
331
  };
332
+ // Returns the IP address a request should be attributed to: the value of its
333
+ // x-forwarded-for header (Caddy runs in front of Kilotest, so a real client's address
334
+ // arrives this way, not as the socket's own remote address), else the socket's remote
335
+ // address, else 'unknown' if neither is available.
336
+ export const getRequestIP = (request: IncomingMessage): string | string[] => {
337
+ const forwardedFor = request.headers['x-forwarded-for'];
338
+ const remoteAddress = request.socket.remoteAddress;
339
+ return forwardedFor || remoteAddress || 'unknown';
340
+ };
312
341
  // Creates an error object about a suspicious request.
313
342
  export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
314
343
  const {method, url, headers} = request;
315
- const forwardedFor = headers['x-forwarded-for'];
316
- const remoteAddress = request.socket.remoteAddress;
317
- const ip = forwardedFor || remoteAddress || 'unknown';
318
344
  return {
319
345
  message: 'Invalid request',
320
346
  reason,
321
- 'IP address': ip,
347
+ 'IP address': getRequestIP(request),
322
348
  method,
323
349
  URL: url,
324
350
  'user agent': headers['user-agent'] || 'none',
@@ -326,6 +352,39 @@ export const getAbuseError = (request: IncomingMessage, reason: string | undefin
326
352
  time: new Date().toISOString()
327
353
  };
328
354
  };
355
+ // Logs one line for every request, successful or not, once it finishes. Unlike
356
+ // getAbuseError (logged only for rejected requests, as a rare, human-read-in-the-moment
357
+ // dump), this is a terse, single-line-per-request JSON record, intended to be high-volume
358
+ // and machine-parseable, so that any kind of suspicious traffic pattern (not only the
359
+ // specific rejection reasons getAbuseError already covers) can be recognized later. It
360
+ // deliberately does not consult isMetricsExcluded: that exclusion exists to keep the
361
+ // maintainer's own manual testing out of usage counts, but abuse visibility should see
362
+ // all traffic, including the maintainer's own, or it recreates the same kind of blind
363
+ // spot this logging exists to close.
364
+ const logRequestOnFinish = (request: IncomingMessage, response: ServerResponse): void => {
365
+ const {method, headers} = request;
366
+ const ip = getRequestIP(request);
367
+ const userAgent = headers['user-agent'] || 'none';
368
+ const path = new URL(request.url as string, 'https://localhost:3000').pathname;
369
+ const originalEnd = response.end.bind(response);
370
+ // Reassign response.end so that every path through handleRequest logs exactly once,
371
+ // right before the response is actually sent, rather than requiring every dispatch
372
+ // branch to remember to log itself (the same class of gap that left successful
373
+ // requests unlogged in the first place). No query string is logged, so that a
374
+ // parameter such as authCode is never written to a log file.
375
+ response.end = ((...args: Parameters<typeof originalEnd>) => {
376
+ console.log(JSON.stringify({
377
+ type: 'request',
378
+ time: new Date().toISOString(),
379
+ ip,
380
+ method,
381
+ path,
382
+ status: response.statusCode,
383
+ userAgent
384
+ }));
385
+ return originalEnd(...args);
386
+ }) as typeof response.end;
387
+ };
329
388
  // Returns whether a request carries the metrics-exclusion cookie with the value that
330
389
  // proves it. A request without a valid AUTH_CODE configured can never match, since
331
390
  // getExclusionCookieValue then hashes an empty string, which no cookie should equal.
@@ -487,6 +546,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
487
546
  response.end('{}');
488
547
  return;
489
548
  }
549
+ // Log this request once it finishes, for general abuse/usage visibility (see GitHub
550
+ // issue #3). Placed after the smoke-test short-circuit above, so CI's own smoke-test
551
+ // traffic is excluded from this log exactly as it is already excluded from metrics.
552
+ logRequestOnFinish(request, response);
490
553
  // Records a pageViews or apiOperations metric, unless this request's browser has proven,
491
554
  // via a valid authCode submitted earlier on metrics.html, that it belongs to the
492
555
  // maintainer testing Kilotest manually rather than to real usage.
@@ -636,13 +699,21 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
636
699
  // Otherwise, if it is for an HTML page other than the home page:
637
700
  else if (pageName.endsWith('.html')) {
638
701
  const topic = pageName.slice(0, -5);
639
- // If the page can be generated and is not POST-only (a POST-only path also matches
702
+ // If it is for a page's old, pre-rename name:
703
+ if (renamedPagePrefixes[topic]) {
704
+ // Redirect the client permanently to the current page name, preserving any
705
+ // further path segments and the query string.
706
+ const newLocation = `/${renamedPagePrefixes[topic]}.html${pathTail ? `/${pathTail}` : ''}${search}`;
707
+ response.writeHead(301, {Location: newLocation});
708
+ response.end();
709
+ }
710
+ // Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
640
711
  // the '*.html*' GET pattern, but its handler expects POST's argument list and
641
712
  // performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
642
713
  // too, but its handler serves both methods the same way, so it is not excluded here,
643
714
  // except for a page in noDirectGetPages, which stays excluded from GET even though
644
715
  // it is self-submitting, since it must be reachable only via another page's POST):
645
- if (
716
+ else if (
646
717
  answer[topic] &&
647
718
  !noDirectGetPages.has(topic) &&
648
719
  (!isPathAllowed('POST', pathname) || selfSubmittingManagerPages.has(topic))
@@ -793,6 +864,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
793
864
  );
794
865
  }
795
866
  }
867
+ // Otherwise, if the request is a HEAD request: only the home page supports HEAD, since
868
+ // UptimeRobot's uptime check is the only known HEAD caller and it targets only that path
869
+ // (see "Health monitoring" in docs/SERVICE.md). Its response must match what GET / would
870
+ // send, minus the body, so that a monitor reading headers alone sees the real page state.
871
+ else if (method === 'HEAD') {
872
+ if (['/', '/index.html'].includes(pathname)) {
873
+ setHeaders('text/html', '/index.html', 'medium');
874
+ response.end();
875
+ }
876
+ else {
877
+ await serveError({message: `ERROR: Invalid HEAD request (${pathname})`}, response, true);
878
+ }
879
+ }
796
880
  // Otherwise, if the request is a POST request:
797
881
  else if (method === 'POST') {
798
882
  // If the path is not authorized for POST requests:
@@ -815,7 +899,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
815
899
  else if (pageName === 'requestTest.html') {
816
900
  const {description, url, why} = postData as {description?: string; url: string; why?: string};
817
901
  // If the request is valid:
818
- if (description && url.startsWith('https://') && why) {
902
+ if (description && isURL(url) && why) {
819
903
  // If a report on the page is already available:
820
904
  if (await isReportAvailable(description, url)) {
821
905
  // Report the error.
@@ -877,7 +961,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
877
961
  const {target, authCode} = postData as {target: string; authCode?: string};
878
962
  const [url, description] = target.split('\t') as [string, string];
879
963
  // If the request is valid:
880
- if (url.startsWith('https://') && authCode === process.env.AUTH_CODE) {
964
+ if (isURL(url) && isValidAuthCode(authCode)) {
881
965
  // Set the non-location headers for a response.
882
966
  setHeaders('text/html', null, 'ultra');
883
967
  // If the request is an approval:
@@ -885,19 +969,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
885
969
  // Set a location header for a response.
886
970
  response.setHeader('content-location', pathname);
887
971
  // Process the approval and get the answer data about the remaining requests.
888
- const answerData = await answer.enqueue(url, description, authCode);
889
- // If the answer data are valid:
890
- if (answerData.status === 'ok') {
891
- await recordMetric('managerActivity', 'requestAction.html', 'ok');
892
- // Serve the test-order page with the remaining recommendations.
893
- response.end(answerData.answerPage);
894
- }
895
- // Otherwise, i.e. if they are invalid:
896
- else {
897
- await recordMetric('managerActivity', 'requestAction.html', 'error');
898
- // Report the error.
899
- await serveError({message: answerData.message}, response, true);
900
- }
972
+ const answerData = await answer.enqueue(url, description);
973
+ await recordMetric('managerActivity', 'requestAction.html', 'ok');
974
+ // Serve the test-order page with the remaining recommendations.
975
+ response.end(answerData.answerPage);
901
976
  }
902
977
  // Otherwise, i.e. if it is a rejection:
903
978
  else {
@@ -968,7 +1043,12 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
968
1043
  setHeaders('text/html', pathname, 'ultra');
969
1044
  // Reconstruct a query string from the POST body, so the page's answer() function
970
1045
  // can read its submitted parameters the same way it reads a GET query string.
971
- const search = `?${new URLSearchParams(postData as Record<string, string>).toString()}`;
1046
+ // querystring.stringify (rather than the URLSearchParams object constructor) is
1047
+ // required here, because postData, from querystring.parse in getPOSTData, holds
1048
+ // repeated form fields (e.g. multiple checked checkboxes sharing a name) as an
1049
+ // array; the URLSearchParams constructor would join such an array into a single
1050
+ // comma-separated value instead of preserving it as repeated key=value pairs.
1051
+ const search = `?${querystring.stringify(postData as Record<string, string | string[]>)}`;
972
1052
  // Get the answer data. The method is passed so the handler only processes a
973
1053
  // submission for POST, never for GET, regardless of what its query string contains.
974
1054
  const answerData = await answer[topic]!(pathTail, search, method);
@@ -1159,7 +1239,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1159
1239
  }
1160
1240
  }
1161
1241
  }
1162
- // Otherwise, i.e. if it is neither a GET nor a POST request:
1242
+ // Otherwise, i.e. if it is none of GET, HEAD, or POST:
1163
1243
  else {
1164
1244
  // Report its invalidity. (Caddy handles OPTIONS requests.)
1165
1245
  await serveError({message: 'ERROR: Invalid request method'}, response, true);
@@ -60,6 +60,7 @@ const htmlRoutes = [
60
60
  '/expungeReportsForm.html',
61
61
  '/pruneReportsForm.html',
62
62
  '/rewindReportsForm.html',
63
+ '/deleteNotesForm.html',
63
64
  '/renewWCAG.html',
64
65
  '/renewWCAGForm.html',
65
66
  '/enqueue.html',
package/mcp.test.ts CHANGED
@@ -157,7 +157,7 @@ test('requestFeature handler returns content and structuredContent', async () =>
157
157
  const server = createMCPServer();
158
158
  const countBefore = getToolCallCount('requestFeature');
159
159
  const result = await (server as any)._registeredTools.requestFeature.handler({
160
- feature: 'A new feature idea'
160
+ feature: 'A new feature idea worth considering'
161
161
  });
162
162
  assert.ok(result.content);
163
163
  assert.equal(result.content[0].type, 'text');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "49.0.0",
3
+ "version": "51.1.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",