@edgehero/pi-dispatch-receiver 0.1.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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The label predicate and its helpers: the `{any, all, none}` evaluation every forge's gate runs, plus the
3
+ * two small readers around it.
4
+ *
5
+ * Shared rather than copied (issue #42). This is the rule that decides whether an event becomes a paid
6
+ * agent run, and on the github label path it IS the approval gate (CONST-TRIGGER-AUTHOR-GATE). Two
7
+ * implementations of that would eventually disagree, and the forge whose copy drifted would be the one
8
+ * quietly running work nobody approved.
9
+ *
10
+ * Pure and total: reads only its arguments, touches no I/O, never throws -- which is what keeps each
11
+ * forge's gate decidable offline, without a server, a socket, or a queue.
12
+ */
13
+
14
+ /** The set of label names present on an issue/PR; non-string names are dropped. */
15
+ export function labelSet(labels) {
16
+ const arr = Array.isArray(labels) ? labels : [];
17
+ return new Set(arr.map((l) => l?.name).filter((n) => typeof n === "string"));
18
+ }
19
+
20
+ /** First rule (in file order) whose predicate matches `L`, or undefined. The rule carries its raw-file `index`. */
21
+ export function firstMatchingRule(rules, L) {
22
+ for (const rule of rules ?? []) {
23
+ if (matchesRule(L, rule.predicate)) return rule;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * The label that satisfied a matched rule's positive selector, for `trigger.matched.label`: the first
30
+ * `any` entry present in `L`, else `all[0]` (all ⊆ L holds whenever the rule matched, so membership is
31
+ * guaranteed), else null. Deterministic in rule order -- honest about WHICH label opened the gate, not
32
+ * merely that one did.
33
+ */
34
+ export function matchedLabel(L, predicate) {
35
+ const anyHit = (predicate?.any ?? []).find((x) => L.has(x));
36
+ if (anyHit !== undefined) return anyHit;
37
+ return predicate?.all?.[0] ?? null;
38
+ }
39
+
40
+ /**
41
+ * Per-rule label predicate over the label set `L`:
42
+ * (any empty OR L∩any ≠ ∅) AND (all ⊆ L) AND (L∩none = ∅).
43
+ * An empty `any` is vacuously true, so the `all`/`none` clauses carry the requirement; the loader
44
+ * guarantees at least one positive selector where the predicate is the approval gate (label triggers and
45
+ * `labeled` PR triggers), so a validated approval rule can never match every event. Reads only its
46
+ * arguments and never throws -- the gate's purity extends here, and the defensive `?? []` covers a rule
47
+ * the loader has already validated.
48
+ */
49
+ export function matchesRule(L, rule) {
50
+ const any = rule?.any ?? [];
51
+ const all = rule?.all ?? [];
52
+ const none = rule?.none ?? [];
53
+ if (any.length > 0 && !any.some((x) => L.has(x))) return false;
54
+ if (!all.every((x) => L.has(x))) return false;
55
+ if (none.some((x) => L.has(x))) return false;
56
+ return true;
57
+ }
58
+
59
+ /** Escape a literal string for safe embedding in a RegExp -- the trigger phrase is config, not a pattern. */
60
+ export function escapeRegExp(literal) {
61
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The webhook receiver: a thin producer that turns a verified GitHub webhook into (at most) one queued
3
+ * job -- or, when the matched trigger opted into `run.replicas`, into exactly that many independent ones
4
+ * (REQ-REPLICA-RUNS). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
5
+ * HMAC trust boundary), `filter` (the trigger/author gate), and the SHARED `enqueueGitHubJob` -- and adds
6
+ * only the glue: parse the verified body, project the payload subset, route on the filter's verdict, and
7
+ * map the outcome to a status code.
8
+ *
9
+ * DES-TRIGGER-OUTSIDE-PI: the trigger lives outside pi. This module imports the shared `enqueueGitHubJob`
10
+ * and never re-implements `queue.add` -- the queue's jobId/dedup/retention policy has exactly one owner,
11
+ * so there is no drift between what the receiver enqueues and what every other producer does.
12
+ *
13
+ * no-pii-in-logs: logs carry stable non-PII identifiers only -- the delivery GUID, repo full_name, issue
14
+ * number, flow, and a drop/failure reason. Never an issue title/body, a comment body, or a login.
15
+ *
16
+ * DES-ADMIN-VIA-PI-EXTENSION: the receiver exposes only the webhook handler. There is no admin,
17
+ * dashboard, or admin-extension route here -- the admin surface is a pi extension in the operator's
18
+ * session and binds no port.
19
+ */
20
+
21
+ import { makeVerifiedHandler } from "./verify.mjs";
22
+ import { filter } from "./filter.mjs";
23
+ import { enqueueForgeJob, enqueueGitHubJob, enqueueGitLabJob } from "@edgehero/pi-dispatch/queue";
24
+ import { filterGitLab } from "./filter-gitlab.mjs";
25
+ import { parseGitLabSubset } from "./gitlab-subset.mjs";
26
+ import { makeGitLabVerifiedHandler } from "./verify-gitlab.mjs";
27
+ import { filterForgejo } from "./filter-forgejo.mjs";
28
+ import { parseForgejoSubset } from "./forgejo-subset.mjs";
29
+ import { filterAzure } from "./filter-azure.mjs";
30
+ import { parseAzureSubset } from "./azure-subset.mjs";
31
+ import { makeAzureVerifiedHandler } from "./verify-azure.mjs";
32
+
33
+ /** Write a small JSON body with an explicit status; a 204 carries no body. verify's `respond` is private. */
34
+ function respond(res, status, obj) {
35
+ if (status === 204) {
36
+ res.writeHead(204);
37
+ res.end();
38
+ return;
39
+ }
40
+ res.writeHead(status, { "content-type": "application/json" });
41
+ res.end(JSON.stringify(obj ?? {}));
42
+ }
43
+
44
+ /**
45
+ * Project ONLY the INT-WEBHOOK-PAYLOAD-SUBSET fields out of a parsed webhook payload. Nothing outside
46
+ * this set flows onward to the filter or the job: the subset is the whole surface the trigger decision
47
+ * and the enqueued job are allowed to see.
48
+ */
49
+ export function parseSubset(payload) {
50
+ const pr = payload.pull_request;
51
+ return {
52
+ action: payload.action,
53
+ sender: { id: payload.sender?.id },
54
+ issue: {
55
+ number: payload.issue?.number,
56
+ title: payload.issue?.title,
57
+ body: payload.issue?.body,
58
+ labels: Array.isArray(payload.issue?.labels) ? payload.issue.labels.map((l) => ({ name: l?.name })) : [],
59
+ // Presence marker only: an issue_comment on a PR carries issue.pull_request, so the comment path
60
+ // routes it as a pull_request target. A boolean keeps a URL/login out of the subset.
61
+ pull_request: payload.issue?.pull_request != null,
62
+ },
63
+ comment: { body: payload.comment?.body, author_association: payload.comment?.author_association },
64
+ // pull_request event fields (labeled/opened/synchronize/reopened). head/base are attacker-controlled
65
+ // fork DATA -- projected for the flow's event.json, NEVER used as a clone ref (the worker clones the
66
+ // base default-branch SHA). Absent for non-PR events.
67
+ pull_request: pr
68
+ ? {
69
+ number: pr.number,
70
+ title: pr.title,
71
+ body: pr.body,
72
+ author_association: pr.author_association,
73
+ labels: Array.isArray(pr.labels) ? pr.labels.map((l) => ({ name: l?.name })) : [],
74
+ head: { ref: pr.head?.ref, sha: pr.head?.sha, repo: { full_name: pr.head?.repo?.full_name } },
75
+ base: { ref: pr.base?.ref },
76
+ }
77
+ : undefined,
78
+ repository: { full_name: payload.repository?.full_name },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Build the receiver's `node:http` handler.
84
+ *
85
+ * ROUTING BY PATH, not by header (issue #42). Each forge gets its own path, and with it its own secret and
86
+ * its own trust regime, decided before a byte of the body is read. Discriminating on headers instead would
87
+ * hand that choice to the sender: Forgejo emits `X-GitHub-*` on every delivery, so header-sniffing cannot
88
+ * even tell two forges apart reliably, and a request that could select which gate it faced would always
89
+ * select the weakest one available.
90
+ *
91
+ * `/` stays mapped to GitHub. Existing deployments configured their webhook URL before any path existed,
92
+ * and silently 404-ing them would look exactly like the harness being down.
93
+ *
94
+ * A forge with no configuration gets no route at all -- not a route that answers 401. An endpoint that
95
+ * responds to an unconfigured forge is an endpoint an operator can believe is armed.
96
+ */
97
+ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null }) {
98
+ const github = makeGitHubHandler({ queue, selfId, cfg, log });
99
+
100
+ // A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
101
+ // it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
102
+ // with a null handler is a CONFIGURED-OFF forge and answers 404; a path absent from the table entirely
103
+ // falls through to GitHub, which is what keeps `/` working.
104
+ const routes = {
105
+ "/gitlab": gitlab ? makeGitLabHandler({ queue, cfg, log, ...gitlab }) : null,
106
+ "/forgejo": forgejo ? makeForgejoHandler({ queue, cfg, log, ...forgejo }) : null,
107
+ "/azure": azure ? makeAzureHandler({ queue, cfg, log, ...azure }) : null,
108
+ };
109
+
110
+ return async function receiverHandler(req, res) {
111
+ const path = pathOf(req.url);
112
+ if (Object.hasOwn(routes, path)) {
113
+ const handler = routes[path];
114
+ // Not a 401. An endpoint that answers "unauthorized" for a forge nobody configured is an endpoint
115
+ // an operator can believe is armed and merely mis-keyed.
116
+ if (!handler) return respond(res, 404, { error: `${path.slice(1)} webhooks are not configured` });
117
+ return await handler(req, res);
118
+ }
119
+ return await github(req, res);
120
+ };
121
+ }
122
+
123
+ /** The path portion of a request URL, without query or trailing slash. Never throws on a malformed URL. */
124
+ function pathOf(url) {
125
+ const raw = String(url ?? "/").split("?")[0];
126
+ return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
127
+ }
128
+
129
+ /**
130
+ * The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
131
+ * request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
132
+ * precondition D2 guarantees before it runs.
133
+ */
134
+ function makeGitHubHandler({ queue, selfId, cfg, log }) {
135
+ return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
136
+ let subset;
137
+ try {
138
+ subset = parseSubset(JSON.parse(rawBody));
139
+ } catch {
140
+ return respond(res, 400, { error: "invalid-json" });
141
+ }
142
+
143
+ const result = filter(event, subset, cfg, selfId, delivery);
144
+ if (!result.enqueue) {
145
+ log?.({ event: "dropped", delivery, reason: result.reason });
146
+ return respond(res, 204);
147
+ }
148
+
149
+ // REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, and it
150
+ // belongs here because this is where the 202/503 decision already lives. Absent `replicas` is `1` and
151
+ // the call below is byte-identical to the single enqueue it replaced -- no `replica` key on the job,
152
+ // so the jobId, the dedup id and `data` are all exactly what they were.
153
+ //
154
+ // PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
155
+ // replica k throws, the catch below answers 503, GitHub redelivers, replicas 1..k-1 dedup on their own
156
+ // now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
157
+ const replicas = result.job.replicas ?? 1;
158
+ try {
159
+ for (let i = 1; i <= replicas; i++) {
160
+ await enqueueGitHubJob(queue, replicas > 1 ? { ...result.job, replica: i } : result.job);
161
+ }
162
+ } catch (err) {
163
+ // Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
164
+ log?.({ event: "enqueue_failed", delivery, reason: err?.message });
165
+ return respond(res, 503, { error: "enqueue-failed" }); // GitHub redelivers; dedup by GUID coalesces
166
+ }
167
+
168
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas });
169
+ return respond(res, 202, { status: "queued" });
170
+ });
171
+ }
172
+
173
+ /**
174
+ * The GitLab arm. Same shape as the GitHub one -- verify, project, gate, enqueue, respond -- with one step
175
+ * the GitHub path does not need: the actor's project access level is RESOLVED here, between verification
176
+ * and the gate, because GitLab puts no `author_association` in the payload (gitlab-members.mjs).
177
+ *
178
+ * That resolution is a network call, and its three outcomes are deliberately not two:
179
+ * - a determinate verdict (authorized or not) is handed to the gate, which decides;
180
+ * - an INDETERMINATE lookup is a 503, so GitLab redelivers and the stable `webhook-id` dedups the
181
+ * retry. Answering 204 would drop real work during an outage, and it would look on the wire exactly
182
+ * like a stranger being correctly refused.
183
+ * The lookup runs only for events that could still fire -- after verification, and after the payload has
184
+ * been projected -- so an unauthenticated flood cannot make this project call GitLab at all.
185
+ */
186
+ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAuthority, now }) {
187
+ return makeGitLabVerifiedHandler({ mode, secret, ...(now ? { now } : {}) }, async ({ rawBody, delivery }, res) => {
188
+ let subset;
189
+ try {
190
+ subset = parseGitLabSubset(JSON.parse(rawBody));
191
+ } catch {
192
+ return respond(res, 400, { error: "invalid-json" });
193
+ }
194
+
195
+ const resolved = await resolveAuthority(subset.project?.id, subset.user?.id);
196
+ if (resolved.indeterminate) {
197
+ // The reason names the lookup, never the actor -- `user.username` is personal data and exists
198
+ // only to have been asked about (no-pii-in-logs).
199
+ log?.({ event: "gitlab_access_lookup_failed", delivery, reason: resolved.indeterminate });
200
+ return respond(res, 503, { error: "access-lookup-failed" });
201
+ }
202
+
203
+ const result = filterGitLab(subset, cfg.triggers?.gitlab, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
204
+ if (!result.enqueue) {
205
+ log?.({ event: "dropped", delivery, reason: result.reason });
206
+ return respond(res, 204);
207
+ }
208
+
209
+ try {
210
+ await enqueueGitLabJob(queue, result.job);
211
+ } catch (err) {
212
+ log?.({ event: "enqueue_failed", delivery, reason: err?.message });
213
+ return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
214
+ }
215
+
216
+ // `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
217
+ // the semantic dedup key makes, because the two are separate number sequences.
218
+ const sep = result.job.target.type === "pull_request" ? "!" : "#";
219
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
220
+ return respond(res, 202, { status: "queued" });
221
+ });
222
+ }
223
+
224
+ /**
225
+ * The Forgejo arm. Structurally the GitLab one -- verify, project, RESOLVE, gate, enqueue, respond -- with
226
+ * one difference that is worth stating because it is invisible in the code: the verification step is
227
+ * GITHUB'S, unmodified.
228
+ *
229
+ * Forgejo signs the raw body with HMAC-SHA256 and sends `X-Hub-Signature-256`, `X-GitHub-Delivery` and
230
+ * `X-GitHub-Event`, which is byte-for-byte what `makeVerifiedHandler` already reads. So
231
+ * CONST-HMAC-OVER-RAW-BODY is satisfied here by EXISTING code rather than new code, and
232
+ * REQ-DEDUP-BY-DELIVERY-GUID transfers with no adaptation at all. What Forgejo needs is its own SECRET,
233
+ * which is why it is a separate handler and a separate path: `makeVerifiedHandler` closes over one secret
234
+ * per construction, and serving two sources from one would mean either sharing a secret between forges or
235
+ * letting the request choose which one it was checked against.
236
+ */
237
+ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority }) {
238
+ return makeVerifiedHandler({ secret }, async ({ rawBody, event, delivery }, res) => {
239
+ let subset;
240
+ try {
241
+ subset = parseForgejoSubset(JSON.parse(rawBody));
242
+ } catch {
243
+ return respond(res, 400, { error: "invalid-json" });
244
+ }
245
+
246
+ const resolved = await resolveAuthority(subset.repository?.full_name, subset.sender?.login);
247
+ if (resolved.indeterminate) {
248
+ // The reason names the lookup, never the actor -- `sender.login` is personal data and exists here
249
+ // only to have been asked about (no-pii-in-logs).
250
+ log?.({ event: "forgejo_permission_lookup_failed", delivery, reason: resolved.indeterminate });
251
+ return respond(res, 503, { error: "permission-lookup-failed" });
252
+ }
253
+
254
+ const result = filterForgejo(event, subset, cfg.triggers?.forgejo, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
255
+ if (!result.enqueue) {
256
+ log?.({ event: "dropped", delivery, reason: result.reason });
257
+ return respond(res, 204);
258
+ }
259
+
260
+ try {
261
+ await enqueueForgeJob(queue, "forgejo", result.job);
262
+ } catch (err) {
263
+ log?.({ event: "enqueue_failed", delivery, reason: err?.message });
264
+ return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
265
+ }
266
+
267
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow });
268
+ return respond(res, 202, { status: "queued" });
269
+ });
270
+ }
271
+
272
+ /**
273
+ * The Azure DevOps arm. The same five steps, with two things no other arm has.
274
+ *
275
+ * THE DELIVERY ID COMES OUT OF THE BODY. Azure sends no delivery-id header, so the dedup key
276
+ * (REQ-DEDUP-BY-DELIVERY-GUID) is the payload's own top-level `id` GUID. `verify-gitlab.mjs` refuses to do
277
+ * this and 400s instead -- correctly, for a forge that HAS a header. Azure has none, so the choice is a
278
+ * body-derived key or no dedup at all, and the ordering is unaffected: the body is parsed here, inside
279
+ * `onVerified`, after the credential check. A delivery with no `id` is refused rather than run
280
+ * undeduplicated.
281
+ *
282
+ * THE ACTOR MAY BE AN EMAIL. A pull-request delivery names a GUID; a work item names only
283
+ * `"Display Name <email>"`. Both the resolver and the bot-loop guard handle both forms -- see
284
+ * filter-azure.mjs, where the ordering constraint is stated in full.
285
+ */
286
+ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority }) {
287
+ return makeAzureVerifiedHandler({ mode, secret, headerName }, async ({ rawBody }, res) => {
288
+ let subset;
289
+ try {
290
+ subset = parseAzureSubset(JSON.parse(rawBody));
291
+ } catch {
292
+ return respond(res, 400, { error: "invalid-json" });
293
+ }
294
+
295
+ const delivery = subset.id;
296
+ if (typeof delivery !== "string" || delivery === "") {
297
+ // Without it there is no dedup key, and a redelivery would be billed as new work. A synthesised
298
+ // key would dedup some redeliveries and bill for the rest -- a weaker guarantee wearing
299
+ // REQ-DEDUP-BY-DELIVERY-GUID's name, which is worse than a clear refusal.
300
+ return respond(res, 400, { error: "missing-delivery-id" });
301
+ }
302
+
303
+ const resolved = await resolveAuthority(subset.project?.id, subset.actor);
304
+ if (resolved.indeterminate) {
305
+ // The reason names the lookup, never the actor -- an email address is personal data and exists
306
+ // here only to have been asked about (no-pii-in-logs).
307
+ log?.({ event: "azure_membership_lookup_failed", delivery, reason: resolved.indeterminate });
308
+ return respond(res, 503, { error: "membership-lookup-failed" });
309
+ }
310
+
311
+ const result = filterAzure(subset, cfg.triggers?.azure, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
312
+ if (!result.enqueue) {
313
+ log?.({ event: "dropped", delivery, reason: result.reason });
314
+ return respond(res, 204);
315
+ }
316
+
317
+ try {
318
+ await enqueueForgeJob(queue, "azure", result.job);
319
+ } catch (err) {
320
+ log?.({ event: "enqueue_failed", delivery, reason: err?.message });
321
+ return respond(res, 503, { error: "enqueue-failed" });
322
+ }
323
+
324
+ // `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
325
+ // discrimination the semantic dedup key makes.
326
+ const sep = result.job.target.type === "pull_request" ? "!" : "#";
327
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
328
+ return respond(res, 202, { status: "queued" });
329
+ });
330
+ }
package/src/start.mjs ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The receiver entry point: an always-on, public webhook producer that resolves the harness's own
3
+ * identity, then serves `makeReceiver` over `node:http` and enqueues onto the shared queue.
4
+ *
5
+ * DES-TRIGGER-OUTSIDE-PI: the trigger is a separate always-on process, outside the container and the
6
+ * agent. It only produces jobs; it never runs pi.
7
+ *
8
+ * CONST-TRIGGER-AUTHOR-GATE: `selfId` is the bot-loop guard's sole input -- the filter drops any event
9
+ * whose `sender.id` is our own. Resolving it is therefore a HARD-FAIL boot invariant: if identity does
10
+ * not resolve, the rejection propagates and the server is NEVER created. A receiver that listened
11
+ * without `selfId` would run the guard disarmed, and its own completion comments would re-trigger jobs
12
+ * -- an unbounded paid recursion. The worker's auth is best-effort because it can fail a github job
13
+ * per-job; the receiver has no such per-job fallback, so identity resolution is a boot gate.
14
+ *
15
+ * The receiver resolves identity ONLY. It holds no per-repo tokens: minting a scoped token is the
16
+ * worker's job, per container, per job (CONST-TOKEN-SCOPED-PER-JOB).
17
+ *
18
+ * DES-ADMIN-VIA-PI-EXTENSION: this process exposes exactly one surface, the webhook handler. There is no
19
+ * admin, dashboard, or admin-extension route here -- the admin surface is a pi extension in the
20
+ * operator's session and binds no port.
21
+ */
22
+
23
+ import http from "node:http";
24
+ import { watch } from "node:fs";
25
+ import { dirname, basename } from "node:path";
26
+ import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
27
+ import { makeReceiver } from "./receiver.mjs";
28
+ import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
29
+ import { resolveGitLabSelfId } from "@edgehero/pi-dispatch/gitlab-identity";
30
+ import { resolveForgejoSelfId } from "@edgehero/pi-dispatch/forgejo-identity";
31
+ import { resolveAzureSelfId } from "@edgehero/pi-dispatch/azure-identity";
32
+ import { makeResolveAuthority } from "./gitlab-members.mjs";
33
+ import { makeResolveForgejoAuthority } from "./forgejo-members.mjs";
34
+ import { makeResolveAzureAuthority } from "./azure-members.mjs";
35
+ import { makeQueue } from "@edgehero/pi-dispatch/queue";
36
+ import { parseConnection } from "@edgehero/pi-dispatch/connection";
37
+
38
+ /**
39
+ * Boot the receiver. Collaborators are injected (defaulting to the real ones) so the whole wiring is
40
+ * testable offline with no GitHub, no Valkey, and no socket. Returns the listening server.
41
+ */
42
+ export async function startReceiver(
43
+ env = process.env,
44
+ {
45
+ makeAuth = makeGitHubAuth,
46
+ makeQueueFn = makeQueue,
47
+ createServer = http.createServer,
48
+ resolveGitLabSelfId: resolveSelfIdFn = resolveGitLabSelfId,
49
+ makeResolveAuthority: makeResolveAuthorityFn = makeResolveAuthority,
50
+ resolveForgejoSelfId: resolveForgejoSelfIdFn = resolveForgejoSelfId,
51
+ makeResolveForgejoAuthority: makeResolveForgejoAuthorityFn = makeResolveForgejoAuthority,
52
+ resolveAzureSelfId: resolveAzureSelfIdFn = resolveAzureSelfId,
53
+ makeResolveAzureAuthority: makeResolveAzureAuthorityFn = makeResolveAzureAuthority,
54
+ } = {},
55
+ ) {
56
+ // Single-object log line: `makeReceiver` calls `log?.({ event, ... })`, so the sink takes ONE object.
57
+ const log = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
58
+
59
+ const cfg = loadReceiverConfig(env);
60
+
61
+ // HARD-FAIL identity resolution -- NO try/catch. A throw here (absent/bad github auth, unresolvable
62
+ // id) propagates and the server below is never created: without selfId the bot-loop guard cannot
63
+ // run, so refusing to boot is the only safe outcome.
64
+ const { selfId } = await makeAuth(cfg.github);
65
+ log({ event: "self_identity", id: selfId, source: cfg.github.source });
66
+
67
+ // Ride-out connection (no failFast): the receiver is long-running and should survive a Valkey
68
+ // restart, not give up on a transient disconnect.
69
+ const queue = makeQueueFn(parseConnection(cfg.valkeyUrl));
70
+
71
+ // The GitLab arm, when configured. Its identity resolution is HARD-FAIL for the same reason github's
72
+ // is: without a selfId the bot-loop guard cannot run, and a receiver that listens without it turns the
73
+ // harness's own status comment into another paid job.
74
+ let gitlab = null;
75
+ if (cfg.gitlab) {
76
+ const gitlabSelfId = await resolveSelfIdFn({ apiUrl: cfg.gitlab.apiUrl, token: cfg.gitlab.token });
77
+ log({ event: "self_identity", forge: "gitlab", id: gitlabSelfId, mode: cfg.gitlab.mode });
78
+ gitlab = {
79
+ mode: cfg.gitlab.mode,
80
+ secret: cfg.gitlab.secret,
81
+ selfId: gitlabSelfId,
82
+ resolveAuthority: makeResolveAuthorityFn({ apiUrl: cfg.gitlab.apiUrl, token: cfg.gitlab.token }),
83
+ };
84
+ }
85
+
86
+ // The Forgejo arm, when configured. Identity resolution is HARD-FAIL here too, and it is the arm where
87
+ // that matters most: a repo-scoped Forgejo token cannot call GET /user, so an operator who follows the
88
+ // scoping advice without setting FORGEJO_BOT_ID lands exactly here -- and a receiver that shrugged and
89
+ // continued would run with selfId undefined, which never equals a sender id and silently turns the
90
+ // harness's own comments into more paid jobs.
91
+ let forgejo = null;
92
+ if (cfg.forgejo) {
93
+ const forgejoSelfId = await resolveForgejoSelfIdFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token, botId: cfg.forgejo.botId });
94
+ log({ event: "self_identity", forge: "forgejo", id: forgejoSelfId, source: cfg.forgejo.botId ? "FORGEJO_BOT_ID" : "api" });
95
+ forgejo = {
96
+ secret: cfg.forgejo.secret,
97
+ selfId: forgejoSelfId,
98
+ resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token }),
99
+ };
100
+ }
101
+
102
+ // The Azure arm, when configured. Identity resolution is HARD-FAIL here too, and it resolves BOTH forms
103
+ // of the harness's identity in one call: a pull-request delivery names an actor by GUID and a work item
104
+ // names them only by email address, so a guard that knew one form would be blind on half the events.
105
+ let azure = null;
106
+ if (cfg.azure) {
107
+ const azureSelfId = await resolveAzureSelfIdFn({ orgUrl: cfg.azure.orgUrl, token: cfg.azure.token });
108
+ log({ event: "self_identity", forge: "azure", id: azureSelfId.id, hasAccountName: azureSelfId.email !== null, mode: cfg.azure.mode });
109
+ azure = {
110
+ mode: cfg.azure.mode,
111
+ secret: cfg.azure.secret,
112
+ headerName: cfg.azure.headerName,
113
+ selfId: azureSelfId,
114
+ resolveAuthority: makeResolveAzureAuthorityFn({ orgUrl: cfg.azure.orgUrl, token: cfg.azure.token }),
115
+ };
116
+ }
117
+
118
+ const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure });
119
+ const server = createServer(handler);
120
+ server.listen(cfg.port, cfg.bind, () =>
121
+ log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl }),
122
+ );
123
+
124
+ // Graceful shutdown AND the live-trigger watcher only on the real entry (default createServer). Under
125
+ // test injection the fakes are per-test, so a process-wide signal handler or an fs watcher would leak
126
+ // across tests; the reload LOGIC (`reloadTriggers`) is unit-tested directly instead.
127
+ if (createServer === http.createServer) {
128
+ watchTriggers(env, cfg, log);
129
+ const shutdown = async (signal) => {
130
+ log({ event: "receiver_stopping", signal });
131
+ await new Promise((resolve) => server.close(resolve));
132
+ await queue.close();
133
+ process.exit(0);
134
+ };
135
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
136
+ process.once("SIGINT", () => void shutdown("SIGINT"));
137
+ }
138
+
139
+ return server;
140
+ }
141
+
142
+ /**
143
+ * Live-reload watcher: watch the DIRECTORY holding the triggers file (robust to the atomic tmp+rename the
144
+ * admin writes with, which swaps the inode a file-watch would lose), debounce, and re-read on change. A bad
145
+ * edit keeps the running triggers (reloadTriggers never throws) and logs a kept-old notice. Best-effort: a
146
+ * platform without `fs.watch` logs and the receiver simply keeps its boot-time triggers.
147
+ */
148
+ function watchTriggers(env, cfg, log) {
149
+ const path = triggersFilePath(env);
150
+ const dir = dirname(path) || ".";
151
+ const file = basename(path);
152
+ let timer = null;
153
+ try {
154
+ watch(dir, (_event, changed) => {
155
+ if (changed && changed !== file) return; // only our file (null changed name -> reload to be safe)
156
+ clearTimeout(timer);
157
+ timer = setTimeout(() => {
158
+ const res = reloadTriggers(env, cfg);
159
+ if (res.ok) log({ event: "triggers_reloaded" });
160
+ else log({ event: "triggers_reload_invalid", reason: res.invalid, kept: true });
161
+ }, 150);
162
+ }).unref?.();
163
+ log({ event: "triggers_watching", path });
164
+ } catch (err) {
165
+ log({ event: "triggers_watch_unavailable", reason: err?.message });
166
+ }
167
+ }
168
+
169
+ // Entry point when run directly (main: src/start.mjs, no bin). Kept out of startReceiver so tests call
170
+ // it directly. The error line carries only `err.message` -- never a secret or PII.
171
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("start.mjs")) {
172
+ startReceiver(process.env).catch((err) => {
173
+ process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
174
+ process.exitCode = 1;
175
+ });
176
+ }