@popcorn245/cornfield 0.1.2 → 0.2.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 CHANGED
@@ -22,6 +22,7 @@
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
25
+ * hooks: [], // [{ url, events }]: signed JSON events posted to outside code (docs/config.md#hooks); the secret is env only
25
26
  * links: [], // [{ label, href }] shown in the explorer's header
26
27
  * proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
27
28
  * importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
@@ -63,6 +64,10 @@ export const SOURCE_SLOTS = ['live', 'house', 'devices', 'checker'];
63
64
  export const FORGE_TYPES = ['github', 'forgejo'];
64
65
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
65
66
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
67
+ /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
68
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed']);
69
+ /** The most hooks a config lists */
70
+ export const MAX_HOOKS = 20;
66
71
 
67
72
  export const DEFAULTS = Object.freeze({
68
73
  title: null,
@@ -80,6 +85,7 @@ export const DEFAULTS = Object.freeze({
80
85
  cloud: { parent: 'root' },
81
86
  releaseNotes: { breakingLabel: 'breaking' },
82
87
  agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
88
+ hooks: [],
83
89
  links: [],
84
90
  proxy: {},
85
91
  importMap: {},
@@ -173,6 +179,24 @@ export function configProblems(c) {
173
179
  }
174
180
  }
175
181
  }
182
+ if (c.hooks !== undefined) {
183
+ if (!Array.isArray(c.hooks)) out.push('"hooks" is a list of { url, events }.');
184
+ else {
185
+ if (c.hooks.length > MAX_HOOKS) out.push(`"hooks" lists at most ${MAX_HOOKS} hooks.`);
186
+ c.hooks.forEach((h, i) => {
187
+ if (!isObject(h)) { out.push(`hooks[${i}] is { url, events }.`); return; }
188
+ for (const k of Object.keys(h)) if (k !== 'url' && k !== 'events') out.push(`hooks[${i}].${k}: the keys are url, events (the secret is CORNFIELD_HOOK_SECRET in env, never the config).`);
189
+ const bad = hookUrlProblem(h.url);
190
+ if (bad) out.push(`hooks[${i}].url ${bad}`);
191
+ if (h.events === '*') return;
192
+ if (!Array.isArray(h.events) || h.events.length === 0) out.push(`hooks[${i}].events is "*" or a non-empty list of ${HOOK_EVENTS.join(', ')}.`);
193
+ else {
194
+ for (const e of h.events) if (!HOOK_EVENTS.includes(e)) out.push(`hooks[${i}].events: ${JSON.stringify(e)} is not an event; the events are ${HOOK_EVENTS.join(', ')}.`);
195
+ if (new Set(h.events).size !== h.events.length) out.push(`hooks[${i}].events names an event twice.`);
196
+ }
197
+ });
198
+ }
199
+ }
176
200
  if (c.releaseNotes !== undefined) {
177
201
  if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
178
202
  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,6 +227,26 @@ export function configProblems(c) {
203
227
  return out;
204
228
  }
205
229
 
230
+ /** Whether a host is the machine itself: `localhost`, 127.x.x.x or ::1 */
231
+ const isLoopbackHost = (host) => host === 'localhost' || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
232
+
233
+ /** What is wrong with a hook's URL, in words that follow "hooks[i].url", or null: https, or http for the machine itself; no login in it */
234
+ function hookUrlProblem(value) {
235
+ const say = 'is an https URL, like https://chat.example.org/hooks/cornfield.';
236
+ if (typeof value !== 'string') return say;
237
+ let u;
238
+ try {
239
+ u = new URL(value);
240
+ } catch {
241
+ return say;
242
+ }
243
+ if (u.protocol === 'http:' && !isLoopbackHost(u.hostname)) return 'is https; http is only for localhost, 127.0.0.1 and [::1].';
244
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'is an https URL.';
245
+ if (u.username || u.password) return 'has a user name or password in it; a hook is signed with CORNFIELD_HOOK_SECRET instead.';
246
+ if (u.hash) return 'has a # fragment, which is never sent.';
247
+ return null;
248
+ }
249
+
206
250
  /** A short, file-safe name for a repo root, for the default data folder */
207
251
  function slugOf(root) {
208
252
  return path.basename(root).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repo';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popcorn245/cornfield",
3
- "version": "0.1.2",
3
+ "version": "0.2.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/db.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * reviews a cache of the latest review per spec PR and proposal (the PR comment is the truth)
9
9
  * actions the audit of every action cornfield takes on the forge: who, what, when, the forge's answer
10
10
  * forge_cache forge answers kept for a while (an ETag and a body), so a page load is not a dozen API calls
11
+ * hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
12
+ * a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
11
13
  *
12
14
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
13
15
  * the last one it has. `openStore` runs the ones a file lacks, each in its own transaction, so a fresh file and one
@@ -81,6 +83,17 @@ export const MIGRATIONS = Object.freeze([
81
83
  ) STRICT;
82
84
  CREATE INDEX forge_cache_expires_at ON forge_cache (expires_at);
83
85
  `,
86
+ // 2: event hooks (server/hooks.js): which spec PR heads have been announced
87
+ `
88
+ CREATE TABLE hook_seen (
89
+ repo TEXT NOT NULL, -- 'owner/name'
90
+ pr INTEGER NOT NULL,
91
+ head_sha TEXT NOT NULL, -- the PR's head when cornfield first saw it
92
+ slug TEXT NOT NULL, -- the proposal file's slug
93
+ at TEXT NOT NULL,
94
+ PRIMARY KEY (repo, pr, head_sha, slug)
95
+ ) STRICT;
96
+ `,
84
97
  ]);
85
98
 
86
99
  /** The version a file is at: its `user_version` */
@@ -45,6 +45,7 @@ import { ForgeError, RERUN_STATES, canRerun, isMaintainer } from './forge.js';
45
45
  import { sameOrigin } from './proposals.js';
46
46
  import { planSpec, planBuild, startSpec, openBuildIssue } from './pipeline.js';
47
47
  import { PROPOSAL_FILE } from './files.js';
48
+ import { proposalPayload, landedPayload } from './hooks.js';
48
49
 
49
50
  /** The same action on the same target is not asked again for this long (a double click, a retry before the forge has caught up) */
50
51
  export const COOLDOWN_MS = 5000;
@@ -181,7 +182,7 @@ export function actionOf(reqPath) {
181
182
  * @param {import('node:http').IncomingMessage} req
182
183
  * @param {import('node:http').ServerResponse} res
183
184
  * @param {{ n: number, action: 'merge' | 'rerun' | 'spec' | 'build' }} what
184
- * @param {{ config: object, where?: { proposals: string }, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number, publicHost?: string | null }} ctx
185
+ * @param {{ config: object, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, session: { login: string, userId: string } | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number, publicHost?: string | null }} ctx
185
186
  * @param {(status: number, body: object, headers?: object) => void} send
186
187
  */
187
188
  export async function serveAction(req, res, what, ctx, send) {
@@ -275,12 +276,15 @@ export async function serveAction(req, res, what, ctx, send) {
275
276
  if (!plan.existing && plan.sha.toLowerCase() !== sha) throw new ForgeError(`Pull request #${n} has changed since it was looked at (its proposal is now at ${plan.sha.slice(0, 7)}); look again.`, { code: 'conflict' });
276
277
  if (!plan.existing && plan.digest !== digest) throw new ForgeError(`What the build issue for ${slug} would say has changed since it was shown (a new review, or a push); look again.`, { code: 'conflict' });
277
278
  const done = await openBuildIssue(forge, plan);
279
+ // Only an issue this ask made is news: a second ask that found the first one's says nothing again
280
+ if (done.created) ctx.hooks?.emit('build.opened', () => proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login }));
278
281
  await forgetKept(auth, config);
279
282
  out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
280
283
  } else if (action === 'merge') {
281
284
  const method = config.forge.mergeMethod || 'merge';
282
285
  request.method = method;
283
286
  const landed = await forge.merge(n, { onlyIfGreen: true, sha, method });
287
+ ctx.hooks?.emit('pr.landed', () => landedPayload(forge, ctx.where || { proposals: config.proposals }, n, /** @type {string} */ (sha), session.login));
284
288
  await forgetKept(auth, config);
285
289
  out = { status: 200, body: { ok: true, merged: true, number: n, sha: landed.sha, head: sha, method } };
286
290
  } else {
@@ -111,7 +111,7 @@ function notesInput(config, since) {
111
111
  * The reads: the request's route, the forge call that answers it, and the key it is kept under.
112
112
  * @returns {{ key: string, load: (forge: import('./forge.js').Forge) => Promise<object> } | null}
113
113
  */
114
- function routeOf(reqPath, url, config, where) {
114
+ function routeOf(reqPath, url, config, where, hooks = null) {
115
115
  if (reqPath === '/api/forge/release-notes') {
116
116
  const since = sinceOf(url.searchParams.get('since'));
117
117
  return { key: `release-notes?since=${since ?? ''}&label=${config.releaseNotes?.breakingLabel ?? ''}`, load: (forge) => releaseNotes(forge, notesInput(config, since)) };
@@ -136,7 +136,7 @@ function routeOf(reqPath, url, config, where) {
136
136
  key: 'work',
137
137
  load: async (forge) => {
138
138
  const [pulls, issues, branches, specs] = await Promise.all([
139
- forge.pulls({ state: 'all', limit: WORK_PULLS }), forge.issues({ state: 'all', labels: [], limit: WORK_ISSUES }), forge.branches(), listPullProposals(forge, config),
139
+ forge.pulls({ state: 'all', limit: WORK_PULLS }), forge.issues({ state: 'all', labels: [], limit: WORK_ISSUES }), forge.branches(), listPullProposals(forge, config, { hooks }),
140
140
  ]);
141
141
  return {
142
142
  pulls: pulls.map((p) => ({ ...p, body: p.body.slice(0, WORK_BODY_MAX) })),
@@ -220,7 +220,7 @@ async function servePlan(req, res, act, url, ctx) {
220
220
  * @param {import('node:http').ServerResponse} res
221
221
  * @param {string} reqPath the decoded path
222
222
  * @param {URL} url
223
- * @param {{ config: object, where?: { proposals: string }, session: { userId: string, login: string } | null, publicHost?: string | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number }} ctx
223
+ * @param {{ config: object, where?: { proposals: string }, hooks?: import('./hooks.js').Hooks | null, session: { userId: string, login: string } | null, publicHost?: string | null, auth: { store: null | (() => Promise<import('node:sqlite').DatabaseSync>), forgeFor: Function }, now?: () => number }} ctx
224
224
  * `session` is the signed-in maintainer's (null without sign-in)
225
225
  * @returns {Promise<boolean>}
226
226
  */
@@ -270,7 +270,7 @@ export async function serveForge(req, res, reqPath, url, ctx) {
270
270
  return true;
271
271
  }
272
272
  try {
273
- const route = routeOf(reqPath, url, config, ctx.where);
273
+ const route = routeOf(reqPath, url, config, ctx.where, ctx.hooks);
274
274
  if (!route) {
275
275
  send(req, res, 404, { ok: false, error: `${reqPath} is not a forge route.`, code: 'not-found' });
276
276
  return true;
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @module server/hooks
3
+ *
4
+ * Event hooks (PIPE-07, docs/config.md#hooks): cornfield tells outside code what happened. The config's
5
+ * `hooks: [{ url, events }]` lists where; when something happens, `emit(event, payload)` posts one JSON body to each
6
+ * hook that asked for that event:
7
+ *
8
+ * spec.ready a spec PR that is not a draft was first seen, or was seen at a new head: it is waiting for review.
9
+ * A draft (a "Start a spec" stub among them) is never announced; one marked ready is, at its head.
10
+ * cornfield has no webhook receiver: it notices when it next lists the PRs, so this fires when
11
+ * cornfield next looks, not at the push. The first look at a repo after hooks are added announces
12
+ * nothing: it records what is open, so only what changes after it is news
13
+ * spec.approved a maintainer approved a proposal spec.sentBack a maintainer sent one back
14
+ * build.opened the build issue of an approved proposal was opened
15
+ * pr.landed a pull request was landed through cornfield (its Land it); a merge made on the forge sends nothing
16
+ *
17
+ * **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
18
+ * queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
19
+ * (four attempts in all) and every attempt is a row in the `actions` audit table (`action: 'hook'`). A hook that is
20
+ * dead, slow or wrong changes nothing else in cornfield. Deliveries wait in memory only: a restart forgets the ones
21
+ * still being retried.
22
+ *
23
+ * **What is sent.** `{ event, at, slug, pull, headSha, proposalSha256, actor, note?, edits?, mapUrl }`, built here from
24
+ * a fixed list of fields, so nothing else can leak into it: never file contents, a token or an email. The hook's URL
25
+ * comes only from the maintainer's config and is still treated with care: no redirect is followed, a request is given
26
+ * 10 seconds, and at most `MAX_RESPONSE_BYTES` of the answer are read (it is never kept). The audit rows name the hook's
27
+ * origin only, never its path or query, which may hold a secret of the receiver's.
28
+ *
29
+ * **Signed.** Each request carries `X-Cornfield-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body under
30
+ * CORNFIELD_HOOK_SECRET (env only, at least 32 characters; hooks without it are a startup error, `hooksSettings`), and
31
+ * `X-Cornfield-Event`, `X-Cornfield-Delivery` (a UUID, the same on every retry) and `X-Cornfield-Timestamp` (the body's
32
+ * `at`), so a receiver can refuse a replay. The secret is never logged or put in a row.
33
+ */
34
+
35
+ import { createHmac, randomUUID } from 'node:crypto';
36
+ import { HOOK_EVENTS } from '../core/config.js';
37
+ import { pullProposals, readPullProposal } from './proposal-pulls.js';
38
+ import { proposalHash } from './reviews.js';
39
+
40
+ /** @typedef {ReturnType<typeof createHooks>} Hooks */
41
+
42
+ export { HOOK_EVENTS };
43
+ /** The env var the signing secret is read from: never the config file */
44
+ export const HOOK_SECRET_ENV = 'CORNFIELD_HOOK_SECRET';
45
+ export const MIN_HOOK_SECRET = 32;
46
+ /** The wait before each retry; the first attempt is made at once, so a failing hook is tried four times */
47
+ export const BACKOFF_MS = Object.freeze([1_000, 5_000, 30_000]);
48
+ /** How long one request may take */
49
+ export const TIMEOUT_MS = 10_000;
50
+ /** How much of an answer is read before it is dropped */
51
+ export const MAX_RESPONSE_BYTES = 4096;
52
+ /** The most deliveries waiting (or being retried) at once; one past it is dropped, and the audit table says so */
53
+ export const MAX_PENDING = 500;
54
+
55
+ /**
56
+ * The hook settings env gives, or null when the config lists no hooks. Throws, in words, when hooks are listed but cannot
57
+ * work: no secret, a short one, or no sign-in (every event comes from a signed-in maintainer's action or listing).
58
+ * @param {{ hooks?: unknown[] }} config
59
+ * @param {Record<string, string | undefined>} [env]
60
+ * @param {boolean} [signIn] whether sign-in is configured
61
+ * @returns {null | { secret: string }}
62
+ */
63
+ export function hooksSettings(config, env = process.env, signIn = true) {
64
+ if (!Array.isArray(config.hooks) || config.hooks.length === 0) return null;
65
+ const secret = env[HOOK_SECRET_ENV];
66
+ if (typeof secret !== 'string' || secret === '') throw new Error(`The config lists hooks, so ${HOOK_SECRET_ENV} is needed: a random value of at least ${MIN_HOOK_SECRET} characters that signs every event (openssl rand -base64 48). Never put it in the config.`);
67
+ if (secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} is too short: use at least ${MIN_HOOK_SECRET} random characters (openssl rand -base64 48).`);
68
+ if (!signIn) throw new Error('The config lists hooks, but sign-in is not set up. Every event comes from something a signed-in maintainer does in cornfield (a review, a build issue, a landing) or from the spec PRs cornfield lists for them, so without sign-in no hook would ever fire: set up sign-in (docs/sign-in.md), or remove `hooks`.');
69
+ return { secret };
70
+ }
71
+
72
+ /** The HMAC-SHA256 of a body under the secret, as a request header carries it */
73
+ export const sign = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
74
+
75
+ /** A hook's address for the audit table: the origin only (a path or query may be a secret of the receiver's) */
76
+ const originOf = (url) => new URL(url).origin;
77
+
78
+ /** What went wrong with a request, without its message (a fetch error's text can hold the URL) */
79
+ const reasonOf = (err) => {
80
+ if (err && err.name === 'AbortError') return 'timeout';
81
+ const code = err && (err.cause?.code || err.code);
82
+ return typeof code === 'string' ? code : 'error';
83
+ };
84
+
85
+ /** Reads at most `cap` bytes of a response and drops the rest, so the connection is freed */
86
+ async function drain(res, cap) {
87
+ if (!res.body) return;
88
+ const reader = res.body.getReader();
89
+ let read = 0;
90
+ try {
91
+ while (read < cap) {
92
+ const { done, value } = await reader.read();
93
+ if (done) return;
94
+ read += value.length;
95
+ }
96
+ } finally {
97
+ await reader.cancel().catch(() => {});
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The hooks of a server.
103
+ * @param {{
104
+ * hooks: { url: string, events: string[] | '*' }[],
105
+ * secret: string,
106
+ * fetch?: typeof fetch,
107
+ * record?: (entry: { login: string, action: string, target: string, request?: object, status?: number | null, answer?: object | null }) => Promise<void> | void,
108
+ * store?: (() => Promise<import('node:sqlite').DatabaseSync>) | null,
109
+ * now?: () => number,
110
+ * publicUrl?: string | null,
111
+ * timer?: { setTimeout: Function, clearTimeout: Function },
112
+ * backoff?: readonly number[],
113
+ * }} opts `fetch`, `timer` and `backoff` are for tests; `store` is the SQLite file `spec.ready` remembers what it sent in
114
+ */
115
+ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch, record, store = null, now = Date.now, publicUrl = null, timer = { setTimeout, clearTimeout }, backoff = BACKOFF_MS }) {
116
+ if (typeof secret !== 'string' || secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} must be at least ${MIN_HOOK_SECRET} characters.`);
117
+ const targets = hooks.map((h, i) => ({ index: i, url: h.url, origin: originOf(h.url), events: h.events === '*' ? HOOK_EVENTS : h.events }));
118
+ const timers = new Set();
119
+ let pending = 0;
120
+ let closed = false;
121
+
122
+ const later = (fn, ms) => {
123
+ const t = timer.setTimeout(() => { timers.delete(t); fn(); }, ms);
124
+ if (t && typeof t.unref === 'function') t.unref();
125
+ timers.add(t);
126
+ return t;
127
+ };
128
+
129
+ const audit = async (target, request, status, answer) => {
130
+ if (!record || closed) return;
131
+ try {
132
+ await record({ login: 'cornfield', action: 'hook', target, request, status, answer });
133
+ } catch (err) {
134
+ console.error(`[cornfield] a hook delivery could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
135
+ }
136
+ };
137
+
138
+ /** One request, answered with its HTTP status (null when there was none) and why it failed (null when it did not) */
139
+ async function post(t, headers, body) {
140
+ const ctl = new AbortController();
141
+ const cut = timer.setTimeout(() => ctl.abort(), TIMEOUT_MS);
142
+ // A request still in flight at shutdown must not keep the process alive for its last seconds
143
+ if (cut && typeof cut.unref === 'function') cut.unref();
144
+ try {
145
+ // A redirect is never followed: the address is the maintainer's, and only it
146
+ const res = await doFetch(t.url, { method: 'POST', headers, body, redirect: 'manual', signal: ctl.signal });
147
+ await drain(res, MAX_RESPONSE_BYTES);
148
+ return res.status >= 200 && res.status < 300 ? { status: res.status, failed: null } : { status: res.status, failed: res.status >= 300 && res.status < 400 ? 'redirect' : 'status' };
149
+ } catch (err) {
150
+ return { status: null, failed: reasonOf(err) };
151
+ } finally {
152
+ timer.clearTimeout(cut);
153
+ }
154
+ }
155
+
156
+ function deliver(t, event, delivery, body) {
157
+ const headers = {
158
+ 'Content-Type': 'application/json',
159
+ 'User-Agent': 'cornfield-hooks',
160
+ 'X-Cornfield-Event': event,
161
+ 'X-Cornfield-Delivery': delivery,
162
+ 'X-Cornfield-Timestamp': JSON.parse(body).at,
163
+ 'X-Cornfield-Signature': sign(secret, body),
164
+ };
165
+ const target = `${event} to ${t.origin}`;
166
+ const attempt = async (n) => {
167
+ if (closed) { pending -= 1; return; }
168
+ const out = await post(t, headers, body);
169
+ // Shut down while the request was in flight: nothing more is written or scheduled
170
+ if (closed) { pending -= 1; return; }
171
+ const last = out.failed !== null && n > backoff.length;
172
+ const request = { event, delivery, hook: t.index, origin: t.origin, attempt: n };
173
+ await audit(target, request, out.status, out.failed === null ? { ok: true } : { ok: false, reason: out.failed, ...(last ? { gaveUp: true } : { retryInMs: backoff[n - 1] }) });
174
+ if (out.failed === null || last) { pending -= 1; return; }
175
+ later(() => { attempt(n + 1).catch(() => {}); }, backoff[n - 1]);
176
+ };
177
+ attempt(1).catch((err) => console.error(`[cornfield] a hook delivery failed unexpectedly: ${err instanceof Error ? err.message : err}`));
178
+ }
179
+
180
+ /** The body of an event: only the fields of the payload, in a fixed order */
181
+ function bodyOf(event, p) {
182
+ const out = {
183
+ event, at: new Date(now()).toISOString(), slug: p.slug ?? null, pull: p.pull ?? null, headSha: p.headSha ?? null,
184
+ proposalSha256: p.proposalSha256 ?? null, actor: p.actor ?? null,
185
+ };
186
+ if (p.note !== undefined) out.note = p.note;
187
+ if (p.edits !== undefined) out.edits = p.edits;
188
+ out.mapUrl = publicUrl || null;
189
+ return JSON.stringify(out);
190
+ }
191
+
192
+ /**
193
+ * Sends `event` to each hook that asked for it. Returns at once and never throws. The payload is an object, or a
194
+ * function returning one (or a promise of one): that is run in the background too, so gathering it never slows the
195
+ * action down, and a failure gathering it is written down and sends nothing.
196
+ * @param {string} event one of HOOK_EVENTS
197
+ * @param {object | (() => object | Promise<object>)} payload
198
+ */
199
+ function emit(event, payload) {
200
+ try {
201
+ if (closed || !HOOK_EVENTS.includes(event)) return;
202
+ const to = targets.filter((t) => t.events.includes(event));
203
+ if (!to.length) return;
204
+ Promise.resolve()
205
+ .then(() => (typeof payload === 'function' ? payload() : payload))
206
+ .then((p) => {
207
+ const body = bodyOf(event, p || {});
208
+ for (const t of to) {
209
+ if (pending >= MAX_PENDING) {
210
+ audit(`${event} to ${t.origin}`, { event, hook: t.index, origin: t.origin }, null, { ok: false, reason: 'queue-full', gaveUp: true });
211
+ continue;
212
+ }
213
+ pending += 1;
214
+ deliver(t, event, randomUUID(), body);
215
+ }
216
+ })
217
+ .catch((err) => {
218
+ console.error(`[cornfield] the ${event} hook event could not be built: ${err instanceof Error ? err.message : err}`);
219
+ audit(`${event}`, { event }, null, { ok: false, reason: 'payload', gaveUp: true });
220
+ });
221
+ } catch (err) {
222
+ console.error(`[cornfield] the ${event} hook event was not sent: ${err instanceof Error ? err.message : err}`);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * `spec.ready`: the spec PRs a listing just showed. A (pull request, head, proposal) that is not a draft and was not
228
+ * seen before is announced once, and remembered in the SQLite file so a restart does not announce it again. A draft is
229
+ * not recorded, so marking it ready announces it at its head. The first listing of a repo (no `hook_seen` row for it,
230
+ * not even the `pr = 0` row that says it has been looked at) records what is open and announces nothing. Returns at
231
+ * once, never throws.
232
+ * @param {string} repo 'owner/name'
233
+ * @param {import('./proposal-pulls.js').PullEntry[]} entries
234
+ * @param {(entry: import('./proposal-pulls.js').PullEntry) => Promise<string>} read the proposal's text at its head
235
+ */
236
+ function specsSeen(repo, entries, read) {
237
+ // Even an empty listing counts: it is what makes the first spec PR opened afterwards news
238
+ if (closed || !store || !Array.isArray(entries)) return;
239
+ (async () => {
240
+ const db = await store();
241
+ const at = new Date(now()).toISOString();
242
+ const looked = db.prepare('SELECT 1 FROM hook_seen WHERE repo = ? AND pr = 0').get(repo) !== undefined;
243
+ const insert = db.prepare('INSERT OR IGNORE INTO hook_seen (repo, pr, head_sha, slug, at) VALUES (?, ?, ?, ?, ?)');
244
+ for (const e of entries) {
245
+ // Waiting for review is not a draft: a draft is left out, so it is news once it is marked ready
246
+ if (e.pull.draft) continue;
247
+ if (insert.run(repo, e.pull.number, e.sha, e.slug, at).changes !== 1 || !looked) continue;
248
+ emit('spec.ready', async () => ({
249
+ slug: e.slug, pull: e.pull.number, headSha: e.sha, proposalSha256: await hashOf(read, e), actor: e.pull.author,
250
+ }));
251
+ }
252
+ // The repo has been looked at: from now on, what is new is announced
253
+ if (!looked) insert.run(repo, 0, '', '', at);
254
+ })().catch((err) => console.error(`[cornfield] the spec PRs could not be checked for spec.ready: ${err instanceof Error ? err.message : err}`));
255
+ }
256
+
257
+ function close() {
258
+ closed = true;
259
+ for (const t of timers) timer.clearTimeout(t);
260
+ timers.clear();
261
+ }
262
+
263
+ return { emit, specsSeen, close };
264
+ }
265
+
266
+ /** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
267
+ async function hashOf(read, entry) {
268
+ try {
269
+ return proposalHash(await read(entry));
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * The payload of an event about a proposal on a spec PR: the slug, the PR, its head and the file's hash. `extra` is who
277
+ * acted and, for a review, the note and edits.
278
+ * @param {import('./forge.js').Forge} forge
279
+ * @param {{ proposals: string }} where
280
+ * @param {{ slug: string, sha: string, path: string, pull: { number: number } }} entry
281
+ * @param {{ actor: string | null, note?: string, edits?: object[] }} extra
282
+ */
283
+ export async function proposalPayload(forge, where, entry, extra) {
284
+ return { slug: entry.slug, pull: entry.pull.number, headSha: entry.sha, proposalSha256: await hashOf((e) => readPullProposal(forge, where, e), entry), ...extra };
285
+ }
286
+
287
+ /**
288
+ * The payload of `pr.landed`: the PR, the head that landed, and, when it is a spec PR, its proposal's slug and hash (the
289
+ * first when it holds several). A forge that cannot say leaves those null; the event still goes out.
290
+ * @param {import('./forge.js').Forge} forge
291
+ * @param {{ proposals: string }} where
292
+ * @param {number} n the landed pull request
293
+ * @param {string} sha the head that landed
294
+ * @param {string} actor
295
+ */
296
+ export async function landedPayload(forge, where, n, sha, actor) {
297
+ let entry = null;
298
+ try {
299
+ entry = (await pullProposals(forge, where, await forge.pull(n)))[0] ?? null;
300
+ } catch { /* not known: a plain pull request */ }
301
+ if (!entry) return { slug: null, pull: n, headSha: sha, proposalSha256: null, actor };
302
+ return { ...(await proposalPayload(forge, where, entry, { actor })), pull: n, headSha: sha };
303
+ }
@@ -79,11 +79,14 @@ function entryOf(pull, file) {
79
79
  /**
80
80
  * The proposals of every open PR that touches the proposals folder, newest PR first. A forge that cannot answer
81
81
  * gives `problem` in words, with what was found before it; the caller shows both.
82
+ * With `hooks` (server/hooks.js), the spec PRs found are handed over to announce the new ones (`spec.ready`); that runs
83
+ * in the background and changes nothing about the answer.
82
84
  * @param {import('./forge.js').Forge} forge
83
85
  * @param {{ proposals: string }} where
86
+ * @param {{ hooks?: { specsSeen: Function } | null }} [opts]
84
87
  * @returns {Promise<{ entries: PullEntry[], problem: string | null }>}
85
88
  */
86
- export async function listPullProposals(forge, where) {
89
+ export async function listPullProposals(forge, where, { hooks = null } = {}) {
87
90
  const folder = folderOf(where);
88
91
  let pulls;
89
92
  try {
@@ -105,7 +108,23 @@ export async function listPullProposals(forge, where) {
105
108
  }
106
109
  };
107
110
  await Promise.all(Array.from({ length: Math.min(PARALLEL, pulls.length) }, worker));
108
- return { entries: found.flat(), problem };
111
+ const entries = found.flat();
112
+ if (hooks) {
113
+ try { hooks.specsSeen(forge.repo, entries, (entry) => readPullProposal(forge, where, entry)); } catch { /* a hook never gets in the way */ }
114
+ }
115
+ return { entries, problem };
116
+ }
117
+
118
+ /**
119
+ * The proposals a pull request holds (open or not), read at its head.
120
+ * @param {import('./forge.js').Forge} forge
121
+ * @param {{ proposals: string }} where
122
+ * @param {{ number: number, title: string, url: string, draft: boolean, author: { login: string } | null, head: { sha: string } }} pull
123
+ * @returns {Promise<PullEntry[]>}
124
+ */
125
+ export async function pullProposals(forge, where, pull) {
126
+ const folder = folderOf(where);
127
+ return (await filesAt(forge, where, pull)).filter((f) => isProposal(f, folder)).map((f) => entryOf(pull, f));
109
128
  }
110
129
 
111
130
  /**
@@ -44,6 +44,7 @@ import { findProposalFile, listProposalFiles } from './files.js';
44
44
  import { summarize, proposalProblems } from '../core/proposal.js';
45
45
  import { ForgeError } from './forge.js';
46
46
  import { REVIEW_LIMIT, validateReview, latestReview, postReview, migrateStage1Reviews, proposalHash } from './reviews.js';
47
+ import { proposalPayload } from './hooks.js';
47
48
  import { findPullProposal, forgeWords, listPullProposals, pullNumber, readPullProposal } from './proposal-pulls.js';
48
49
  import { hashPullMockups, serveMockups } from './mockups.js';
49
50
 
@@ -195,7 +196,7 @@ async function listWithPulls(where, forgeFor, signedIn) {
195
196
  } catch (err) {
196
197
  return { list: local, problem: forgeWords(err) };
197
198
  }
198
- const { entries, problem } = await listPullProposals(forge, where);
199
+ const { entries, problem } = await listPullProposals(forge, where, { hooks: signedIn?.hooks });
199
200
  if (signedIn && entries.length) await migrateStage1Reviews({ forge, dataDir: where.dataDir, entries, login: signedIn.login, record: signedIn.record });
200
201
  const pulls = await Promise.all(entries.map((entry) => describePull(entry, forge, where, topology)));
201
202
  const list = [...local, ...pulls].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
@@ -273,13 +274,15 @@ async function putPullReview(req, res, where, forgeFor, signedIn, slug, n) {
273
274
  const mockups = await hashPullMockups(forge, where, entry);
274
275
  if (mockups.length) review = { ...review, mockups };
275
276
  }
276
- return send(res, 200, await postReview(forge, n, slug, review, { login: signedIn.login, record: signedIn.record }));
277
+ const posted = await postReview(forge, n, slug, review, { login: signedIn.login, record: signedIn.record });
278
+ signedIn.hooks?.emit(review.verdict === 'approved' ? 'spec.approved' : 'spec.sentBack', () => proposalPayload(forge, where, entry, { actor: signedIn.login, note: review.note, edits: review.edits }));
279
+ return send(res, 200, posted);
277
280
  } catch (err) {
278
281
  return send(res, forgeStatus(err), { error: forgeWords(err) });
279
282
  }
280
283
  }
281
284
 
282
- /** @typedef {{ login: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void }} SignedIn */
285
+ /** @typedef {{ login: string, record: (entry: import('./reviews.js').AuditEntry) => Promise<void> | void, hooks?: import('./hooks.js').Hooks | null }} SignedIn */
283
286
 
284
287
  /**
285
288
  * Answers a request under `/proposals`, or returns false when it is not one.
package/server/server.js CHANGED
@@ -48,6 +48,7 @@ import { proxyTo } from './proxy.js';
48
48
  import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
49
49
  import { assertBind, hostAllowed } from './bind.js';
50
50
  import { createAuth, signInSettings } from './auth.js';
51
+ import { createHooks, hooksSettings } from './hooks.js';
51
52
 
52
53
  export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
53
54
  const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
@@ -120,14 +121,20 @@ export const HA_CACHE_MS = 30_000;
120
121
  /**
121
122
  * The server for one repo. It does not listen: `listen(config)` does, where server/bind.js allows.
122
123
  * @param {object} config a loaded config (core/config.js `loadConfig`)
123
- * @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object }} [opts] what sign-in reads (`env`, and tests' fakes;
124
+ * @param {Parameters<typeof createAuth>[1] & { homeAssistant?: object, hookFetch?: typeof fetch, hookTimer?: { setTimeout: Function, clearTimeout: Function } }} [opts] what sign-in reads (`env`, and tests' fakes;
124
125
  * see server/auth.js); the Home Assistant URL and token are read from the same `env`, and `homeAssistant` is what
125
- * `homeAssistantHouse` is handed besides them (the tests' fake WebSocket)
126
+ * `homeAssistantHouse` is handed besides them (the tests' fake WebSocket); `hookFetch` and `hookTimer` are the
127
+ * tests' fake network and clock for the event hooks (server/hooks.js)
126
128
  * @returns {import('node:http').Server}
127
129
  */
128
130
  export function createCornfieldServer(config, opts = {}) {
129
131
  const { env = process.env, homeAssistant = {} } = opts;
130
132
  const auth = createAuth(config, opts);
133
+ // Hooks listed without a secret (or without sign-in) are a startup error, like a half-configured sign-in
134
+ const hookSettings = hooksSettings(config, env, auth.enabled);
135
+ const hooks = hookSettings
136
+ ? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
137
+ : null;
131
138
  const root = config.root;
132
139
  const importMap = importMapOf(config);
133
140
  const csp = cspOf(importMap);
@@ -172,7 +179,7 @@ export function createCornfieldServer(config, opts = {}) {
172
179
  else res.end();
173
180
  }
174
181
  });
175
- server.on('close', () => auth.close());
182
+ server.on('close', () => { hooks?.close(); auth.close(); });
176
183
  return server;
177
184
 
178
185
  async function answer(req, res) {
@@ -212,11 +219,11 @@ export function createCornfieldServer(config, opts = {}) {
212
219
  const signedIn = gate.session ? { login: gate.session.login } : false;
213
220
 
214
221
  // Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
215
- if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, now: opts.now, publicHost })) return;
222
+ if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, now: opts.now, publicHost })) return;
216
223
  for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
217
224
  const session = gate.session;
218
225
  const forgeFor = session ? () => auth.forgeFor(session) : null;
219
- if (serveProposals(req, res, reqPath, url, where, forgeFor, session ? { login: session.login, record: (entry) => auth.record(entry) } : null)) return;
226
+ if (serveProposals(req, res, reqPath, url, where, forgeFor, session ? { login: session.login, record: (entry) => auth.record(entry), hooks } : null)) return;
220
227
 
221
228
  if (req.method !== 'GET' && req.method !== 'HEAD') {
222
229
  req.resume();