@jrpool/kilotest 47.0.1 → 48.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.
@@ -46,22 +46,30 @@ after(() => {
46
46
 
47
47
  test('requestRetest rejects a nonexistent report', async () => {
48
48
  const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
49
- assert.ok((body['response content']['details about your request'] as any).error);
50
- assert.ok(!logged.some(line => line.includes('request in the API')));
49
+ const details = body['response content']['details about your request'] as any;
50
+ assert.ok(details.error.includes('does not exist'));
51
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
51
52
  });
52
53
 
53
54
  test('requestRetest rejects a superseded report', async () => {
55
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
56
+ // so it is superseded and processTestRequest returns 'superseded', reported via the
57
+ // disposition rather than details.error.
54
58
  const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
55
59
  const details = body['response content']['details about your request'] as any;
56
- assert.ok(details.error.includes('later report'));
57
- assert.ok(!logged.some(line => line.includes('request in the API')));
60
+ assert.equal(details.error, undefined);
61
+ const disposition = body['response content']['disposition of your request'] as any;
62
+ assert.ok(
63
+ disposition['what happens next'].includes('a later report about a page with the same description is available.')
64
+ );
65
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
58
66
  });
59
67
 
60
68
  test('requestRetest rejects a reason shorter than 20 characters', async () => {
61
69
  const body = await response(['260101T0001', 'ct', 'short']);
62
70
  const details = body['response content']['details about your request'] as any;
63
71
  assert.ok(details.error.includes('reason'));
64
- assert.ok(!logged.some(line => line.includes('request in the API')));
72
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
65
73
  });
66
74
 
67
75
  test('requestRetest rejects a reason longer than 100 characters', async () => {
@@ -69,7 +77,58 @@ test('requestRetest rejects a reason longer than 100 characters', async () => {
69
77
  const body = await response(['260101T0001', 'ct', longReason]);
70
78
  const details = body['response content']['details about your request'] as any;
71
79
  assert.ok(details.error.includes('reason'));
72
- assert.ok(!logged.some(line => line.includes('request in the API')));
80
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
81
+ });
82
+
83
+ test('requestRetest rejects a report matching a claimed job by description', async () => {
84
+ // 260101T0001-ct is the "All CantTell Page" report.
85
+ const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
86
+ await fs.writeFile(claimedPath, JSON.stringify({
87
+ target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
88
+ }));
89
+ try {
90
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
91
+ const details = body['response content']['details about your request'] as any;
92
+ assert.equal(details.error, undefined);
93
+ const disposition = body['response content']['disposition of your request'] as any;
94
+ assert.ok(
95
+ disposition['what happens next']
96
+ .includes('a request to test a page with the same description is already approved.')
97
+ );
98
+ }
99
+ finally {
100
+ await fs.unlink(claimedPath);
101
+ }
102
+ });
103
+
104
+ test('requestRetest rejects a report matching a queued job by URL', async () => {
105
+ // 260101T0001-ct's URL is https://example.com/canttell.
106
+ const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
107
+ await fs.writeFile(queuedPath, JSON.stringify({
108
+ target: {what: 'Some Other Page', url: 'https://example.com/canttell'}
109
+ }));
110
+ try {
111
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
112
+ const details = body['response content']['details about your request'] as any;
113
+ assert.equal(details.error, undefined);
114
+ const disposition = body['response content']['disposition of your request'] as any;
115
+ assert.ok(
116
+ disposition['what happens next']
117
+ .includes('a request to test a page with the same URL is already approved.')
118
+ );
119
+ }
120
+ finally {
121
+ await fs.unlink(queuedPath);
122
+ }
123
+ });
124
+
125
+ test('requestRetest rejects a duplicate request', async () => {
126
+ await response(['260101T0001', 'ct', 'A reason that is long enough.']);
127
+ const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
128
+ const details = body['response content']['details about your request'] as any;
129
+ assert.equal(details.error, undefined);
130
+ const disposition = body['response content']['disposition of your request'] as any;
131
+ assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
73
132
  });
74
133
 
75
134
  test('requestRetest accepts a valid retest request for the latest report of a page', async () => {
@@ -78,7 +137,7 @@ test('requestRetest accepts a valid retest request for the latest report of a pa
78
137
  assert.equal(details.error, undefined);
79
138
  assert.equal(details['page to be retested'].description, 'Mixed Outcomes Page');
80
139
  assert.ok(logged.some(line =>
81
- line.startsWith('WARNING (Kilotest: new retest request in the API)')
140
+ line.startsWith('WARNING (Kilotest: new retest request awaits approval)')
82
141
  && line.includes('Mixed Outcomes Page')
83
142
  && line.includes('https://example.com/mixed')
84
143
  ));
@@ -6,8 +6,8 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {z} from 'zod';
9
- import {getResponseMetadata, getThisHost, getToolsFacts, processTestRequest} from './util.ts';
10
- import {getReportExtracts, getReportExtract} from '../util.ts';
9
+ import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {processTestRequest} from '../util.ts';
11
11
  import {requestRetestResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -22,51 +22,67 @@ export const response = async (args: string[]) => {
22
22
  const [timeStamp = '', jobID = '', reason = ''] = args;
23
23
  const thisHost = getThisHost();
24
24
  const reasonLength = reason.length;
25
- // Get data on the report.
26
- const reportExtract = await getReportExtract(timeStamp, jobID);
27
25
  // Initialize the response-content properties.
28
26
  let requestDetails: ResponseContent['details about your request'];
29
27
  let requestDisposition: ResponseContent['disposition of your request'] = null;
30
- // If this failed:
31
- if ('error' in reportExtract) {
32
- requestDetails = {
33
- error: 'request invalid: the specified existing report is not an available report'
34
- };
35
- }
36
- // Otherwise, if the report has been superseded:
37
- else if (
38
- (await getReportExtracts(true))
39
- .every(extract => extract.timeStamp !== timeStamp || extract.jobID !== jobID)
40
- ) {
41
- requestDetails = {
42
- error: 'request invalid: a later report about the page exists'
43
- };
44
- }
45
- // Otherwise, if the encoded reason is too short or too long:
46
- else if (reasonLength < 20 || reasonLength > 100) {
28
+ // If the encoded reason is too short or too long:
29
+ if (reasonLength < 20 || reasonLength > 100) {
47
30
  requestDetails = {
48
31
  error: 'request invalid: your reason is not between 20 and 100 characters long'
49
32
  }
50
33
  }
51
- // Otherwise, i.e. if getting the report succeeded and the request is valid:
34
+ // Otherwise, i.e. if the request is facially valid:
52
35
  else {
53
- // Process the request.
54
- await processTestRequest('retest', reportExtract.description, reportExtract.url, reason);
55
- // Add details about the request.
56
- requestDetails = {
57
- 'date and time received': new Date().toISOString(),
58
- 'page to be retested': {
59
- 'description': reportExtract.description,
60
- 'URL': reportExtract.url
61
- },
62
- 'reason why the page should be retested': reason
63
- };
64
- // Add information about the disposition of the request.
65
- requestDisposition = {
66
- 'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
67
- 'how you can check for completion': 'You can call the listReports tool to learn whether the page has been retested and a new report is available.',
68
- 'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been retested and a new report is available.`
69
- };
36
+ // Process the request. This resolves the cited report's description and URL itself.
37
+ const processResult = await processTestRequest(reason, {timeStamp, jobID});
38
+ // If the cited report does not exist:
39
+ if (processResult.result === 'nonreport') {
40
+ requestDetails = {
41
+ error: 'request invalid: the specified report does not exist'
42
+ };
43
+ }
44
+ // Otherwise, i.e. if the cited report was found:
45
+ else {
46
+ const {result: requestResult, description, url} = processResult;
47
+ // Add details about the request.
48
+ requestDetails = {
49
+ 'date and time received': new Date().toISOString(),
50
+ 'page to be retested': {
51
+ 'description': description,
52
+ 'URL': url
53
+ },
54
+ 'reason why the page should be retested': reason
55
+ };
56
+ // Add information about the disposition of the request.
57
+ if (requestResult === 'ok') {
58
+ requestDisposition = {
59
+ 'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
60
+ 'how you can check for completion': 'You can call the listReports tool to learn whether the page has been retested and a new report is available.',
61
+ 'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been retested and a new report is available.`
62
+ };
63
+ }
64
+ else {
65
+ const failureFact = 'Your request will not be processed, because ';
66
+ let failureReason: string;
67
+ if (requestResult === 'description') {
68
+ failureReason = 'a request to test a page with the same description is already approved.'
69
+ }
70
+ else if (requestResult === 'url') {
71
+ failureReason = 'a request to test a page with the same URL is already approved.'
72
+ }
73
+ else if (requestResult === 'superseded') {
74
+ failureReason = 'a later report about a page with the same description is available.'
75
+ }
76
+ else {
77
+ failureReason = 'an identical request is already awaiting approval.'
78
+ }
79
+ requestDisposition = {
80
+ 'what happens next': `${failureFact}${failureReason}`,
81
+ 'how you can check for completion': 'Not applicable.',
82
+ 'how a web user can check for completion': 'Not applicable.'
83
+ }
84
+ }
85
+ }
70
86
  }
71
87
  // Create the response content.
72
88
  const responseContent: ResponseContent = {
@@ -47,34 +47,90 @@ after(() => {
47
47
  test('requestTest rejects an empty description', async () => {
48
48
  const body = await response(['', 'https://example.com/test', 'A reason that is long enough.']);
49
49
  assert.ok((body['response content']['details about your request'] as any).error);
50
- assert.ok(!logged.some(line => line.includes('request in the API')));
50
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
51
51
  });
52
52
 
53
53
  test('requestTest rejects a description longer than 100 characters', async () => {
54
54
  const longWhat = 'x'.repeat(101);
55
55
  const body = await response([longWhat, 'https://example.com/test', 'A reason that is long enough.']);
56
56
  assert.ok((body['response content']['details about your request'] as any).error);
57
- assert.ok(!logged.some(line => line.includes('request in the API')));
57
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
58
58
  });
59
59
 
60
60
  test('requestTest rejects a URL shorter than 12 characters', async () => {
61
61
  const body = await response(['Test Page', 'short', 'A reason that is long enough.']);
62
62
  assert.ok((body['response content']['details about your request'] as any).error);
63
- assert.ok(!logged.some(line => line.includes('request in the API')));
63
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
64
64
  });
65
65
 
66
66
  test('requestTest rejects a syntactically invalid URL with the correct length', async () => {
67
67
  const body = await response(['Test Page', 'not-a-valid-url', 'A reason that is long enough.']);
68
68
  const details = body['response content']['details about your request'] as any;
69
69
  assert.ok(details.error.includes('invalid URL'));
70
- assert.ok(!logged.some(line => line.includes('request in the API')));
70
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
71
71
  });
72
72
 
73
73
  test('requestTest rejects an already-tested page', async () => {
74
74
  const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
75
75
  const details = body['response content']['details about your request'] as any;
76
- assert.ok(details.error.includes('already been tested'));
77
- assert.ok(!logged.some(line => line.includes('request in the API')));
76
+ // A report for this description and URL already exists, so processTestRequest
77
+ // returns 'retest' and the failure is reported via the disposition, not details.error.
78
+ assert.equal(details.error, undefined);
79
+ const disposition = body['response content']['disposition of your request'] as any;
80
+ assert.ok(
81
+ disposition['what happens next']
82
+ .includes('a report about a page with the same description and URL is available.')
83
+ );
84
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
85
+ });
86
+
87
+ test('requestTest rejects a page matching a claimed job by description', async () => {
88
+ const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
89
+ await fs.writeFile(claimedPath, JSON.stringify({
90
+ target: {what: 'Claimed Test Page', url: 'https://example.com/claimed-job'}
91
+ }));
92
+ try {
93
+ const body = await response(['Claimed Test Page', 'https://example.com/other-url', 'A reason that is long enough.']);
94
+ const details = body['response content']['details about your request'] as any;
95
+ assert.equal(details.error, undefined);
96
+ const disposition = body['response content']['disposition of your request'] as any;
97
+ assert.ok(
98
+ disposition['what happens next']
99
+ .includes('a request to test a page with the same description is already approved.')
100
+ );
101
+ }
102
+ finally {
103
+ await fs.unlink(claimedPath);
104
+ }
105
+ });
106
+
107
+ test('requestTest rejects a page matching a queued job by URL', async () => {
108
+ const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
109
+ await fs.writeFile(queuedPath, JSON.stringify({
110
+ target: {what: 'Some Other Page', url: 'https://example.com/queued-url'}
111
+ }));
112
+ try {
113
+ const body = await response(['A Different Page', 'https://example.com/queued-url', 'A reason that is long enough.']);
114
+ const details = body['response content']['details about your request'] as any;
115
+ assert.equal(details.error, undefined);
116
+ const disposition = body['response content']['disposition of your request'] as any;
117
+ assert.ok(
118
+ disposition['what happens next']
119
+ .includes('a request to test a page with the same URL is already approved.')
120
+ );
121
+ }
122
+ finally {
123
+ await fs.unlink(queuedPath);
124
+ }
125
+ });
126
+
127
+ test('requestTest rejects a duplicate request', async () => {
128
+ await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
129
+ const body = await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
130
+ const details = body['response content']['details about your request'] as any;
131
+ assert.equal(details.error, undefined);
132
+ const disposition = body['response content']['disposition of your request'] as any;
133
+ assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
78
134
  });
79
135
 
80
136
  test('requestTest accepts a valid new page request', async () => {
@@ -83,7 +139,7 @@ test('requestTest accepts a valid new page request', async () => {
83
139
  assert.equal(details.error, undefined);
84
140
  assert.equal(details['page to be tested'].description, 'Brand New Page');
85
141
  assert.ok(logged.some(line =>
86
- line.startsWith('WARNING (Kilotest: new test request in the API)')
142
+ line.startsWith('WARNING (Kilotest: new test request awaits approval)')
87
143
  && line.includes('Brand New Page')
88
144
  && line.includes('https://example.com/brandnew')
89
145
  ));
@@ -6,8 +6,8 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {z} from 'zod';
9
- import {getResponseMetadata, getThisHost, getToolsFacts, processTestRequest} from './util.ts';
10
- import {getReportExtracts, isURL} from '../util.ts';
9
+ import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {isURL, processTestRequest} from '../util.ts';
11
11
  import {requestTestResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -45,36 +45,46 @@ export const response = async (args: string[]) => {
45
45
  }
46
46
  // Otherwise, i.e. if the description and URL are valid:
47
47
  else {
48
- // Get an extract of the available reports.
49
- const reportExtracts = await getReportExtracts();
50
- // If any report is on a page with the specified description and URL:
51
- if (
52
- reportExtracts.some(extract => extract.description === description && extract.url === url)
53
- ) {
54
- requestDetails = {
55
- error: 'request invalid: the page has already been tested and its report is available'
56
- };
57
- }
58
- // Otherwise, i.e. if none is on the page:
59
- else {
60
- // Process the request.
61
- await processTestRequest('test', description, url, reason);
62
- // Add details about the request.
63
- requestDetails = {
64
- 'date and time received': new Date().toISOString(),
65
- 'page to be tested': {
66
- description,
67
- URL: url
68
- },
69
- 'reason why the page should be tested': reason
70
- };
71
- // Add information about the disposition of the request.
48
+ // Process the request.
49
+ const {result: requestResult} = await processTestRequest(reason, {description, url});
50
+ // Add details about the request.
51
+ requestDetails = {
52
+ 'date and time received': new Date().toISOString(),
53
+ 'page to be tested': {
54
+ description,
55
+ URL: url
56
+ },
57
+ 'reason why the page should be tested': reason
58
+ };
59
+ // Add information about the disposition of the request.
60
+ if (requestResult === 'ok') {
72
61
  requestDisposition = {
73
62
  'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
74
63
  'how you can check for completion': 'You can call the listReports tool to learn whether the page has been tested and a report is available.',
75
64
  'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been tested and a report is available.`
76
65
  };
77
66
  }
67
+ else {
68
+ const failureFact = 'Your request will not be processed, because ';
69
+ let failureReason: string;
70
+ if (requestResult === 'description') {
71
+ failureReason = 'a request to test a page with the same description is already approved.'
72
+ }
73
+ else if (requestResult === 'url') {
74
+ failureReason = 'a request to test a page with the same URL is already approved.'
75
+ }
76
+ else if (requestResult === 'retest') {
77
+ failureReason = 'a report about a page with the same description and URL is available.'
78
+ }
79
+ else {
80
+ failureReason = 'an identical request is already awaiting approval.'
81
+ }
82
+ requestDisposition = {
83
+ 'what happens next': `${failureFact}${failureReason}`,
84
+ 'how you can check for completion': 'Not applicable.',
85
+ 'how a web user can check for completion': 'Not applicable.'
86
+ }
87
+ }
78
88
  }
79
89
  // Create the response content.
80
90
  const responseContent: ResponseContent = {
package/api/util.test.ts CHANGED
@@ -7,7 +7,8 @@
7
7
 
8
8
  import {test} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts, processTestRequest} from './util.ts';
10
+ import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts} from './util.ts';
11
+ import {processTestRequest} from '../util.ts';
11
12
 
12
13
  // SETUP
13
14
 
@@ -98,10 +99,10 @@ test('getReportBasics returns an error for a report whose extract cannot be read
98
99
  test('processTestRequest returns an error for a duplicate request', async () => {
99
100
  process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
100
101
  // Submit the same request twice; the second should be a duplicate.
101
- await processTestRequest('test', 'Dup Page', 'https://example.com/dup', 'A reason that is long enough.');
102
- const result: any = await processTestRequest('test', 'Dup Page', 'https://example.com/dup', 'A reason that is long enough.');
103
- assert.equal(result.status, 'error');
104
- assert.equal(result.message, 'Duplicate request');
102
+ const target = {description: 'Dup Page', url: 'https://example.com/dup'};
103
+ await processTestRequest('A reason that is long enough.', target);
104
+ const {result} = await processTestRequest('A reason that is long enough.', target);
105
+ assert.equal(result, 'duplicate');
105
106
  if (savedDBDir !== undefined) {
106
107
  process.env.DB_DIR = savedDBDir;
107
108
  }
package/api/util.ts CHANGED
@@ -5,18 +5,15 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {sendAlert} from '../alerts.ts';
9
8
  import {
10
9
  getAgoDays,
11
10
  getReportExtracts,
12
11
  getNowStamp,
13
- getPlainText,
14
12
  getRandomString,
15
13
  getReportExtract,
16
14
  getReportStats,
17
15
  objectSort,
18
- ruleEngines,
19
- addTestRequest
16
+ ruleEngines
20
17
  } from '../util.ts';
21
18
  import type {ReportExtract} from '../util.ts';
22
19
  import {issues as issueSpecs} from 'testaro-issues';
@@ -83,6 +80,7 @@ export const getRuleEnginesFacts = (ruleEngineIDSet: Iterable<string>) => {
83
80
  // getReportExtracts, it carries a superseded flag; otherwise the flag is computed.
84
81
  // With an extract provided, failure is impossible, so the return type narrows
85
82
  // to ReportBasics; without one, an error object may be returned.
83
+ // XXX Why are these overloads necessary? Can't we just have one function?
86
84
  export function getReportBasics(
87
85
  timeStamp: string, jobID: string, extract: ReportExtract
88
86
  ): Promise<ReportBasics>;
@@ -151,23 +149,3 @@ export const getIssueSpec = (issueID: string) => {
151
149
  // Otherwise, i.e. if it does not exist, return this.
152
150
  return null;
153
151
  };
154
- // Processes a test or retest request from the API.
155
- export const processTestRequest = async (testType: string, description: string, url: string, why: string): Promise<{status: string; message: string} | undefined> => {
156
- // Get an email-safe version of the reason.
157
- const plainWhy = getPlainText(why);
158
- // Add the test request as a transaction.
159
- const updateResult = await addTestRequest(description, url, plainWhy);
160
- // If the request was a duplicate:
161
- if (updateResult.error === 'duplicate') {
162
- // Return this.
163
- return {
164
- status: 'error',
165
- message: 'Duplicate request'
166
- };
167
- }
168
- // Otherwise, i.e. if it was not a duplicate, alert a manager about it.
169
- await sendAlert(
170
- `Kilotest: new ${testType} request in the API`,
171
- `Target: ${description}\nURL: ${url}\nReason: ${plainWhy}`
172
- );
173
- };
package/docs/BACKLOG.md CHANGED
@@ -4,14 +4,6 @@ Engineering tasks and risks that are not yet scheduled.
4
4
 
5
5
  Items marked completed are preserved for about 2 weeks in case of production bugs.
6
6
 
7
- ## Document alerting
8
-
9
- Document when and how Kilotest sends alerts to its maintainer, including the subscription to Resend and the DNS configuration for Resend authentication.
10
-
11
- ## Decide whether to tighten the DMARC policy and add SPF alignment for Resend
12
-
13
- Live DNS for `kilotest.com`, checked 2026-09-16, shows a DMARC record of `v=DMARC1; p=none;` (monitoring only, no `rua=` reporting address) and an SPF record (`v=spf1 include:_spf.porkbun.com ~all`) that does not include `resend.com`. Mail sent via Resend is currently authenticated only by the DKIM record at `resend._domainkey.kilotest.com`. This may be an intentional, working minimal configuration, or an oversight from Resend's onboarding; it has not been confirmed against the Resend dashboard's domain-verification status. Consider confirming, in the Resend dashboard, whether SPF alignment is recommended or required for this domain's deliverability, and once alert deliverability has been stable for a period, whether to tighten DMARC from `p=none` to `p=quarantine` and add an `rua=` address so DMARC aggregate reports reach the maintainer.
14
-
15
7
  ## Widen test-request duplicate detection
16
8
 
17
9
  The duplicate check in `addTestRequest` (`util.ts`), reached by all four test/retest request paths (UI `/requestTest.html` and `/requestRetest.html`; API `requestTest` and `requestRetest` operations), compares an incoming request only against other requests currently pending in `db/jobs/testRequests.json`, i.e., submitted but not yet manually approved by the maintainer into a job. Once a pending request is approved into a job (`web/enqueue/index.ts`), all pending requests for that URL are cleared from `testRequests.json`, so this shared check cannot recognize a duplicate against a request that has already been approved, is currently running, or has already completed. An identical request submitted after approval triggers a fresh alert rather than being filtered.
@@ -205,6 +197,18 @@ Create this file in your project root as `scripts/generate-manifest.js`. It pull
205
197
 
206
198
  Record per-endpoint request counts, latencies, and error rates so that Kilotest managers can observe which API operations are most used and identify performance regressions.
207
199
 
200
+ ## Tighten the DMARC policy after adding aggregate reporting (revised and completed)
201
+
202
+ Resend mail is already fully authenticated and passes DMARC; what remains undecided is reporting and enforcement, not SPF alignment. Confirmed on 2026-09-17 by querying DNS live and by inspecting the full headers of a received alert: SPF passes against a Resend-configured MAIL FROM on `send.kilotest.com` (MX `feedback-smtp.us-east-1.amazonses.com` and TXT `v=spf1 include:amazonses.com ~all`, the pair created by Resend’s “Enable SPF” option), which aligns with the `kilotest.com` From domain under relaxed alignment, and DKIM passes with an aligned `d=kilotest.com` signature (`s=resend`, key published at `resend._domainkey.kilotest.com`). The captured message showed `dmarc=pass` at both the Porkbun forwarding hop and the final Zoho mailbox; Porkbun’s SRS rewrite makes post-forward SPF unaligned, so DKIM is what carries DMARC across the forward. `ALERT_FROM` is `info@kilotest.com`, matching the DMARC domain, and `MANAGER_EMAIL` is the same Porkbun-forwarded address, which terminates at `pool@jpdev.pro` on Zoho.
203
+
204
+ The maintainer sends no mail as `@kilotest.com` (human mail is sent from `pool@jpdev.pro`), so Resend alerts are currently the domain’s only legitimate sending stream, and tightening DMARC risks no known sender. If transactional mail is added later it will also use `info@kilotest.com`; any provider other than Resend would need its own SPF and DKIM alignment configured before deployment under an enforced policy. The root-domain SPF record has no Resend mechanism and needs none: SPF is evaluated against the MAIL FROM domain, which for Resend is `send.kilotest.com`, not the root domain.
205
+
206
+ Consider: (1) adding `rua=mailto:info@kilotest.com` to the `_dmarc.kilotest.com` record while still at `p=none` (aggregate reports are zipped XML and reach Zoho through the existing forward); (2) after a few weeks of reports confirm that no unexpected stream is failing, moving to `p=quarantine`, optionally with a `pct=` ramp, and later to `p=reject`; (3) reflecting any DNS change in `docs/SERVICE.md` and its DNS CSV.
207
+
208
+ ## Document alerting (completed)
209
+
210
+ Document when and how Kilotest sends alerts to its maintainer, including the subscription to Resend and the DNS configuration for Resend authentication.
211
+
208
212
  ## Serve explanation page for human MCP requests (completed)
209
213
 
210
214
  When the `/mcp` path is requested without a `text/event-stream` accept header, serve an HTML page explaining that the endpoint is for AI agents and providing a link to the AI tutorial.
package/docs/SERVICE.md CHANGED
@@ -145,6 +145,26 @@ The DNS records for `kilotest.com` are configured as follows. In each case, `TTL
145
145
  - **Host**: _acme-challenge.kilotest.com
146
146
  - **Answers**: validation tokens provided by Porkbun ACME client
147
147
 
148
+ 6. **TXT** (SPF for Porkbun-handled mail)
149
+ - **Host**: kilotest.com
150
+ - **Answer**: `v=spf1 include:_spf.porkbun.com ~all`
151
+
152
+ 7. **MX and TXT** (Resend MAIL FROM domain; provides SPF alignment for Resend mail)
153
+ - **Host**: send.kilotest.com
154
+ - **Answers**: MX `feedback-smtp.us-east-1.amazonses.com` (priority 10); TXT `v=spf1 include:amazonses.com ~all`
155
+
156
+ 8. **TXT** (Resend DKIM public key)
157
+ - **Host**: resend._domainkey.kilotest.com
158
+ - **Answer**: an RSA public key in `p=...` form, issued by Resend
159
+
160
+ 9. **TXT** (DMARC policy)
161
+ - **Host**: _dmarc.kilotest.com
162
+ - **Answer**: `v=DMARC1; p=none;`
163
+
164
+ 10. **TXT** (site verification)
165
+ - **Host**: kilotest.com
166
+ - **Answers**: `google-site-verification=...` and `smithery-verification=...` tokens issued by Google and Smithery
167
+
148
168
  The DNS configuration as CSV:
149
169
 
150
170
  ```csv
@@ -153,7 +173,13 @@ kilotest.com,kilotest.com,A,149.28.208.106,3600,0
153
173
  kilotest.com,www.kilotest.com,CNAME,kilotest.com,3600,0
154
174
  kilotest.com,kilotest.com,MX,fwd1.porkbun.com,3600,10
155
175
  kilotest.com,kilotest.com,MX,fwd2.porkbun.com,3600,20
176
+ kilotest.com,send.kilotest.com,MX,feedback-smtp.us-east-1.amazonses.com,3600,10
156
177
  kilotest.com,kilotest.com,TXT,"v=spf1 include:_spf.porkbun.com ~all",3600,0
178
+ kilotest.com,send.kilotest.com,TXT,"v=spf1 include:amazonses.com ~all",3600,0
179
+ kilotest.com,resend._domainkey.kilotest.com,TXT,"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDWjPkYLQ+xYcmMRODdXoXl7QTXK5TkhNQXjl5HcPvk1D5A5eqtjuw17lgZOQy1wiUq8BGId+Iq3BWO32kYTJo+lWMQJ8/Me4M0xQ0qaYPmtTJ9cQ8oyrFUuheu7T0MqTYEwYRbHwE74WkQSQTHnCx0Uy98pcXqOaucA3n1v3YkjQIDAQAB",3600,0
180
+ kilotest.com,_dmarc.kilotest.com,TXT,"v=DMARC1; p=none;",3600,0
181
+ kilotest.com,kilotest.com,TXT,google-site-verification=EnNe2chUyUaV645ENd3c6pHs1PZ_Zb3u5516HSirvrA,3600,0
182
+ kilotest.com,kilotest.com,TXT,smithery-verification=6a3a2327c26bc478be748041f340e14fa50c4386eea3b2692eeffda703b8b3f9,3600,0
157
183
  kilotest.com,_acme-challenge.kilotest.com,TXT,GIzAHOFW416fHp7cuTkg4gvJDsyuPZvsPlSsnyViFLQ,3600,0
158
184
  kilotest.com,_acme-challenge.kilotest.com,TXT,qQdIIiUSC76PvYuku-AxPsRPY-fJV7T7i3b8fimlFjU,3600,0
159
185
  ```
@@ -356,7 +382,7 @@ Kilotest uses [Resend](https://resend.com/) as its transactional email delivery
356
382
 
357
383
  ### DNS configuration for Resend
358
384
 
359
- Resend authenticates outgoing mail using DKIM, and mail receivers additionally consult SPF and DMARC records for the sending domain. The `kilotest.com` DNS zone (managed at [porkbun.com](https://porkbun.com/); see the DNS Configuration section above for the full record set) currently contains the following records relevant to Resend, confirmed live on 2026-09-16 by querying DNS directly:
385
+ Resend authenticates outgoing mail using DKIM, and mail receivers additionally consult SPF and DMARC records for the sending domain. The `kilotest.com` DNS zone (managed at [porkbun.com](https://porkbun.com/); see the DNS Configuration section above for the full record set) currently contains the following records relevant to Resend, confirmed live on 2026-09-17 by querying DNS directly and by inspecting the full headers of a delivered alert message:
360
386
 
361
387
  1. **DKIM (DomainKeys Identified Mail)**
362
388
  - **Record type**: TXT (not a CNAME)
@@ -364,10 +390,10 @@ Resend authenticates outgoing mail using DKIM, and mail receivers additionally c
364
390
  - **Value**: an RSA public key in `p=...` form, issued by Resend for this domain.
365
391
 
366
392
  2. **SPF (Sender Policy Framework)**
367
- - **Record type**: TXT
368
- - **Host**: `kilotest.com` (the root domain)
369
- - **Value**: `v=spf1 include:_spf.porkbun.com ~all`
370
- - This record does **not** include `include:resend.com` or any other Resend-related mechanism. Whether Resend's deliverability for this domain depends on that inclusion, or relies on DKIM alignment alone under the current relaxed DMARC policy (see below), has not been confirmed; this is worth confirming against the Resend dashboard's domain-verification status rather than assuming either way.
393
+ - **Record types**: MX and TXT
394
+ - **Host**: `send.kilotest.com` (a dedicated MAIL FROM subdomain, not the root domain)
395
+ - **Values**: MX `feedback-smtp.us-east-1.amazonses.com` (priority 10) and TXT `v=spf1 include:amazonses.com ~all`
396
+ - This pair is what Resend’s “Enable SPF” option creates. It makes Resend send with a MAIL FROM on `send.kilotest.com`, which aligns with the `kilotest.com` From domain under relaxed DMARC alignment. Because Resend sends through Amazon SES, the include target is `amazonses.com`, not `resend.com`. The separate root-domain TXT record `v=spf1 include:_spf.porkbun.com ~all` authorizes Porkbun’s servers for mail sent as `@kilotest.com` through Porkbun; SPF is evaluated against the MAIL FROM domain, so the root record plays no role in Resend authentication and needs no Resend mechanism.
371
397
 
372
398
  3. **DMARC (Domain-based Message Authentication, Reporting and Conformance)**
373
399
  - **Record type**: TXT
@@ -375,7 +401,7 @@ Resend authenticates outgoing mail using DKIM, and mail receivers additionally c
375
401
  - **Value**: `v=DMARC1; p=none;`
376
402
  - The `p=none` policy is monitoring-only: it asks receivers to take no special action on mail that fails alignment, rather than quarantining or rejecting it. No `rua=` reporting address is configured, so no aggregate DMARC reports are being sent anywhere.
377
403
 
378
- These records, along with the MX records already documented above, are the complete set of email-related DNS configuration for `kilotest.com` as of the date given above. Any future change to Resend's configuration (for example, rotating the DKIM key, or tightening the DMARC policy from `p=none` to `p=quarantine` once deliverability is confirmed stable) should be reflected here and in the DNS Configuration section's CSV.
404
+ A delivered alert message inspected on 2026-09-17 confirms this configuration end to end: `spf=pass` with `smtp.mailfrom` on `send.kilotest.com`, `dkim=pass` with `d=kilotest.com` (`s=resend`), and `dmarc=pass` at both the Porkbun forwarding hop and the final mailbox. Any future change to Resend’s configuration (for example, rotating the DKIM key, adding a `rua=` reporting address to the DMARC record, or tightening the DMARC policy from `p=none` to `p=quarantine`) should be reflected here and in the DNS Configuration section’s CSV.
379
405
 
380
406
  ### Setting environment variables in production
381
407