@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.
- package/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
|
@@ -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
|
+
}
|
package/src/receiver.mjs
ADDED
|
@@ -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
|
+
}
|