@popcorn245/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.
- package/core/config.js +44 -0
- package/explorer/explorer.css +10 -0
- 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 +29 -0
- package/server/defuse.js +16 -0
- package/server/forge-actions.js +9 -57
- package/server/forge-routes.js +4 -4
- package/server/hooks.js +303 -0
- package/server/pipeline.js +3 -10
- package/server/proposal-pulls.js +21 -2
- package/server/proposals.js +15 -20
- package/server/review-api.js +288 -0
- package/server/review-tokens.js +161 -0
- package/server/reviews.js +15 -8
- package/server/server.js +20 -5
- package/server/submit-review.js +167 -0
- package/server/throttle.js +93 -0
|
@@ -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
|
+
}
|