@edgehero/pi-dispatch-receiver 1.1.0 → 1.3.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/src/poller.mjs CHANGED
@@ -43,9 +43,18 @@
43
43
  * a closed PR has nothing left to act on, the same call `merge`/`close` get in the
44
44
  * action vocabulary. REST spells `state` in upper case where the webhook spells it
45
45
  * lower; `parseSubset` folds it, so both transports produce the same job.
46
+ * - closed: the SAME /issues/events feed as `label` -- `closed` entries carry the CLOSER as
47
+ * `actor` and cover issues AND PRs (`issue.pull_request` is the discriminator again,
48
+ * and a MERGED PR emits `closed` here too, which is what lets a close trigger release
49
+ * post-merge work). Consumed only when a close rule is armed (hasCloseTriggers), so an
50
+ * unarmed deployment's cycle stays byte-identical to a pre-#231 run: no PR fetch, no
51
+ * permission traffic. The closer's write access is resolved via the shared
52
+ * collaborator-permission lookup BEFORE the gate, mirroring the webhook arm
53
+ * (issue #231; see `gate` for the indeterminate-lookup retry bound).
46
54
  *
47
55
  * DEDUP IDS (REQ-DEDUP-BY-DELIVERY-GUID): polling has no delivery GUID, so each source mints a
48
- * deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (label events),
56
+ * deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (the /issues/events
57
+ * feed: label AND close entries, one feed so one id family),
49
58
  * `poll-c<commentId>` (comments), `poll-pr<number>-<headSha7>` (PR actions; sha-keyed so a retried
50
59
  * cycle cannot double-enqueue while a real new push mints a new id -- with the honest corollary that
51
60
  * a same-sha reopen inside the retention window coalesces with its own `opened` job), and
@@ -62,6 +71,10 @@
62
71
  * poll:<owner/repo>:etag:reviews hash: PR number -> that PR's reviews-endpoint validator. A HASH
63
72
  * rather than a key per PR, so the family below stays enumerable
64
73
  * and `touchRepo` can still refresh it as a unit.
74
+ * poll:<owner/repo>:close-gate:<delivery> consecutive INDETERMINATE closer-lookup attempts for ONE
75
+ * close event (issue #231). Deliberately OUTSIDE the touched
76
+ * family, with a short ~1-day TTL of its own: the counter must
77
+ * decay with the outage it measures, not live with the repo.
65
78
  * All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful repo poll. 35 days
66
79
  * deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
67
80
  * the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
@@ -98,7 +111,8 @@ import { configError } from "@edgehero/pi-dispatch/config";
98
111
  import { parseConnection } from "@edgehero/pi-dispatch/connection";
99
112
  import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
100
113
  import { enqueueGitHubJob, makeQueue } from "@edgehero/pi-dispatch/queue";
101
- import { filter } from "./filter.mjs";
114
+ import { filter, hasCloseTriggers, wantsCloserAuthority } from "./filter.mjs";
115
+ import { makeResolveGitHubAuthority } from "./github-members.mjs";
102
116
  import { parseSubset } from "./receiver.mjs";
103
117
  import { loadPollerConfig } from "./poller-config.mjs";
104
118
 
@@ -119,6 +133,13 @@ const MAX_PR_PAGES = 10;
119
133
  const MAX_REVIEW_PRS = 50;
120
134
  // The hash value marking a PR that left the open list. Cannot collide with a head sha (hex only).
121
135
  const CLOSED_MARKER = "closed";
136
+ // The closer-authority retry bound (issue #231): how many consecutive cycles an INDETERMINATE
137
+ // collaborator-permission lookup may hold the events cursor before the close is dropped loudly.
138
+ // ~20 cycles at the default 60s interval is a real outage, not a blip; see `gate` for the tradeoff.
139
+ const CLOSE_GATE_MAX_ATTEMPTS = 20;
140
+ // The retry counter's own TTL: it measures ONE outage around ONE event, so it decays in a day rather
141
+ // than riding the 35-day cursor family (touchRepo never refreshes it -- see the module header).
142
+ const CLOSE_GATE_TTL_SECONDS = 24 * 3600;
122
143
 
123
144
  /** Thrown by the API helper when the credential's quota is exhausted; carries the reset time in ms. */
124
145
  class RateLimited extends Error {
@@ -151,6 +172,7 @@ export async function startPoller(env = process.env, deps = {}) {
151
172
  fsDeps = {},
152
173
  makeAuth = makeGitHubAuth,
153
174
  makeQueueFn = makeQueue,
175
+ makeResolveGitHubAuthority: makeResolveGitHubAuthorityFn = makeResolveGitHubAuthority,
154
176
  } = deps;
155
177
 
156
178
  const cfg = loadPollerConfig(env, fsDeps);
@@ -173,6 +195,16 @@ export async function startPoller(env = process.env, deps = {}) {
173
195
  ? makeAppInstallationTokenFn(cfg.github, { fetchFn, readFile, now })
174
196
  : async () => (await getAuth()).mintToken());
175
197
 
198
+ // The closer-authority resolver (issue #231), built over the poller's OWN mint above -- the injected
199
+ // factory default, start.mjs's convention. On the app source that mint hands back the CACHED,
200
+ // UNSCOPED installation token (makeAppInstallationTokenFn below), and the resolver's job-shaped
201
+ // `{ repo }` argument is simply ignored by it. That is fine for what this token does here: one
202
+ // read-only permission lookup that never leaves this process. The webhook arm's per-delivery
203
+ // metadata:read narrowing (start.mjs) is the stricter posture; the poller's cache is the deliberate
204
+ // cost tradeoff its own header already records (the credential must read every polled repo anyway),
205
+ // and github-members.mjs names this cache as the recorded fallback to per-delivery minting.
206
+ const resolveCloserAuthority = makeResolveGitHubAuthorityFn({ mintToken, fetchFn });
207
+
176
208
  // Cursor store. ioredis is imported lazily so tests injecting a fake never load the driver. The
177
209
  // client rides out disconnects (ioredis reconnects on its own) -- same posture as the receiver's
178
210
  // queue connection: a long-running producer should survive a Valkey restart.
@@ -198,7 +230,7 @@ export async function startPoller(env = process.env, deps = {}) {
198
230
  if (ownRedis) await redisClient.quit();
199
231
  };
200
232
 
201
- const ctx = { cfg, selfId, fetchFn, redis: redisClient, enqueue, out, now, random };
233
+ const ctx = { cfg, selfId, fetchFn, redis: redisClient, enqueue, out, now, random, resolveCloserAuthority };
202
234
 
203
235
  // The repo set: explicit POLL_REPOS, or the App installation's list (cfg.repos === null only when
204
236
  // the source is app -- poller-config enforces it). An empty discovery is a config error, not an
@@ -493,9 +525,29 @@ async function pollLabelEvents(ctx, api, repo, stats) {
493
525
  for (const ev of fresh) {
494
526
  if (ev.event === "labeled" && ev.issue) {
495
527
  await handleLabeledEvent(ctx, api, repo, ev, stats);
528
+ } else if (ev.event === "closed" && ev.issue && hasCloseTriggers(ctx.cfg?.triggers?.github)) {
529
+ // The coarse hasCloseTriggers guard IS the byte-identity switch (issue #231): with no close
530
+ // rule armed, a `closed` entry takes the same do-nothing path every unhandled event always
531
+ // has -- no PR fetch, no permission traffic, a cycle indistinguishable from a pre-#231 run.
532
+ // An entry with no `issue` field is malformed and skipped the same way, never thrown on.
533
+ try {
534
+ await handleClosedEvent(ctx, api, repo, ev, stats);
535
+ } catch (err) {
536
+ // The scoped catch that keeps a close-gate spell from starving the WHOLE repo: a throw
537
+ // here (an indeterminate closer lookup below its bound, a PR fetch blip) must hold THIS
538
+ // feed's cursor before the failed event -- a monotone cursor cannot skip one entry and
539
+ // come back -- but the comment and pull feeds have their OWN cursors and their own real
540
+ // work, so ending only the events feed for this cycle lets pollRepo continue to them.
541
+ // RateLimited still propagates: the quota is credential-global and pollRepo's caller
542
+ // sleeps the whole roster out, which no per-feed catch may swallow.
543
+ if (err instanceof RateLimited) throw err;
544
+ ctx.out({ event: "poll_close_gate_retry", repo, delivery: `poll-e${ev.id}`, reason: err?.message });
545
+ return;
546
+ }
496
547
  }
497
548
  // Advance ONLY after the event is handled: an enqueue/fetch failure above leaves the cursor on
498
- // the last success, so the retry next cycle resumes at the exact failed event.
549
+ // the last success, so the retry next cycle resumes at the exact failed event. Unmatched and
550
+ // unarmed closes advance past here exactly like every unhandled event always has.
499
551
  await setWithTtl(ctx, k.events, String(ev.id));
500
552
  }
501
553
  }
@@ -527,6 +579,42 @@ async function handleLabeledEvent(ctx, api, repo, ev, stats) {
527
579
  }
528
580
  }
529
581
 
582
+ /**
583
+ * One `closed` event -> the webhook payload it corresponds to -> the unchanged gate (issue #231).
584
+ * handleLabeledEvent's twin, split on the same discriminator: the events feed hands us the ISSUE
585
+ * view, and `issue.pull_request` marks a PR. An issue routes as `issues closed` with the issue object
586
+ * as-is; a PR must route as `pull_request closed`, and the issue view lacks the PR fields the job's
587
+ * target carries (title/body/head/base as the webhook subset shapes them), so the PR object is
588
+ * fetched once per event -- field-for-field parity again, paid only on NEW closed events under an
589
+ * ARMED close rule (the caller's hasCloseTriggers guard). A MERGED PR emits `closed` in this feed
590
+ * too, exactly as the webhook's `closed` action covers merged -- which is what lets a prClose
591
+ * trigger release post-merge work over polling.
592
+ *
593
+ * A failing PR fetch rides the labeled twin's own retry discipline: the throw holds the events
594
+ * cursor and retries next cycle (scoped to this feed since #231). Unbounded on purpose -- unlike an
595
+ * indeterminate LOOKUP, a fetch failure here has no counter, because the one input that could make
596
+ * it permanent (a deleted PR) is a GitHub-support-only operation the labeled path has carried
597
+ * unbounded since it shipped, and a bound would spend its complexity on a case nobody can produce.
598
+ *
599
+ * `sender` carries the closer's LOGIN as well as the id, alone among this module's synthesized
600
+ * payloads: the collaborator-permission lookup is by username because that is the only key GitHub's
601
+ * endpoint takes. Same justification and same obligation as the webhook subset's `sender.login`
602
+ * (parseSubset): it exists to have been asked about, it is never logged, and the job literal keeps
603
+ * `trigger.sender` at `{ id }` alone.
604
+ */
605
+ async function handleClosedEvent(ctx, api, repo, ev, stats) {
606
+ const deliveryId = `poll-e${ev.id}`;
607
+ const sender = { id: ev.actor?.id, login: ev.actor?.login };
608
+ if (ev.issue.pull_request != null) {
609
+ const pr = (await api.get(`/repos/${repo}/pulls/${ev.issue.number}`)).json;
610
+ const payload = { action: "closed", sender, pull_request: pr, repository: { full_name: repo } };
611
+ await gate(ctx, "pull_request", payload, deliveryId, stats);
612
+ } else {
613
+ const payload = { action: "closed", sender, issue: ev.issue, repository: { full_name: repo } };
614
+ await gate(ctx, "issues", payload, deliveryId, stats);
615
+ }
616
+ }
617
+
530
618
  /**
531
619
  * The comment feed: /issues/comments?since=<cursor>, cursor = newest processed updated_at.
532
620
  *
@@ -770,12 +858,59 @@ async function pollReviews(ctx, api, repo, open, stats) {
770
858
 
771
859
  /**
772
860
  * The single choke point every synthesized payload passes through, and deliberately the receiver's
773
- * exact pipeline: parseSubset -> filter (UNCHANGED, same cfg/selfId) -> replica fanout ->
774
- * enqueueGitHubJob, with the receiver's own log shapes. Partial replica failure is idempotent for the
775
- * same reason it is there: the failed cycle re-runs, replicas 1..k-1 dedup on their taken jobIds.
861
+ * exact pipeline: parseSubset -> (closer-authority resolution, close deliveries only) -> filter
862
+ * (UNCHANGED, same cfg/selfId) -> replica fanout -> enqueueGitHubJob, with the receiver's own log
863
+ * shapes. Partial replica failure is idempotent for the same reason it is there: the failed cycle
864
+ * re-runs, replicas 1..k-1 dedup on their taken jobIds.
865
+ *
866
+ * The authority step (issue #231) mirrors the webhook arm's placement exactly -- after the subset,
867
+ * before the gate -- and `wantsCloserAuthority` is the same shared derivation, so only a close
868
+ * delivery an armed close rule matches ever costs a lookup: it is false by construction for every
869
+ * other source this module synthesizes (label, comment, PR diff, review), for a self-close, and for
870
+ * a close nothing wants. A DETERMINATE answer rides into filter as the sixth argument, so a
871
+ * stranger's close is the filter's own `closer-not-allowed` drop and the events cursor advances
872
+ * past it -- an unauthorized close never wedges the feed.
873
+ *
874
+ * An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (503, forge
875
+ * redelivers) has no analogue here -- the poller IS its own redelivery. So: bounded retry, then a
876
+ * loud skip. Below the bound this THROWS, on purpose, into pollLabelEvents' scoped catch: the cycle
877
+ * logs `poll_close_gate_retry`, the events cursor is still sitting BEFORE this event (the caller
878
+ * advances it only after a handler returns), so the next cycle retries exactly this close -- and
879
+ * only the EVENTS feed ends for the cycle, because a monotone cursor cannot skip an entry and come
880
+ * back, while the comment and pull feeds run on their own cursors and must not starve behind a
881
+ * close-gate spell. The attempt counter lives in redis (INCR + its own short TTL), not in memory,
882
+ * so a restart mid-outage cannot reset the bound. AT the bound the close is dropped WITHOUT
883
+ * enqueueing and the cursor advances: one close dropped loudly (`poll_close_gate_gave_up`, with the
884
+ * delivery id an operator can act on) beats later label and close events wedged forever behind a
885
+ * lookup that may never come back -- after ~20 cycles this is an outage, not a blip. A crash
886
+ * between the give-up log and the cursor write re-logs the give-up once on restart (attempt 21):
887
+ * self-limiting, and preferable to advancing before the operator has a line to act on. The counter
888
+ * is not deleted on a determinate answer; its TTL decays it, and the cursor has moved past the
889
+ * delivery id for good.
776
890
  */
777
891
  async function gate(ctx, eventName, payload, deliveryId, stats) {
778
- const result = filter(eventName, parseSubset(payload), ctx.cfg, ctx.selfId, deliveryId);
892
+ const subset = parseSubset(payload);
893
+
894
+ let closerAuthorized;
895
+ if (wantsCloserAuthority(eventName, subset, ctx.cfg?.triggers?.github, ctx.selfId)) {
896
+ const repo = subset.repository?.full_name;
897
+ const resolved = await ctx.resolveCloserAuthority(repo, subset.sender?.login);
898
+ if (resolved.indeterminate) {
899
+ const counterKey = `poll:${repo}:close-gate:${deliveryId}`;
900
+ const attempts = await ctx.redis.incr(counterKey);
901
+ await ctx.redis.expire(counterKey, CLOSE_GATE_TTL_SECONDS);
902
+ if (attempts < CLOSE_GATE_MAX_ATTEMPTS) {
903
+ // The reason names the lookup, never the actor (no-pii-in-logs): resolver reasons are
904
+ // fixed tokens, and this message becomes pollRepo's poll_repo_failed line.
905
+ throw new Error(`closer permission lookup indeterminate (${resolved.indeterminate})`);
906
+ }
907
+ ctx.out({ event: "poll_close_gate_gave_up", repo, delivery: deliveryId, reason: resolved.indeterminate });
908
+ return;
909
+ }
910
+ closerAuthorized = resolved.authorized;
911
+ }
912
+
913
+ const result = filter(eventName, subset, ctx.cfg, ctx.selfId, deliveryId, closerAuthorized);
779
914
  if (!result.enqueue) {
780
915
  ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
781
916
  return;
package/src/receiver.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { makeVerifiedHandler } from "./verify.mjs";
23
- import { filter } from "./filter.mjs";
23
+ import { filter, wantsCloserAuthority } from "./filter.mjs";
24
24
  import { enqueueForgeJob, enqueueGitHubJob, enqueueGitLabJob } from "@edgehero/pi-dispatch/queue";
25
25
  import { filterGitLab } from "./filter-gitlab.mjs";
26
26
  import { parseGitLabSubset } from "./gitlab-subset.mjs";
@@ -51,7 +51,14 @@ export function parseSubset(payload) {
51
51
  const pr = payload.pull_request;
52
52
  return {
53
53
  action: payload.action,
54
- sender: { id: payload.sender?.id },
54
+ // `login` is CARRIED since issue #231, where this subset deliberately excluded it before. It is
55
+ // personal data, and it is here for exactly one consumer -- the CLOSER's collaborator-permission
56
+ // lookup, which is by username because that is the only key GitHub's endpoint takes. Same
57
+ // justification, and same obligation, as `sender.login` in the Forgejo subset: it exists to have
58
+ // been asked about, it is never logged (no-pii-in-logs covers everything the resolution path
59
+ // writes down), and it is never enqueued -- the filter's job literal keeps `trigger.sender` at
60
+ // `{ id }` alone.
61
+ sender: { id: payload.sender?.id, login: payload.sender?.login },
55
62
  issue: {
56
63
  number: payload.issue?.number,
57
64
  title: payload.issue?.title,
@@ -127,12 +134,16 @@ export function parseSubset(payload) {
127
134
  * undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
128
135
  * failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
129
136
  */
130
- export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null }) {
137
+ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority }) {
131
138
  // Built only when the deployment serves GitHub. Construction is not free of the secret either: the
132
139
  // `new Webhooks({ secret })` inside makeVerifiedHandler throws "options.secret required" on an absent
133
140
  // one, so not building the arm is what lets a github-free deployment legitimately have no secret --
134
141
  // no placeholder, nothing papered over, and no handler holding a secret nobody chose.
135
- const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log }) : null;
142
+ //
143
+ // `resolveAuthority` is the GITHUB closer resolver (issue #231), riding top-level beside `selfId`
144
+ // because github's dependencies always have -- the other forges bundle theirs in per-forge objects.
145
+ // Optional, because only a close delivery an armed close rule matches ever consults it.
146
+ const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) : null;
136
147
 
137
148
  // A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
138
149
  // it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
@@ -204,8 +215,15 @@ async function fanout(job, enqueue) {
204
215
  * The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
205
216
  * request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
206
217
  * precondition D2 guarantees before it runs.
218
+ *
219
+ * One step the pre-#231 arm did not have, and the ONLY GitHub path that pays a network call before the
220
+ * gate: a close delivery an armed close rule matches has the CLOSER's authority resolved here, between
221
+ * verification and the gate, because the payload carries no association for the closer at all
222
+ * (github-members.mjs). `wantsCloserAuthority` is the same findCloseRule derivation the filter's route
223
+ * uses, so a lookup is never spent on a delivery the gate then ignores -- every label, comment, PR and
224
+ * review delivery, and every close nothing wants, stays payload-only and byte-identical to before.
207
225
  */
208
- function makeGitHubHandler({ queue, selfId, cfg, log }) {
226
+ function makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) {
209
227
  return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
210
228
  let subset;
211
229
  try {
@@ -214,7 +232,30 @@ function makeGitHubHandler({ queue, selfId, cfg, log }) {
214
232
  return respond(res, 400, { error: "invalid-json" });
215
233
  }
216
234
 
217
- const result = filter(event, subset, cfg, selfId, delivery);
235
+ let closerAuthorized;
236
+ if (wantsCloserAuthority(event, subset, cfg?.triggers?.github, selfId)) {
237
+ if (!resolveAuthority) {
238
+ // Defensive only: unreachable in a wired receiver -- start.mjs hard-fails on github auth
239
+ // before `/` is ever mounted, and the resolver is built over that same auth object. Fail
240
+ // closed but RETRYABLE, because a wiring fault must not read on the wire as a stranger
241
+ // being correctly refused.
242
+ return respond(res, 503, { error: "permission-lookup-failed" });
243
+ }
244
+ const resolved = await resolveAuthority(subset.repository?.full_name, subset.sender?.login);
245
+ if (resolved.indeterminate) {
246
+ // The reason names the lookup, never the actor -- `sender.login` is personal data and exists
247
+ // here only to have been asked about (no-pii-in-logs).
248
+ log?.({ event: "github_permission_lookup_failed", delivery, reason: resolved.indeterminate });
249
+ // 503: GitHub redelivers, the GUID jobId coalesces the retry. GitHub's auto-redelivery is
250
+ // weaker than GitLab's, so a lookup outage outlasting the window can lose a webhook-only
251
+ // deployment's close -- the polling transport's closed source is the backstop where the
252
+ // poller runs, with its own bounded retry against the same lookup.
253
+ return respond(res, 503, { error: "permission-lookup-failed" });
254
+ }
255
+ closerAuthorized = resolved.authorized;
256
+ }
257
+
258
+ const result = filter(event, subset, cfg, selfId, delivery, closerAuthorized);
218
259
  if (!result.enqueue) {
219
260
  log?.({ event: "dropped", delivery, reason: result.reason });
220
261
  return respond(res, 204);
package/src/start.mjs CHANGED
@@ -15,8 +15,14 @@
15
15
  * issue #99) -- an arm whose endpoint does not exist has no guard to arm, and the invariant that matters is
16
16
  * that the two are decided by the SAME property, never separately.
17
17
  *
18
- * The receiver resolves identity ONLY. It holds no per-repo tokens: minting a scoped token is the
19
- * worker's job, per container, per job (CONST-TOKEN-SCOPED-PER-JOB).
18
+ * The receiver holds no JOB credentials. Minting a job's scoped token is the worker's business, per
19
+ * container, per job (CONST-TOKEN-SCOPED-PER-JOB), and that claim keeps its full force. What the
20
+ * receiver ALSO uses, since issue #231, is a per-close-delivery token for a permission QUESTION --
21
+ * does the account that closed this item hold write access (CONST-TRIGGER-AUTHOR-GATE's close arm).
22
+ * On the App source that token is minted repo-scoped AND narrowed to metadata:read (the mint passes
23
+ * the narrowing through, so a leak of it can write nothing); on pat/gh it is the operator's own
24
+ * standing token, already resident in this process's env, used for one read. Never a job credential
25
+ * on any source: no container ever receives it, and it exists only for the lookup it served.
20
26
  *
21
27
  * DES-ADMIN-VIA-PI-EXTENSION: this process exposes exactly one surface, the webhook handler. There is no
22
28
  * admin, dashboard, or admin-extension route here -- the admin surface is a pi extension in the
@@ -36,6 +42,7 @@ import { resolveAzureSelfId } from "@edgehero/pi-dispatch/azure-identity";
36
42
  import { makeResolveAuthority } from "./gitlab-members.mjs";
37
43
  import { makeResolveForgejoAuthority } from "./forgejo-members.mjs";
38
44
  import { makeResolveAzureAuthority } from "./azure-members.mjs";
45
+ import { makeResolveGitHubAuthority } from "./github-members.mjs";
39
46
  import { makeQueue } from "@edgehero/pi-dispatch/queue";
40
47
  import { parseConnection } from "@edgehero/pi-dispatch/connection";
41
48
 
@@ -55,6 +62,7 @@ export async function startReceiver(
55
62
  makeResolveForgejoAuthority: makeResolveForgejoAuthorityFn = makeResolveForgejoAuthority,
56
63
  resolveAzureSelfId: resolveAzureSelfIdFn = resolveAzureSelfId,
57
64
  makeResolveAzureAuthority: makeResolveAzureAuthorityFn = makeResolveAzureAuthority,
65
+ makeResolveGitHubAuthority: makeResolveGitHubAuthorityFn = makeResolveGitHubAuthority,
58
66
  } = {},
59
67
  ) {
60
68
  // Single-object log line: `makeReceiver` calls `log?.({ event, ... })`, so the sink takes ONE object.
@@ -74,12 +82,25 @@ export async function startReceiver(
74
82
  // harness's own completion comments would re-trigger jobs forever. Read that as: never make one of these
75
83
  // two conditions unconditional without the other.
76
84
  let selfId;
85
+ let resolveAuthority;
77
86
  if (cfg.servesGithub) {
78
87
  // HARD-FAIL identity resolution -- NO try/catch. A throw here (absent/bad github auth, unresolvable
79
88
  // id) propagates and the server below is never created: without selfId the bot-loop guard cannot
80
89
  // run, so refusing to boot is the only safe outcome.
81
- ({ selfId } = await makeAuth(cfg.github));
90
+ //
91
+ // The WHOLE auth object is kept, not just selfId: the closer resolver below mints its per-delivery
92
+ // metadata-read token through this same object (issue #231), so identity and mint capability stay
93
+ // one credential decision -- an arm that resolved its identity is exactly the arm that can answer
94
+ // a permission question. This is also why the github handler's missing-resolver 503 is unreachable
95
+ // in a wired receiver: a boot that fails here mounts no `/` at all.
96
+ const auth = await makeAuth(cfg.github);
97
+ selfId = auth.selfId;
82
98
  log({ event: "self_identity", id: selfId, source: cfg.github.source });
99
+ // The lookup token asks the mint to narrow to metadata:read -- the App path honors it GitHub-side,
100
+ // so the token this process holds for the permission question cannot write even if leaked; the
101
+ // pat/gh sources cannot narrow (the operator's standing token is what it is, and it already lives
102
+ // in this process's env), which is why the header above words the claim per source.
103
+ resolveAuthority = makeResolveGitHubAuthorityFn({ mintToken: (job) => auth.mintToken({ ...job, permissions: { metadata: "read" } }) });
83
104
  } else {
84
105
  // Said out loud, because the alternative is an operator staring at a label trigger that does nothing.
85
106
  // The two ways out are the two signals `decideServesGithub` reads, so the line names both.
@@ -137,7 +158,7 @@ export async function startReceiver(
137
158
  };
138
159
  }
139
160
 
140
- const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure });
161
+ const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
141
162
  const server = createServer(handler);
142
163
  server.listen(cfg.port, cfg.bind, () =>
143
164
  log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl }),