@edgehero/pi-dispatch-receiver 1.2.0 → 1.4.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/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 }),