cornfield 0.2.0 → 0.4.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/core/config.js +15 -2
- package/explorer/explorer.css +10 -0
- package/explorer/forge-panel-model.js +17 -0
- package/explorer/forge-panel.js +41 -2
- package/explorer/index.html +32 -0
- package/explorer/main.js +3 -0
- package/explorer/review-tokens-model.js +86 -0
- package/explorer/review-tokens.js +216 -0
- package/package.json +1 -1
- package/server/auth.js +15 -0
- package/server/db.js +31 -0
- package/server/defuse.js +16 -0
- package/server/forge-actions.js +27 -60
- package/server/forge-routes.js +10 -1
- package/server/lander.js +394 -0
- package/server/pipeline.js +3 -10
- package/server/proposals.js +14 -22
- package/server/review-api.js +288 -0
- package/server/review-tokens.js +161 -0
- package/server/reviews.js +31 -8
- package/server/server.js +22 -6
- package/server/submit-review.js +177 -0
- package/server/throttle.js +93 -0
package/server/proposals.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* holds it when the maintainer approves (`hashPullMockups`); every approval also carries `proposal`, the SHA-256 of the
|
|
20
20
|
* proposal file at the head (`proposalHash`). The page never sends either, and a PUT that does is refused. The agent drafting the spec
|
|
21
21
|
* reads it (`cornfield review <slug>`) and folds the edits into the proposal. The PUT is same-origin only
|
|
22
|
-
* (`sameOrigin`), at most `REVIEW_LIMIT` bytes,
|
|
23
|
-
*
|
|
22
|
+
* (`sameOrigin`), at most `REVIEW_LIMIT` bytes, and goes through `submitReview` (server/submit-review.js), the one path the review
|
|
23
|
+
* API shares: the review is checked, the maintainer asked of the forge now, and only a proposal the forge says the PR
|
|
24
|
+
* holds is reviewed; every attempt is written to the `actions` audit table. A proposal in the checkout or a worktree has no
|
|
24
25
|
* PR yet, so there is nothing to review it on: the PUT answers 409 and says to push it as a spec PR. Stage 1 kept
|
|
25
26
|
* the review in the data folder; `migrateStage1Reviews` carries those over once, when the menu is opened.
|
|
26
27
|
*
|
|
@@ -43,10 +44,10 @@ import path from 'node:path';
|
|
|
43
44
|
import { findProposalFile, listProposalFiles } from './files.js';
|
|
44
45
|
import { summarize, proposalProblems } from '../core/proposal.js';
|
|
45
46
|
import { ForgeError } from './forge.js';
|
|
46
|
-
import { REVIEW_LIMIT,
|
|
47
|
-
import {
|
|
47
|
+
import { REVIEW_LIMIT, latestReview, migrateStage1Reviews } from './reviews.js';
|
|
48
|
+
import { submitReview, refusal } from './submit-review.js';
|
|
48
49
|
import { findPullProposal, forgeWords, listPullProposals, pullNumber, readPullProposal } from './proposal-pulls.js';
|
|
49
|
-
import {
|
|
50
|
+
import { serveMockups } from './mockups.js';
|
|
50
51
|
|
|
51
52
|
// The review's checks live in server/reviews.js; these are re-exported for the callers that always had them here
|
|
52
53
|
export { REVIEW_LIMIT, MAX_REVIEW_EDITS, MAX_NOTE_CHARS, validateReview } from './reviews.js';
|
|
@@ -261,28 +262,19 @@ async function putPullReview(req, res, where, forgeFor, signedIn, slug, n) {
|
|
|
261
262
|
}
|
|
262
263
|
// What an approval covers is the server's to record, from the files themselves, never the page's word
|
|
263
264
|
if (parsed !== null && typeof parsed === 'object' && ('mockups' in parsed || 'proposal' in parsed)) return send(res, 400, { error: 'What an approval covers (the proposal and its mockups) is recorded by the server; a review does not send it.' });
|
|
264
|
-
const checked = validateReview(parsed);
|
|
265
|
-
if (!checked.ok) return send(res, 400, { error: checked.error });
|
|
266
265
|
try {
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// The proposal as the head holds it now: a later push that changes it is not this approval's
|
|
273
|
-
review = { ...review, proposal: proposalHash(await readPullProposal(forge, where, entry)) };
|
|
274
|
-
const mockups = await hashPullMockups(forge, where, entry);
|
|
275
|
-
if (mockups.length) review = { ...review, mockups };
|
|
276
|
-
}
|
|
277
|
-
const posted = await postReview(forge, n, slug, review, { login: signedIn.login, record: signedIn.record });
|
|
278
|
-
signedIn.hooks?.emit(review.verdict === 'approved' ? 'spec.approved' : 'spec.sentBack', () => proposalPayload(forge, where, entry, { actor: signedIn.login, note: review.note, edits: review.edits }));
|
|
279
|
-
return send(res, 200, posted);
|
|
266
|
+
// The checks and the post are submitReview's, shared with the review API (server/submit-review.js)
|
|
267
|
+
const found = await submitReview({
|
|
268
|
+
forge: forgeFor, where, repo: signedIn.repo, who: { login: signedIn.login, userId: signedIn.userId }, record: signedIn.record, limits: signedIn.limits, hooks: signedIn.hooks, lander: signedIn.lander,
|
|
269
|
+
}, { pull: n, slug, review: parsed });
|
|
270
|
+
return send(res, 200, found);
|
|
280
271
|
} catch (err) {
|
|
281
|
-
|
|
272
|
+
const out = refusal(err);
|
|
273
|
+
return send(res, out.status, { error: out.body.error }, out.headers);
|
|
282
274
|
}
|
|
283
275
|
}
|
|
284
276
|
|
|
285
|
-
/** @typedef {{ login: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void, hooks?: import('./hooks.js').Hooks | null }} SignedIn */
|
|
277
|
+
/** @typedef {{ login: string, userId: string, repo: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void, limits: { auth: object, now?: () => number }, hooks?: import('./hooks.js').Hooks | null, lander?: import('./lander.js').Lander | null }} SignedIn who is signed in, the repo, where their actions are recorded, and what the flood and repeat limits count by */
|
|
286
278
|
|
|
287
279
|
/**
|
|
288
280
|
* Answers a request under `/proposals`, or returns false when it is not one.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/review-api
|
|
3
|
+
*
|
|
4
|
+
* The review API (spec 0002, issue #67): Approve or Send back from somewhere that is not the button, a script or a chat
|
|
5
|
+
* reaction, with a review token the maintainer made in the dashboard. The tokens are kept by the review-tokens module.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/reviews { pull, slug, sha256, verdict, note?, edits? }, with `Authorization: Bearer cf_rt_…`
|
|
8
|
+
* GET /api/review-tokens the signed-in maintainer's tokens: id, label, made, expires, revoked, last used (never a secret)
|
|
9
|
+
* POST /api/review-tokens { label, days? } -> the new token, in this answer only
|
|
10
|
+
* POST /api/review-tokens/<id>/revoke ends one of theirs for good
|
|
11
|
+
*
|
|
12
|
+
* **The call takes the token and nothing else.** It runs before the sign-in guard (a script has no cookie) and reads no
|
|
13
|
+
* cookie: a session cookie is never a substitute for the token, and a request that sends only one is 401. Being no
|
|
14
|
+
* cookie-borne request it needs no same-origin check (a browser never adds an `Authorization` header by itself); the
|
|
15
|
+
* `Host` check in front of every route still applies. It is a review like the button's: both go through `submitReview`
|
|
16
|
+
* (server/submit-review.js), which asks the forge NOW whether the token's owner still maintains the repo (a demoted owner is
|
|
17
|
+
* 403), checks the proposal's SHA-256 at the PR's head (409), needs a note to send back (400), counts the flood and the
|
|
18
|
+
* repeat, and posts the comment as the owner with their kept sign-in (`auth.forgeForUser`; without a live one, 401). The
|
|
19
|
+
* comment says it came through the API and names the token's label.
|
|
20
|
+
*
|
|
21
|
+
* **Every call is a row in `actions`** (`review`, with the token's id and label in the request, never the token):
|
|
22
|
+
*
|
|
23
|
+
* no token, or one that no row has 401 (`WWW-Authenticate: Bearer`), logged under `(no token)`. Past `ANONYMOUS_PER_MINUTE` a minute
|
|
24
|
+
* nothing is written and the answer is 429, so anyone who can reach the server cannot fill the table
|
|
25
|
+
* a token that is revoked or expired 403, under its owner
|
|
26
|
+
* a live token whatever `submitReview` answers: 200 with the review, or 400, 401, 403, 404, 409, 429, 502
|
|
27
|
+
*
|
|
28
|
+
* The three routes under `/api/review-tokens` are for the signed-in maintainer only: they pass the sign-in guard like
|
|
29
|
+
* every other route, take a POST from cornfield's own page only (`sameOrigin`) with a JSON body, and a review token is
|
|
30
|
+
* no way to them (the guard reads cookies, never `Authorization`), so a token cannot make tokens. A token is
|
|
31
|
+
* made after the forge is asked who the maintainer is now, and the make and the revoke are rows in `actions` too
|
|
32
|
+
* (`review-token`), begun before the change is made.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ForgeError, isMaintainer } from './forge.js';
|
|
36
|
+
import { sameOrigin, readBody } from './proposals.js';
|
|
37
|
+
import { REVIEW_LIMIT } from './reviews.js';
|
|
38
|
+
import { MAX_ACTIVE, MAX_DAYS, DEFAULT_DAYS, bearerOf, daysOf, findToken, labelProblem, listTokens, mintToken, revokeToken, touchToken } from './review-tokens.js';
|
|
39
|
+
import { refusal, submitReview } from './submit-review.js';
|
|
40
|
+
import { Refused, countAttempt } from './throttle.js';
|
|
41
|
+
|
|
42
|
+
export const REVIEWS_PATH = '/api/reviews';
|
|
43
|
+
export const TOKENS_PATH = '/api/review-tokens';
|
|
44
|
+
/** Calls with no token, or one nobody holds, that are written down in a minute, all callers together */
|
|
45
|
+
export const ANONYMOUS_PER_MINUTE = 20;
|
|
46
|
+
/** What the audit row of a call nobody could name says in place of a login */
|
|
47
|
+
export const NO_TOKEN_LOGIN = '(no token)';
|
|
48
|
+
/** The keys of a call's body */
|
|
49
|
+
const CALL_KEYS = ['pull', 'slug', 'sha256', 'verdict', 'note', 'edits'];
|
|
50
|
+
const TOKEN_BODY_LIMIT = 4096;
|
|
51
|
+
const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
52
|
+
|
|
53
|
+
function send(res, status, body, headers = {}) {
|
|
54
|
+
res.writeHead(status, { ...HEADERS, ...headers });
|
|
55
|
+
res.end(JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
59
|
+
|
|
60
|
+
/** @typedef {{ config: any, where: any, auth: any, hooks?: import('./hooks.js').Hooks | null, lander?: import('./lander.js').Lander | null, now?: () => number, publicHost?: string | null }} ApiContext */
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `POST /api/reviews`. The caller has found the path to be this one; this runs before the sign-in guard.
|
|
64
|
+
* @param {import('node:http').IncomingMessage} req
|
|
65
|
+
* @param {import('node:http').ServerResponse} res
|
|
66
|
+
* @param {ApiContext} ctx
|
|
67
|
+
*/
|
|
68
|
+
export async function serveReviewCall(req, res, ctx) {
|
|
69
|
+
const { config, where, auth } = ctx;
|
|
70
|
+
const now = ctx.now ?? Date.now;
|
|
71
|
+
if (!auth.enabled) {
|
|
72
|
+
req.resume();
|
|
73
|
+
return send(res, 403, { ok: false, error: 'Review tokens need sign-in: set it up first (docs/sign-in.md).', code: 'forbidden' });
|
|
74
|
+
}
|
|
75
|
+
if (req.method !== 'POST') {
|
|
76
|
+
req.resume();
|
|
77
|
+
return send(res, 405, { ok: false, error: 'A review is sent with POST.', code: 'invalid' }, { Allow: 'POST' });
|
|
78
|
+
}
|
|
79
|
+
const repo = config.forge.repo;
|
|
80
|
+
const audit = async (login, request, status, body, target = repo) => {
|
|
81
|
+
try {
|
|
82
|
+
await auth.record({ login, action: 'review', target, request: { via: 'api', ...request }, status, answer: { error: body.error, code: body.code } });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(`[cornfield] a refused review call could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// The token, from the header alone; a cookie is not looked at
|
|
89
|
+
const presented = bearerOf(req.headers.authorization);
|
|
90
|
+
const db = presented ? await auth.store() : null;
|
|
91
|
+
const found = presented && db ? findToken(db, presented, now()) : null;
|
|
92
|
+
if (!found) {
|
|
93
|
+
req.resume();
|
|
94
|
+
const reason = presented ? 'unknown-token' : 'no-token';
|
|
95
|
+
const flood = countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE);
|
|
96
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
97
|
+
const body = { error: 'A review token is needed: send "Authorization: Bearer cf_rt_…". A token is made in the dashboard; a sign-in is not taken in its place.', code: 'unauthorized' };
|
|
98
|
+
await audit(NO_TOKEN_LOGIN, { reason }, 401, body);
|
|
99
|
+
return send(res, 401, { ok: false, ...body }, { 'WWW-Authenticate': 'Bearer realm="cornfield"' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { row, state } = found;
|
|
103
|
+
const token = { id: row.id, label: row.label };
|
|
104
|
+
if (state !== 'active') {
|
|
105
|
+
req.resume();
|
|
106
|
+
const flood = countAttempt(ctx, row.user_id, now());
|
|
107
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
108
|
+
const body = { error: state === 'revoked' ? 'This review token was revoked; make a new one in the dashboard.' : 'This review token has expired; make a new one in the dashboard.', code: 'forbidden' };
|
|
109
|
+
await audit(row.login, { reason: state, token }, 403, body);
|
|
110
|
+
return send(res, 403, { ok: false, ...body });
|
|
111
|
+
}
|
|
112
|
+
touchToken(/** @type {import('node:sqlite').DatabaseSync} */ (db), row.id, now());
|
|
113
|
+
|
|
114
|
+
// The body: whatever is wrong with it is submitReview's to refuse, after the flood count and with a row
|
|
115
|
+
let input;
|
|
116
|
+
try {
|
|
117
|
+
const body = await readBody(req, REVIEW_LIMIT);
|
|
118
|
+
if (body.tooLarge) input = { problem: { status: 413, message: `A review is at most ${REVIEW_LIMIT / 1024} KB.` } };
|
|
119
|
+
else input = inputOf(body.text);
|
|
120
|
+
} catch {
|
|
121
|
+
input = { problem: { status: 400, message: 'The request could not be read.' } };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const found = await submitReview({
|
|
125
|
+
forge: () => auth.forgeForUser(row.user_id), where, repo, who: { login: row.login, userId: row.user_id },
|
|
126
|
+
record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks, lander: ctx.lander,
|
|
127
|
+
stillAllowed: async () => {
|
|
128
|
+
const again = findToken(await auth.store(), /** @type {string} */ (presented), now());
|
|
129
|
+
if (again && again.state === 'active') return null;
|
|
130
|
+
return again && again.state === 'expired' ? 'This review token expired while the review was being checked; nothing was posted.' : 'This review token was revoked while the review was being checked; nothing was posted.';
|
|
131
|
+
},
|
|
132
|
+
}, input);
|
|
133
|
+
return send(res, 200, { ok: true, ...found });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const out = refusal(err);
|
|
136
|
+
return send(res, out.status, { ok: false, ...out.body }, out.headers);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A call's body as `submitReview` takes it, or the problem with it */
|
|
141
|
+
function inputOf(text) {
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(text);
|
|
145
|
+
} catch {
|
|
146
|
+
// Not the parser's words: they quote the body, which the audit row would then keep (a token pasted in by mistake)
|
|
147
|
+
return { problem: { status: 400, message: 'The body is not valid JSON.' } };
|
|
148
|
+
}
|
|
149
|
+
if (!plainObject(parsed)) return { problem: { status: 400, message: 'A review call is a JSON object: { pull, slug, sha256, verdict, note?, edits? }.' } };
|
|
150
|
+
const extra = Object.keys(parsed).find((k) => !CALL_KEYS.includes(k));
|
|
151
|
+
// What an approval covers (the proposal and its mockups) is recorded by the server from the files themselves
|
|
152
|
+
if (extra !== undefined) return { problem: { status: 400, message: `A review call has only pull, slug, sha256, verdict, note and edits, not "${extra}".` } };
|
|
153
|
+
const { pull, slug, sha256, ...review } = parsed;
|
|
154
|
+
return { pull, slug, sha256, review };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// The routes of the signed-in maintainer: make, list, revoke
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** What a token looks like to the dashboard: no hash, no secret */
|
|
162
|
+
const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, last_used, state }) => ({ id, login, label, created: created_at, expires: expires_at, revoked: revoked_at, lastUsed: last_used, state });
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The `/api/review-tokens` routes. The caller has passed the sign-in guard, so `session` is a maintainer's.
|
|
166
|
+
* @param {import('node:http').IncomingMessage} req
|
|
167
|
+
* @param {import('node:http').ServerResponse} res
|
|
168
|
+
* @param {string} reqPath
|
|
169
|
+
* @param {ApiContext & { session: { login: string, userId: string } | null }} ctx
|
|
170
|
+
* @returns {Promise<boolean>} whether the path was one of these
|
|
171
|
+
*/
|
|
172
|
+
export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
173
|
+
const revoke = /^\/api\/review-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
174
|
+
if (reqPath !== TOKENS_PATH && !revoke) return false;
|
|
175
|
+
const { config, auth, session } = ctx;
|
|
176
|
+
const now = ctx.now ?? Date.now;
|
|
177
|
+
const allow = revoke ? 'POST' : 'GET, POST';
|
|
178
|
+
const method = req.method;
|
|
179
|
+
if (!allow.split(', ').includes(method || '')) {
|
|
180
|
+
req.resume();
|
|
181
|
+
send(res, 405, { ok: false, error: 'Method not allowed.', code: 'invalid' }, { Allow: allow });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (!session || !auth.store) {
|
|
185
|
+
req.resume();
|
|
186
|
+
send(res, 403, { ok: false, error: 'Review tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).', code: 'forbidden' });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
const db = await auth.store();
|
|
190
|
+
if (method === 'GET') {
|
|
191
|
+
send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now()).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A change: from cornfield's own page only, as JSON, counted and written down, and the forge asked who this is now
|
|
196
|
+
const flood = countAttempt(ctx, session.userId, now());
|
|
197
|
+
if (flood) {
|
|
198
|
+
req.resume();
|
|
199
|
+
send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const what = revoke ? 'revoke' : 'mint';
|
|
203
|
+
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(revoke ? { id: Number(revoke[1]) } : {}) });
|
|
204
|
+
let rowId = null;
|
|
205
|
+
/** @type {{ status: number, body: object }} */
|
|
206
|
+
let out;
|
|
207
|
+
try {
|
|
208
|
+
try {
|
|
209
|
+
rowId = await auth.begin({ login: session.login, action: 'review-token', target: config.forge.repo, request });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`[cornfield] the audit row of a review token could not be written: ${err instanceof Error ? err.message : err}`);
|
|
212
|
+
throw new Refused(503, 'cornfield cannot write its audit log, so it will not change review tokens.', 'unavailable');
|
|
213
|
+
}
|
|
214
|
+
if (!sameOrigin(req, ctx.publicHost ?? null)) {
|
|
215
|
+
req.resume();
|
|
216
|
+
throw new Refused(403, 'Do this from cornfield\'s own page.', 'forbidden');
|
|
217
|
+
}
|
|
218
|
+
const body = revoke ? {} : await jsonBody(req);
|
|
219
|
+
const forge = auth.forgeFor(session);
|
|
220
|
+
const me = await forge.me();
|
|
221
|
+
if (String(me.id) !== String(session.userId)) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
|
|
222
|
+
if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers make review tokens; ${me.login} is not one now.`, 'forbidden');
|
|
223
|
+
if (revoke) {
|
|
224
|
+
const done = revokeToken(db, session.userId, Number(revoke[1]), now());
|
|
225
|
+
if (!done) throw new Refused(404, 'You have no review token by that number.', 'not-found');
|
|
226
|
+
request.label = done.label;
|
|
227
|
+
out = { status: 200, body: { ok: true, token: publicToken(done) } };
|
|
228
|
+
} else {
|
|
229
|
+
const unknown = Object.keys(body).find((k) => k !== 'label' && k !== 'days');
|
|
230
|
+
if (unknown !== undefined) throw new Refused(400, `A token has a label and, if you like, a number of days, not "${unknown}".`, 'invalid');
|
|
231
|
+
const problem = labelProblem(body.label);
|
|
232
|
+
if (problem) throw new Refused(400, problem, 'invalid');
|
|
233
|
+
let days;
|
|
234
|
+
try {
|
|
235
|
+
days = daysOf(body.days);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
throw new Refused(400, err.message, 'invalid');
|
|
238
|
+
}
|
|
239
|
+
Object.assign(request, { label: body.label, days });
|
|
240
|
+
let made;
|
|
241
|
+
try {
|
|
242
|
+
made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days }, now());
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof RangeError) throw new Refused(409, err.message, 'conflict');
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
request.id = made.row.id;
|
|
248
|
+
// The token is in this answer and nowhere else: not in the audit row, not in a log
|
|
249
|
+
out = { status: 201, body: { ok: true, secret: made.token, token: publicToken({ ...made.row, state: 'active' }) } };
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err instanceof Refused) out = { status: err.status, body: { ok: false, error: err.message, code: err.code } };
|
|
253
|
+
else if (err instanceof ForgeError) {
|
|
254
|
+
const r = refusal(err);
|
|
255
|
+
out = { status: r.status, body: { ok: false, ...r.body } };
|
|
256
|
+
} else {
|
|
257
|
+
console.error(`[cornfield] review token ${what}: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
258
|
+
out = { status: 500, body: { ok: false, error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' } };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (rowId !== null) {
|
|
262
|
+
try {
|
|
263
|
+
await auth.finish(rowId, { request, status: out.status, answer: 'error' in out.body ? { error: out.body.error, code: out.body.code } : { ok: true, id: request.id } });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(`[cornfield] the audit row of a review token could not be finished: ${err instanceof Error ? err.message : err}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
send(res, out.status, out.body);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A small JSON object body, or a Refused */
|
|
273
|
+
async function jsonBody(req) {
|
|
274
|
+
if (!/^application\/json(?:\s*;|$)/i.test(String(req.headers['content-type'] || ''))) {
|
|
275
|
+
req.resume();
|
|
276
|
+
throw new Refused(415, 'Send the request as JSON (Content-Type: application/json).', 'invalid');
|
|
277
|
+
}
|
|
278
|
+
const body = await readBody(req, TOKEN_BODY_LIMIT);
|
|
279
|
+
if (body.tooLarge) throw new Refused(413, 'The request is too large for a token.', 'invalid');
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(body.text);
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Refused(400, 'The request is not a JSON object.', 'invalid');
|
|
285
|
+
}
|
|
286
|
+
if (!plainObject(parsed)) throw new Refused(400, 'The request is not a JSON object.', 'invalid');
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/review-tokens
|
|
3
|
+
*
|
|
4
|
+
* Review tokens: what a maintainer hands a script or a chat bot so it can Approve or Send back a proposal through
|
|
5
|
+
* `POST /api/reviews` (server/review-api.js). The store over the `review_tokens` table of `cornfield.db`
|
|
6
|
+
* (server/db.js); nothing here knows about HTTP.
|
|
7
|
+
*
|
|
8
|
+
* - A token is `cf_rt_` and 32 random bytes in base64url. It is shown once, when it is made; only its SHA-256 is
|
|
9
|
+
* kept. A presented token is found by the hash of what was presented (an indexed equality on a hash of a 256-bit
|
|
10
|
+
* secret, so there is no secret-dependent comparison to time); the token itself is never stored, logged or
|
|
11
|
+
* returned again.
|
|
12
|
+
* - It belongs to the maintainer who made it (their forge user id and login), has a label they chose (the review
|
|
13
|
+
* comment names it), and expires: 30 days unless asked, 90 at most.
|
|
14
|
+
* - It can be revoked, by its owner, at once and for good. `last_used` says when it was last presented while valid.
|
|
15
|
+
* - What a token is worth is not kept in the row: the owner's maintainer status is asked of the forge on every call
|
|
16
|
+
* (server/submit-review.js), so a token of someone who is demoted stops working with no row changed.
|
|
17
|
+
*
|
|
18
|
+
* Times are ISO strings, as everywhere in the file; `now` is milliseconds and is passed in, so tests need no clock.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {{ id: number, user_id: string, login: string, label: string, created_at: string, expires_at: string, revoked_at: string | null, last_used: string | null }} TokenRow
|
|
21
|
+
* @typedef {'active' | 'expired' | 'revoked'} TokenState
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/** What every token starts with, so a leaked one is recognised (by a secret scanner too) */
|
|
27
|
+
export const TOKEN_PREFIX = 'cf_rt_';
|
|
28
|
+
/** Random bytes in a token */
|
|
29
|
+
export const TOKEN_BYTES = 32;
|
|
30
|
+
export const DEFAULT_DAYS = 30;
|
|
31
|
+
export const MAX_DAYS = 90;
|
|
32
|
+
/** Live tokens one maintainer may hold at once */
|
|
33
|
+
export const MAX_ACTIVE = 20;
|
|
34
|
+
export const MAX_LABEL = 60;
|
|
35
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
/** A token as presented: the prefix and base64url (43 characters for 32 bytes); anything else is not one */
|
|
37
|
+
const TOKEN_SHAPE = /^cf_rt_[A-Za-z0-9_-]{43,128}$/;
|
|
38
|
+
/** A label is words: letters, digits, spaces and a little punctuation. No backtick, no @, no newline: the comment quotes it */
|
|
39
|
+
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
|
|
40
|
+
const COLUMNS = 'id, user_id, login, label, created_at, expires_at, revoked_at, last_used';
|
|
41
|
+
|
|
42
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
43
|
+
|
|
44
|
+
/** The SHA-256 of a token, as hex: what the table keeps */
|
|
45
|
+
export function hashToken(token) {
|
|
46
|
+
return createHash('sha256').update(String(token)).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The token a request presents: the value of `Authorization: Bearer <token>`, or null when there is none, or it is
|
|
51
|
+
* not shaped like one of ours (the scheme is matched in any case, as RFC 9110 says).
|
|
52
|
+
* @param {string | string[] | undefined} header
|
|
53
|
+
* @returns {string | null}
|
|
54
|
+
*/
|
|
55
|
+
export function bearerOf(header) {
|
|
56
|
+
if (typeof header !== 'string') return null;
|
|
57
|
+
const m = /^bearer[ \t]+(\S+)$/i.exec(header.trim());
|
|
58
|
+
return m && TOKEN_SHAPE.test(m[1]) ? m[1] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What is wrong with a label, in words, or null.
|
|
63
|
+
* @param {unknown} label
|
|
64
|
+
*/
|
|
65
|
+
export function labelProblem(label) {
|
|
66
|
+
if (typeof label !== 'string' || !label.trim()) return 'Give the token a label: what will use it, like "chat bot".';
|
|
67
|
+
if (label.trim() !== label) return 'A label does not start or end with a space.';
|
|
68
|
+
if (label.length > MAX_LABEL) return `A label is at most ${MAX_LABEL} characters.`;
|
|
69
|
+
if (!LABEL.test(label)) return 'A label is letters, digits, spaces and . _ : / ( ) - only.';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How many days a token lasts, from what was asked: `DEFAULT_DAYS` when nothing was. Throws a `RangeError` in words.
|
|
75
|
+
* @param {unknown} days
|
|
76
|
+
*/
|
|
77
|
+
export function daysOf(days) {
|
|
78
|
+
if (days === undefined || days === null) return DEFAULT_DAYS;
|
|
79
|
+
if (typeof days !== 'number' || !Number.isInteger(days) || days < 1 || days > MAX_DAYS) throw new RangeError(`A token lasts 1 to ${MAX_DAYS} days.`);
|
|
80
|
+
return days;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a row is usable at `now`.
|
|
85
|
+
* @param {TokenRow} row
|
|
86
|
+
* @param {number} now
|
|
87
|
+
* @returns {TokenState}
|
|
88
|
+
*/
|
|
89
|
+
export function stateOf(row, now) {
|
|
90
|
+
if (row.revoked_at) return 'revoked';
|
|
91
|
+
return Date.parse(row.expires_at) <= now ? 'expired' : 'active';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Makes a token for `owner`. The token is in the answer once; the table keeps its hash. Throws a `RangeError` in words
|
|
96
|
+
* for a label or a number of days that is not allowed, or when the owner holds `MAX_ACTIVE` live tokens already.
|
|
97
|
+
* Tokens that ended (expired or revoked) more than `MAX_DAYS` days ago are dropped on the way.
|
|
98
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
99
|
+
* @param {{ userId: string, login: string, label: string, days?: number }} owner
|
|
100
|
+
* @param {number} now
|
|
101
|
+
* @returns {{ token: string, row: TokenRow }}
|
|
102
|
+
*/
|
|
103
|
+
export function mintToken(db, { userId, login, label, days }, now) {
|
|
104
|
+
const problem = labelProblem(label);
|
|
105
|
+
if (problem) throw new RangeError(problem);
|
|
106
|
+
const lasts = daysOf(days);
|
|
107
|
+
const nowIso = iso(now);
|
|
108
|
+
db.prepare('DELETE FROM review_tokens WHERE COALESCE(revoked_at, expires_at) <= ?').run(iso(now - MAX_DAYS * DAY_MS));
|
|
109
|
+
const live = /** @type {{ n: number }} */ (db.prepare('SELECT COUNT(*) AS n FROM review_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?').get(String(userId), nowIso)).n;
|
|
110
|
+
if (live >= MAX_ACTIVE) throw new RangeError(`You hold ${MAX_ACTIVE} live tokens; revoke one you do not use before making another.`);
|
|
111
|
+
const token = `${TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
|
112
|
+
const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
113
|
+
.run(hashToken(token), String(userId), login, label, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
|
|
114
|
+
return { token, row: /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id)) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The row a presented token names, with whether it is usable, or null when no token has that hash.
|
|
119
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
120
|
+
* @param {string} token what was presented
|
|
121
|
+
* @param {number} now
|
|
122
|
+
* @returns {{ row: TokenRow, state: TokenState } | null}
|
|
123
|
+
*/
|
|
124
|
+
export function findToken(db, token, now) {
|
|
125
|
+
const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE token_hash = ?`).get(hashToken(token)));
|
|
126
|
+
return row ? { row, state: stateOf(row, now) } : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Notes that a token was presented (and valid) just now */
|
|
130
|
+
export function touchToken(db, id, now) {
|
|
131
|
+
db.prepare('UPDATE review_tokens SET last_used = ? WHERE id = ?').run(iso(now), id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* An owner's tokens, newest first, each with its state. Never the hash, never the token.
|
|
136
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
137
|
+
* @param {string} userId
|
|
138
|
+
* @param {number} now
|
|
139
|
+
* @returns {(TokenRow & { state: TokenState })[]}
|
|
140
|
+
*/
|
|
141
|
+
export function listTokens(db, userId, now) {
|
|
142
|
+
return /** @type {TokenRow[]} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? ORDER BY id DESC`).all(String(userId)))
|
|
143
|
+
.map((row) => ({ ...row, state: stateOf(row, now) }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Revokes one of an owner's tokens, for good. Another owner's id is no token to them. Revoking a token that is
|
|
148
|
+
* revoked already keeps the first time.
|
|
149
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
150
|
+
* @param {string} userId
|
|
151
|
+
* @param {number} id
|
|
152
|
+
* @param {number} now
|
|
153
|
+
* @returns {(TokenRow & { state: TokenState }) | null} the token as it is now, or null when the owner has none by that id
|
|
154
|
+
*/
|
|
155
|
+
export function revokeToken(db, userId, id, now) {
|
|
156
|
+
const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ? AND user_id = ?`).get(id, String(userId)));
|
|
157
|
+
if (!row) return null;
|
|
158
|
+
if (!row.revoked_at) db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE id = ?').run(iso(now), id);
|
|
159
|
+
const after = /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id));
|
|
160
|
+
return { ...after, state: stateOf(after, now) };
|
|
161
|
+
}
|
package/server/reviews.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* @typedef {{ verdict: 'approved' | 'notes', note: string, edits: object[], mockups?: ReviewedMockup[], proposal?: string }} Review
|
|
42
42
|
* @typedef {Review & { version: 1, slug: string, at: string, by: string, comment: { id: string, url: string } }} FoundReview
|
|
43
43
|
* @typedef {{ login: string, action: string, target: string, request: object, status: number | null, answer: object | null }} AuditEntry
|
|
44
|
+
* @typedef {{ token: { id: number, label: string } }} ApiVia how a review reached cornfield when it was not the button: the review token it came with
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
47
|
import fs from 'node:fs';
|
|
@@ -49,6 +50,7 @@ import path from 'node:path';
|
|
|
49
50
|
import { isMaintainer, ForgeError } from './forge.js';
|
|
50
51
|
import { PROPOSAL_FILE } from './files.js';
|
|
51
52
|
import { MAX_MOCKUPS, mockupPathProblem } from '../core/proposal.js';
|
|
53
|
+
import { defuse } from './defuse.js';
|
|
52
54
|
|
|
53
55
|
// ---------------------------------------------------------------------------
|
|
54
56
|
// The review a person sends
|
|
@@ -136,14 +138,19 @@ const FENCE = '```';
|
|
|
136
138
|
* then the fenced block.
|
|
137
139
|
* @param {string} slug
|
|
138
140
|
* @param {Review} review
|
|
139
|
-
* @param {{ login: string, carriedOver?: string }} by who sent it; `carriedOver` is the day of a Stage 1 review being migrated
|
|
141
|
+
* @param {{ login: string, carriedOver?: string, via?: ApiVia }} by who sent it; `carriedOver` is the day of a Stage 1 review being migrated;
|
|
142
|
+
* `via` says it came through the review API (server/review-api.js) with the token of that label, which the line names (never the token)
|
|
140
143
|
*/
|
|
141
|
-
export function reviewComment(slug, review, { login, carriedOver }) {
|
|
144
|
+
export function reviewComment(slug, review, { login, carriedOver, via }) {
|
|
142
145
|
const verdict = review.verdict === 'approved' ? 'Approved' : 'Sent back';
|
|
143
146
|
const withEdits = review.edits.length ? ` with ${plural(review.edits.length, 'edit')}` : '';
|
|
144
|
-
const how = carriedOver ? `${login}, in cornfield on ${carriedOver}; carried over from before reviews were comments`
|
|
147
|
+
const how = carriedOver ? `${login}, in cornfield on ${carriedOver}; carried over from before reviews were comments`
|
|
148
|
+
: via ? `${login}, through the review API with the token \`${via.token.label}\`` : `${login}, in cornfield`;
|
|
145
149
|
const line = `${verdict} \`${slug}\`${withEdits} (${how}).`;
|
|
146
|
-
|
|
150
|
+
// A note that came through the API may be relayed from anywhere (a chat): in the quote people read, its mentions and
|
|
151
|
+
// references are broken so it pings and links nobody. The block keeps the note as it was sent, for the agent.
|
|
152
|
+
const shown = via ? defuse(review.note.trim()) : review.note.trim();
|
|
153
|
+
const quoted = shown ? `\n\n${shown.split(/\r?\n/).map((l) => (l ? `> ${l}` : '>')).join('\n')}` : '';
|
|
147
154
|
const json = JSON.stringify({ verdict: review.verdict, note: review.note, edits: review.edits, ...(review.mockups ? { mockups: review.mockups } : {}), ...(review.proposal ? { proposal: review.proposal } : {}) }, null, 2);
|
|
148
155
|
return `${line}${quoted}\n\n${FENCE}${REVIEW_TAG} ${slug}\n${json}\n${FENCE}`;
|
|
149
156
|
}
|
|
@@ -207,12 +214,12 @@ export async function latestReview(forge, n, slug, comments) {
|
|
|
207
214
|
* @param {number} n
|
|
208
215
|
* @param {string} slug
|
|
209
216
|
* @param {Review} review a checked review (`validateReview`)
|
|
210
|
-
* @param {{ login: string, record?: (entry: AuditEntry) => Promise<void> | void, carriedOver?: string }} who
|
|
217
|
+
* @param {{ login: string, record?: (entry: AuditEntry) => Promise<void> | void, carriedOver?: string, via?: ApiVia }} who `via`: the review came through the API, with this token (its id and label go in the comment and the audit row)
|
|
211
218
|
* @returns {Promise<FoundReview>}
|
|
212
219
|
*/
|
|
213
|
-
export async function postReview(forge, n, slug, review, { login, record, carriedOver }) {
|
|
220
|
+
export async function postReview(forge, n, slug, review, { login, record, carriedOver, via }) {
|
|
214
221
|
const target = `${forge.repo}#${n}`;
|
|
215
|
-
const request = { slug, ...review, ...(carriedOver ? { migrated: carriedOver } : {}) };
|
|
222
|
+
const request = { slug, ...review, ...(carriedOver ? { migrated: carriedOver } : {}), ...(via ? { via: 'api', token: via.token } : {}) };
|
|
216
223
|
const audit = async (status, answer) => {
|
|
217
224
|
if (!record) return;
|
|
218
225
|
try {
|
|
@@ -221,7 +228,7 @@ export async function postReview(forge, n, slug, review, { login, record, carrie
|
|
|
221
228
|
console.error(`[cornfield] the review of ${target} was posted but could not be written to the audit table: ${err.message}`);
|
|
222
229
|
}
|
|
223
230
|
};
|
|
224
|
-
const body = reviewComment(slug, review, { login, carriedOver });
|
|
231
|
+
const body = reviewComment(slug, review, { login, carriedOver, via });
|
|
225
232
|
try {
|
|
226
233
|
if (body.length > MAX_COMMENT_CHARS) throw new ForgeError(`That review is too long for a comment (${body.length} characters, at most ${MAX_COMMENT_CHARS}); shorten the note or send fewer edits.`, { code: 'invalid' });
|
|
227
234
|
const comment = await forge.comment(n, body);
|
|
@@ -233,6 +240,22 @@ export async function postReview(forge, n, slug, review, { login, record, carrie
|
|
|
233
240
|
}
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Posts a plain note on PR `n`, as the token's user: the other thing cornfield says on a spec PR besides a review (server/lander.js:
|
|
245
|
+
* a landing on approval that was cancelled or left open). It goes by the same comment call as a review, with every mention and
|
|
246
|
+
* reference in it defused, since part of it (a check's name) is the forge's text and not cornfield's. The caller records it.
|
|
247
|
+
* Throws what the forge throws.
|
|
248
|
+
* @param {import('./forge.js').Forge} forge
|
|
249
|
+
* @param {number} n
|
|
250
|
+
* @param {string} text
|
|
251
|
+
* @returns {Promise<import('./forge.js').Comment>}
|
|
252
|
+
*/
|
|
253
|
+
export async function postNote(forge, n, text) {
|
|
254
|
+
const body = defuse(text);
|
|
255
|
+
if (body.length > MAX_COMMENT_CHARS) throw new ForgeError(`That note is too long for a comment (${body.length} characters, at most ${MAX_COMMENT_CHARS}).`, { code: 'invalid' });
|
|
256
|
+
return forge.comment(n, body);
|
|
257
|
+
}
|
|
258
|
+
|
|
236
259
|
// ---------------------------------------------------------------------------
|
|
237
260
|
// Stage 1 reviews, once
|
|
238
261
|
// ---------------------------------------------------------------------------
|