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/server.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* POST /api/forge/pulls/<n>/merge|rerun land a green pull request, re-run a failed check (server/forge-actions.js)
|
|
21
21
|
* POST /api/forge/issues/<n>/spec, /pulls/<n>/build the pipeline's steps (server/pipeline.js, server/forge-actions.js)
|
|
22
22
|
* POST /api/forge/release-drafts the release notes kept as a DRAFT release on the forge (never published, no tag made)
|
|
23
|
+
* POST /api/reviews Approve or Send back with a review token (`Authorization: Bearer cf_rt_…`), no cookie: the one
|
|
24
|
+
* route that answers before the sign-in guard, and takes the token alone (server/review-api.js)
|
|
25
|
+
* * /api/review-tokens… make, list and revoke review tokens: the signed-in maintainer only (server/review-api.js)
|
|
23
26
|
* * /proposals… server/proposals.js (with sign-in, the open spec PRs too: server/proposal-pulls.js,
|
|
24
27
|
* and a review is a comment on one: server/reviews.js)
|
|
25
28
|
* * <proxy path>/… server/proxy.js, for each path the config's `proxy` names
|
|
@@ -44,11 +47,13 @@ import { CLOUD_FILE } from '../core/cloud.js';
|
|
|
44
47
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
45
48
|
import { serveProposals } from './proposals.js';
|
|
46
49
|
import { serveForge } from './forge-routes.js';
|
|
50
|
+
import { REVIEWS_PATH, serveReviewCall, serveTokenRoutes } from './review-api.js';
|
|
47
51
|
import { proxyTo } from './proxy.js';
|
|
48
52
|
import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
|
|
49
53
|
import { assertBind, hostAllowed } from './bind.js';
|
|
50
54
|
import { createAuth, signInSettings } from './auth.js';
|
|
51
55
|
import { createHooks, hooksSettings } from './hooks.js';
|
|
56
|
+
import { createLander } from './lander.js';
|
|
52
57
|
|
|
53
58
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
54
59
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
@@ -102,7 +107,7 @@ export function clientConfig(config, signIn = false) {
|
|
|
102
107
|
version: PKG.version,
|
|
103
108
|
title: config.title,
|
|
104
109
|
links: config.links || [],
|
|
105
|
-
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge' } : null,
|
|
110
|
+
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge', ...(config.approveLands ? { approveLands: true } : {}) } : null,
|
|
106
111
|
sources: browserSources(config),
|
|
107
112
|
signIn,
|
|
108
113
|
};
|
|
@@ -121,10 +126,10 @@ export const HA_CACHE_MS = 30_000;
|
|
|
121
126
|
/**
|
|
122
127
|
* The server for one repo. It does not listen: `listen(config)` does, where server/bind.js allows.
|
|
123
128
|
* @param {object} config a loaded config (core/config.js `loadConfig`)
|
|
124
|
-
* @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function } }} [opts] what sign-in reads (`env`, and tests' fakes;
|
|
129
|
+
* @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function }, landTimer?: { setTimeout: Function, clearTimeout: Function } }} [opts] what sign-in reads (`env`, and tests' fakes;
|
|
125
130
|
* see server/auth.js); the Home Assistant URL and token are read from the same `env`, and `homeAssistant` is what
|
|
126
131
|
* `homeAssistantHouse` is handed besides them (the tests' fake WebSocket); `hookFetch` and `hookTimer` are the
|
|
127
|
-
* tests' fake network and clock for the event hooks (server/hooks.js)
|
|
132
|
+
* tests' fake network and clock for the event hooks (server/hooks.js); `landTimer` is the clock of landing on approval (server/lander.js)
|
|
128
133
|
* @returns {import('node:http').Server}
|
|
129
134
|
*/
|
|
130
135
|
export function createCornfieldServer(config, opts = {}) {
|
|
@@ -135,6 +140,8 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
135
140
|
const hooks = hookSettings
|
|
136
141
|
? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
|
|
137
142
|
: null;
|
|
143
|
+
// Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
|
|
144
|
+
if (config.approveLands && !auth.enabled) throw new Error('The config says proposals.approveLands, but sign-in is not set up. An approval is a signed-in maintainer\'s, and it lands as them, so without sign-in nothing would ever land: set up sign-in (docs/sign-in.md), or remove approveLands.');
|
|
138
145
|
const root = config.root;
|
|
139
146
|
const importMap = importMapOf(config);
|
|
140
147
|
const csp = cspOf(importMap);
|
|
@@ -142,6 +149,11 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
142
149
|
const publicHost = config.publicUrl ? new URL(config.publicUrl).hostname : null;
|
|
143
150
|
const where = { root, proposals: config.proposals, worktrees: config.worktrees, topology: config.topology, dataDir: config.dataDir, publicHost };
|
|
144
151
|
const fixtureCache = new Map();
|
|
152
|
+
const lander = config.approveLands ? createLander({ config, where, auth, hooks, now: opts.now, timer: opts.landTimer }) : null;
|
|
153
|
+
if (lander) {
|
|
154
|
+
// What a restart left waiting: looked at again, in the background
|
|
155
|
+
lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
156
|
+
}
|
|
145
157
|
const proxies = Object.entries(config.proxy || {});
|
|
146
158
|
const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
|
|
147
159
|
const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
|
|
@@ -179,7 +191,7 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
179
191
|
else res.end();
|
|
180
192
|
}
|
|
181
193
|
});
|
|
182
|
-
server.on('close', () => { hooks?.close(); auth.close(); });
|
|
194
|
+
server.on('close', () => { lander?.close(); hooks?.close(); auth.close(); });
|
|
183
195
|
return server;
|
|
184
196
|
|
|
185
197
|
async function answer(req, res) {
|
|
@@ -214,16 +226,20 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
214
226
|
req.resume();
|
|
215
227
|
return sendJson(res, 200, { ok: true });
|
|
216
228
|
}
|
|
229
|
+
// The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
|
|
230
|
+
if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
|
|
217
231
|
const gate = await auth.gate(req, res, reqPath, url);
|
|
218
232
|
if (gate.done) return;
|
|
219
233
|
const signedIn = gate.session ? { login: gate.session.login } : false;
|
|
220
234
|
|
|
221
235
|
// Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
|
|
222
|
-
if (await
|
|
236
|
+
if (await serveTokenRoutes(req, res, reqPath, { config, where, auth, session: gate.session, now: opts.now, publicHost })) return;
|
|
237
|
+
if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, lander, now: opts.now, publicHost })) return;
|
|
223
238
|
for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
|
|
224
239
|
const session = gate.session;
|
|
225
240
|
const forgeFor = session ? () => auth.forgeFor(session) : null;
|
|
226
|
-
|
|
241
|
+
const reviewer = session ? { login: session.login, userId: session.userId, repo: config.forge.repo, record: (entry) => auth.record(entry), limits: { auth, now: opts.now }, hooks, lander } : null;
|
|
242
|
+
if (serveProposals(req, res, reqPath, url, where, forgeFor, reviewer)) return;
|
|
227
243
|
|
|
228
244
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
229
245
|
req.resume();
|
|
@@ -0,0 +1,177 @@
|
|
|
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. So is landing on approval
|
|
32
|
+
* (`proposals.approveLands`, server/lander.js): after an approval is posted, and never for a send-back, `lander.start` is
|
|
33
|
+
* begun in the background with the head that was approved. It is not awaited and cannot fail the review: the review is
|
|
34
|
+
* already on the PR, and what goes wrong in landing is the lander's to say on the PR.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { ForgeError, isMaintainer } from './forge.js';
|
|
38
|
+
import { PROPOSAL_FILE } from './files.js';
|
|
39
|
+
import { findPullProposal, forgeWords, readPullProposal } from './proposal-pulls.js';
|
|
40
|
+
import { hashPullMockups } from './mockups.js';
|
|
41
|
+
import { postReview, proposalHash, validateReview } from './reviews.js';
|
|
42
|
+
import { Refused, countAttempt, throttle } from './throttle.js';
|
|
43
|
+
import { proposalPayload } from './hooks.js';
|
|
44
|
+
|
|
45
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
46
|
+
const MAX_PULL = 999_999_999;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} ReviewContext
|
|
50
|
+
* @property {() => import('./forge.js').Forge | Promise<import('./forge.js').Forge>} forge the forge as the person reviewing (may throw a ForgeError `unauthorized`)
|
|
51
|
+
* @property {{ proposals: string }} where the server's `where` (server/server.js)
|
|
52
|
+
* @property {string} repo `owner/name`, for the audit's target
|
|
53
|
+
* @property {{ login: string, userId: string }} who who is reviewing, by the forge's id
|
|
54
|
+
* @property {(entry: import('./reviews.js').AuditEntry) => Promise<void> | void} record writes a row of the audit table
|
|
55
|
+
* @property {{ auth: object, now?: () => number }} limits what `server/throttle.js` counts by (one `auth` per server)
|
|
56
|
+
* @property {import('./reviews.js').ApiVia} [via] set when the review came through the API: the token it came with
|
|
57
|
+
* @property {import('./hooks.js').Hooks | null} [hooks] the event hooks (server/hooks.js): `spec.approved` or `spec.sentBack` after a post
|
|
58
|
+
* @property {import('./lander.js').Lander | null} [lander] landing on approval (server/lander.js), set only when the config says `proposals.approveLands`
|
|
59
|
+
* @property {boolean} [requireSha256] the caller must name the version it read (the API does; the button reads the head itself)
|
|
60
|
+
* @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the post, after every
|
|
61
|
+
* forge call: null to go on, or the words for why not (the API's token was revoked or expired meanwhile), refused 403
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks and posts a review. Throws a `Refused` or a `ForgeError` (answer with `refusal(err)`); resolves with the
|
|
66
|
+
* review as the PR will now show it.
|
|
67
|
+
* @param {ReviewContext} ctx
|
|
68
|
+
* @param {{ pull: unknown, slug: unknown, review: unknown, sha256?: unknown, problem?: { status: number, message: string } }} input
|
|
69
|
+
* `review` is `{ verdict, note?, edits? }` as the caller sent it; `sha256` the proposal file's hash as the caller read it
|
|
70
|
+
* (absent for the button, which sends none); `problem` is something already wrong with the request itself (it was not
|
|
71
|
+
* JSON, it was too large): it is refused here, after the flood count and with a row, like every other refusal
|
|
72
|
+
* @returns {Promise<import('./reviews.js').FoundReview>}
|
|
73
|
+
*/
|
|
74
|
+
export async function submitReview(ctx, { pull, slug, review, sha256 = null, problem }) {
|
|
75
|
+
const { who, repo, via } = ctx;
|
|
76
|
+
const now = ctx.limits.now ?? Date.now;
|
|
77
|
+
const validPull = typeof pull === 'number' && Number.isInteger(pull) && pull >= 1 && pull <= MAX_PULL;
|
|
78
|
+
const target = validPull ? `${repo}#${pull}` : repo;
|
|
79
|
+
// What the audit row says was asked: only what is small, plain and of the right shape, never the whole body
|
|
80
|
+
// 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)
|
|
81
|
+
const slugShown = typeof slug === 'string' && slug.length <= 200 && PROPOSAL_FILE.test(`${slug}.json`) ? slug : undefined;
|
|
82
|
+
const asked = /** @type {Record<string, unknown>} */ ({ slug: slugShown, pull: validPull ? pull : undefined, ...(isSha256(sha256) ? { sha256 } : {}), ...(via ? { via: 'api', token: via.token } : {}) });
|
|
83
|
+
|
|
84
|
+
const flood = countAttempt(ctx.limits, who.userId, now());
|
|
85
|
+
if (flood) throw flood;
|
|
86
|
+
|
|
87
|
+
let recorded = false;
|
|
88
|
+
const record = async (entry) => {
|
|
89
|
+
recorded = true;
|
|
90
|
+
return ctx.record(entry);
|
|
91
|
+
};
|
|
92
|
+
let release = () => {};
|
|
93
|
+
try {
|
|
94
|
+
if (problem) throw new Refused(problem.status, problem.message, 'invalid');
|
|
95
|
+
if (!validPull) throw new Refused(400, 'Say which pull request: its number.', 'invalid');
|
|
96
|
+
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');
|
|
97
|
+
if ((sha256 !== null && sha256 !== undefined) || ctx.requireSha256) {
|
|
98
|
+
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');
|
|
99
|
+
}
|
|
100
|
+
release = throttle(ctx.limits, who.userId, `review|${pull}|${slug}`, now());
|
|
101
|
+
|
|
102
|
+
if (review !== null && typeof review === 'object') {
|
|
103
|
+
const verdict = /** @type {any} */ (review).verdict;
|
|
104
|
+
if (verdict === 'approved' || verdict === 'notes') asked.verdict = verdict;
|
|
105
|
+
if (Array.isArray(/** @type {any} */ (review).edits)) asked.edits = /** @type {any} */ (review).edits.length;
|
|
106
|
+
}
|
|
107
|
+
const checked = validateReview(review);
|
|
108
|
+
if (!checked.ok) throw new Refused(400, checked.error, 'invalid');
|
|
109
|
+
|
|
110
|
+
const forge = await ctx.forge();
|
|
111
|
+
const me = await forge.me();
|
|
112
|
+
if (String(me.id) !== who.userId) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
|
|
113
|
+
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');
|
|
114
|
+
|
|
115
|
+
const entry = await findPullProposal(forge, ctx.where, slug, pull);
|
|
116
|
+
if (!entry) throw new Refused(404, `Pull request #${pull} is not open or does not hold the proposal ${slug}.`, 'not-found');
|
|
117
|
+
// The proposal as the head holds it now: a later push that changes it is not this review's
|
|
118
|
+
const head = proposalHash(await readPullProposal(forge, ctx.where, entry));
|
|
119
|
+
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');
|
|
120
|
+
|
|
121
|
+
let checkedReview = checked.value;
|
|
122
|
+
if (checkedReview.verdict === 'approved') {
|
|
123
|
+
checkedReview = { ...checkedReview, proposal: head };
|
|
124
|
+
const mockups = await hashPullMockups(forge, ctx.where, entry);
|
|
125
|
+
if (mockups.length) checkedReview = { ...checkedReview, mockups };
|
|
126
|
+
}
|
|
127
|
+
// The checks above took a while: a token revoked (or expired) since it was looked up does not post
|
|
128
|
+
if (ctx.stillAllowed) {
|
|
129
|
+
const why = await ctx.stillAllowed();
|
|
130
|
+
if (why) throw new Refused(403, why, 'forbidden');
|
|
131
|
+
}
|
|
132
|
+
const posted = await postReview(forge, pull, slug, checkedReview, { login: me.login, record, via });
|
|
133
|
+
// The one place a review is posted, so the button and the API fire the same event; the actor is who the forge says
|
|
134
|
+
// the poster is (with the API, the token's owner), never a name the caller sent
|
|
135
|
+
ctx.hooks?.emit(posted.verdict === 'approved' ? 'spec.approved' : 'spec.sentBack', () => proposalPayload(forge, ctx.where, entry, { actor: me.login, note: checkedReview.note, edits: checkedReview.edits }));
|
|
136
|
+
if (posted.verdict === 'approved' && ctx.lander) {
|
|
137
|
+
const { lander } = ctx;
|
|
138
|
+
Promise.resolve()
|
|
139
|
+
.then(() => lander.start({ pull, slug, sha: entry.sha, who: { userId: who.userId, login: me.login } }))
|
|
140
|
+
.catch((err) => console.error(`[cornfield] landing ${target} on approval could not begin: ${err instanceof Error ? err.message : err}`));
|
|
141
|
+
}
|
|
142
|
+
return posted;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (!(err instanceof Refused) && !(err instanceof ForgeError)) console.error(`[cornfield] the review of ${target}: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
145
|
+
// postReview writes the row of a review it tried to post; anything refused before that is written here
|
|
146
|
+
if (!recorded) {
|
|
147
|
+
const out = refusal(err);
|
|
148
|
+
try {
|
|
149
|
+
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 } });
|
|
150
|
+
} catch (writeErr) {
|
|
151
|
+
console.error(`[cornfield] the refusal of the review of ${target} could not be written to the audit table: ${writeErr instanceof Error ? writeErr.message : writeErr}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
} finally {
|
|
156
|
+
release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The answer to give for an error `submitReview` threw: the status, `{ error, code }` and the headers to send.
|
|
162
|
+
* A ForgeError keeps the words of the forge; anything else is a 500 that says nothing of what went wrong.
|
|
163
|
+
* @param {unknown} err
|
|
164
|
+
* @returns {{ status: number, body: { error: string, code: string }, headers: Record<string, string> }}
|
|
165
|
+
*/
|
|
166
|
+
export function refusal(err) {
|
|
167
|
+
if (err instanceof Refused) return { status: err.status, body: { error: err.message, code: err.code }, headers: err.headers };
|
|
168
|
+
if (err instanceof ForgeError) {
|
|
169
|
+
const status = { 'not-found': 404, invalid: 400, unauthorized: 401, 'rate-limited': 429 }[err.code] ?? 502;
|
|
170
|
+
const wait = err.rateLimit && err.rateLimit.retryAfter != null ? { 'Retry-After': String(Math.ceil(err.rateLimit.retryAfter)) } : {};
|
|
171
|
+
return { status, body: { error: forgeWords(err), code: err.code }, headers: wait };
|
|
172
|
+
}
|
|
173
|
+
return { status: 500, body: { error: 'The review could not be saved; the log says why.', code: 'unavailable' }, headers: {} };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The checks on a `sha256` the caller names: 64 lower-case hex digits */
|
|
177
|
+
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
|
+
}
|