@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/package.json +2 -2
- package/src/azure-members.mjs +4 -1
- package/src/close.mjs +34 -0
- package/src/config.mjs +34 -9
- package/src/filter-azure.mjs +4 -0
- package/src/filter-forgejo.mjs +107 -0
- package/src/filter-gitlab.mjs +112 -2
- package/src/filter.mjs +167 -1
- package/src/forgejo-members.mjs +4 -1
- package/src/forgejo-subset.mjs +14 -2
- package/src/github-members.mjs +140 -0
- package/src/gitlab-members.mjs +4 -1
- package/src/poller.mjs +143 -8
- package/src/receiver.mjs +47 -6
- package/src/start.mjs +25 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
*
|
|
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
|
-
|
|
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 }),
|