cornfield 0.2.0 → 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.
@@ -879,6 +879,16 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
879
879
  .hx-confirm-pre:focus-visible { outline: 2px solid var(--hx-indigo); outline-offset: 2px; }
880
880
  .hx-confirm-note { margin: 0; color: var(--hx-text-2); line-height: 1.5; }
881
881
  .hx-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
882
+ .hx-tokens-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
883
+ .hx-tokens-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--hx-line); background: var(--hx-well-soft); }
884
+ .hx-tokens-item[data-state="revoked"], .hx-tokens-item[data-state="expired"] { opacity: 0.65; }
885
+ .hx-tokens-label { font-weight: 600; color: var(--hx-ink); overflow-wrap: anywhere; }
886
+ .hx-tokens-meta { grid-column: 1; }
887
+ .hx-tokens-item .hx-btn { grid-column: 2; grid-row: 1 / span 2; }
888
+ .hx-tokens-row { display: flex; gap: 8px; align-items: center; }
889
+ .hx-tokens-row .hx-forge-input { flex: 1; }
890
+ .hx-tokens-form { display: grid; grid-template-columns: 1fr 5rem; gap: 8px; align-items: end; }
891
+ .hx-tokens-form .hx-btn { grid-column: 1 / -1; justify-self: start; }
882
892
  .hx-forge-nodes { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-left: 62px; }
883
893
  .hx-forge-node { padding: 1px 8px; border-radius: 9999px; border: 1px solid var(--hx-amber); background: var(--hx-well-soft); color: var(--hx-text-2); font-size: 11px; }
884
894
  .hx-forge-node:hover { background: var(--hx-control-hover); }
@@ -103,6 +103,12 @@
103
103
  <span id="forge-btn-lit" class="hx-forge-lit" hidden></span>
104
104
  </button>
105
105
 
106
+ <!-- Review tokens (explorer/review-tokens.js): shown only when somebody is signed in to a forge -->
107
+ <button type="button" id="tokens-btn" class="hx-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="tokens-dialog" aria-label="Review tokens" title="Review tokens: let a script or a chat bot approve or send back a proposal as you" hidden>
108
+ <span aria-hidden="true">⚿</span>
109
+ <span class="hx-only-wide">Review tokens</span>
110
+ </button>
111
+
106
112
  <!-- The live layer's chip (explorer/live.js, spec 0029): shown once a live layer is on; a dot on a phone, its line on tap -->
107
113
  <div id="live-wrap" class="hx-menu-wrap hx-live" data-tone="idle" hidden>
108
114
  <button type="button" id="live-chip" class="hx-btn hx-live-chip" aria-haspopup="true" aria-expanded="false" aria-label="Live layer">
@@ -315,6 +321,32 @@
315
321
  </div>
316
322
  </div>
317
323
 
324
+ <!-- Review tokens (review-tokens.js): make one (shown once), list them, revoke; a modal dialog over a scrim, the focus stays in it -->
325
+ <div id="tokens-dialog" class="hx-confirm-scrim" hidden>
326
+ <div class="hx-confirm glass" role="dialog" aria-modal="true" aria-labelledby="tokens-title" aria-describedby="tokens-intro">
327
+ <h2 id="tokens-title" class="hx-confirm-title" tabindex="-1">Review tokens</h2>
328
+ <p id="tokens-intro" class="hx-confirm-note">A review token lets a script or a chat bot approve or send back a proposal as you (<code class="hx-code">POST /api/reviews</code>). It works while you are signed in here and still maintain the repo; it expires, and you can revoke it below.</p>
329
+ <p id="tokens-msg" class="hx-forge-msg" role="status" hidden></p>
330
+ <div id="tokens-secret" class="hx-tokens-secret" hidden>
331
+ <label class="hx-forge-field" for="tokens-secret-value">Your new token (copy it now, it is not shown again)</label>
332
+ <div class="hx-tokens-row">
333
+ <input id="tokens-secret-value" class="hx-forge-input hx-mono" type="text" readonly spellcheck="false" autocomplete="off" />
334
+ <button type="button" class="hx-btn" data-tokens-copy>Copy</button>
335
+ </div>
336
+ </div>
337
+ <ul id="tokens-list" class="hx-tokens-list" aria-label="Your review tokens"></ul>
338
+ <form id="tokens-form" class="hx-tokens-form" autocomplete="off">
339
+ <label class="hx-forge-field">Label<input id="tokens-label" class="hx-forge-input" type="text" maxlength="60" placeholder="chat bot" /></label>
340
+ <label class="hx-forge-field">Days<input id="tokens-days" class="hx-forge-input" type="text" inputmode="numeric" maxlength="2" aria-describedby="tokens-days-hint" /></label>
341
+ <span id="tokens-days-hint" class="hx-sr-only"></span>
342
+ <button type="submit" id="tokens-make" class="hx-btn hx-btn--approve">Make a token</button>
343
+ </form>
344
+ <div class="hx-confirm-actions">
345
+ <button type="button" class="hx-btn" data-tokens-close>Close</button>
346
+ </div>
347
+ </div>
348
+ </div>
349
+
318
350
  <script type="module" src="/explorer/main.js"></script>
