cornfield 0.1.2 → 0.3.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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @module server/submit-review
3
+ *
4
+ * The one place a review is checked and posted. The Approve and Send back buttons (`PUT /proposals/<slug>/review`,
5
+ * server/proposals.js) and the review API (`POST /api/reviews`, server/review-api.js) both call `submitReview`, and
6
+ * nothing else posts a review, so the two cannot drift: a token-holder's approval counts exactly like the button's.
7
+ *
8
+ * In this order, each step refusing in words (a `Refused`, or the forge's own `ForgeError`):
9
+ *
10
+ * 1. a flood: more than `ATTEMPTS_PER_MINUTE` attempts by this person in a minute is 429, and nothing is written down
11
+ * (server/throttle.js), so a flood of refused attempts cannot grow the audit table
12
+ * 2. a repeat: the same review of the same proposal again within a few seconds, one at a time, and a ceiling of
13
+ * actions per person per minute (429)
14
+ * 3. the review itself (`validateReview`): a verdict, a note to send back, edits that are objects with an op (400)
15
+ * 4. the forge, as the person: with the button, their session's token; with the API, the token owner's newest live
16
+ * sign-in (`auth.forgeForUser`), because the comment has to be the maintainer's own for `latestReview` to count it
17
+ * 5. a maintainer NOW: the forge is asked who that token is and what it may do on the repo, not what the session
18
+ * remembered 15 minutes ago; a different person, or one who is no longer a maintainer, is 403
19
+ * 6. the PR is open and holds the proposal (404)
20
+ * 7. the proposal file's SHA-256 at the PR's head: when the caller names the version it read (`sha256`; the API
21
+ * requires it) and the head holds another, 409, so a review counts only for the version that was read. An
22
+ * approval records the head's hash (`proposal`) and its mockups' (`mockups`), as the server read them, never the caller's word
23
+ * 8. with the API, the token once more (`stillAllowed`): one revoked or expired while the forge was being asked is 403
24
+ * 9. the comment is posted as that person (`postReview`)
25
+ *
26
+ * Every attempt that gets past step 1 is a row in `actions` (`record`), whatever the answer: a posted review and a forge
27
+ * that refused it are written by `postReview`, every refusal before that here. The audit never holds a token's secret,
28
+ * only its id and label.
29
+ *
30
+ * `submitReview` is the one place a review has been posted, so the event hooks' `spec.approved` and `spec.sentBack`
31
+ * (server/hooks.js) are emitted here, after `postReview`, not in either caller.
32
+ */
33
+
34
+ import { ForgeError, isMaintainer } from './forge.js';
35
+ import { PROPOSAL_FILE } from './files.js';
36
+ import { findPullProposal, forgeWords, readPullProposal } from './proposal-pulls.js';
37
+ import { hashPullMockups } from './mockups.js';
38
+ import { postReview, proposalHash, validateReview } from './reviews.js';
39
+ import { Refused, countAttempt, throttle } from './throttle.js';
40
+ import { proposalPayload } from './hooks.js';
41
+
42
+ const SHA256 = /^[0-9a-f]{64}$/;
43
+ const MAX_PULL = 999_999_999;
44
+
45
+ /**
46
+ * @typedef {object} ReviewContext
47
+ * @property {() => import('./forge.js').Forge | Promise<import('./forge.js').Forge>} forge the forge as the person reviewing (may throw a ForgeError `unauthorized`)
48
+ * @property {{ proposals: string }} where the server's `where` (server/server.js)
49
+ * @property {string} repo `owner/name`, for the audit's target
50
+ * @property {{ login: string, userId: string }} who who is reviewing, by the forge's id
51
+ * @property {(entry: import('./reviews.js').AuditEntry) => Promise<void> | void} record writes a row of the audit table
52
+ * @property {{ auth: object, now?: () => number }} limits what `server/throttle.js` counts by (one `auth` per server)
53
+ * @property {import('./reviews.js').ApiVia} [via] set when the review came through the API: the token it came with
54
+ * @property {import('./hooks.js').Hooks | null} [hooks] the event hooks (server/hooks.js): `spec.approved` or `spec.sentBack` after a post
55
+ * @property {boolean} [requireSha256] the caller must name the version it read (the API does; the button reads the head itself)
56
+ * @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the post, after every
57
+ * forge call: null to go on, or the words for why not (the API's token was revoked or expired meanwhile), refused 403
58
+ */
59
+
60
+ /**
61
+ * Checks and posts a review. Throws a `Refused` or a `ForgeError` (answer with `refusal(err)`); resolves with the
62
+ * review as the PR will now show it.
63
+ * @param {ReviewContext} ctx
64
+ * @param {{ pull: unknown, slug: unknown, review: unknown, sha256?: unknown, problem?: { status: number, message: string } }} input
65
+ * `review` is `{ verdict, note?, edits? }` as the caller sent it; `sha256` the proposal file's hash as the caller read it
66
+ * (absent for the button, which sends none); `problem` is something already wrong with the request itself (it was not
67
+ * JSON, it was too large): it is refused here, after the flood count and with a row, like every other refusal
68
+ * @returns {Promise<import('./reviews.js').FoundReview>}
69
+ */
70
+ export async function submitReview(ctx, { pull, slug, review, sha256 = null, problem }) {
71
+ const { who, repo, via } = ctx;
72
+ const now = ctx.limits.now ?? Date.now;
73
+ const validPull = typeof pull === 'number' && Number.isInteger(pull) && pull >= 1 && pull <= MAX_PULL;
74
+ const target = validPull ? `${repo}#${pull}` : repo;
75
+ // What the audit row says was asked: only what is small, plain and of the right shape, never the whole body
76
+ // A slug or a hash only when it has the shape of one: anything else the caller sent is not kept (it could be a secret pasted in the wrong field)
77
+ const slugShown = typeof slug === 'string' && slug.length <= 200 && PROPOSAL_FILE.test(`${slug}.json`) ? slug : undefined;
78
+ const asked = /** @type {Record<string, unknown>} */ ({ slug: slugShown, pull: validPull ? pull : undefined, ...(isSha256(sha256) ? { sha256 } : {}), ...(via ? { via: 'api', token: via.token } : {}) });
79
+
80
+ const flood = countAttempt(ctx.limits, who.userId, now());
81
+ if (flood) throw flood;
82
+
83
+ let recorded = false;
84
+ const record = async (entry) => {
85
+ recorded = true;
86
+ return ctx.record(entry);
87
+ };
88
+ let release = () => {};
89
+ try {
90
+ if (problem) throw new Refused(problem.status, problem.message, 'invalid');
91
+ if (!validPull) throw new Refused(400, 'Say which pull request: its number.', 'invalid');
92
+ if (typeof slug !== 'string' || slug.length > 200 || !PROPOSAL_FILE.test(`${slug}.json`)) throw new Refused(400, 'Say which proposal: its name in the proposals folder.', 'invalid');
93
+ if ((sha256 !== null && sha256 !== undefined) || ctx.requireSha256) {
94
+ if (!isSha256(sha256)) throw new Refused(400, 'Say which version you read: "sha256" is the proposal file\'s SHA-256, 64 lower-case hex digits.', 'invalid');
95
+ }
96
+ release = throttle(ctx.limits, who.userId, `review|${pull}|${slug}`, now());
97
+
98
+ if (review !== null && typeof review === 'object') {
99
+ const verdict = /** @type {any} */ (review).verdict;
100
+ if (verdict === 'approved' || verdict === 'notes') asked.verdict = verdict;
101
+ if (Array.isArray(/** @type {any} */ (review).edits)) asked.edits = /** @type {any} */ (review).edits.length;
102
+ }
103
+ const checked = validateReview(review);
104
+ if (!checked.ok) throw new Refused(400, checked.error, 'invalid');
105
+
106
+ const forge = await ctx.forge();
107
+ const me = await forge.me();
108
+ if (String(me.id) !== who.userId) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
109
+ if (!isMaintainer(await forge.permission(repo, me.login))) throw new Refused(403, `Only the repo's maintainers review proposals; ${me.login} is not one now.`, 'forbidden');
110
+
111
+ const entry = await findPullProposal(forge, ctx.where, slug, pull);
112
+ if (!entry) throw new Refused(404, `Pull request #${pull} is not open or does not hold the proposal ${slug}.`, 'not-found');
113
+ // The proposal as the head holds it now: a later push that changes it is not this review's
114
+ const head = proposalHash(await readPullProposal(forge, ctx.where, entry));
115
+ if (typeof sha256 === 'string' && sha256 !== head) throw new Refused(409, `${slug} has changed since you read it (the head of pull request #${pull} holds ${head.slice(0, 12)}…, not ${sha256.slice(0, 12)}…); read it again and review that version.`, 'conflict');
116
+
117
+ let checkedReview = checked.value;
118
+ if (checkedReview.verdict === 'approved') {
119
+ checkedReview = { ...checkedReview, proposal: head };
120
+ const mockups = await hashPullMockups(forge, ctx.where, entry);
121
+ if (mockups.length) checkedReview = { ...checkedReview, mockups };
122
+ }
123
+ // The checks above took a while: a token revoked (or expired) since it was looked up does not post
124
+ if (ctx.stillAllowed) {
125
+ const why = await ctx.stillAllowed();
126
+ if (why) throw new Refused(403, why, 'forbidden');
127
+ }
128
+ const posted = await postReview(forge, pull, slug, checkedReview, { login: me.login, record, via });
129
+ // The one place a review is posted, so the button and the API fire the same event; the actor is who the forge says
130
+ // the poster is (with the API, the token's owner), never a name the caller sent
131
+ ctx.hooks?.emit(posted.verdict === 'approved' ? 'spec.approved' : 'spec.sentBack', () => proposalPayload(forge, ctx.where, entry, { actor: me.login, note: checkedReview.note, edits: checkedReview.edits }));
132
+ return posted;
133
+ } catch (err) {
134
+ if (!(err instanceof Refused) && !(err instanceof ForgeError)) console.error(`[cornfield] the review of ${target}: ${err instanceof Error ? err.stack || err.message : err}`);
135
+ // postReview writes the row of a review it tried to post; anything refused before that is written here
136
+ if (!recorded) {
137
+ const out = refusal(err);
138
+ try {
139
+ await ctx.record({ login: who.login, action: 'review', target, request: asked, status: err instanceof ForgeError ? err.status ?? (err.code === 'unavailable' ? null : out.status) : out.status, answer: { error: out.body.error, code: out.body.code } });
140
+ } catch (writeErr) {
141
+ console.error(`[cornfield] the refusal of the review of ${target} could not be written to the audit table: ${writeErr instanceof Error ? writeErr.message : writeErr}`);
142
+ }
143
+ }
144
+ throw err;
145
+ } finally {
146
+ release();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The answer to give for an error `submitReview` threw: the status, `{ error, code }` and the headers to send.
152
+ * A ForgeError keeps the words of the forge; anything else is a 500 that says nothing of what went wrong.
153
+ * @param {unknown} err
154
+ * @returns {{ status: number, body: { error: string, code: string }, headers: Record<string, string> }}
155
+ */
156
+ export function refusal(err) {
157
+ if (err instanceof Refused) return { status: err.status, body: { error: err.message, code: err.code }, headers: err.headers };
158
+ if (err instanceof ForgeError) {
159
+ const status = { 'not-found': 404, invalid: 400, unauthorized: 401, 'rate-limited': 429 }[err.code] ?? 502;
160
+ const wait = err.rateLimit && err.rateLimit.retryAfter != null ? { 'Retry-After': String(Math.ceil(err.rateLimit.retryAfter)) } : {};
161
+ return { status, body: { error: forgeWords(err), code: err.code }, headers: wait };
162
+ }
163
+ return { status: 500, body: { error: 'The review could not be saved; the log says why.', code: 'unavailable' }, headers: {} };
164
+ }
165
+
166
+ /** The checks on a `sha256` the caller names: 64 lower-case hex digits */
167
+ export const isSha256 = (v) => typeof v === 'string' && SHA256.test(v);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @module server/throttle
3
+ *
4
+ * The flood and repeat limits of everything cornfield does on a forge as a person (server/forge-actions.js: land,
5
+ * re-run, the pipeline's steps; server/submit-review.js: Approve and Send back, from the button and from the API).
6
+ * They live in one place so the same person is counted once whichever door they come in by. State is kept in memory,
7
+ * per `auth` object (one per server), keyed by the forge's user id:
8
+ *
9
+ * countAttempt a ceiling on attempts of any kind per person per minute (`ATTEMPTS_PER_MINUTE`), counted before
10
+ * anything is written down, so refused attempts cannot grow the audit table without end
11
+ * throttle not twice: one at a time for the same key, `COOLDOWN_MS` before it is asked again, and a ceiling of
12
+ * `PER_MINUTE` actions per person per minute
13
+ *
14
+ * A refusal is a `Refused`: a status (429, or the action's own), words, a code and the headers to answer with.
15
+ */
16
+
17
+ /** The same action on the same target is not asked again for this long (a double click, a retry before the forge has caught up) */
18
+ export const COOLDOWN_MS = 5000;
19
+ /** The most actions one person takes in a minute */
20
+ export const PER_MINUTE = 20;
21
+ /** The most attempts of any kind (refused ones too) one person makes in a minute, before any is written down */
22
+ export const ATTEMPTS_PER_MINUTE = 60;
23
+
24
+ /** @type {WeakMap<object, { busy: Set<string>, last: Map<string, number>, perUser: Map<string, number[]>, attempts: Map<string, number[]> }>} */
25
+ const throttles = new WeakMap();
26
+
27
+ function throttlesOf(auth) {
28
+ let t = throttles.get(auth);
29
+ if (!t) throttles.set(auth, (t = { busy: new Set(), last: new Map(), perUser: new Map(), attempts: new Map() }));
30
+ return t;
31
+ }
32
+
33
+ /** An action's own refusal: the status to answer and the words */
34
+ export class Refused extends Error {
35
+ /**
36
+ * @param {number} status
37
+ * @param {string} message
38
+ * @param {string} code
39
+ * @param {Record<string, string>} [headers]
40
+ */
41
+ constructor(status, message, code, headers = {}) {
42
+ super(message);
43
+ this.status = status;
44
+ this.code = code;
45
+ this.headers = headers;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Counts an attempt of any kind; refuses one past the ceiling, without writing anything down (the release draft shares it).
51
+ * @param {{ auth: object }} ctx
52
+ * @param {string} userId whom to count (the forge's id for them)
53
+ * @param {number} at now, in ms
54
+ * @param {number} [max] the ceiling per minute (`ATTEMPTS_PER_MINUTE`)
55
+ * @returns {Refused | null}
56
+ */
57
+ export function countAttempt(ctx, userId, at, max = ATTEMPTS_PER_MINUTE) {
58
+ const t = throttlesOf(ctx.auth);
59
+ for (const [k, list] of t.attempts) if (!list.length || at - list[list.length - 1] >= 60_000) t.attempts.delete(k);
60
+ const mine = (t.attempts.get(userId) || []).filter((when) => at - when < 60_000);
61
+ if (mine.length >= max) {
62
+ t.attempts.set(userId, mine);
63
+ return new Refused(429, `That is ${max} attempts in a minute; wait a little.`, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil((60_000 - (at - mine[0])) / 1000))) });
64
+ }
65
+ mine.push(at);
66
+ t.attempts.set(userId, mine);
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Refuses a repeat and a flood; answers the function that releases `key` when the attempt is over.
72
+ * @param {{ auth: object }} ctx
73
+ * @param {string} userId
74
+ * @param {string} key the action and its target, like `merge|12|` or `review|23|0013-meals`
75
+ * @param {number} at
76
+ * @returns {() => void}
77
+ * @throws {Refused}
78
+ */
79
+ export function throttle(ctx, userId, key, at) {
80
+ const t = throttlesOf(ctx.auth);
81
+ for (const [k, when] of t.last) if (at - when >= COOLDOWN_MS) t.last.delete(k);
82
+ const mine = (t.perUser.get(userId) || []).filter((when) => at - when < 60_000);
83
+ t.perUser.set(userId, mine);
84
+ const wait = (ms, words) => new Refused(429, words, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil(ms / 1000))) });
85
+ if (t.busy.has(key)) throw wait(COOLDOWN_MS, 'That is already being done; wait for it to finish.');
86
+ const before = t.last.get(key);
87
+ if (before !== undefined && at - before < COOLDOWN_MS) throw wait(COOLDOWN_MS - (at - before), 'That was just asked; wait a few seconds before asking again.');
88
+ if (mine.length >= PER_MINUTE) throw wait(60_000 - (at - mine[0]), `That is ${PER_MINUTE} actions in a minute; wait a little.`);
89
+ t.busy.add(key);
90
+ t.last.set(key, at);
91
+ mine.push(at);
92
+ return () => { t.busy.delete(key); };
93
+ }