@popcorn245/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/core/config.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* host: '127.0.0.1', // loopback, unless sign-in is configured with an https publicUrl (server/bind.js)
|
|
19
19
|
* publicUrl: null, // the origin people open it at; sign-in needs it (server/auth.js)
|
|
20
20
|
* worktrees: '.claude/worktrees', // a folder of git worktrees whose unpushed proposals are listed too
|
|
21
|
-
* proposals: 'docs/specs/proposals', // where proposal files live, in the checkout and in each worktree
|
|
21
|
+
* proposals: 'docs/specs/proposals', // where proposal files live, in the checkout and in each worktree; or { dir, approveLands } (docs/config.md#proposals)
|
|
22
22
|
* topology: 'topology.json',
|
|
23
23
|
* personas: { roles: null }, // the roles a persona may have (null: any)
|
|
24
24
|
* releaseNotes: { breakingLabel: 'breaking' }, // the pull request label that lists a change under "Breaking" in the release notes
|
|
@@ -93,6 +93,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
const KEYS = Object.keys(DEFAULTS);
|
|
96
|
+
/** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
|
|
97
|
+
export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
|
|
96
98
|
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots'];
|
|
97
99
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
98
100
|
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
@@ -201,9 +203,17 @@ export function configProblems(c) {
|
|
|
201
203
|
if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
|
|
202
204
|
else if (c.releaseNotes.breakingLabel !== undefined && (!isText(c.releaseNotes.breakingLabel) || c.releaseNotes.breakingLabel.includes(','))) out.push('releaseNotes.breakingLabel is a label\'s name, like "breaking".');
|
|
203
205
|
}
|
|
204
|
-
for (const key of ['worktrees', '
|
|
206
|
+
for (const key of ['worktrees', 'topology']) {
|
|
205
207
|
if (c[key] !== undefined && c[key] !== null && !inRepo(c[key])) out.push(`"${key}" is a folder or file inside the repo, written relative to it.`);
|
|
206
208
|
}
|
|
209
|
+
// The folder as a string (always so), or { dir?, approveLands? }: the object form is how the folder's options are set
|
|
210
|
+
if (c.proposals !== undefined && c.proposals !== null) {
|
|
211
|
+
if (isObject(c.proposals)) {
|
|
212
|
+
for (const k of Object.keys(c.proposals)) if (!PROPOSALS_KEYS.includes(k)) out.push(`proposals.${k}: the keys are ${PROPOSALS_KEYS.join(', ')}.`);
|
|
213
|
+
if (c.proposals.dir !== undefined && !inRepo(c.proposals.dir)) out.push('proposals.dir is the proposals folder, inside the repo and written relative to it.');
|
|
214
|
+
if (c.proposals.approveLands !== undefined && typeof c.proposals.approveLands !== 'boolean') out.push('proposals.approveLands is true or false: whether approving a spec also lands its pull request once every check is green.');
|
|
215
|
+
} else if (!inRepo(c.proposals)) out.push('"proposals" is the proposals folder inside the repo, written relative to it (or { dir, approveLands }).');
|
|
216
|
+
}
|
|
207
217
|
if (c.links !== undefined) {
|
|
208
218
|
if (!Array.isArray(c.links)) out.push('"links" is a list of { label, href }.');
|
|
209
219
|
else c.links.forEach((l, i) => {
|
|
@@ -280,6 +290,9 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
280
290
|
deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
|
|
281
291
|
agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
|
|
282
292
|
};
|
|
293
|
+
// `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
|
|
294
|
+
config.approveLands = isObject(raw.proposals) && raw.proposals.approveLands === true;
|
|
295
|
+
config.proposals = (isObject(raw.proposals) ? raw.proposals.dir : raw.proposals) ?? DEFAULTS.proposals;
|
|
283
296
|
if (env.CORNFIELD_PORT) config.port = Number(env.CORNFIELD_PORT);
|
|
284
297
|
if (env.PORT && !env.CORNFIELD_PORT) config.port = Number(env.PORT);
|
|
285
298
|
if (env.CORNFIELD_HOST) config.host = env.CORNFIELD_HOST;
|
package/explorer/explorer.css
CHANGED
|
@@ -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); }
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* specBlock, buildOffers the pipeline's steps (PIPE-05): why a spec cannot be started on an issue; which proposals of a spec pull request can have a build issue
|
|
23
23
|
* planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords
|
|
24
24
|
* where a step's plan is read, what the confirm step shows of it, what is sent to confirm it, and what is said of one that is made already
|
|
25
|
+
* landingUrl, cancelLandingUrl, landingWords
|
|
26
|
+
* a spec pull request waiting to land on its approval (`proposals.approveLands`, server/lander.js): where it is read, where it is cancelled, and the words
|
|
25
27
|
* notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest
|
|
26
28
|
* the Release notes view: what to ask, the tags to start from, and the words around a draft
|
|
27
29
|
*/
|
|
@@ -54,6 +56,21 @@ export const listKeyOf = (kind, state) => (kind === 'deploys' ? 'deploys' : `${k
|
|
|
54
56
|
/** The address of one pull request with its files and checks */
|
|
55
57
|
export const pullUrl = (n) => `/api/forge/pulls/${n}`;
|
|
56
58
|
|
|
59
|
+
/** Where a pull request's landing on approval is read (GET) and cancelled (POST) */
|
|
60
|
+
export const landingUrl = (n) => `/api/forge/pulls/${n}/landing`;
|
|
61
|
+
export const cancelLandingUrl = (n) => `/api/forge/pulls/${n}/landing/cancel`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The line for a pull request waiting to land on its approval: "Will land when checks pass".
|
|
65
|
+
* @param {{ slug: string, by: string, since: string } | null} landing what the server says is waiting
|
|
66
|
+
* @param {number} nowMs
|
|
67
|
+
* @returns {string} empty when nothing is waiting
|
|
68
|
+
*/
|
|
69
|
+
export function landingWords(landing, nowMs) {
|
|
70
|
+
if (!landing) return '';
|
|
71
|
+
return `Will land when checks pass. ${landing.slug} was approved by ${landing.by} ${relativeTime(landing.since, nowMs)}; it lands at that head only, and a push cancels it.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
/** Whether a list's item passes the filters (the state of a pull request is decided here: open, merged, closed without landing) */
|
|
58
75
|
export function matches(item, kind, filters) {
|
|
59
76
|
const f = { ...defaultFilters(), ...filters };
|
package/explorer/forge-panel.js
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* it goes, how, each check); a scrim covers the page, the dialog is `aria-modal`, Tab stays inside, Escape and Cancel close it and give the
|
|
30
30
|
* focus back, and it is announced. What is sent is the head that was shown: a push since is refused by the server,
|
|
31
31
|
* which checks everything again (server/forge-actions.js). While one is in flight the buttons are off.
|
|
32
|
+
* - With `proposals.approveLands` on, a spec pull request that was approved and is waiting for its checks says **Will land when
|
|
33
|
+
* checks pass** (read from `GET /api/forge/pulls/<n>/landing`, the server's own state) with a **Cancel** that POSTs to
|
|
34
|
+
* `…/landing/cancel` (server/lander.js). With the key off the panel asks nothing and shows nothing more.
|
|
32
35
|
* - The pipeline's own steps (PIPE-05, server/pipeline.js), each one button with a confirm step that shows exactly what
|
|
33
36
|
* will be made, read from the server first (the plan, with a digest the server must find again):
|
|
34
37
|
* 1. an open issue in the Issues list has **Start a spec …**: a branch, a stub proposal and a draft spec pull request;
|
|
@@ -56,7 +59,7 @@ import { LAYER_DEFS, toggleLayer } from './layers.js';
|
|
|
56
59
|
import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
|
|
57
60
|
import {
|
|
58
61
|
STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
|
|
59
|
-
mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
62
|
+
landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
60
63
|
specBlock, buildOffers, planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords,
|
|
61
64
|
listKeyOf, litForDeploy, nodesForRestarts, deployWords, deployMeta,
|
|
62
65
|
DRAFTS_URL, notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest,
|
|
@@ -79,6 +82,7 @@ const ui = {
|
|
|
79
82
|
error: '',
|
|
80
83
|
selected: null, // the pull request's number
|
|
81
84
|
detail: null, // { pull, files, checks, at, rate, stale } of the selected one
|
|
85
|
+
landing: null, // what waits to land on its approval for the selected pull request ({ slug, by, since }), or null
|
|
82
86
|
detailError: '',
|
|
83
87
|
lit: null, // litFor(detail, nodes)
|
|
84
88
|
deploy: null, // the selected deploy's id; the deploy itself is `deployItem`
|
|
@@ -181,6 +185,7 @@ async function selectPull(n) {
|
|
|
181
185
|
ui.litDeploy = null;
|
|
182
186
|
ui.selected = n;
|
|
183
187
|
ui.detail = null;
|
|
188
|
+
ui.landing = null;
|
|
184
189
|
ui.detailError = '';
|
|
185
190
|
ui.lit = null;
|
|
186
191
|
ui.note = null;
|
|
@@ -198,10 +203,35 @@ async function selectPull(n) {
|
|
|
198
203
|
}
|
|
199
204
|
ui.detail = { pull: res.body.pull, files: res.body.files || [], checks: res.body.checks || [], specs: res.body.specs || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
200
205
|
relight();
|
|
206
|
+
await loadLanding(n, token);
|
|
201
207
|
$('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the pull request's files are what was asked for, not the row that was tapped
|
|
202
208
|
announce(selectionWords());
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
/** Asks the server whether the selected pull request waits to land on its approval (a read of the server's own state, not of the forge); nothing is shown when it does not */
|
|
212
|
+
async function loadLanding(n, token) {
|
|
213
|
+
if (!(ui.config && ui.config.forge && ui.config.forge.approveLands)) return; // the key is off: nothing to ask
|
|
214
|
+
const res = await request(landingUrl(n), true);
|
|
215
|
+
if (token !== pullToken || ui.selected !== n) return;
|
|
216
|
+
ui.landing = res.ok && res.body && res.body.landing ? res.body.landing : null;
|
|
217
|
+
paint();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Stops the landing that waits on the selected pull request: a POST from this page, then the pull request is read again */
|
|
221
|
+
async function cancelLanding(n) {
|
|
222
|
+
if (ui.busy) return;
|
|
223
|
+
ui.busy = 'landing';
|
|
224
|
+
ui.note = null;
|
|
225
|
+
paint();
|
|
226
|
+
const res = await post(cancelLandingUrl(n), {});
|
|
227
|
+
ui.busy = '';
|
|
228
|
+
ui.note = { text: res.ok ? `Pull request #${n} will not land on its own. It stays open.` : `Not cancelled: ${(res.body && res.body.error) || 'cornfield did not answer.'}`, ok: res.ok };
|
|
229
|
+
announce(ui.note.text);
|
|
230
|
+
ui.landing = null;
|
|
231
|
+
paint();
|
|
232
|
+
if (ui.selected === n) await refreshForge({ quiet: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
205
235
|
/** Lets go of the selected pull request: nothing is lit any more */
|
|
206
236
|
export function clearPull() {
|
|
207
237
|
pullToken++;
|
|
@@ -210,6 +240,7 @@ export function clearPull() {
|
|
|
210
240
|
ui.selected = null;
|
|
211
241
|
ui.note = null;
|
|
212
242
|
ui.detail = null;
|
|
243
|
+
ui.landing = null;
|
|
213
244
|
ui.detailError = '';
|
|
214
245
|
ui.lit = null;
|
|
215
246
|
state.forgeLit = null;
|
|
@@ -570,6 +601,10 @@ function detailHtml() {
|
|
|
570
601
|
const act = block
|
|
571
602
|
? `<p class="hx-forge-why hx-faint">${pull.state === 'merged' ? '' : 'Cannot be landed from here. '}${escHtml(block)}</p>`
|
|
572
603
|
: `<button type="button" class="hx-btn hx-btn--approve hx-forge-act" data-forge-merge="${pull.number}"${ui.busy ? ' disabled' : ''}>Land it …</button><p class="hx-forge-why hx-faint">Opens a confirm step that shows what will land. Never forced, never deployed.</p>`;
|
|
604
|
+
// A spec approved with `proposals.approveLands` waits for its checks: said here, with a way to stop it
|
|
605
|
+
const waiting = ui.landing && pull.state === 'open'
|
|
606
|
+
? `<div class="hx-forge-step"><p class="hx-forge-notice hx-forge-notice--ok" role="status">${escHtml(landingWords(ui.landing, now()))}</p><button type="button" class="hx-btn hx-forge-act" data-forge-landing-cancel="${pull.number}"${ui.busy ? ' disabled' : ''}>Cancel</button></div>`
|
|
607
|
+
: '';
|
|
573
608
|
const note = ui.note ? `<p class="hx-forge-notice${ui.note.ok ? ' hx-forge-notice--ok' : ''}">${escHtml(ui.note.text)}</p>` : '';
|
|
574
609
|
// Step 2 of the pipeline: a spec pull request with an approved proposal opens its build issue
|
|
575
610
|
const offers = buildOffers(ui.detail);
|
|
@@ -589,6 +624,7 @@ function detailHtml() {
|
|
|
589
624
|
${build}
|
|
590
625
|
<h4 class="hx-forge-subact">Land it</h4>
|
|
591
626
|
${note}
|
|
627
|
+
${waiting}
|
|
592
628
|
${act}
|
|
593
629
|
<h4 class="hx-forge-sub">${escHtml(plural(lit.files.length, 'file'))} changed, ${mapped} on the map</h4>
|
|
594
630
|
${key}
|
|
@@ -951,6 +987,7 @@ export async function refreshForge({ quiet = false } = {}) {
|
|
|
951
987
|
if (token !== pullToken || !res.ok) return;
|
|
952
988
|
ui.detail = { pull: res.body.pull, files: res.body.files || [], checks: res.body.checks || [], specs: res.body.specs || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
953
989
|
relight();
|
|
990
|
+
await loadLanding(n, token);
|
|
954
991
|
}
|
|
955
992
|
}
|
|
956
993
|
|
|
@@ -1005,6 +1042,8 @@ function onClick(e) {
|
|
|
1005
1042
|
announce(`${node.label}, ${node.type}`);
|
|
1006
1043
|
return;
|
|
1007
1044
|
}
|
|
1045
|
+
const unland = t.closest('[data-forge-landing-cancel]');
|
|
1046
|
+
if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
|
|
1008
1047
|
const merge = t.closest('[data-forge-merge]');
|
|
1009
1048
|
if (merge) { openConfirm('[data-forge-merge]'); return; }
|
|
1010
1049
|
const spec = t.closest('[data-forge-spec]');
|
|
@@ -1119,5 +1158,5 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
|
|
|
1119
1158
|
pullToken++;
|
|
1120
1159
|
notesToken++;
|
|
1121
1160
|
state.forgeLit = null;
|
|
1122
|
-
Object.assign(ui, { config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
|
|
1161
|
+
Object.assign(ui, { config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
|
|
1123
1162
|
}
|
package/explorer/index.html
CHANGED
|
@@ -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": "@popcorn245/cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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() {
|