@edgehero/pi-dispatch-receiver 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
6
  "keywords": [
@@ -7,7 +7,8 @@
7
7
  * one, which is a real cost this arm carries and the other three do not:
8
8
  *
9
9
  * 1. the actor -> a subject DESCRIPTOR. A pull-request payload gives a GUID, a work-item payload gives
10
- * only an email address, so there are two lookups depending on which the event carried.
10
+ * only an email address, so there are two lookups depending on which the event carried. The email one
11
+ * is a LIST walk and may itself span several requests -- see the pagination note on resolveDescriptor.
11
12
  * 2. the descriptor -> membership of the project's own group.
12
13
  *
13
14
  * Both can go indeterminate, so the indeterminate surface here is wider than GitLab's, not merely equal to
@@ -29,6 +30,19 @@
29
30
 
30
31
  import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
31
32
 
33
+ /**
34
+ * How many pages of the organisation's user list one delivery may walk while looking for an email address.
35
+ *
36
+ * The cap is counted in PAGES and not in users, because the page size is Azure's to choose and is not part
37
+ * of the request -- what this bounds is request AMPLIFICATION: one webhook must never turn into an
38
+ * unbounded crawl of a directory, and an org that grew a page while the loop was running must still
39
+ * terminate. Twenty is chosen to be comfortably past any real organisation at Azure's own page size
40
+ * (hundreds of subjects per page) and still a fixed number of round trips.
41
+ *
42
+ * Running out of pages is NOT a refusal -- see resolveDescriptor.
43
+ */
44
+ const MAX_USER_PAGES = 20;
45
+
32
46
  /**
33
47
  * Build the resolver.
34
48
  *
@@ -44,6 +58,21 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
44
58
  // Azure authenticates a PAT as HTTP Basic with an empty username.
45
59
  const auth = `Basic ${Buffer.from(`:${token}`, "utf8").toString("base64")}`;
46
60
 
61
+ /**
62
+ * One Graph GET. `{ body, continuation }` on a 2xx, `{ indeterminate }` on anything else -- the whole
63
+ * error taxonomy of this module lives here, once, so a new call site cannot invent a different one.
64
+ *
65
+ * `continuation` carries Azure's paging cursor for the LIST endpoints and is null for everything else.
66
+ * The Graph does not use a Link header and does not put the cursor in the body: a page that is not the
67
+ * last one returns an `X-MS-ContinuationToken` RESPONSE HEADER, which the caller sends back as a
68
+ * `continuationToken` QUERY PARAMETER on the next request. It is read here rather than in a sibling
69
+ * helper so the two single-object descriptor lookups and the membership lookup keep sharing one code
70
+ * path; they simply ignore a field Azure never sets for them.
71
+ *
72
+ * `headers.get` is called defensively. A real `Response` always has it, but `fetchFn` is injected, and a
73
+ * fake that returns a plain object must not turn a lookup into a TypeError -- which would escape as a
74
+ * throw rather than as this module's `{ indeterminate }`.
75
+ */
47
76
  async function get(url) {
48
77
  let res;
49
78
  try {
@@ -55,8 +84,9 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
55
84
  // Status only. An Azure error body can echo the request, and the request carried the token.
56
85
  return { indeterminate: `azure lookup returned ${res.status}` };
57
86
  }
87
+ const continuation = typeof res.headers?.get === "function" ? res.headers.get("x-ms-continuationtoken") : null;
58
88
  try {
59
- return { body: await res.json() };
89
+ return { body: await res.json(), continuation: continuation || null };
60
90
  } catch (err) {
61
91
  return { indeterminate: `azure lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
62
92
  }
@@ -85,6 +115,12 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
85
115
  // Membership is TRANSITIVE via `direction=up`: a member of a team inside the project is a member of
86
116
  // the project, and asking only for direct membership would refuse most real organisations -- the same
87
117
  // mistake `members/all` avoids on GitLab.
118
+ //
119
+ // This reads ONE page, unlike the users listing above, and that is a scope statement rather than a
120
+ // claim: what is being listed here is one subject's own containers, not a directory, so the two are
121
+ // not the same size of question. `get` surfaces `continuation` for this call too, so if this ever
122
+ // needs following the mechanism is already here -- but no cap or verdict is invented for a paging
123
+ // behaviour nothing in this codebase has observed on this endpoint.
88
124
  const memberships = await get(`${vssps}/_apis/graph/memberships/${encodeURIComponent(descriptor.value)}?direction=up&api-version=7.1-preview.1`);
89
125
  if (memberships.indeterminate) return memberships;
90
126
  const list = memberships.body?.value;
@@ -94,7 +130,38 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
94
130
  return { authorized: list.some((m) => m?.containerDescriptor === container) };
95
131
  };
96
132
 
97
- /** The actor's subject descriptor: by GUID for a pull request, by email for a work item. */
133
+ /**
134
+ * The actor's subject descriptor: by GUID for a pull request, by email for a work item.
135
+ *
136
+ * THE EMAIL PATH IS PAGINATED, and that is the whole difficulty of this function. Azure's Graph has no
137
+ * lookup-by-mail-address endpoint, so the organisation's user list is fetched and filtered locally --
138
+ * and that list is paged. Reading only the first page meant that in any organisation whose directory
139
+ * exceeds one page, an actor beyond it resolved to nobody, which is DETERMINATE: the gate refused, the
140
+ * receiver answered 204, and work-item (tag and comment) triggers simply never fired for those people,
141
+ * behind a status indistinguishable from a stranger being correctly turned away. Pull requests were
142
+ * never affected -- a PR names its actor by GUID and takes the direct descriptor lookup above.
143
+ *
144
+ * So the pages are followed (see `get` for the header/query-parameter mechanism), and the three ways out
145
+ * are deliberately three different answers:
146
+ *
147
+ * - FOUND, on any page -> the descriptor. The loop stops at the hit, so an actor on page 1 still costs
148
+ * exactly one request; nobody pays for pagination that was not needed.
149
+ * - the listing ENDED (a page with no continuation token) and the actor was not in it -> determinate
150
+ * `null`, which the caller refuses. This is the honest refusal: the entire directory was read.
151
+ * - the PAGE CAP was reached while Azure was still offering more -> `{ indeterminate }`, never `null`.
152
+ * The search was abandoned, not completed, and the module's own docblock is explicit that
153
+ * indeterminate and unauthorized must not be conflated: this way the receiver answers 503 and Azure
154
+ * redelivers, instead of burying an exhausted search inside a 204 that reads as a policy decision.
155
+ *
156
+ * A FILTERED LOOKUP WOULD BE BETTER AND IS NOT USED. Nothing in the Graph surface this module already
157
+ * speaks offers a by-mail filter on `graph/users` (`subjectTypes` selects kinds of subject, not
158
+ * identities), and the endpoints that come close -- a subject query, the older Identities API -- return
159
+ * shapes this file has never handled and whose descriptor flavour may not be the one `graph/memberships`
160
+ * accepts. Guessing one would repeat exactly the mistake the security-namespace paragraph above refuses:
161
+ * a confident answer about a different object. The list walk is coarser and verifiable, so it stays.
162
+ *
163
+ * The address is still never logged or returned, including in the indeterminate reason.
164
+ */
98
165
  async function resolveDescriptor(actor) {
99
166
  if (typeof actor?.id === "string" && actor.id !== "") {
100
167
  const res = await get(`${vssps}/_apis/graph/descriptors/${encodeURIComponent(actor.id)}?api-version=7.1-preview.1`);
@@ -103,15 +170,23 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
103
170
  return { value: typeof value === "string" && value !== "" ? value : null };
104
171
  }
105
172
  if (typeof actor?.email === "string" && actor.email !== "") {
106
- // There is no lookup-by-email endpoint, so the users list is filtered. `subjectTypes=aad,msa`
107
- // excludes groups and service principals, which cannot be the human this gate is about.
108
- const res = await get(`${vssps}/_apis/graph/users?subjectTypes=aad,msa&api-version=7.1-preview.1`);
109
- if (res.indeterminate) return res;
110
- const users = res.body?.value;
111
- if (!Array.isArray(users)) return { indeterminate: "azure users lookup returned no array" };
112
173
  const wanted = actor.email.toLowerCase();
113
- const hit = users.find((u) => String(u?.principalName ?? "").toLowerCase() === wanted || String(u?.mailAddress ?? "").toLowerCase() === wanted);
114
- return { value: typeof hit?.descriptor === "string" ? hit.descriptor : null };
174
+ let token = null;
175
+ for (let page = 0; page < MAX_USER_PAGES; page++) {
176
+ // `subjectTypes=aad,msa` excludes groups and service principals, which cannot be the human this
177
+ // gate is about. The continuation token is appended only when there is one, so the FIRST request
178
+ // is byte-identical to the single-page one this replaced.
179
+ const res = await get(`${vssps}/_apis/graph/users?subjectTypes=aad,msa&api-version=7.1-preview.1${token ? `&continuationToken=${encodeURIComponent(token)}` : ""}`);
180
+ if (res.indeterminate) return res;
181
+ const users = res.body?.value;
182
+ if (!Array.isArray(users)) return { indeterminate: "azure users lookup returned no array" };
183
+ const hit = users.find((u) => String(u?.principalName ?? "").toLowerCase() === wanted || String(u?.mailAddress ?? "").toLowerCase() === wanted);
184
+ if (hit) return { value: typeof hit.descriptor === "string" ? hit.descriptor : null };
185
+ if (!res.continuation) return { value: null }; // the list ended: a determinate "not in this org"
186
+ token = res.continuation;
187
+ }
188
+ // Still more pages on offer. Nothing was decided, so nothing is refused.
189
+ return { indeterminate: `azure users lookup did not reach the actor within ${MAX_USER_PAGES} pages` };
115
190
  }
116
191
  // Neither a GUID nor a parseable address: the delivery named nobody this gate can ask about.
117
192
  return { value: null };
package/src/cli.mjs CHANGED
@@ -22,7 +22,8 @@ const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub
22
22
  reads api.github.com with the operator's own credential instead
23
23
 
24
24
  Config comes from the environment (see .env.example): WEBHOOK_SECRET is required for serve
25
- (poll needs none — there is no inbound delivery to verify), PI_TRIGGERS_FILE overrides the
25
+ only when your triggers name github (poll needs none either — there is no inbound delivery to
26
+ verify, and a forge-only deployment has no github endpoint), PI_TRIGGERS_FILE overrides the
26
27
  ./triggers.json default, VALKEY_URL names the queue, RECEIVER_PORT/RECEIVER_BIND choose where
27
28
  serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches and how often.`;
28
29
 
package/src/config.mjs CHANGED
@@ -8,8 +8,15 @@
8
8
  * trigger schema is likewise single-sourced from `@edgehero/pi-dispatch/triggers` -- both services validate
9
9
  * the WHOLE unified triggers file and each selects the `on.type` it owns (issue #20).
10
10
  *
11
- * - `webhookSecret` is REQUIRED: without it the receiver cannot verify `X-Hub-Signature-256` over the
12
- * raw body, and an unverified webhook is a forgeable paid-agent trigger (CONST-HMAC-OVER-RAW-BODY).
11
+ * - `webhookSecret` is REQUIRED WHENEVER THE DEPLOYMENT SERVES GITHUB (`servesGithub`, below): without it
12
+ * the receiver cannot verify `X-Hub-Signature-256` over the raw body, and an unverified webhook is a
13
+ * forgeable paid-agent trigger (CONST-HMAC-OVER-RAW-BODY). A GitLab-only / Forgejo-only / Azure-only
14
+ * deployment has no GitHub endpoint and therefore nothing for that secret to verify, so demanding one
15
+ * there blocked a deployment the harness fully supports (issue #99).
16
+ * - `servesGithub` is that decision, made HERE and named, because three separate things hang off it and
17
+ * they must never disagree: the WEBHOOK_SECRET requirement, whether `/` is mounted at all
18
+ * (receiver.mjs), and whether boot resolves the harness's GitHub identity to arm the bot-loop guard
19
+ * (start.mjs). Deriving it inline in two places is how a route ends up live with a disarmed guard.
13
20
  * - `triggers` is the receiver's webhook allowlist, grouped by type: label rules (the label IS the
14
21
  * collaborator approval), the single comment trigger (phrase + default flow), and pull_request rules.
15
22
  * Only collaborators can apply labels, so the label/PR-label allowlist is the human approval gate
@@ -36,24 +43,72 @@ const DEFAULT_TRIGGERS_PATH = "./triggers.json";
36
43
  * (`readFile`, `fileExists`) so the loader is hermetically testable and never touches disk in tests.
37
44
  */
38
45
  export function loadReceiverConfig(env = process.env, { readFile = readFileSync, fileExists = existsSync } = {}) {
46
+ // Triggers and the GitHub auth block are parsed FIRST, before the secret check, because the secret is
47
+ // now conditional on what they say. Their own fail-loud errors therefore surface first -- a garbled
48
+ // GITHUB_AUTH_SOURCE is reported as a garbled GITHUB_AUTH_SOURCE, never laundered into a confusing
49
+ // complaint about WEBHOOK_SECRET.
50
+ const triggers = loadTriggers(env, readFile, fileExists);
51
+ const github = loadGitHubAuth(env, fileExists);
52
+ const servesGithub = decideServesGithub(env, triggers.github);
53
+
39
54
  const webhookSecret = env.WEBHOOK_SECRET;
40
- if (webhookSecret === undefined || webhookSecret.trim() === "") {
55
+ if (servesGithub && (webhookSecret === undefined || webhookSecret.trim() === "")) {
56
+ // Same message and same behaviour as before for every deployment that serves GitHub: the secret is
57
+ // the whole trust boundary of the `/` endpoint, so a receiver that would mount it without one must
58
+ // refuse to start rather than answer 401 forever (or, worse, accept forged deliveries).
41
59
  throw configError("WEBHOOK_SECRET is required; refusing to start a receiver that cannot verify signatures");
42
60
  }
43
61
 
44
62
  return {
63
+ // Passed through as the env gave it (possibly undefined) when the deployment serves no GitHub. Nothing
64
+ // reads it in that case -- receiver.mjs builds no github handler at all -- and `servesGithub` beside it
65
+ // is what says so, so an unread absent secret can never be mistaken for an armed endpoint.
45
66
  webhookSecret,
67
+ servesGithub,
46
68
  valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379", // mirrors worker config: producer and consumer share one queue
47
69
  port: positiveInt(env, "RECEIVER_PORT", 3000),
48
70
  bind: env.RECEIVER_BIND ?? "0.0.0.0",
49
- triggers: loadTriggers(env, readFile, fileExists),
50
- github: loadGitHubAuth(env, fileExists),
71
+ triggers,
72
+ github,
51
73
  gitlab: loadGitLabConfig(env),
52
74
  forgejo: loadForgejoConfig(env),
53
75
  azure: loadAzureConfig(env),
54
76
  };
55
77
  }
56
78
 
79
+ /**
80
+ * Does this deployment actually SERVE GitHub? Three things hang off the answer -- the WEBHOOK_SECRET
81
+ * requirement above, whether `/` is mounted (receiver.mjs), and whether boot resolves the harness's own
82
+ * GitHub id to arm the bot-loop guard (start.mjs) -- so it is decided once, here, from the operator's own
83
+ * files rather than assumed.
84
+ *
85
+ * Either of two signals is a yes:
86
+ *
87
+ * - THE TRIGGERS FILE NAMES A GITHUB WEBHOOK RULE. `triggers.github` is the group `loadTriggers` built
88
+ * from every `run.kind: "github"` webhook entry, and `emptyGroup()` is what a forge the file never
89
+ * mentions gets. So an empty group -- zero label rules, no comment trigger, zero pull_request rules --
90
+ * means a signed GitHub delivery could not fire anything even if it arrived: the endpoint would verify
91
+ * the HMAC, match no rule, and answer 204 forever. That is not a GitHub deployment.
92
+ *
93
+ * - GITHUB_AUTH_SOURCE IS SET EXPLICITLY. An operator who names an auth source has said "GitHub" out
94
+ * loud, and they may well be arming the receiver before the first rule exists (or driving it from the
95
+ * poller, which is GitHub-only). Explicit intent wins over the inferred signal, and it is also what
96
+ * keeps this change byte-identical to today's behaviour for every deployment that sets the variable.
97
+ * Read from `env` and NOT from the parsed block: `loadGitHubAuth` defaults `source` to "gh", so
98
+ * `github.source` cannot tell an explicit choice from a default and would answer yes for everyone.
99
+ *
100
+ * Deliberately NOT a signal: the presence of GITHUB_PAT / GITHUB_APP_* alone. Those are credentials a
101
+ * shared env file may carry for the worker (which mints per-job tokens and is a separate process); a
102
+ * credential lying around is not a statement that this receiver terminates GitHub webhooks.
103
+ */
104
+ function decideServesGithub(env, githubGroup) {
105
+ const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0;
106
+ // `loadGitHubAuth` has already refused a garbled value by the time we get here, so anything non-empty
107
+ // here is one of pat|gh|app -- a real choice, not a typo we would be reading as consent.
108
+ const explicitAuthSource = env.GITHUB_AUTH_SOURCE !== undefined && env.GITHUB_AUTH_SOURCE !== "";
109
+ return hasGithubTriggers || explicitAuthSource;
110
+ }
111
+
57
112
  /**
58
113
  * The GitLab endpoint's configuration, or `null` when the deployment serves no GitLab -- in which case no
59
114
  * `/gitlab` route exists at all, rather than one that answers 401. An endpoint that responds is an endpoint
@@ -246,6 +301,14 @@ export function triggersFilePath(env = process.env) {
246
301
  * restart, mirroring how the worker re-reads the settings overlay per job. If the new file is
247
302
  * missing/unparseable/invalid, the running triggers are KEPT (never crash a live receiver on a bad edit)
248
303
  * and the reason is returned. Returns `{ ok: true }` or `{ invalid }`.
304
+ *
305
+ * `servesGithub` is deliberately NOT recomputed. It is a BOOT decision, because the two other things it
306
+ * governs are boot facts: whether `/` was mounted and whether the harness's GitHub identity was resolved.
307
+ * Recomputing it here would let a live file edit mount a github endpoint whose bot-loop guard was never
308
+ * armed -- the one state this design exists to make unreachable. Both directions of a live edit are
309
+ * therefore safe and neither is silent: adding a github rule to a github-free receiver leaves `/` 404ing
310
+ * until a restart (the operator's next step anyway, since the deployment also needs a webhook secret), and
311
+ * removing the last github rule leaves `/` verifying deliveries that now match nothing and answer 204.
249
312
  */
250
313
  export function reloadTriggers(env, cfg, { readFile = readFileSync, fileExists = existsSync } = {}) {
251
314
  try {
package/src/receiver.mjs CHANGED
@@ -88,19 +88,34 @@ export function parseSubset(payload) {
88
88
  * even tell two forges apart reliably, and a request that could select which gate it faced would always
89
89
  * select the weakest one available.
90
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.
91
+ * `/` stays mapped to GitHub -- for a deployment that SERVES GitHub. Existing deployments configured their
92
+ * webhook URL before any path existed, and silently 404-ing them would look exactly like the harness being
93
+ * down, so the path itself never moves.
93
94
  *
94
95
  * 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
+ * responds to an unconfigured forge is an endpoint an operator can believe is armed. GitHub is no longer
97
+ * the exception to that rule (issue #99): a GitLab-only / Forgejo-only / Azure-only deployment has
98
+ * `cfg.servesGithub === false`, builds no github handler, and 404s `/`.
99
+ *
100
+ * The github arm is gated on `cfg.servesGithub` being TRUTHY, not on it not being `false`, and that
101
+ * direction is load-bearing. `start.mjs` skips resolving the harness's GitHub identity when the property is
102
+ * off, so a config object that somehow reached here without the property would otherwise mount `/` with
103
+ * `selfId === undefined` -- a live endpoint whose bot-loop guard compares every `sender.id` against
104
+ * undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
105
+ * failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
96
106
  */
97
107
  export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null }) {
98
- const github = makeGitHubHandler({ queue, selfId, cfg, log });
108
+ // Built only when the deployment serves GitHub. Construction is not free of the secret either: the
109
+ // `new Webhooks({ secret })` inside makeVerifiedHandler throws "options.secret required" on an absent
110
+ // one, so not building the arm is what lets a github-free deployment legitimately have no secret --
111
+ // no placeholder, nothing papered over, and no handler holding a secret nobody chose.
112
+ const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log }) : null;
99
113
 
100
114
  // A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
101
115
  // it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
102
116
  // 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.
117
+ // falls through to GitHub, which is what keeps `/` working -- and a configured-off GitHub answers the
118
+ // same 404 from the fallthrough itself, below.
104
119
  const routes = {
105
120
  "/gitlab": gitlab ? makeGitLabHandler({ queue, cfg, log, ...gitlab }) : null,
106
121
  "/forgejo": forgejo ? makeForgejoHandler({ queue, cfg, log, ...forgejo }) : null,
@@ -116,6 +131,12 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
116
131
  if (!handler) return respond(res, 404, { error: `${path.slice(1)} webhooks are not configured` });
117
132
  return await handler(req, res);
118
133
  }
134
+ // The GitHub fallthrough (`/` and anything unrouted). Absent when the deployment serves no GitHub, and
135
+ // 404 rather than 401 or 405 for the same reason the table's null arms are: a status that discusses
136
+ // credentials or methods says "armed, and you got something wrong", and an operator who reads that
137
+ // about an endpoint that cannot fire anything will go hunting for a mis-keyed secret for an hour. The
138
+ // message is spelled out rather than derived from `path`, which is "/" here and would slice to nothing.
139
+ if (!github) return respond(res, 404, { error: "github webhooks are not configured" });
119
140
  return await github(req, res);
120
141
  };
121
142
  }
package/src/start.mjs CHANGED
@@ -6,11 +6,14 @@
6
6
  * agent. It only produces jobs; it never runs pi.
7
7
  *
8
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.
9
+ * whose `sender.id` is our own. Resolving it is therefore a HARD-FAIL boot invariant WHEREVER A FORGE
10
+ * ENDPOINT IS LIVE: if identity does not resolve, the rejection propagates and the server is NEVER
11
+ * created. A receiver that listened without `selfId` would run the guard disarmed, and its own completion
12
+ * comments would re-trigger jobs -- an unbounded paid recursion. The worker's auth is best-effort because
13
+ * it can fail a github job per-job; the receiver has no such per-job fallback, so identity resolution is a
14
+ * boot gate. Every arm here is gated on its own forge being configured, github included (`cfg.servesGithub`,
15
+ * issue #99) -- an arm whose endpoint does not exist has no guard to arm, and the invariant that matters is
16
+ * that the two are decided by the SAME property, never separately.
14
17
  *
15
18
  * The receiver resolves identity ONLY. It holds no per-repo tokens: minting a scoped token is the
16
19
  * worker's job, per container, per job (CONST-TOKEN-SCOPED-PER-JOB).
@@ -58,11 +61,29 @@ export async function startReceiver(
58
61
 
59
62
  const cfg = loadReceiverConfig(env);
60
63
 
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 });
64
+ // The GitHub arm, when the deployment actually serves GitHub -- now conditional, exactly like the three
65
+ // sibling arms below (issue #99). It was unconditional, and since GITHUB_AUTH_SOURCE defaults to `gh` and
66
+ // the gh path shells out to `gh auth token`, a GitLab-only deployment could not boot without installing
67
+ // and logging into the GitHub CLI it has no use for.
68
+ //
69
+ // SKIPPING THIS IS ONLY SAFE BECAUSE THE ROUTE IS ALSO ABSENT. `cfg.servesGithub` gates both: this
70
+ // identity resolution AND whether `makeReceiver` mounts `/` at all. `selfId` is the bot-loop guard's sole
71
+ // input, so the two MUST stay coupled -- if a future change mounts `/` unconditionally again, this
72
+ // resolution has to come back with it, or the github endpoint would run its guard disarmed and the
73
+ // harness's own completion comments would re-trigger jobs forever. Read that as: never make one of these
74
+ // two conditions unconditional without the other.
75
+ let selfId;
76
+ if (cfg.servesGithub) {
77
+ // HARD-FAIL identity resolution -- NO try/catch. A throw here (absent/bad github auth, unresolvable
78
+ // id) propagates and the server below is never created: without selfId the bot-loop guard cannot
79
+ // run, so refusing to boot is the only safe outcome.
80
+ ({ selfId } = await makeAuth(cfg.github));
81
+ log({ event: "self_identity", id: selfId, source: cfg.github.source });
82
+ } else {
83
+ // Said out loud, because the alternative is an operator staring at a label trigger that does nothing.
84
+ // The two ways out are the two signals `decideServesGithub` reads, so the line names both.
85
+ log({ event: "github_arm_skipped", reason: "no github triggers and GITHUB_AUTH_SOURCE unset" });
86
+ }
66
87
 
67
88
  // Ride-out connection (no failFast): the receiver is long-running and should survive a Valkey
68
89
  // restart, not give up on a transient disconnect.