319
351
  </body>
320
352
  </html>
package/explorer/main.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * bench.js /?bench=1 stopwatch and /?fixture=5k (the generated topology, from the server)
18
18
  * keyboard.js the map by keyboard and the screen reader's announcer; keyboard-model.js its pure half
19
19
  * live.js the live layer: the chip, polling, laying a live source's answer over the map; live-model.js its pure half
20
+ * review-tokens.js review tokens for the review API, when somebody is signed in: make one (shown once), list, revoke; review-tokens-model.js its pure half
20
21
  * forge-panel.js the issues and PRs panel, when the config names a forge: a PR lights the nodes it changes; forge-panel-model.js its pure half
21
22
  * theme.js light or dark: the system setting and the header toggle; theme-model.js the colours (theme-boot.js before the first paint)
22
23
  */
@@ -41,6 +42,7 @@ import { initHouse } from './house.js';
41
42
  import { initChecks } from './check.js';
42
43
  import { initKeyboard } from './keyboard.js';
43
44
  import { initForgePanel } from './forge-panel.js';
45
+ import { initReviewTokens } from './review-tokens.js';
44
46
  import { loadClientConfig, loadSources, applyBranding } from './plugins.js';
45
47
  import { cycleTheme, initTheme } from './theme.js';
46
48
 
