cornfield 0.7.0 → 0.9.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/README.md +1 -0
- package/core/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +94 -11
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +12 -1
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +512 -19
- package/explorer/index.html +46 -0
- package/explorer/panels.js +24 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +68 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +33 -5
- package/server/forge-github.js +12 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +24 -7
- package/server/hooks.js +100 -1
- package/server/proposal-pulls.js +3 -3
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +32 -8
- package/server/spec-poll.js +71 -14
- package/server/threads.js +275 -0
package/server/proposal-pulls.js
CHANGED
|
@@ -84,7 +84,7 @@ function entryOf(pull, file) {
|
|
|
84
84
|
* @param {import('./forge.js').Forge} forge
|
|
85
85
|
* @param {{ proposals: string }} where
|
|
86
86
|
* @param {{ hooks?: { specsSeen: Function } | null }} [opts]
|
|
87
|
-
* @returns {Promise<{ entries: PullEntry[], problem: string | null }>}
|
|
87
|
+
* @returns {Promise<{ entries: PullEntry[], problem: string | null, pulls: import('./forge.js').Pull[] }>} `pulls`: the open pull requests listed (a thread look reads their text, server/threads.js)
|
|
88
88
|
*/
|
|
89
89
|
export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
90
90
|
const folder = folderOf(where);
|
|
@@ -92,7 +92,7 @@ export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
|
92
92
|
try {
|
|
93
93
|
pulls = await forge.pulls({ state: 'open' });
|
|
94
94
|
} catch (err) {
|
|
95
|
-
return { entries: [], problem: forgeWords(err) };
|
|
95
|
+
return { entries: [], problem: forgeWords(err), pulls: [] };
|
|
96
96
|
}
|
|
97
97
|
let problem = null;
|
|
98
98
|
const found = pulls.map(() => []);
|
|
@@ -112,7 +112,7 @@ export async function listPullProposals(forge, where, { hooks = null } = {}) {
|
|
|
112
112
|
if (hooks) {
|
|
113
113
|
try { hooks.specsSeen(forge.repo, entries, (entry) => readPullProposal(forge, where, entry)); } catch { /* a hook never gets in the way */ }
|
|
114
114
|
}
|
|
115
|
-
return { entries, problem };
|
|
115
|
+
return { entries, problem, pulls };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
package/server/review-api.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
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 the other 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
|
|
11
13
|
*
|
|
12
14
|
* **The call takes the token and nothing else.** It runs before the sign-in guard (a script has no cookie) and reads no
|
|
13
15
|
* cookie: a session cookie is never a substitute for the token, and a request that sends only one is 401. Being no
|
|
@@ -41,6 +43,7 @@ import { Refused, countAttempt } from './throttle.js';
|
|
|
41
43
|
|
|
42
44
|
export const REVIEWS_PATH = '/api/reviews';
|
|
43
45
|
export const TOKENS_PATH = '/api/review-tokens';
|
|
46
|
+
export const AGENT_TOKENS_PATH = '/api/agent-tokens';
|
|
44
47
|
/** Calls with no token, or one nobody holds, that are written down in a minute, all callers together */
|
|
45
48
|
export const ANONYMOUS_PER_MINUTE = 20;
|
|
46
49
|
/** What the audit row of a call nobody could name says in place of a login */
|
|
@@ -88,7 +91,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
88
91
|
// The token, from the header alone; a cookie is not looked at
|
|
89
92
|
const presented = bearerOf(req.headers.authorization);
|
|
90
93
|
const db = presented ? await auth.store() : null;
|
|
91
|
-
const found = presented && db ? findToken(db, presented, now()) : null;
|
|
94
|
+
const found = presented && db ? findToken(db, presented, now(), 'review') : null;
|
|
92
95
|
if (!found) {
|
|
93
96
|
req.resume();
|
|
94
97
|
const reason = presented ? 'unknown-token' : 'no-token';
|
|
@@ -101,6 +104,15 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
101
104
|
|
|
102
105
|
const { row, state } = found;
|
|
103
106
|
const token = { id: row.id, label: row.label };
|
|
107
|
+
// An agent token asks for runs and is no review token: refused whatever its state, and not counted as used
|
|
108
|
+
if (state === 'wrong-scope') {
|
|
109
|
+
req.resume();
|
|
110
|
+
const flood = countAttempt(ctx, row.user_id, now());
|
|
111
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
112
|
+
const body = { error: `This is an ${row.scope} token. Reviews take a review token; make one in the dashboard.`, code: 'forbidden' };
|
|
113
|
+
await audit(row.login, { reason: 'wrong-scope', token, scope: row.scope }, 403, body);
|
|
114
|
+
return send(res, 403, { ok: false, ...body });
|
|
115
|
+
}
|
|
104
116
|
if (state !== 'active') {
|
|
105
117
|
req.resume();
|
|
106
118
|
const flood = countAttempt(ctx, row.user_id, now());
|
|
@@ -125,7 +137,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
125
137
|
forge: () => auth.forgeForUser(row.user_id), where, repo, who: { login: row.login, userId: row.user_id },
|
|
126
138
|
record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks, lander: ctx.lander,
|
|
127
139
|
stillAllowed: async () => {
|
|
128
|
-
const again = findToken(await auth.store(), /** @type {string} */ (presented), now());
|
|
140
|
+
const again = findToken(await auth.store(), /** @type {string} */ (presented), now(), 'review');
|
|
129
141
|
if (again && again.state === 'active') return null;
|
|
130
142
|
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
143
|
},
|
|
@@ -159,7 +171,7 @@ function inputOf(text) {
|
|
|
159
171
|
// ---------------------------------------------------------------------------
|
|
160
172
|
|
|
161
173
|
/** 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 });
|
|
174
|
+
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
175
|
|
|
164
176
|
/**
|
|
165
177
|
* The `/api/review-tokens` routes. The caller has passed the sign-in guard, so `session` is a maintainer's.
|
|
@@ -170,8 +182,10 @@ const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, las
|
|
|
170
182
|
* @returns {Promise<boolean>} whether the path was one of these
|
|
171
183
|
*/
|
|
172
184
|
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;
|
|
185
|
+
const revoke = /^\/api\/(review|agent)-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
186
|
+
if (reqPath !== TOKENS_PATH && reqPath !== AGENT_TOKENS_PATH && !revoke) return false;
|
|
187
|
+
/** @type {'review' | 'agent'} the scope of the tokens this path is about */
|
|
188
|
+
const scope = revoke ? /** @type {'review' | 'agent'} */ (revoke[1]) : reqPath === AGENT_TOKENS_PATH ? 'agent' : 'review';
|
|
175
189
|
const { config, auth, session } = ctx;
|
|
176
190
|
const now = ctx.now ?? Date.now;
|
|
177
191
|
const allow = revoke ? 'POST' : 'GET, POST';
|
|
@@ -183,12 +197,12 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
183
197
|
}
|
|
184
198
|
if (!session || !auth.store) {
|
|
185
199
|
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)
|
|
200
|
+
send(res, 403, { ok: false, error: `${scope === 'agent' ? 'Agent' : 'Review'} tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
|
|
187
201
|
return true;
|
|
188
202
|
}
|
|
189
203
|
const db = await auth.store();
|
|
190
204
|
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 });
|
|
205
|
+
send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now(), scope).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
|
|
192
206
|
return true;
|
|
193
207
|
}
|
|
194
208
|
|
|
@@ -200,7 +214,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
200
214
|
return true;
|
|
201
215
|
}
|
|
202
216
|
const what = revoke ? 'revoke' : 'mint';
|
|
203
|
-
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(revoke ? { id: Number(revoke[
|
|
217
|
+
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(scope === 'agent' ? { scope } : {}), ...(revoke ? { id: Number(revoke[2]) } : {}) });
|
|
204
218
|
let rowId = null;
|
|
205
219
|
/** @type {{ status: number, body: object }} */
|
|
206
220
|
let out;
|
|
@@ -219,10 +233,10 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
219
233
|
const forge = auth.forgeFor(session);
|
|
220
234
|
const me = await forge.me();
|
|
221
235
|
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
|
|
236
|
+
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
237
|
if (revoke) {
|
|
224
|
-
const done = revokeToken(db, session.userId, Number(revoke[
|
|
225
|
-
if (!done) throw new Refused(404,
|
|
238
|
+
const done = revokeToken(db, session.userId, Number(revoke[2]), now(), scope);
|
|
239
|
+
if (!done) throw new Refused(404, `You have no ${scope} token by that number.`, 'not-found');
|
|
226
240
|
request.label = done.label;
|
|
227
241
|
out = { status: 200, body: { ok: true, token: publicToken(done) } };
|
|
228
242
|
} else {
|
|
@@ -239,7 +253,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
239
253
|
Object.assign(request, { label: body.label, days });
|
|
240
254
|
let made;
|
|
241
255
|
try {
|
|
242
|
-
made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days }, now());
|
|
256
|
+
made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days, scope }, now());
|
|
243
257
|
} catch (err) {
|
|
244
258
|
if (err instanceof RangeError) throw new Refused(409, err.message, 'conflict');
|
|
245
259
|
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` or `agent`, and is good for the one API its scope names; each API refuses the other's
|
|
10
|
+
* token (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_…` and an agent token `cf_at_…`, 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'} 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']);
|
|
34
|
+
export const PREFIXES = Object.freeze({ review: 'cf_rt_', agent: 'cf_at_' });
|
|
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)_[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
|
|
|
@@ -48,7 +57,7 @@ export function hashToken(token) {
|
|
|
48
57
|
|
|
49
58
|
/**
|
|
50
59
|
* 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).
|
|
60
|
+
* 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
61
|
* @param {string | string[] | undefined} header
|
|
53
62
|
* @returns {string | null}
|
|
54
63
|
*/
|
|
@@ -96,21 +105,22 @@ export function stateOf(row, now) {
|
|
|
96
105
|
* for a label or a number of days that is not allowed, or when the owner holds `MAX_ACTIVE` live tokens already.
|
|
97
106
|
* Tokens that ended (expired or revoked) more than `MAX_DAYS` days ago are dropped on the way.
|
|
98
107
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
99
|
-
* @param {{ userId: string, login: string, label: string, days?: number }} owner
|
|
108
|
+
* @param {{ userId: string, login: string, label: string, days?: number, scope?: TokenScope }} owner `scope`: `review` unless said
|
|
100
109
|
* @param {number} now
|
|
101
110
|
* @returns {{ token: string, row: TokenRow }}
|
|
102
111
|
*/
|
|
103
|
-
export function mintToken(db, { userId, login, label, days }, now) {
|
|
112
|
+
export function mintToken(db, { userId, login, label, days, scope = 'review' }, now) {
|
|
113
|
+
if (!SCOPES.includes(scope)) throw new RangeError(`A token's scope is one of ${SCOPES.join(', ')}.`);
|
|
104
114
|
const problem = labelProblem(label);
|
|
105
115
|
if (problem) throw new RangeError(problem);
|
|
106
116
|
const lasts = daysOf(days);
|
|
107
117
|
const nowIso = iso(now);
|
|
108
118
|
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);
|
|
119
|
+
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;
|
|
120
|
+
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.`);
|
|
121
|
+
const token = `${PREFIXES[scope]}${randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
|
122
|
+
const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, scope, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
123
|
+
.run(hashToken(token), String(userId), login, label, scope, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
|
|
114
124
|
return { token, row: /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id)) };
|
|
115
125
|
}
|
|
116
126
|
|
|
@@ -121,9 +131,12 @@ export function mintToken(db, { userId, login, label, days }, now) {
|
|
|
121
131
|
* @param {number} now
|
|
122
132
|
* @returns {{ row: TokenRow, state: TokenState } | null}
|
|
123
133
|
*/
|
|
124
|
-
export function findToken(db, token, now) {
|
|
134
|
+
export function findToken(db, token, now, scope) {
|
|
125
135
|
const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE token_hash = ?`).get(hashToken(token)));
|
|
126
|
-
|
|
136
|
+
if (!row) return null;
|
|
137
|
+
// 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
|
|
138
|
+
if (scope !== undefined && (row.scope !== scope || !String(token).startsWith(PREFIXES[row.scope]))) return { row, state: /** @type {const} */ ('wrong-scope') };
|
|
139
|
+
return { row, state: stateOf(row, now) };
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
/** Notes that a token was presented (and valid) just now */
|
|
@@ -132,14 +145,17 @@ export function touchToken(db, id, now) {
|
|
|
132
145
|
}
|
|
133
146
|
|
|
134
147
|
/**
|
|
135
|
-
* An owner's tokens, newest first, each with its state. Never the hash, never the token.
|
|
148
|
+
* 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
149
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
137
150
|
* @param {string} userId
|
|
138
151
|
* @param {number} now
|
|
152
|
+
* @param {TokenScope} [scope]
|
|
139
153
|
* @returns {(TokenRow & { state: TokenState })[]}
|
|
140
154
|
*/
|
|
141
|
-
export function listTokens(db, userId, now) {
|
|
142
|
-
return /** @type {TokenRow[]} */ (
|
|
155
|
+
export function listTokens(db, userId, now, scope) {
|
|
156
|
+
return /** @type {TokenRow[]} */ (scope
|
|
157
|
+
? db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? AND scope = ? ORDER BY id DESC`).all(String(userId), scope)
|
|
158
|
+
: db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? ORDER BY id DESC`).all(String(userId)))
|
|
143
159
|
.map((row) => ({ ...row, state: stateOf(row, now) }));
|
|
144
160
|
}
|
|
145
161
|
|
|
@@ -150,11 +166,12 @@ export function listTokens(db, userId, now) {
|
|
|
150
166
|
* @param {string} userId
|
|
151
167
|
* @param {number} id
|
|
152
168
|
* @param {number} now
|
|
169
|
+
* @param {TokenScope} [scope] when named, a token of the other scope is no token to them here
|
|
153
170
|
* @returns {(TokenRow & { state: TokenState }) | null} the token as it is now, or null when the owner has none by that id
|
|
154
171
|
*/
|
|
155
|
-
export function revokeToken(db, userId, id, now) {
|
|
172
|
+
export function revokeToken(db, userId, id, now, scope) {
|
|
156
173
|
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;
|
|
174
|
+
if (!row || (scope && row.scope !== scope)) return null;
|
|
158
175
|
if (!row.revoked_at) db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE id = ?').run(iso(now), id);
|
|
159
176
|
const after = /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id));
|
|
160
177
|
return { ...after, state: stateOf(after, now) };
|
package/server/server.js
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* POST /api/forge/release-drafts the release notes kept as a DRAFT release on the forge (never published, no tag made)
|
|
23
23
|
* POST /api/reviews Approve or Send back with a review token (`Authorization: Bearer cf_rt_…`), no cookie: the one
|
|
24
24
|
* route that answers before the sign-in guard, and takes the token alone (server/review-api.js)
|
|
25
|
-
* * /api/review-tokens
|
|
25
|
+
* * /api/review-tokens…, /api/agent-tokens…
|
|
26
|
+
* make, list and revoke review tokens, and agent tokens: the signed-in maintainer only (server/review-api.js)
|
|
27
|
+
* POST /api/agent-requests ask for an agent run with an agent token (`Authorization: Bearer cf_at_…`), no cookie: before the guard too (server/agent-api.js)
|
|
28
|
+
* * /api/agent-runs… the dashboard's runs: list, one, start, stop; the signed-in maintainer only (server/agent-api.js, server/agent-runs.js)
|
|
26
29
|
* * /proposals… server/proposals.js (with sign-in, the open spec PRs too: server/proposal-pulls.js,
|
|
27
30
|
* and a review is a comment on one: server/reviews.js)
|
|
28
31
|
* * <proxy path>/… server/proxy.js, for each path the config's `proxy` names
|
|
@@ -42,11 +45,14 @@ import http from 'node:http';
|
|
|
42
45
|
import path from 'node:path';
|
|
43
46
|
import { createHash } from 'node:crypto';
|
|
44
47
|
import { fileURLToPath } from 'node:url';
|
|
45
|
-
import { browserSources, BUILTIN_SOURCES, specPollMs } from '../core/config.js';
|
|
48
|
+
import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, DEFAULTS, ideaLabel } from '../core/config.js';
|
|
46
49
|
import { CLOUD_FILE } from '../core/cloud.js';
|
|
47
50
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
48
51
|
import { serveProposals } from './proposals.js';
|
|
49
52
|
import { serveForge } from './forge-routes.js';
|
|
53
|
+
import { hasAdapter } from './forge.js';
|
|
54
|
+
import { createRuns } from './agent-runs.js';
|
|
55
|
+
import { REQUESTS_PATH, serveAgentCall, serveRunRoutes } from './agent-api.js';
|
|
50
56
|
import { REVIEWS_PATH, serveReviewCall, serveTokenRoutes } from './review-api.js';
|
|
51
57
|
import { proxyTo } from './proxy.js';
|
|
52
58
|
import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
|
|
@@ -59,7 +65,7 @@ import { createSpecPoll } from './spec-poll.js';
|
|
|
59
65
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
60
66
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
61
67
|
/** The pure core modules the explorer imports (no Node built-ins, no files): the only ones of core/ served */
|
|
62
|
-
export const BROWSER_CORE = ['schema.js', 'proposal.js', 'personas.js', 'cloud.js', 'text.js'];
|
|
68
|
+
export const BROWSER_CORE = ['schema.js', 'proposal.js', 'personas.js', 'cloud.js', 'text.js', 'mentions.js'];
|
|
63
69
|
/** cornfield's own browser sources, by request path */
|
|
64
70
|
const BROWSER_PLUGINS = new Map(Object.values(BUILTIN_SOURCES).map((p) => [p, path.join(CORNFIELD_DIR, p)]));
|
|
65
71
|
const PKG = JSON.parse(fs.readFileSync(path.join(CORNFIELD_DIR, 'package.json'), 'utf8'));
|
|
@@ -109,6 +115,8 @@ export function clientConfig(config, signIn = false) {
|
|
|
109
115
|
title: config.title,
|
|
110
116
|
links: config.links || [],
|
|
111
117
|
forge: config.forge ? { type: config.forge.type, url: config.forge.url || (config.forge.type === 'github' ? 'https://github.com' : null), repo: config.forge.repo, mergeMethod: config.forge.mergeMethod || 'merge', ...(config.approveLands ? { approveLands: true } : {}) } : null,
|
|
118
|
+
// Threads (docs/threads.md): the idea label, or false when `threads: false` turns the Threads tab off
|
|
119
|
+
threads: config.threads === false ? false : { label: ideaLabel(config) },
|
|
112
120
|
sources: browserSources(config),
|
|
113
121
|
signIn,
|
|
114
122
|
};
|
|
@@ -156,9 +164,16 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
156
164
|
// What a restart left waiting: looked at again, in the background
|
|
157
165
|
lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
158
166
|
}
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
167
|
+
// Agent runs (server/agent-runs.js) need a signed-in maintainer to ask as and a forge to ask on; the runs are followed on the spec poll's tick
|
|
168
|
+
const runs = config.forge && auth.enabled && hasAdapter(config.forge.type) ? createRuns({ config, where, auth, hooks, now: opts.now, onAsk: () => specPoll?.start() }) : null;
|
|
169
|
+
runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
170
|
+
// Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens. The same timer follows
|
|
171
|
+
// agent runs: it is started for them when there is a run to follow, or as one is asked for, and does nothing while there is none
|
|
172
|
+
const specMs = hooks ? specPollMs(config) : null;
|
|
173
|
+
const runsEvery = config.hookPoll === undefined ? DEFAULTS.hookPoll.specs : config.hookPoll.specs;
|
|
174
|
+
const pollMs = specMs ?? (runs && runsEvery !== false ? parseDuration(runsEvery) : null);
|
|
175
|
+
const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, runs, specs: specMs !== null, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
|
|
176
|
+
let serverClosed = false;
|
|
162
177
|
const proxies = Object.entries(config.proxy || {});
|
|
163
178
|
const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
|
|
164
179
|
const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
|
|
@@ -196,8 +211,14 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
196
211
|
else res.end();
|
|
197
212
|
}
|
|
198
213
|
});
|
|
199
|
-
server.on('listening', () =>
|
|
200
|
-
|
|
214
|
+
server.on('listening', () => {
|
|
215
|
+
if (specMs !== null) specPoll?.start();
|
|
216
|
+
else if (specPoll && runs) {
|
|
217
|
+
// Runs asked for before a restart are followed again from the start
|
|
218
|
+
runs.hasWork().then((work) => { if (work && !serverClosed) specPoll.start(); }).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
server.on('close', () => { serverClosed = true; specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
|
|
201
222
|
return server;
|
|
202
223
|
|
|
203
224
|
async function answer(req, res) {
|
|
@@ -234,12 +255,15 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
234
255
|
}
|
|
235
256
|
// The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
|
|
236
257
|
if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
|
|
258
|
+
// …and so does asking for a run: an agent token, not a session
|
|
259
|
+
if (reqPath === REQUESTS_PATH) return serveAgentCall(req, res, { config, auth, runs, now: opts.now });
|
|
237
260
|
const gate = await auth.gate(req, res, reqPath, url);
|
|
238
261
|
if (gate.done) return;
|
|
239
262
|
const signedIn = gate.session ? { login: gate.session.login } : false;
|
|
240
263
|
|
|
241
264
|
// Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
|
|
242
265
|
if (await serveTokenRoutes(req, res, reqPath, { config, where, auth, session: gate.session, now: opts.now, publicHost })) return;
|
|
266
|
+
if (await serveRunRoutes(req, res, reqPath, { config, auth, runs, session: gate.session, now: opts.now, publicHost })) return;
|
|
243
267
|
if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, lander, now: opts.now, publicHost })) return;
|
|
244
268
|
for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
|
|
245
269
|
const session = gate.session;
|
package/server/spec-poll.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module server/spec-poll
|
|
3
3
|
*
|
|
4
|
-
* `spec.ready` without anyone at the dashboard (PIPE-10, docs/config.md#hook-poll).
|
|
4
|
+
* `spec.ready` and the `thread.*` events without anyone at the dashboard (PIPE-10, docs/config.md#hook-poll, docs/threads.md).
|
|
5
|
+
* cornfield notices a spec PR when it
|
|
5
6
|
* lists the open ones, and that listing used to run only when a maintainer opened the proposals menu or the issues and PRs
|
|
6
7
|
* panel. A team that reviews from chat never does, so this lists them on a timer.
|
|
7
8
|
*
|
|
@@ -11,12 +12,22 @@
|
|
|
11
12
|
* nothing here decides what is news. The forge answers come through the same per-head cache, so a tick with nothing new
|
|
12
13
|
* costs the open-PR listing (a call a page) plus the two calls that check the token's owner; see the docs for the count.
|
|
13
14
|
*
|
|
15
|
+
* **Threads ride the same tick.** When a hook wants `thread.opened`, `thread.message` or `thread.linked` (and threads are on),
|
|
16
|
+
* the look also reads the ideas and the spec PRs' conversations (server/threads.js) with the same sign-in, the same timer and
|
|
17
|
+
* the same backoff: one look, one set of calls to the forge for the open PRs, then what the database says is new
|
|
18
|
+
* (`hooks.threadCursors`: an idea whose update time is the one last seen is not read again, and the rest are read from their
|
|
19
|
+
* newest seen comment on). A failure in either half is the tick's failure.
|
|
20
|
+
*
|
|
14
21
|
* **Whose token.** Never one from the config. The newest live sign-in of a user the forge confirms is a maintainer now
|
|
15
22
|
* (`auth.forgeForUser`, then `me()` and `permission()` with that token; the same check the review API and the lander make).
|
|
16
23
|
* A user who is not, or whose token the forge refuses, is passed over for the next newest, at most `MAX_CANDIDATES`; their
|
|
17
24
|
* token is only ever asked those two questions, never used to list. With no such session the tick does nothing and says so
|
|
18
25
|
* once for the stretch (and once more when a session is back). Whose sign-in it looks with is logged each time that changes.
|
|
19
26
|
*
|
|
27
|
+
* **It also follows agent runs** (issue #80, server/agent-runs.js): when there is a run to look at (`runs.hasWork()`, a question of the
|
|
28
|
+
* database alone, so an idle repo costs the forge nothing), the same tick hands the same maintainer's forge to `runs.follow`, which reads the
|
|
29
|
+
* runs' comments and pull requests. A server with no `spec.ready` hook still polls for runs (`specs: false`) and then does nothing else.
|
|
30
|
+
*
|
|
20
31
|
* **Never in the way.** One tick runs at a time (the next is scheduled when one ends, so a slow forge cannot pile them up).
|
|
21
32
|
* A forge error is logged and never thrown; the next tick waits twice the interval until one succeeds. The timer is
|
|
22
33
|
* unref'd and `close()` clears it.
|
|
@@ -24,6 +35,9 @@
|
|
|
24
35
|
|
|
25
36
|
import { isMaintainer } from './forge.js';
|
|
26
37
|
import { listPullProposals } from './proposal-pulls.js';
|
|
38
|
+
import { gatherThreads, snapshotOf, POLL_LIMIT } from './threads.js';
|
|
39
|
+
import { hookWants, threadsOn, THREAD_EVENTS } from '../core/config.js';
|
|
40
|
+
import { readTopology } from '../core/validate.js';
|
|
27
41
|
|
|
28
42
|
/** How long after start the first look is made: soon, so the repo's silent first look is not left to the first interval */
|
|
29
43
|
export const FIRST_MS = 15_000;
|
|
@@ -38,14 +52,17 @@ const words = (err) => (err instanceof Error ? err.message : String(err));
|
|
|
38
52
|
* config: any,
|
|
39
53
|
* where: { proposals: string },
|
|
40
54
|
* auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge> },
|
|
41
|
-
* hooks: import('./hooks.js').Hooks,
|
|
55
|
+
* hooks: import('./hooks.js').Hooks | null,
|
|
56
|
+
* runs?: { hasWork: () => Promise<boolean>, follow: (forge: import('./forge.js').Forge) => Promise<unknown> } | null,
|
|
57
|
+
* specs?: boolean,
|
|
42
58
|
* intervalMs: number,
|
|
43
59
|
* now?: () => number,
|
|
44
60
|
* timer?: { setTimeout: Function, clearTimeout: Function },
|
|
45
61
|
* firstMs?: number,
|
|
46
|
-
* }} opts `now`, `timer` and `firstMs` are the tests' clock
|
|
62
|
+
* }} opts `now`, `timer` and `firstMs` are the tests' clock; `runs` is the agent runs engine, whose runs are followed on the same tick, and
|
|
63
|
+
* `specs: false` leaves the spec PRs alone (no hook wants `spec.ready`), so only the runs are looked at
|
|
47
64
|
*/
|
|
48
|
-
export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = Date.now, timer = { setTimeout, clearTimeout }, firstMs = FIRST_MS }) {
|
|
65
|
+
export function createSpecPoll({ config, where, auth, hooks, runs = null, specs = true, intervalMs, now = Date.now, timer = { setTimeout, clearTimeout }, firstMs = FIRST_MS }) {
|
|
49
66
|
const repo = config.forge.repo;
|
|
50
67
|
const log = (what) => console.error(`[cornfield] spec.ready poll: ${what}`);
|
|
51
68
|
let handle = null;
|
|
@@ -71,8 +88,34 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
|
|
|
71
88
|
return null;
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/** The checkout's nodes, for a hook's `nodes` to be the ones a mention resolves to; none when there is no map */
|
|
92
|
+
function mapNodes() {
|
|
93
|
+
try {
|
|
94
|
+
const topology = readTopology(config);
|
|
95
|
+
return topology && Array.isArray(topology.nodes) ? topology.nodes : [];
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The ideas and the spec PRs' conversations, read from where the database left off and handed to the hooks */
|
|
102
|
+
async function lookAtThreads(forge, withSpecs) {
|
|
103
|
+
const cursors = await hooks.threadCursors(repo);
|
|
104
|
+
const got = await gatherThreads(forge, config, where, { state: 'all', limit: POLL_LIMIT, hooks: withSpecs ? hooks : null, cursors: cursors.looked ? cursors.threads : null });
|
|
105
|
+
await hooks.threadsSeen(repo, snapshotOf(got, mapNodes()));
|
|
106
|
+
return got.problem;
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
/** One look. Resolves to what happened; never rejects. */
|
|
75
110
|
async function look() {
|
|
111
|
+
// Nothing to look at: no forge call is made (an idle repo with only runs to follow costs nothing)
|
|
112
|
+
let followRuns = false;
|
|
113
|
+
try {
|
|
114
|
+
followRuns = Boolean(runs) && await /** @type {NonNullable<typeof runs>} */ (runs).hasWork();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log(`the runs could not be counted: ${words(err)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!specs && !followRuns) return 'ok';
|
|
76
119
|
let found;
|
|
77
120
|
try {
|
|
78
121
|
found = await maintainerForge();
|
|
@@ -90,19 +133,33 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
|
|
|
90
133
|
}
|
|
91
134
|
if (noSession) log('a signed-in maintainer is back; checking the open spec PRs again');
|
|
92
135
|
noSession = false;
|
|
93
|
-
if (found.login !== lookingAs) log(`looking at the open spec PRs with the sign-in of @${found.login}, a maintainer`);
|
|
136
|
+
if (found.login !== lookingAs) log(`looking at the open spec PRs${threadsOn(config) && hookWants(config, THREAD_EVENTS) ? ' and threads' : ''} with the sign-in of @${found.login}, a maintainer`);
|
|
94
137
|
lookingAs = found.login;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
138
|
+
let out = 'ok';
|
|
139
|
+
if (specs) {
|
|
140
|
+
try {
|
|
141
|
+
const specReady = hookWants(config, ['spec.ready']);
|
|
142
|
+
const threads = threadsOn(config) && hookWants(config, THREAD_EVENTS);
|
|
143
|
+
// One listing of the open PRs serves both: the threads' look makes it itself when it runs
|
|
144
|
+
const problem = threads ? await lookAtThreads(found.forge, specReady) : (await listPullProposals(found.forge, where, { hooks })).problem;
|
|
145
|
+
if (problem) {
|
|
146
|
+
log(`the forge could not be asked: ${problem}`);
|
|
147
|
+
out = 'error';
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log(`failed: ${words(err)}`);
|
|
151
|
+
out = 'error';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (followRuns && !closed) {
|
|
155
|
+
try {
|
|
156
|
+
await /** @type {NonNullable<typeof runs>} */ (runs).follow(found.forge);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
log(`the runs could not be followed: ${words(err)}`);
|
|
159
|
+
out = 'error';
|
|
100
160
|
}
|
|
101
|
-
return 'ok';
|
|
102
|
-
} catch (err) {
|
|
103
|
-
log(`failed: ${words(err)}`);
|
|
104
|
-
return 'error';
|
|
105
161
|
}
|
|
162
|
+
return out;
|
|
106
163
|
}
|
|
107
164
|
|
|
108
165
|
function schedule(ms) {
|