cornfield 0.8.0 → 0.10.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/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +63 -5
- package/explorer/explorer.css +3 -1
- package/explorer/forge-panel.js +203 -5
- package/explorer/index.html +28 -0
- package/explorer/review-tokens-model.js +12 -1
- package/explorer/review-tokens.js +13 -7
- package/explorer/runs-model.js +107 -0
- package/package.json +1 -1
- package/server/agent-api.js +228 -0
- package/server/agent-runs.js +906 -0
- package/server/auth.js +58 -3
- package/server/db.js +84 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +7 -0
- package/server/forge.js +5 -1
- package/server/hooks.js +19 -0
- package/server/review-api.js +28 -12
- package/server/review-tokens.js +59 -25
- package/server/runner-api.js +270 -0
- package/server/server.js +43 -7
- package/server/spec-poll.js +41 -15
package/server/auth.js
CHANGED
|
@@ -42,6 +42,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, ran
|
|
|
42
42
|
import { createForge as defaultCreateForge, isMaintainer, ForgeError } from './forge.js';
|
|
43
43
|
import { githubApiUrl } from './forge-github.js';
|
|
44
44
|
import { sameOrigin } from './proposals.js';
|
|
45
|
+
import { revokeOwnerTokens } from './review-tokens.js';
|
|
45
46
|
|
|
46
47
|
/** How often a session's permission is asked of the forge again */
|
|
47
48
|
export const RECHECK_MS = 15 * 60 * 1000;
|
|
@@ -335,6 +336,8 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
|
|
|
335
336
|
forgeFor: () => { throw new Error('Sign-in is not configured.'); },
|
|
336
337
|
forgeForUser: async () => { throw new Error('Sign-in is not configured.'); },
|
|
337
338
|
record: async () => { throw new Error('Sign-in is not configured.'); },
|
|
339
|
+
knownPermission: async () => null,
|
|
340
|
+
demoted: async () => {},
|
|
338
341
|
begin: async () => { throw new Error('Sign-in is not configured.'); },
|
|
339
342
|
finish: async () => { throw new Error('Sign-in is not configured.'); },
|
|
340
343
|
store: null,
|
|
@@ -610,6 +613,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
|
|
|
610
613
|
return true;
|
|
611
614
|
};
|
|
612
615
|
const due = () => expiresAt !== null && Date.parse(expiresAt) - REFRESH_SKEW_MS <= now();
|
|
616
|
+
/** Who the forge says this token is: the login a `permission` question about the owner names */
|
|
617
|
+
let ownLogin = row.login;
|
|
618
|
+
/**
|
|
619
|
+
* What the call answered, looked at once on its way out: when the forge says the owner themself does not maintain the repo, their API
|
|
620
|
+
* tokens (review, agent, runner) are revoked there and then, wherever in cornfield the question was asked
|
|
621
|
+
*/
|
|
622
|
+
const noted = async (prop, args, result) => {
|
|
623
|
+
if (prop === 'me' && result && typeof result.login === 'string') ownLogin = result.login;
|
|
624
|
+
if (prop === 'permission' && args[0] === forgeConfig.repo && typeof args[1] === 'string' && args[1].toLowerCase() === ownLogin.toLowerCase() && !isMaintainer(result)) {
|
|
625
|
+
await demoted(row.user_id, ownLogin, result);
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
};
|
|
613
629
|
return new Proxy({}, {
|
|
614
630
|
get(_, prop) {
|
|
615
631
|
const member = forge[prop];
|
|
@@ -625,18 +641,40 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
|
|
|
625
641
|
}
|
|
626
642
|
}
|
|
627
643
|
try {
|
|
628
|
-
return await forge[prop](...args);
|
|
644
|
+
return await noted(prop, args, await forge[prop](...args));
|
|
629
645
|
} catch (err) {
|
|
630
646
|
if (!(err instanceof ForgeError) || err.code !== 'unauthorized') throw err;
|
|
631
647
|
// The forge refused the token: renew it (when it can be) and ask once more; else its refusal stands
|
|
632
648
|
if (!(await renewNow())) throw err;
|
|
633
649
|
}
|
|
634
|
-
return forge[prop](...args);
|
|
650
|
+
return noted(prop, args, await forge[prop](...args));
|
|
635
651
|
};
|
|
636
652
|
},
|
|
637
653
|
});
|
|
638
654
|
}
|
|
639
655
|
|
|
656
|
+
/**
|
|
657
|
+
* A token owner the forge says no longer maintains the repo: every API token of theirs (review, agent and runner) is revoked at once, and
|
|
658
|
+
* the revoking is a row in the audit table. Never throws: what learned it goes on with its own answer.
|
|
659
|
+
* @param {string} userId
|
|
660
|
+
* @param {string} login
|
|
661
|
+
* @param {string} permission what the forge said
|
|
662
|
+
*/
|
|
663
|
+
async function demoted(userId, login, permission) {
|
|
664
|
+
try {
|
|
665
|
+
const n = revokeOwnerTokens(await store(), userId, now());
|
|
666
|
+
if (!n) return;
|
|
667
|
+
try {
|
|
668
|
+
(await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
669
|
+
.run(iso(now()), login, 'token-revoke', forgeConfig.repo, JSON.stringify({ reason: 'owner-not-maintainer', permission }), null, JSON.stringify({ revoked: n }));
|
|
670
|
+
} catch (err) {
|
|
671
|
+
console.error(`[cornfield] the revoking of ${login}'s tokens could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
console.error(`[cornfield] the tokens of ${login}, no longer a maintainer, could not be revoked: ${err instanceof Error ? err.message : err}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
640
678
|
async function callback(req, res, url) {
|
|
641
679
|
// The state cookie is cleared only by an answer to the sign-in it belongs to: a link from anywhere else
|
|
642
680
|
// (`/auth/callback?error=x`) cannot cancel one in progress
|
|
@@ -668,7 +706,11 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
|
|
|
668
706
|
file.prepare('INSERT INTO sessions (id, forge, user_id, login, token, permission, created_at, checked_at, expires_at, refresh_token, token_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
669
707
|
.run(id, forgeConfig.type, String(user.id), user.login, keep ? sealFor(id, 'token', token) : NO_TOKEN, permission, iso(at), iso(at), iso(at + sessionMs),
|
|
670
708
|
keep && given.refreshToken !== '' ? sealFor(id, 'refresh_token', given.refreshToken) : '', keep ? given.expiresAt : null);
|
|
671
|
-
if (!keep)
|
|
709
|
+
if (!keep) {
|
|
710
|
+
await revoke(token);
|
|
711
|
+
// Signed in, and not a maintainer: whatever API tokens they made while they were one stop working now
|
|
712
|
+
await demoted(String(user.id), user.login, permission);
|
|
713
|
+
}
|
|
672
714
|
redirect(res, '/', [cookie(SESSION_COOKIE, value, { maxAge: sessionMs / 1000, secure }), clearState]);
|
|
673
715
|
}
|
|
674
716
|
|
|
@@ -740,6 +782,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
|
|
|
740
782
|
* decides what happens when this throws (the action it records has usually happened already).
|
|
741
783
|
* @param {{ login: string, action: string, target: string, request?: object, status?: number | null, answer?: object | null }} entry
|
|
742
784
|
*/
|
|
785
|
+
/**
|
|
786
|
+
* What cornfield last heard of a user's permission on the repo: their newest sign-in's (asked of the forge at sign-in and every 15 minutes
|
|
787
|
+
* while they use cornfield), or null when it has no sign-in of theirs at all. A runner token is not asked of the forge per call; this is its
|
|
788
|
+
* cheap guard (server/runner-api.js).
|
|
789
|
+
* @param {string} userId
|
|
790
|
+
* @returns {Promise<string | null>}
|
|
791
|
+
*/
|
|
792
|
+
async knownPermission(userId) {
|
|
793
|
+
const row = /** @type {{ permission: string } | undefined} */ ((await store()).prepare('SELECT permission FROM sessions WHERE user_id = ? ORDER BY checked_at DESC, rowid DESC LIMIT 1').get(String(userId)));
|
|
794
|
+
return row ? row.permission : null;
|
|
795
|
+
},
|
|
796
|
+
/** Revokes a user's API tokens when cornfield has learned they no longer maintain the repo (see `demoted`) */
|
|
797
|
+
demoted,
|
|
743
798
|
async record({ login, action, target, request = {}, status = null, answer = null }) {
|
|
744
799
|
(await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
745
800
|
.run(iso(now()), login, action, target, JSON.stringify(request), status, answer === null ? null : JSON.stringify(answer));
|
package/server/db.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* thread_seen the (repo, issue, comment) triples whose thread.* hook event was sent, and each thread's last-seen time, so a
|
|
16
16
|
* restart sends none twice and a look asks the forge only for what is new (server/hooks.js, server/threads.js);
|
|
17
17
|
* a row with `issue = 0` says the repo's threads have been looked at once
|
|
18
|
+
* runs the agent runs cornfield asked for: the request, where it was posted, the latest report, the pull request found for
|
|
19
|
+
* it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
|
|
20
|
+
* (review, agent, or runner: migration 8, which also gives a run its transport and the runner token that claimed it)
|
|
21
|
+
* run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
|
|
18
22
|
* sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
|
|
19
23
|
*
|
|
20
24
|
* The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
|
|
@@ -148,6 +152,86 @@ export const MIGRATIONS = Object.freeze([
|
|
|
148
152
|
PRIMARY KEY (repo, issue, comment)
|
|
149
153
|
) STRICT;
|
|
150
154
|
`,
|
|
155
|
+
// 7: agent runs (server/agent-runs.js, spec 0005): a table of the runs cornfield asked for, what it has seen of them, and a scope on
|
|
156
|
+
// the API tokens so one store holds review tokens and agent tokens
|
|
157
|
+
`
|
|
158
|
+
ALTER TABLE review_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent')); -- what the token may call: POST /api/reviews, or POST /api/agent-requests
|
|
159
|
+
|
|
160
|
+
CREATE TABLE runs (
|
|
161
|
+
run_id TEXT PRIMARY KEY, -- run-<ulid>, minted with the request
|
|
162
|
+
repo TEXT NOT NULL, -- 'owner/name'
|
|
163
|
+
kind TEXT NOT NULL,
|
|
164
|
+
profile TEXT,
|
|
165
|
+
issue INTEGER, -- what the run is about (at least one of the three)
|
|
166
|
+
proposal TEXT,
|
|
167
|
+
thread INTEGER,
|
|
168
|
+
target INTEGER NOT NULL, -- the issue or pull request the request, and the stop, were posted on
|
|
169
|
+
comment_id TEXT NOT NULL DEFAULT '', -- the request comment; '' while the post is in flight (the row already counts as open)
|
|
170
|
+
comment_url TEXT,
|
|
171
|
+
request TEXT NOT NULL CHECK (json_valid(request)), -- the request as posted: no credential in it, ever
|
|
172
|
+
requested_by TEXT NOT NULL,
|
|
173
|
+
requested_by_id TEXT NOT NULL,
|
|
174
|
+
requested_at TEXT NOT NULL,
|
|
175
|
+
via TEXT NOT NULL DEFAULT 'dashboard', -- 'dashboard' | 'api'
|
|
176
|
+
state TEXT NOT NULL DEFAULT 'requested' CHECK (state IN ('requested', 'claimed', 'working', 'done', 'failed', 'stopped')), -- the runner's latest report
|
|
177
|
+
reported_at TEXT,
|
|
178
|
+
reported_by TEXT,
|
|
179
|
+
note TEXT,
|
|
180
|
+
url TEXT,
|
|
181
|
+
tokens INTEGER,
|
|
182
|
+
cost REAL,
|
|
183
|
+
stop_requested_at TEXT,
|
|
184
|
+
stop_requested_by TEXT,
|
|
185
|
+
pull INTEGER, -- the pull request that carries the run's id
|
|
186
|
+
pull_state TEXT CHECK (pull_state IS NULL OR pull_state IN ('open', 'merged', 'closed')),
|
|
187
|
+
pull_url TEXT,
|
|
188
|
+
pull_by TEXT,
|
|
189
|
+
pull_trusted INTEGER NOT NULL DEFAULT 0, -- 1 when its author is a runner, a listed agent author or a maintainer: only then does it move the state
|
|
190
|
+
linked_via TEXT CHECK (linked_via IS NULL OR linked_via IN ('body', 'commit', 'branch')),
|
|
191
|
+
branch TEXT,
|
|
192
|
+
head_sha TEXT,
|
|
193
|
+
checks TEXT CHECK (checks IS NULL OR checks IN ('success', 'failure', 'running', 'none')),
|
|
194
|
+
last_activity TEXT NOT NULL -- the latest report, push or pull request activity (or the request itself)
|
|
195
|
+
) STRICT;
|
|
196
|
+
CREATE INDEX runs_repo ON runs (repo, requested_at);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE run_seen (
|
|
199
|
+
run_id TEXT NOT NULL, -- '' for what belongs to no run (a pull request's commits already read)
|
|
200
|
+
key TEXT NOT NULL, -- 'c:<comment>:<n>' a block judged, 'ev:…' a hook event sent, 'commits:<pull>@<sha>'
|
|
201
|
+
at TEXT NOT NULL,
|
|
202
|
+
PRIMARY KEY (run_id, key)
|
|
203
|
+
) STRICT;
|
|
204
|
+
`,
|
|
205
|
+
// 8: push and pull transports, and runner tokens (server/agent-runs.js, server/runner-api.js, spec 0005 D2, D5): a run records how its request
|
|
206
|
+
// travels and which runner token claimed it; the token scope check gains 'runner'. SQLite cannot change a CHECK, so review_tokens is rebuilt
|
|
207
|
+
// (the documented way: a new table, the rows copied with their ids, the old one dropped, the new one renamed) and keeps its id sequence
|
|
208
|
+
`
|
|
209
|
+
ALTER TABLE runs ADD COLUMN transport TEXT NOT NULL DEFAULT 'forge' CHECK (transport IN ('forge', 'push', 'pull')); -- how the request reaches the runner; a push or pull run has no request comment (comment_id stays '')
|
|
210
|
+
ALTER TABLE runs ADD COLUMN claimed_by INTEGER; -- the runner token (review_tokens.id) that claimed a pull run, or that first reported on a push run
|
|
211
|
+
ALTER TABLE runs ADD COLUMN claimed_at TEXT;
|
|
212
|
+
CREATE INDEX runs_queue ON runs (repo, transport, state, requested_at);
|
|
213
|
+
|
|
214
|
+
CREATE TABLE review_tokens_new (
|
|
215
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
216
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
217
|
+
user_id TEXT NOT NULL,
|
|
218
|
+
login TEXT NOT NULL,
|
|
219
|
+
label TEXT NOT NULL,
|
|
220
|
+
created_at TEXT NOT NULL,
|
|
221
|
+
expires_at TEXT NOT NULL,
|
|
222
|
+
revoked_at TEXT,
|
|
223
|
+
last_used TEXT,
|
|
224
|
+
scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent', 'runner')) -- what the token may call: POST /api/reviews, POST /api/agent-requests, or the runner routes
|
|
225
|
+
) STRICT;
|
|
226
|
+
INSERT INTO review_tokens_new (id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope)
|
|
227
|
+
SELECT id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope FROM review_tokens;
|
|
228
|
+
UPDATE sqlite_sequence SET seq = MAX(seq, COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'review_tokens'), 0)) WHERE name = 'review_tokens_new';
|
|
229
|
+
INSERT INTO sqlite_sequence (name, seq) SELECT 'review_tokens_new', seq FROM sqlite_sequence WHERE name = 'review_tokens'
|
|
230
|
+
AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'review_tokens_new');
|
|
231
|
+
DROP TABLE review_tokens;
|
|
232
|
+
ALTER TABLE review_tokens_new RENAME TO review_tokens;
|
|
233
|
+
CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
|
|
234
|
+
`,
|
|
151
235
|
]);
|
|
152
236
|
|
|
153
237
|
/** The version a file is at: its `user_version` */
|
package/server/forge-forgejo.js
CHANGED
|
@@ -82,6 +82,9 @@ const FILE_STATUS = { added: 'added', copied: 'added', deleted: 'removed', remov
|
|
|
82
82
|
const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename || null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
|
|
83
83
|
|
|
84
84
|
/** @returns {import('./forge.js').Comment} */
|
|
85
|
+
/** @returns {import('./forge.js').PullCommit} */
|
|
86
|
+
const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
|
|
87
|
+
|
|
85
88
|
const toComment = (c) => ({ id: String(c.id), author: author(c.user), body: c.body ?? '', url: c.html_url, createdAt: c.created_at, updatedAt: c.updated_at });
|
|
86
89
|
|
|
87
90
|
// A warning does not count as passing, so it stands in the way like a failure
|
|
@@ -237,6 +240,10 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
|
|
|
237
240
|
return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
238
241
|
},
|
|
239
242
|
|
|
243
|
+
async pullCommits(n) {
|
|
244
|
+
return (await client.list(`repos/${repo}/pulls/${n}/commits?limit=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
245
|
+
},
|
|
246
|
+
|
|
240
247
|
async comments(n, { since } = {}) {
|
|
241
248
|
return (await client.list(`repos/${repo}/issues/${n}/comments?limit=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
|
|
242
249
|
},
|
package/server/forge-github.js
CHANGED
|
@@ -66,6 +66,9 @@ const toPull = (p) => ({
|
|
|
66
66
|
url: p.html_url, createdAt: p.created_at, updatedAt: p.updated_at, mergedAt: p.merged_at ?? null,
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
/** @returns {import('./forge.js').PullCommit} */
|
|
70
|
+
const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
|
|
71
|
+
|
|
69
72
|
const FILE_STATUS = { added: 'added', copied: 'added', removed: 'removed', renamed: 'renamed', modified: 'modified', changed: 'modified', unchanged: 'modified' };
|
|
70
73
|
/** @returns {import('./forge.js').PullFile} */
|
|
71
74
|
const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename ?? null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
|
|
@@ -230,6 +233,10 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
|
|
|
230
233
|
return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
231
234
|
},
|
|
232
235
|
|
|
236
|
+
async pullCommits(n) {
|
|
237
|
+
return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
238
|
+
},
|
|
239
|
+
|
|
233
240
|
async comments(n, { since } = {}) {
|
|
234
241
|
return (await client.list(`repos/${repo}/issues/${n}/comments?per_page=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
|
|
235
242
|
},
|
package/server/forge.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* issue(n) one issue (not a pull request: that is `not-found`)
|
|
15
15
|
* searchIssues(words) the issues whose text holds the words (one page; the forge's search, which can lag a moment)
|
|
16
16
|
* pullFiles(n) the files a pull request changes
|
|
17
|
+
* pullCommits(n) the commits of a pull request, oldest first: their shas and full messages (a run's trailer may be in one)
|
|
17
18
|
* comments(n, { since }) the comments on an issue or pull request, oldest first; `since` (an ISO time) only those
|
|
18
19
|
* created or edited after it, which is how a thread is followed without reading it all again
|
|
19
20
|
* comment(n, body) posts one, and answers it
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
* @typedef {{ number: number, title: string, body: string, state: 'open' | 'closed' | 'merged', draft: boolean, author: Author | null, labels: string[], head: { ref: string, sha: string }, base: { ref: string }, url: string, createdAt: string, updatedAt: string, mergedAt: string | null }} Pull
|
|
56
57
|
* @typedef {{ path: string, status: 'added' | 'modified' | 'removed' | 'renamed', previousPath: string | null, additions: number, deletions: number }} PullFile
|
|
57
58
|
* @typedef {{ id: string, author: Author | null, body: string, url: string, createdAt: string, updatedAt: string }} Comment
|
|
59
|
+
* @typedef {{ sha: string, message: string }} PullCommit
|
|
58
60
|
* @typedef {'queued' | 'running' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled'} CheckState
|
|
59
61
|
* @typedef {{ id: string, name: string, state: CheckState, url: string | null, rerunnable: boolean }} Check
|
|
60
62
|
* @typedef {{ name: string, sha: string, protected: boolean }} Branch
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
* createPull(pull: { title: string, body: string, head: string, base: string, draft: boolean }): Promise<Pull>,
|
|
84
86
|
* createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
|
|
85
87
|
* pullFiles(n: number): Promise<PullFile[]>,
|
|
88
|
+
* pullCommits(n: number): Promise<PullCommit[]>,
|
|
86
89
|
* comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
|
|
87
90
|
* comment(n: number, body: string): Promise<Comment>,
|
|
88
91
|
* checks(ref: string): Promise<Check[]>,
|
|
@@ -114,7 +117,7 @@ import { ForgeError, FILE_MAX_BYTES } from './forge-http.js';
|
|
|
114
117
|
export { ForgeError, rateLimitWords } from './forge-http.js';
|
|
115
118
|
|
|
116
119
|
/** The calls of the interface (spec 0001 section 3, with `pull(n)`), and `rateLimit()`: what the last answer said is left */
|
|
117
|
-
export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
|
|
120
|
+
export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'pullCommits', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
|
|
118
121
|
export const PERMISSIONS = Object.freeze(['admin', 'maintain', 'write', 'triage', 'read', 'none']);
|
|
119
122
|
/** A check that has finished and does not stand in the way: success, or one the forge says does not count */
|
|
120
123
|
export const GREEN_STATES = Object.freeze(['success', 'neutral', 'skipped']);
|
|
@@ -276,6 +279,7 @@ export function wrap(a, forge) {
|
|
|
276
279
|
return a.searchIssues(words.trim());
|
|
277
280
|
},
|
|
278
281
|
pullFiles: async (n) => a.pullFiles(number(n)),
|
|
282
|
+
pullCommits: async (n) => a.pullCommits(number(n)),
|
|
279
283
|
comments: async (n, opts) => {
|
|
280
284
|
if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
|
|
281
285
|
const since = opts && opts.since;
|
package/server/hooks.js
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* what is there and sends nothing; each (repo, thread, comment) is sent once, across restarts (`thread_seen`)
|
|
24
24
|
* session.expiring a maintainer's sign-in ends within a day (`watchSessions`, below). Its body is its own:
|
|
25
25
|
* `{ event, at, actor, expiresAt, mapUrl }`
|
|
26
|
+
* agent.requested a maintainer asked for an agent run (server/agent-runs.js). Its body is its own: `{ event, at, request, actor, mapUrl }`,
|
|
27
|
+
* `request` being the whole request JSON of the contract (docs/agent-contract.md), which holds no credential by construction
|
|
28
|
+
* run.claimed run.state run.stopRequested run.finished a run was claimed by its runner, changed state, was asked to stop, ended. They are
|
|
29
|
+
* seen when cornfield looks (the spec poll, server/spec-poll.js), each once. Their body is their own:
|
|
30
|
+
* `{ event, at, runId, state, pull?, issue?, actor, mapUrl }`
|
|
26
31
|
*
|
|
27
32
|
* **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
|
|
28
33
|
* queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
|
|
@@ -46,6 +51,7 @@ import { createHmac, randomUUID } from 'node:crypto';
|
|
|
46
51
|
import { HOOK_EVENTS } from '../core/config.js';
|
|
47
52
|
import { pullProposals, readPullProposal } from './proposal-pulls.js';
|
|
48
53
|
import { proposalHash } from './reviews.js';
|
|
54
|
+
import { validateRequest } from '../core/agent-contract.js';
|
|
49
55
|
|
|
50
56
|
/** @typedef {ReturnType<typeof createHooks>} Hooks */
|
|
51
57
|
|
|
@@ -194,6 +200,19 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
194
200
|
function bodyOf(event, p) {
|
|
195
201
|
// A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
|
|
196
202
|
if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
|
|
203
|
+
// A request goes out as it was posted, and is checked once more on the way: nothing like a credential can leave through a hook
|
|
204
|
+
if (event === 'agent.requested') {
|
|
205
|
+
const checked = validateRequest(p.request);
|
|
206
|
+
if (!checked.ok) throw new Error(checked.error);
|
|
207
|
+
return JSON.stringify({ event, at: new Date(now()).toISOString(), request: checked.value, actor: p.actor ?? null, mapUrl: publicUrl || null });
|
|
208
|
+
}
|
|
209
|
+
// The events of a run (claimed, state, stopRequested, finished) say which run, in what state, and about what
|
|
210
|
+
if (event.startsWith('run.')) {
|
|
211
|
+
return JSON.stringify({
|
|
212
|
+
event, at: new Date(now()).toISOString(), runId: p.runId ?? null, state: p.state ?? null,
|
|
213
|
+
...(p.pull != null ? { pull: p.pull } : {}), ...(p.issue != null ? { issue: p.issue } : {}), actor: p.actor ?? null, mapUrl: publicUrl || null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
197
216
|
// A thread's event is only which thread, which comment, who and what it links to: never the words said
|
|
198
217
|
if (event.startsWith('thread.')) {
|
|
199
218
|
const out = { event, at: new Date(now()).toISOString(), thread: p.thread ?? null };
|
package/server/review-api.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* GET /api/review-tokens the signed-in maintainer's tokens: id, label, made, expires, revoked, last used (never a secret)
|
|
9
9
|
* POST /api/review-tokens { label, days? } -> the new token, in this answer only
|
|
10
10
|
* POST /api/review-tokens/<id>/revoke ends one of theirs for good
|
|
11
|
+
* * /api/agent-tokens… the same three for tokens of another scope: an agent token asks for runs
|
|
12
|
+
* (`POST /api/agent-requests`, server/agent-api.js) and is no good here, as a review token is no good there
|
|
13
|
+
* * /api/runner-tokens… and for a runner token, which lists, claims and reports on runs (server/runner-api.js)
|
|
11
14
|
*
|
|
12
15
|
* **The call takes the token and nothing else.** It runs before the sign-in guard (a script has no cookie) and reads no
|
|
13
16
|
* cookie: a session cookie is never a substitute for the token, and a request that sends only one is 401. Being no
|
|
@@ -41,6 +44,8 @@ import { Refused, countAttempt } from './throttle.js';
|
|
|
41
44
|
|
|
42
45
|
export const REVIEWS_PATH = '/api/reviews';
|
|
43
46
|
export const TOKENS_PATH = '/api/review-tokens';
|
|
47
|
+
export const AGENT_TOKENS_PATH = '/api/agent-tokens';
|
|
48
|
+
export const RUNNER_TOKENS_PATH = '/api/runner-tokens';
|
|
44
49
|
/** Calls with no token, or one nobody holds, that are written down in a minute, all callers together */
|
|
45
50
|
export const ANONYMOUS_PER_MINUTE = 20;
|
|
46
51
|
/** What the audit row of a call nobody could name says in place of a login */
|
|
@@ -88,7 +93,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
88
93
|
// The token, from the header alone; a cookie is not looked at
|
|
89
94
|
const presented = bearerOf(req.headers.authorization);
|
|
90
95
|
const db = presented ? await auth.store() : null;
|
|
91
|
-
const found = presented && db ? findToken(db, presented, now()) : null;
|
|
96
|
+
const found = presented && db ? findToken(db, presented, now(), 'review') : null;
|
|
92
97
|
if (!found) {
|
|
93
98
|
req.resume();
|
|
94
99
|
const reason = presented ? 'unknown-token' : 'no-token';
|
|
@@ -101,6 +106,15 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
101
106
|
|
|
102
107
|
const { row, state } = found;
|
|
103
108
|
const token = { id: row.id, label: row.label };
|
|
109
|
+
// An agent token asks for runs and is no review token: refused whatever its state, and not counted as used
|
|
110
|
+
if (state === 'wrong-scope') {
|
|
111
|
+
req.resume();
|
|
112
|
+
const flood = countAttempt(ctx, row.user_id, now());
|
|
113
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
114
|
+
const body = { error: `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Reviews take a review token; make one in the dashboard.`, code: 'forbidden' };
|
|
115
|
+
await audit(row.login, { reason: 'wrong-scope', token, scope: row.scope }, 403, body);
|
|
116
|
+
return send(res, 403, { ok: false, ...body });
|
|
117
|
+
}
|
|
104
118
|
if (state !== 'active') {
|
|
105
119
|
req.resume();
|
|
106
120
|
const flood = countAttempt(ctx, row.user_id, now());
|
|
@@ -125,7 +139,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
125
139
|
forge: () => auth.forgeForUser(row.user_id), where, repo, who: { login: row.login, userId: row.user_id },
|
|
126
140
|
record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks, lander: ctx.lander,
|
|
127
141
|
stillAllowed: async () => {
|
|
128
|
-
const again = findToken(await auth.store(), /** @type {string} */ (presented), now());
|
|
142
|
+
const again = findToken(await auth.store(), /** @type {string} */ (presented), now(), 'review');
|
|
129
143
|
if (again && again.state === 'active') return null;
|
|
130
144
|
return again && again.state === 'expired' ? 'This review token expired while the review was being checked; nothing was posted.' : 'This review token was revoked while the review was being checked; nothing was posted.';
|
|
131
145
|
},
|
|
@@ -159,7 +173,7 @@ function inputOf(text) {
|
|
|
159
173
|
// ---------------------------------------------------------------------------
|
|
160
174
|
|
|
161
175
|
/** What a token looks like to the dashboard: no hash, no secret */
|
|
162
|
-
const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, last_used, state }) => ({ id, login, label, created: created_at, expires: expires_at, revoked: revoked_at, lastUsed: last_used, state });
|
|
176
|
+
const publicToken = ({ id, login, label, scope, created_at, expires_at, revoked_at, last_used, state }) => ({ id, login, label, scope, created: created_at, expires: expires_at, revoked: revoked_at, lastUsed: last_used, state });
|
|
163
177
|
|
|
164
178
|
/**
|
|
165
179
|
* The `/api/review-tokens` routes. The caller has passed the sign-in guard, so `session` is a maintainer's.
|
|
@@ -170,8 +184,10 @@ const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, las
|
|
|
170
184
|
* @returns {Promise<boolean>} whether the path was one of these
|
|
171
185
|
*/
|
|
172
186
|
export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
173
|
-
const revoke = /^\/api\/review-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
174
|
-
if (reqPath !== TOKENS_PATH && !revoke) return false;
|
|
187
|
+
const revoke = /^\/api\/(review|agent|runner)-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
188
|
+
if (reqPath !== TOKENS_PATH && reqPath !== AGENT_TOKENS_PATH && reqPath !== RUNNER_TOKENS_PATH && !revoke) return false;
|
|
189
|
+
/** @type {import('./review-tokens.js').TokenScope} the scope of the tokens this path is about */
|
|
190
|
+
const scope = revoke ? /** @type {import('./review-tokens.js').TokenScope} */ (revoke[1]) : reqPath === AGENT_TOKENS_PATH ? 'agent' : reqPath === RUNNER_TOKENS_PATH ? 'runner' : 'review';
|
|
175
191
|
const { config, auth, session } = ctx;
|
|
176
192
|
const now = ctx.now ?? Date.now;
|
|
177
193
|
const allow = revoke ? 'POST' : 'GET, POST';
|
|
@@ -183,12 +199,12 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
183
199
|
}
|
|
184
200
|
if (!session || !auth.store) {
|
|
185
201
|
req.resume();
|
|
186
|
-
send(res, 403, { ok: false, error: 'Review tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md)
|
|
202
|
+
send(res, 403, { ok: false, error: `${scope === 'agent' ? 'Agent' : scope === 'runner' ? 'Runner' : 'Review'} tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
|
|
187
203
|
return true;
|
|
188
204
|
}
|
|
189
205
|
const db = await auth.store();
|
|
190
206
|
if (method === 'GET') {
|
|
191
|
-
send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now()).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
|
|
207
|
+
send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now(), scope).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
|
|
192
208
|
return true;
|
|
193
209
|
}
|
|
194
210
|
|
|
@@ -200,7 +216,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
200
216
|
return true;
|
|
201
217
|
}
|
|
202
218
|
const what = revoke ? 'revoke' : 'mint';
|
|
203
|
-
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(revoke ? { id: Number(revoke[
|
|
219
|
+
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(scope !== 'review' ? { scope } : {}), ...(revoke ? { id: Number(revoke[2]) } : {}) });
|
|
204
220
|
let rowId = null;
|
|
205
221
|
/** @type {{ status: number, body: object }} */
|
|
206
222
|
let out;
|
|
@@ -219,10 +235,10 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
219
235
|
const forge = auth.forgeFor(session);
|
|
220
236
|
const me = await forge.me();
|
|
221
237
|
if (String(me.id) !== String(session.userId)) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
|
|
222
|
-
if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers make
|
|
238
|
+
if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers make ${scope} tokens; ${me.login} is not one now.`, 'forbidden');
|
|
223
239
|
if (revoke) {
|
|
224
|
-
const done = revokeToken(db, session.userId, Number(revoke[
|
|
225
|
-
if (!done) throw new Refused(404,
|
|
240
|
+
const done = revokeToken(db, session.userId, Number(revoke[2]), now(), scope);
|
|
241
|
+
if (!done) throw new Refused(404, `You have no ${scope} token by that number.`, 'not-found');
|
|
226
242
|
request.label = done.label;
|
|
227
243
|
out = { status: 200, body: { ok: true, token: publicToken(done) } };
|
|
228
244
|
} else {
|
|
@@ -239,7 +255,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
239
255
|
Object.assign(request, { label: body.label, days });
|
|
240
256
|
let made;
|
|
241
257
|
try {
|
|
242
|
-
made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days }, now());
|
|
258
|
+
made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days, scope }, now());
|
|
243
259
|
} catch (err) {
|
|
244
260
|
if (err instanceof RangeError) throw new Refused(409, err.message, 'conflict');
|
|
245
261
|
throw err;
|
package/server/review-tokens.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module server/review-tokens
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `POST /api/reviews` (server/review-api.js)
|
|
6
|
-
* (server/db.js); nothing here knows
|
|
4
|
+
* API tokens: what a maintainer hands a script or a chat bot so it can Approve or Send back a proposal through
|
|
5
|
+
* `POST /api/reviews` (server/review-api.js), or ask for an agent run through `POST /api/agent-requests`
|
|
6
|
+
* (server/agent-api.js). The store over the `review_tokens` table of `cornfield.db` (server/db.js); nothing here knows
|
|
7
|
+
* about HTTP.
|
|
7
8
|
*
|
|
8
|
-
* - A token
|
|
9
|
+
* - **A token has a scope,** `review`, `agent` or `runner`, and is good for the one API its scope names; each API refuses the
|
|
10
|
+
* others' tokens (403) and says so. One store serves both, so hashing, expiry, revoking, `last_used` and the limits are written
|
|
11
|
+
* once and cannot drift. A review token is `cf_rt_…`, an agent token `cf_at_…` and a runner token `cf_nt_…`, so a leaked one is told apart on
|
|
12
|
+
* sight (and by a secret scanner). Rows from before scopes existed are review tokens.
|
|
13
|
+
* - A token is its prefix and 32 random bytes in base64url. It is shown once, when it is made; only its SHA-256 is
|
|
9
14
|
* kept. A presented token is found by the hash of what was presented (an indexed equality on a hash of a 256-bit
|
|
10
15
|
* secret, so there is no secret-dependent comparison to time); the token itself is never stored, logged or
|
|
11
16
|
* returned again.
|
|
@@ -17,14 +22,18 @@
|
|
|
17
22
|
*
|
|
18
23
|
* Times are ISO strings, as everywhere in the file; `now` is milliseconds and is passed in, so tests need no clock.
|
|
19
24
|
*
|
|
20
|
-
* @typedef {
|
|
21
|
-
* @typedef {
|
|
25
|
+
* @typedef {'review' | 'agent' | 'runner'} TokenScope
|
|
26
|
+
* @typedef {{ id: number, user_id: string, login: string, label: string, scope: TokenScope, created_at: string, expires_at: string, revoked_at: string | null, last_used: string | null }} TokenRow
|
|
27
|
+
* @typedef {'active' | 'expired' | 'revoked' | 'wrong-scope'} TokenState `wrong-scope` only from `findToken` asked for a scope
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import { createHash, randomBytes } from 'node:crypto';
|
|
25
31
|
|
|
26
|
-
/**
|
|
27
|
-
export const
|
|
32
|
+
/** The scopes a token may have, and what each one's tokens start with, so a leaked one is recognised (by a secret scanner too) */
|
|
33
|
+
export const SCOPES = Object.freeze(['review', 'agent', 'runner']);
|
|
34
|
+
export const PREFIXES = Object.freeze({ review: 'cf_rt_', agent: 'cf_at_', runner: 'cf_nt_' });
|
|
35
|
+
/** What a review token starts with (the original, kept for the callers that always had it) */
|
|
36
|
+
export const TOKEN_PREFIX = PREFIXES.review;
|
|
28
37
|
/** Random bytes in a token */
|
|
29
38
|
export const TOKEN_BYTES = 32;
|
|
30
39
|
export const DEFAULT_DAYS = 30;
|
|
@@ -34,10 +43,10 @@ export const MAX_ACTIVE = 20;
|
|
|
34
43
|
export const MAX_LABEL = 60;
|
|
35
44
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
36
45
|
/** A token as presented: the prefix and base64url (43 characters for 32 bytes); anything else is not one */
|
|
37
|
-
const TOKEN_SHAPE = /^
|
|
46
|
+
const TOKEN_SHAPE = /^cf_(?:rt|at|nt)_[A-Za-z0-9_-]{43,128}$/;
|
|
38
47
|
/** A label is words: letters, digits, spaces and a little punctuation. No backtick, no @, no newline: the comment quotes it */
|
|
39
48
|
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
|
|
40
|
-
const COLUMNS = 'id, user_id, login, label, created_at, expires_at, revoked_at, last_used';
|
|
49
|
+
const COLUMNS = 'id, user_id, login, label, scope, created_at, expires_at, revoked_at, last_used';
|
|
41
50
|
|
|
42
51
|
const iso = (ms) => new Date(ms).toISOString();
|
|
43
52
|
|
|
@@ -46,9 +55,15 @@ export function hashToken(token) {
|
|
|
46
55
|
return createHash('sha256').update(String(token)).digest('hex');
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
/** Which scope a presented token's shape names (by its prefix; the row's `scope` is what counts), or null when it is not shaped like one of ours */
|
|
59
|
+
export function scopeOfShape(token) {
|
|
60
|
+
if (typeof token !== 'string' || !TOKEN_SHAPE.test(token)) return null;
|
|
61
|
+
return /** @type {TokenScope} */ (Object.keys(PREFIXES).find((k) => token.startsWith(PREFIXES[k])) ?? null);
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/**
|
|
50
65
|
* The token a request presents: the value of `Authorization: Bearer <token>`, or null when there is none, or it is
|
|
51
|
-
* not shaped like one of ours (the scheme is matched in any case, as RFC 9110 says).
|
|
66
|
+
* not shaped like one of ours (either scope's; which API it is good for is the row's `scope`) (the scheme is matched in any case, as RFC 9110 says).
|
|
52
67
|
* @param {string | string[] | undefined} header
|
|
53
68
|
* @returns {string | null}
|
|
54
69
|
*/
|
|
@@ -96,21 +111,22 @@ export function stateOf(row, now) {
|
|
|
96
111
|
* for a label or a number of days that is not allowed, or when the owner holds `MAX_ACTIVE` live tokens already.
|
|
97
112
|
* Tokens that ended (expired or revoked) more than `MAX_DAYS` days ago are dropped on the way.
|
|
98
113
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
99
|
-
* @param {{ userId: string, login: string, label: string, days?: number }} owner
|
|
114
|
+
* @param {{ userId: string, login: string, label: string, days?: number, scope?: TokenScope }} owner `scope`: `review` unless said
|
|
100
115
|
* @param {number} now
|
|
101
116
|
* @returns {{ token: string, row: TokenRow }}
|
|
102
117
|
*/
|
|
103
|
-
export function mintToken(db, { userId, login, label, days }, now) {
|
|
118
|
+
export function mintToken(db, { userId, login, label, days, scope = 'review' }, now) {
|
|
119
|
+
if (!SCOPES.includes(scope)) throw new RangeError(`A token's scope is one of ${SCOPES.join(', ')}.`);
|
|
104
120
|
const problem = labelProblem(label);
|
|
105
121
|
if (problem) throw new RangeError(problem);
|
|
106
122
|
const lasts = daysOf(days);
|
|
107
123
|
const nowIso = iso(now);
|
|
108
124
|
db.prepare('DELETE FROM review_tokens WHERE COALESCE(revoked_at, expires_at) <= ?').run(iso(now - MAX_DAYS * DAY_MS));
|
|
109
|
-
const live = /** @type {{ n: number }} */ (db.prepare('SELECT COUNT(*) AS n FROM review_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?').get(String(userId), nowIso)).n;
|
|
110
|
-
if (live >= MAX_ACTIVE) throw new RangeError(`You hold ${MAX_ACTIVE} live tokens; revoke one you do not use before making another.`);
|
|
111
|
-
const token = `${
|
|
112
|
-
const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
113
|
-
.run(hashToken(token), String(userId), login, label, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
|
|
125
|
+
const live = /** @type {{ n: number }} */ (db.prepare('SELECT COUNT(*) AS n FROM review_tokens WHERE user_id = ? AND scope = ? AND revoked_at IS NULL AND expires_at > ?').get(String(userId), scope, nowIso)).n;
|
|
126
|
+
if (live >= MAX_ACTIVE) throw new RangeError(`You hold ${MAX_ACTIVE} live ${scope === 'review' ? '' : `${scope} `}tokens; revoke one you do not use before making another.`);
|
|
127
|
+
const token = `${PREFIXES[scope]}${randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
|
128
|
+
const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, scope, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
129
|
+
.run(hashToken(token), String(userId), login, label, scope, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
|
|
114
130
|
return { token, row: /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id)) };
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -121,9 +137,12 @@ export function mintToken(db, { userId, login, label, days }, now) {
|
|
|
121
137
|
* @param {number} now
|
|
122
138
|
* @returns {{ row: TokenRow, state: TokenState } | null}
|
|
123
139
|
*/
|
|
124
|
-
export function findToken(db, token, now) {
|
|
140
|
+
export function findToken(db, token, now, scope) {
|
|
125
141
|
const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE token_hash = ?`).get(hashToken(token)));
|
|
126
|
-
|
|
142
|
+
if (!row) return null;
|
|
143
|
+
// The scope is part of the lookup, not of each route: a token found for another API, or whose prefix is not its scope's, is no token here
|
|
144
|
+
if (scope !== undefined && (row.scope !== scope || !String(token).startsWith(PREFIXES[row.scope]))) return { row, state: /** @type {const} */ ('wrong-scope') };
|
|
145
|
+
return { row, state: stateOf(row, now) };
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
/** Notes that a token was presented (and valid) just now */
|
|
@@ -132,17 +151,31 @@ export function touchToken(db, id, now) {
|
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
/**
|
|
135
|
-
* An owner's tokens, newest first, each with its state. Never the hash, never the token.
|
|
154
|
+
* An owner's tokens of one scope (all of them when none is named), newest first, each with its state. Never the hash, never the token.
|
|
136
155
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
137
156
|
* @param {string} userId
|
|
138
157
|
* @param {number} now
|
|
158
|
+
* @param {TokenScope} [scope]
|
|
139
159
|
* @returns {(TokenRow & { state: TokenState })[]}
|
|
140
160
|
*/
|
|
141
|
-
export function listTokens(db, userId, now) {
|
|
142
|
-
return /** @type {TokenRow[]} */ (
|
|
161
|
+
export function listTokens(db, userId, now, scope) {
|
|
162
|
+
return /** @type {TokenRow[]} */ (scope
|
|
163
|
+
? db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? AND scope = ? ORDER BY id DESC`).all(String(userId), scope)
|
|
164
|
+
: db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? ORDER BY id DESC`).all(String(userId)))
|
|
143
165
|
.map((row) => ({ ...row, state: stateOf(row, now) }));
|
|
144
166
|
}
|
|
145
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Revokes every live token of an owner, of every scope (review, agent, runner): what is done when cornfield learns they no longer maintain the
|
|
170
|
+
* repo. A token revoked already keeps its first time. Answers how many were revoked.
|
|
171
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
172
|
+
* @param {string} userId
|
|
173
|
+
* @param {number} now
|
|
174
|
+
*/
|
|
175
|
+
export function revokeOwnerTokens(db, userId, now) {
|
|
176
|
+
return Number(db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').run(iso(now), String(userId)).changes);
|
|
177
|
+
}
|
|
178
|
+
|
|
146
179
|
/**
|
|
147
180
|
* Revokes one of an owner's tokens, for good. Another owner's id is no token to them. Revoking a token that is
|
|
148
181
|
* revoked already keeps the first time.
|
|
@@ -150,11 +183,12 @@ export function listTokens(db, userId, now) {
|
|
|
150
183
|
* @param {string} userId
|
|
151
184
|
* @param {number} id
|
|
152
185
|
* @param {number} now
|
|
186
|
+
* @param {TokenScope} [scope] when named, a token of the other scope is no token to them here
|
|
153
187
|
* @returns {(TokenRow & { state: TokenState }) | null} the token as it is now, or null when the owner has none by that id
|
|
154
188
|
*/
|
|
155
|
-
export function revokeToken(db, userId, id, now) {
|
|
189
|
+
export function revokeToken(db, userId, id, now, scope) {
|
|
156
190
|
const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ? AND user_id = ?`).get(id, String(userId)));
|
|
157
|
-
if (!row) return null;
|
|
191
|
+
if (!row || (scope && row.scope !== scope)) return null;
|
|
158
192
|
if (!row.revoked_at) db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE id = ?').run(iso(now), id);
|
|
159
193
|
const after = /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id));
|
|
160
194
|
return { ...after, state: stateOf(after, now) };
|