@@ -207,6 +209,7 @@ loadTopology().then(async (ok) => {
207
209
  const config = await configReady;
208
210
  const { liveSource, floorRenderer, checker } = await loadSources(config);
209
211
  initForgePanel(config); // issues and PRs: only when the config names a forge (forge-panel.js)
212
+ initReviewTokens(config); // review tokens for the review API: only for somebody signed in (review-tokens.js)
210
213
  initWhatIf(); // before a proposal can open, so its edits come with it
211
214
  initReview(); // after it: the review controls sit under the edit controls on the bar
212
215
  initMockups(); // a proposal's screens open from the node they name
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @module explorer/review-tokens-model
3
+ *
4
+ * The pure half of the review tokens panel (spec 0002): no DOM, no `window`, no clock (the time is passed in).
5
+ * review-tokens.js is the half that touches the page and the network; the words and the request are decided here so
6
+ * they can be tested without a page.
7
+ *
8
+ * TOKENS_URL the server's address for them (server/review-api.js)
9
+ * makeRequest what a new token's form sends: { label, days? }, or why it cannot be sent yet
10
+ * tokenLine one token as the list shows it: its label, its state, when it ends, when it was last used
11
+ * revokeUrl the address that revokes one
12
+ * errorWords what a refusal of the server says, in plain words
13
+ *
14
+ * The server answers a list as `{ ok, tokens: [{ id, login, label, created, expires, revoked, lastUsed, state }], defaultDays, maxDays, maxActive }`
15
+ * and a new token as `{ ok, secret, token }`: the secret is in that answer only.
16
+ */
17
+
18
+ import { relativeTime } from './review-model.js';
19
+
20
+ export const TOKENS_URL = '/api/review-tokens';
21
+ export const DEFAULT_DAYS = 30;
22
+ export const MAX_DAYS = 90;
23
+ export const MAX_LABEL = 60;
24
+ const DAY = 24 * 60 * 60 * 1000;
25
+ const HOUR = 60 * 60 * 1000;
26
+ const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
27
+
28
+ /** The address that revokes token `id` */
29
+ export const revokeUrl = (id) => `${TOKENS_URL}/${encodeURIComponent(String(id))}/revoke`;
30
+
31
+ /**
32
+ * What the form sends: the label, and the days when they were given. The server checks everything again.
33
+ * @param {string} label
34
+ * @param {string} days what the field holds; empty is the default
35
+ * @returns {{ ok: true, body: { label: string, days?: number } } | { ok: false, error: string }}
36
+ */
37
+ export function makeRequest(label, days) {
38
+ const text = typeof label === 'string' ? label.trim() : '';
39
+ if (!text) return { ok: false, error: 'Give the token a label: what will use it, like "chat bot".' };
40
+ if (text.length > MAX_LABEL) return { ok: false, error: `A label is at most ${MAX_LABEL} characters.` };
41
+ if (!LABEL.test(text)) return { ok: false, error: 'A label is letters, digits, spaces and . _ : / ( ) - only.' };
42
+ const d = typeof days === 'string' ? days.trim() : '';
43
+ if (d === '') return { ok: true, body: { label: text } };
44
+ if (!/^\d{1,3}$/.test(d) || Number(d) < 1 || Number(d) > MAX_DAYS) return { ok: false, error: `A token lasts 1 to ${MAX_DAYS} days.` };
45
+ return { ok: true, body: { label: text, days: Number(d) } };
46
+ }
47
+
48
+ /** "in 12 days", "in 5 hours", "in under an hour", or "" for a time that does not read or is not ahead */
49
+ function untilWords(iso, nowMs) {
50
+ const then = typeof iso === 'string' ? Date.parse(iso) : NaN;
51
+ if (!Number.isFinite(then) || !Number.isFinite(nowMs) || then <= nowMs) return '';
52
+ const left = then - nowMs;
53
+ if (left < HOUR) return 'in under an hour';
54
+ if (left < DAY) { const h = Math.floor(left / HOUR); return `in ${h} hour${h === 1 ? '' : 's'}`; }
55
+ const d = Math.floor(left / DAY);
56
+ return `in ${d} day${d === 1 ? '' : 's'}`;
57
+ }
58
+
59
+ /**
60
+ * One token as the list shows it.
61
+ * @param {{ id: number, label: string, expires: string, revoked: string | null, lastUsed: string | null, state: string }} token
62
+ * @param {number} nowMs
63
+ * @returns {{ id: number, label: string, state: 'active' | 'expired' | 'revoked', status: string, used: string, canRevoke: boolean }}
64
+ */
65
+ export function tokenLine(token, nowMs) {
66
+ const state = token.state === 'revoked' || token.state === 'expired' ? token.state : 'active';
67
+ const status = state === 'revoked' ? `Revoked ${relativeTime(token.revoked, nowMs)}`.trim()
68
+ : state === 'expired' ? `Expired ${relativeTime(token.expires, nowMs)}`.trim()
69
+ : `Expires ${untilWords(token.expires, nowMs)}`.trim();
70
+ const used = token.lastUsed ? `Last used ${relativeTime(token.lastUsed, nowMs)}` : 'Never used';
71
+ return { id: token.id, label: String(token.label), state, status, used, canRevoke: state !== 'revoked' };
72
+ }
73
+
74
+ /**
75
+ * What a refusal says, in plain words: the server's own where it gave some.
76
+ * @param {number | null} status null when the server could not be reached
77
+ * @param {{ error?: string } | null} body
78
+ */
79
+ export function errorWords(status, body) {
80
+ if (status === null) return 'cornfield could not be reached. Try again in a moment.';
81
+ if (status === 401) return 'Your sign-in has ended. Sign in again.';
82
+ if (body && typeof body.error === 'string' && body.error) return body.error;
83
+ if (status === 403) return 'Only the repo\'s maintainers can do that.';
84
+ if (status === 429) return 'Too many tries. Wait a little.';
85
+ return 'That did not work. Try again in a moment.';
86
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @module explorer/review-tokens
3
+ *
4
+ * Review tokens in the dashboard (spec 0002, server/review-api.js): a signed-in maintainer makes a token so a script or a
5
+ * chat bot can Approve or Send back a proposal as them (`POST /api/reviews`), sees the tokens they hold and revokes one.
6
+ * A header button opens a dialog over the map (a scrim, `aria-modal`, the focus stays in it, Escape and Close give it
7
+ * back to the button). Shown only when the config names a forge and somebody is signed in: without sign-in there are no
8
+ * tokens.
9
+ *
10
+ * GET /api/review-tokens the list: label, when it ends, when it was last used
11
+ * POST /api/review-tokens { label, days? } -> the new token, **in that answer only**
12
+ * POST /api/review-tokens/<id>/revoke ends one
13
+ *
14
+ * A new token is put in a read-only field with a Copy button and the words that it is not shown again; it lives in
15
+ * that field and nowhere else (not in a variable, a storage or an announcement for a screen reader), and Close or
16
+ * making another empties it. The list never holds a secret; the server has only a hash.
17
+ *
18
+ * The words and the request are review-tokens-model.js, tested without a page.
19
+ */
20
+
21
+ import { escHtml } from './state.js';
22
+ import { announce } from './keyboard.js';
23
+ import { TOKENS_URL, DEFAULT_DAYS, MAX_DAYS, makeRequest, revokeUrl, tokenLine, errorWords } from './review-tokens-model.js';
24
+
25
+ const $ = (id) => document.getElementById(id);
26
+ let wired = false;
27
+ let now = () => Date.now();
28
+ const ui = { open: false, busy: false, tokens: [], loaded: false, message: null };
29
+
30
+ const dialog = () => $('tokens-dialog');
31
+
32
+ async function request(method, url, payload) {
33
+ try {
34
+ const res = await fetch(url, { method, cache: 'no-store', headers: payload === undefined ? {} : { 'Content-Type': 'application/json' }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }) });
35
+ let body = null;
36
+ try { body = await res.json(); } catch { /* no body */ }
37
+ return { ok: !!res.ok && !!body && body.ok === true, status: res.status, body };
38
+ } catch {
39
+ return { ok: false, status: null, body: null };
40
+ }
41
+ }
42
+
43
+ function say(text, ok = true) {
44
+ ui.message = text ? { text, ok } : null;
45
+ paint();
46
+ if (text) announce(text);
47
+ }
48
+
49
+ /** Everything the dialog shows, from `ui` */
50
+ function paint() {
51
+ if (!dialog()) return;
52
+ const list = $('tokens-list');
53
+ if (!ui.loaded) list.innerHTML = '<li class="hx-faint">Loading …</li>';
54
+ else if (!ui.tokens.length) list.innerHTML = '<li class="hx-faint">You have no review tokens yet.</li>';
55
+ else {
56
+ list.innerHTML = ui.tokens.map((t) => {
57
+ const line = tokenLine(t, now());
58
+ return `<li class="hx-tokens-item" data-state="${line.state}"><span class="hx-tokens-label">${escHtml(line.label)}</span> <span class="hx-tokens-meta hx-faint">${escHtml(line.status)} · ${escHtml(line.used)}</span> ${line.canRevoke ? `<button type="button" class="hx-btn hx-btn--quiet" data-revoke="${escHtml(String(line.id))}" aria-label="Revoke ${escHtml(line.label)}"${ui.busy ? ' disabled' : ''}>Revoke</button>` : ''}</li>`;
59
+ }).join('');
60
+ }
61
+ const msg = $('tokens-msg');
62
+ msg.hidden = !ui.message;
63
+ msg.textContent = ui.message ? ui.message.text : '';
64
+ msg.classList.toggle('hx-forge-msg--bad', !!ui.message && !ui.message.ok);
65
+ $('tokens-make').disabled = ui.busy;
66
+ }
67
+
68
+ async function load() {
69
+ const res = await request('GET', TOKENS_URL);
70
+ if (res.ok && Array.isArray(res.body.tokens)) {
71
+ ui.tokens = res.body.tokens;
72
+ ui.loaded = true;
73
+ paint();
74
+ return;
75
+ }
76
+ ui.loaded = true;
77
+ ui.tokens = [];
78
+ say(errorWords(res.status, res.body), false);
79
+ }
80
+
81
+ function clearSecret() {
82
+ const box = $('tokens-secret');
83
+ $('tokens-secret-value').value = '';
84
+ box.hidden = true;
85
+ }
86
+
87
+ async function make(e) {
88
+ e.preventDefault();
89
+ if (ui.busy) return;
90
+ const checked = makeRequest($('tokens-label').value, $('tokens-days').value);
91
+ if (!checked.ok) { say(checked.error, false); $('tokens-label').focus(); return; }
92
+ clearSecret();
93
+ ui.busy = true;
94
+ say('');
95
+ const res = await request('POST', TOKENS_URL, checked.body);
96
+ ui.busy = false;
97
+ if (!res.ok || typeof res.body.secret !== 'string') { say(errorWords(res.status, res.body), false); return; }
98
+ // The one time the secret is seen: in the field, and not in a live region
99
+ $('tokens-secret-value').value = res.body.secret;
100
+ $('tokens-secret').hidden = false;
101
+ $('tokens-label').value = '';
102
+ $('tokens-days').value = '';
103
+ say(`Made the token "${res.body.token.label}". Copy it now: it is not shown again.`);
104
+ $('tokens-secret-value').focus();
105
+ $('tokens-secret-value').select();
106
+ await load();
107
+ }
108
+
109
+ async function revoke(id) {
110
+ if (ui.busy) return;
111
+ ui.busy = true;
112
+ paint();
113
+ const res = await request('POST', revokeUrl(id), {});
114
+ ui.busy = false;
115
+ if (!res.ok) say(errorWords(res.status, res.body), false);
116
+ else say(`Revoked "${res.body.token.label}". It no longer works.`);
117
+ await load();
118
+ }
119
+
120
+ async function copy() {
121
+ const field = $('tokens-secret-value');
122
+ try {
123
+ await navigator.clipboard.writeText(field.value);
124
+ announce('Copied.');
125
+ say('Copied. It is not shown again.');
126
+ } catch {
127
+ field.focus();
128
+ field.select();
129
+ say('Select the token and copy it by hand.', false);
130
+ }
131
+ }
132
+
133
+ /** While the dialog is open the focus cannot leave it */
134
+ function keepFocusIn(e) {
135
+ const el = dialog();
136
+ if (ui.open && el && !el.contains(e.target)) $('tokens-title').focus();
137
+ }
138
+
139
+ export function openTokens() {
140
+ const el = dialog();
141
+ if (!el || ui.open) return;
142
+ ui.open = true;
143
+ ui.loaded = false;
144
+ ui.message = null;
145
+ document.addEventListener('focusin', keepFocusIn);
146
+ el.hidden = false;
147
+ $('tokens-btn').setAttribute('aria-expanded', 'true');
148
+ paint();
149
+ $('tokens-title').focus();
150
+ load();
151
+ }
152
+
153
+ export function closeTokens({ restoreFocus = true } = {}) {
154
+ const el = dialog();
155
+ if (!el || !ui.open) return;
156
+ ui.open = false;
157
+ clearSecret();
158
+ el.hidden = true;
159
+ document.removeEventListener('focusin', keepFocusIn);
160
+ $('tokens-btn').setAttribute('aria-expanded', 'false');
161
+ if (restoreFocus) $('tokens-btn').focus();
162
+ }
163
+
164
+ function onClick(e) {
165
+ if (e.target === dialog()) { closeTokens(); return; } // the scrim
166
+ const t = e.target.closest ? e.target.closest('button') : null;
167
+ if (!t) return;
168
+ if (t.hasAttribute('data-tokens-close')) closeTokens();
169
+ else if (t.hasAttribute('data-tokens-copy')) copy();
170
+ else if (t.hasAttribute('data-revoke')) revoke(t.getAttribute('data-revoke'));
171
+ }
172
+
173
+ /** Escape closes; Tab stays inside; nothing reaches the map behind */
174
+ function onKey(e) {
175
+ e.stopPropagation();
176
+ if (e.key === 'Escape') { e.preventDefault(); closeTokens(); return; }
177
+ if (e.key !== 'Tab') return;
178
+ const items = [...dialog().querySelectorAll('button:not([disabled]), input:not([disabled])')].filter((el) => !el.closest('[hidden]'));
179
+ if (!items.length) { e.preventDefault(); return; }
180
+ const at = items.indexOf(document.activeElement);
181
+ const next = e.shiftKey ? (at <= 0 ? items.length - 1 : at - 1) : (at === -1 || at === items.length - 1 ? 0 : at + 1);
182
+ e.preventDefault();
183
+ items[next].focus();
184
+ }
185
+
186
+ /**
187
+ * Wires the button and the dialog (main.js calls this once the config is read). With no forge in the config, or nobody
188
+ * signed in, both stay hidden, and this returns false.
189
+ * @param {{ forge?: { type: string, repo: string } | null, signIn?: false | { login: string } }} config what `/cornfield/config.json` said
190
+ * @param {{ clock?: () => number }} [opts] `clock` for the times shown (tests)
191
+ * @returns {boolean} whether it is on
192
+ */
193
+ export function initReviewTokens(config, opts = {}) {
194
+ const btn = $('tokens-btn');
195
+ if (!config || !config.forge || !config.signIn || !btn || !dialog()) return false;
196
+ if (opts.clock) now = opts.clock;
197
+ btn.hidden = false;
198
+ $('tokens-days').placeholder = String(DEFAULT_DAYS);
199
+ $('tokens-days-hint').textContent = `1 to ${MAX_DAYS}, ${DEFAULT_DAYS} if left empty`;
200
+ if (!wired) {
201
+ wired = true;
202
+ btn.addEventListener('click', openTokens);
203
+ dialog().addEventListener('click', onClick);
204
+ dialog().addEventListener('keydown', onKey);
205
+ $('tokens-form').addEventListener('submit', make);
206
+ }
207
+ return true;
208
+ }
209
+
210
+ /** For tests: forget the page this was wired to, and everything shown */
211
+ export function resetReviewTokensForTests(clock = () => Date.now()) {
212
+ if (ui.open) closeTokens({ restoreFocus: false });
213
+ wired = false;
214
+ now = clock;
215
+ Object.assign(ui, { open: false, busy: false, tokens: [], loaded: false, message: null });
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cornfield",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
5
5
  "license": "MIT",
6
6
  "author": "popcorn245 (https://github.com/popcorn245)",
package/server/auth.js CHANGED
@@ -279,6 +279,7 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
279
279
  /** Stage 1: everything passes; the routes under /auth/ are not there */
280
280
  gate: async () => ({ done: false, session: null }),
281
281
  forgeFor: () => { throw new Error('Sign-in is not configured.'); },
282
+ forgeForUser: async () => { throw new Error('Sign-in is not configured.'); },
282
283
  record: async () => { throw new Error('Sign-in is not configured.'); },
283
284
  begin: async () => { throw new Error('Sign-in is not configured.'); },
284
285
  finish: async () => { throw new Error('Sign-in is not configured.'); },
@@ -578,6 +579,20 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
578
579
  if (token === null) throw new ForgeError(`${forgeName} sign-in has to be renewed; sign in again.`, { code: 'unauthorized', forge: forgeName });
579
580
  return forgeWith(token);
580
581
  },
582
+ /**
583
+ * The forge as a user who may not be at the dashboard now, with the token their newest live sign-in keeps (a
584
+ * maintainer's session row holds it, encrypted, until the session ends: sign-out, expiry, or a demotion). This is how
585
+ * a review that came through the API (server/review-api.js) is posted as its token's owner. Throws a ForgeError
586
+ * `unauthorized` when they have no live sign-in, so there is nothing to post with. It does not check the permission:
587
+ * the caller asks the forge, with this token, every time.
588
+ * @param {string} userId the forge's id for the user
589
+ */
590
+ async forgeForUser(userId) {
591
+ const row = /** @type {SessionRow | undefined} */ ((await store()).prepare('SELECT * FROM sessions WHERE user_id = ? AND token <> ? AND expires_at > ? ORDER BY created_at DESC, rowid DESC LIMIT 1').get(String(userId), NO_TOKEN, iso(now())));
592
+ const token = row ? openToken(keys.enc, row.token) : null;
593
+ if (token === null) throw new ForgeError(`The token's owner has no live sign-in to ${forgeName}; they sign in to cornfield again, then the token works again.`, { code: 'unauthorized', forge: forgeName });
594
+ return forgeWith(token);
595
+ },
581
596
  /** The SQLite file (opened on first use), for the routes that keep answers in it (server/forge-routes.js) */
582
597
  store,
583
598
  close() {
package/server/db.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * reviews a cache of the latest review per spec PR and proposal (the PR comment is the truth)
9
9
  * actions the audit of every action cornfield takes on the forge: who, what, when, the forge's answer
10
10
  * forge_cache forge answers kept for a while (an ETag and a body), so a page load is not a dozen API calls
11
+ * review_tokens the review API's tokens: a SHA-256 hash, owner, label, expiry, revoked, last used (never the token)
11
12
  * hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
12
13
  * a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
13
14
  *
@@ -94,6 +95,21 @@ export const MIGRATIONS = Object.freeze([
94
95
  PRIMARY KEY (repo, pr, head_sha, slug)
95
96
  ) STRICT;
96
97
  `,
98
+ // 3: review tokens, for the review API (server/review-tokens.js)
99
+ `
100
+ CREATE TABLE review_tokens (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ token_hash TEXT NOT NULL UNIQUE, -- the SHA-256 of the token, hex; the token itself is never kept
103
+ user_id TEXT NOT NULL, -- the owner, by the forge's id (a rename is followed)
104
+ login TEXT NOT NULL, -- the owner's login when it was made
105
+ label TEXT NOT NULL, -- what the owner calls it; the review comment names it
106
+ created_at TEXT NOT NULL,
107
+ expires_at TEXT NOT NULL,
108
+ revoked_at TEXT,
109
+ last_used TEXT
110
+ ) STRICT;
111
+ CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
112
+ `,
97
113
  ]);
98
114
 
99
115
  /** The version a file is at: its `user_version` */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @module server/defuse
3
+ *
4
+ * Breaking the mentions and references in text cornfield posts on the forge for someone else: `@user`, `@org/team`,
5
+ * `#1`, `owner/repo#1`, `GH-1`, and the links the forge would make of `https://…` or `www.…`. A zero-width space after
6
+ * the character keeps the text as it reads and stops it notifying anyone or linking anything. The pipeline's issues
7
+ * (server/pipeline.js) and a review that came through the review API (server/reviews.js) use it.
8
+ */
9
+
10
+ /** A zero-width space: it ends a mention or a reference without showing */
11
+ export const ZW = '​';
12
+
13
+ /** Text with every mention, reference and auto-link broken (see the module's head) */
14
+ export function defuse(text) {
15
+ return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
16
+ }
@@ -45,14 +45,12 @@ import { ForgeError, RERUN_STATES, canRerun, isMaintainer } from './forge.js';
45
45
  import { sameOrigin } from './proposals.js';
46
46
  import { planSpec, planBuild, startSpec, openBuildIssue } from './pipeline.js';
47
47
  import { PROPOSAL_FILE } from './files.js';
48
+ import { Refused, throttle, countAttempt } from './throttle.js';
49
+
50
+ // The limits live in server/throttle.js (reviews share them); these are re-exported for the callers that always had them here
51
+ export { COOLDOWN_MS, PER_MINUTE, ATTEMPTS_PER_MINUTE, countAttempt } from './throttle.js';
48
52
  import { proposalPayload, landedPayload } from './hooks.js';
49
53
 
50
- /** 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) */
51
- export const COOLDOWN_MS = 5000;
52
- /** The most actions one person takes in a minute */
53
- export const PER_MINUTE = 20;
54
- /** The most attempts of any kind (refused ones too) one person makes in a minute, before any is written down */
55
- export const ATTEMPTS_PER_MINUTE = 60;
56
54
  /** A request body is a sha and a check's id */
57
55
  const MAX_BODY = 4096;
58
56
 
@@ -63,39 +61,6 @@ const DIGEST = /^[0-9a-f]{32}$/;
63
61
  const DOING = { merge: 'Landing', rerun: 'Re-running', spec: 'Starting a spec', build: 'Opening the build issue' };
64
62
  const ONLY_MAINTAINERS = { merge: 'land pull requests or re-run checks', rerun: 'land pull requests or re-run checks', spec: 'start specs', build: 'open build issues' };
65
63
 
66
- /** @type {WeakMap<object, { busy: Set<string>, last: Map<string, number>, perUser: Map<string, number[]>, attempts: Map<string, number[]> }>} */
67
- const throttles = new WeakMap();
68
-
69
- function throttlesOf(auth) {
70
- let t = throttles.get(auth);
71
- if (!t) throttles.set(auth, (t = { busy: new Set(), last: new Map(), perUser: new Map(), attempts: new Map() }));
72
- return t;
73
- }
74
-
75
- /** Counts an attempt of any kind; refuses one past the ceiling, without writing anything down (the release draft shares it) */
76
- export function countAttempt(ctx, userId, at) {
77
- const t = throttlesOf(ctx.auth);
78
- for (const [k, list] of t.attempts) if (!list.length || at - list[list.length - 1] >= 60_000) t.attempts.delete(k);
79
- const mine = (t.attempts.get(userId) || []).filter((when) => at - when < 60_000);
80
- if (mine.length >= ATTEMPTS_PER_MINUTE) {
81
- t.attempts.set(userId, mine);
82
- return new Refused(429, `That is ${ATTEMPTS_PER_MINUTE} attempts in a minute; wait a little.`, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil((60_000 - (at - mine[0])) / 1000))) });
83
- }
84
- mine.push(at);
85
- t.attempts.set(userId, mine);
86
- return null;
87
- }
88
-
89
- /** An action's own refusal: the status to answer and the words */
90
- class Refused extends Error {
91
- constructor(status, message, code, headers = {}) {
92
- super(message);
93
- this.status = status;
94
- this.code = code;
95
- this.headers = headers;
96
- }
97
- }
98
-
99
64
  /** Reads a small JSON object body, or refuses */
100
65
  function readJson(req) {
101
66
  return new Promise((resolve, reject) => {
@@ -130,23 +95,6 @@ function readJson(req) {
130
95
  });
131
96
  }
132
97
 
133
- /** Refuses a repeat and a flood; answers the key to release when the attempt is over */
134
- function throttle(ctx, userId, key, at) {
135
- const t = throttlesOf(ctx.auth);
136
- for (const [k, when] of t.last) if (at - when >= COOLDOWN_MS) t.last.delete(k);
137
- const mine = (t.perUser.get(userId) || []).filter((when) => at - when < 60_000);
138
- t.perUser.set(userId, mine);
139
- const wait = (ms, words) => new Refused(429, words, 'rate-limited', { 'Retry-After': String(Math.max(1, Math.ceil(ms / 1000))) });
140
- if (t.busy.has(key)) throw wait(COOLDOWN_MS, 'That is already being done; wait for it to finish.');
141
- const before = t.last.get(key);
142
- if (before !== undefined && at - before < COOLDOWN_MS) throw wait(COOLDOWN_MS - (at - before), 'That was just asked; wait a few seconds before asking again.');
143
- if (mine.length >= PER_MINUTE) throw wait(60_000 - (at - mine[0]), `That is ${PER_MINUTE} actions in a minute; wait a little.`);
144
- t.busy.add(key);
145
- t.last.set(key, at);
146
- mine.push(at);
147
- return () => { t.busy.delete(key); };
148
- }
149
-
150
98
  /**
151
99
  * The permission of the signed-in user on the repo, asked of the forge now with their own token. Refuses unless it is
152
100
  * the same forge user as the session's and a maintainer.
@@ -35,6 +35,7 @@ import { ForgeError, MAX_BODY, MAX_TITLE, isMaintainer } from './forge.js';
35
35
  import { PROPOSAL_FILE } from './files.js';
36
36
  import { findPullProposal, folderOf, readPullProposal } from './proposal-pulls.js';
37
37
  import { latestReview, proposalHash } from './reviews.js';
38
+ import { defuse } from './defuse.js';
38
39
  import { applyEdits, slug as slugOf, validateProposal } from '../core/proposal.js';
39
40
 
40
41
  /** The longest slug of a proposal's name made from an issue's title, before its number */
@@ -62,16 +63,8 @@ export function plain(value, max = 200) {
62
63
  return defuse(clip(one, max)).replace(/[&<>\\`*_[\]|~!]/g, (c) => `&#${c.charCodeAt(0)};`);
63
64
  }
64
65
 
65
- /** A zero-width space: it ends a mention or a reference without showing */
66
- const ZW = '\u200B';
67
-
68
- /**
69
- * Text with every mention and reference broken: `@user`, `@org/team`, `#1`, `owner/repo#1`, `GH-1`, and links the forge
70
- * would make of `https://…` or `www.…`. A zero-width space after the character keeps the text as it reads.
71
- */
72
- export function defuse(text) {
73
- return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
74
- }
66
+ // Mentions and references are broken by server/defuse.js, which reviews share; re-exported for the callers that had it here
67
+ export { defuse } from './defuse.js';
75
68
 
76
69
  /** Untrusted text as a code span: no backtick or control character can end it */
77
70
  export function code(value, max = 100) {