@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/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 +11 -0
- package/src/filter-forgejo.mjs +112 -0
- package/src/filter-gitlab.mjs +117 -2
- package/src/filter.mjs +173 -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/filter.mjs
CHANGED
|
@@ -33,9 +33,19 @@
|
|
|
33
33
|
* All three are hard-coded here, never config-optional -- an ungated auto-trigger is an unbounded paid run
|
|
34
34
|
* started by whoever opens a fork PR (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
|
|
35
35
|
*
|
|
36
|
+
* CLOSE ROUTES (issue #231): `issues.closed` and `pull_request.closed` route over the close-trigger
|
|
37
|
+
* groups (`triggers.issue` / `triggers.prClose`) via the ONE shared derivation in ./close.mjs. Their
|
|
38
|
+
* gate is `closerAuthorized`, the OPTIONAL sixth parameter: the receiver's pre-resolved answer to
|
|
39
|
+
* "does the account that closed this item hold write access". It is a parameter because this module
|
|
40
|
+
* is pure and that answer needs a network lookup the receiver performs (only when
|
|
41
|
+
* `wantsCloserAuthority` below says a rule wants this close). Strict `=== true` -- anything else,
|
|
42
|
+
* absence included, fails closed -- so every existing five-argument call site behaves byte-identically:
|
|
43
|
+
* their routes never read the value.
|
|
44
|
+
*
|
|
36
45
|
* `selfId` is the numeric id of whichever identity posts as the harness (the App's bot user, or the PAT
|
|
37
46
|
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
38
47
|
*/
|
|
48
|
+
import { findCloseRule } from "./close.mjs";
|
|
39
49
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
40
50
|
|
|
41
51
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
@@ -48,8 +58,17 @@ const REVIEW_ACTION = "review_submitted";
|
|
|
48
58
|
const REVIEW_EVENT_ACTION = "submitted";
|
|
49
59
|
// PR_AUTO_ACTIONS is deliberately NOT extended with REVIEW_ACTION: it exists only to select the
|
|
50
60
|
// `pr-author-not-allowed` drop reason, and the review path has reasons of its own.
|
|
61
|
+
//
|
|
62
|
+
// GitHub's close word (issue #231), byte-equal to PR_CLOSE_ACTIONS.github in the shared forge table
|
|
63
|
+
// (worker/src/triggers.mjs). Spelled here rather than imported, deliberately: this file is the GITHUB
|
|
64
|
+
// gate and already spells GitHub's action vocabulary in its own Sets above -- pulling the per-forge
|
|
65
|
+
// table into it would suggest this gate routes other forges' words, which it never does (each forge
|
|
66
|
+
// has a filter of its own, and the config grouping already consumed the table to build the groups this
|
|
67
|
+
// file reads). The word cannot drift: it is what the wire sends, and the route arm below matches the
|
|
68
|
+
// raw payload action against it.
|
|
69
|
+
const CLOSE_ACTION = "closed";
|
|
51
70
|
|
|
52
|
-
export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
71
|
+
export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthorized) {
|
|
53
72
|
// (0) Fail-closed on identity. MUST precede the self compare -- see header, ordering constraint.
|
|
54
73
|
if (typeof subset?.sender?.id !== "number") {
|
|
55
74
|
return { enqueue: false, reason: "missing-sender-id" };
|
|
@@ -85,6 +104,16 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
85
104
|
// text the harness has already been paid to read, and a dismissal removes a verdict rather than
|
|
86
105
|
// stating one.
|
|
87
106
|
resolved = routePullRequest(subset, triggers, REVIEW_ACTION);
|
|
107
|
+
} else if (eventName === "issues" && action === CLOSE_ACTION) {
|
|
108
|
+
// The issue close-trigger route (issue #231). The closer-authority gate lives INSIDE the route,
|
|
109
|
+
// after the rule match -- see routeClose for why it cannot sit up here.
|
|
110
|
+
resolved = routeIssueClose(subset, triggers, closerAuthorized);
|
|
111
|
+
} else if (eventName === "pull_request" && action === CLOSE_ACTION) {
|
|
112
|
+
// `closed` is deliberately NOT in PR_ACTIONS: a close rule gates on the CLOSER's resolved write
|
|
113
|
+
// access while every other PR rule gates on the author's association or a collaborator's label,
|
|
114
|
+
// and one route cannot gate on two different actors -- the same line config.mjs's prClose split
|
|
115
|
+
// draws, which is what makes `triggers.prClose` the only list this arm ever reads.
|
|
116
|
+
resolved = routePrClose(subset, triggers, closerAuthorized);
|
|
88
117
|
} else {
|
|
89
118
|
return { enqueue: false, reason: "unhandled-event" };
|
|
90
119
|
}
|
|
@@ -121,6 +150,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
121
150
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
122
151
|
// basename(folder) restraint already exists to prevent.
|
|
123
152
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
153
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
154
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
155
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
156
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
157
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
124
158
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
125
159
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
126
160
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
@@ -166,6 +200,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
166
200
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
167
201
|
image: rule.image,
|
|
168
202
|
skillsDir: rule.skillsDir,
|
|
203
|
+
secrets: rule.secrets,
|
|
204
|
+
secretsProfile: rule.secretsProfile,
|
|
169
205
|
instructions: rule.instructions,
|
|
170
206
|
resume: rule.resume,
|
|
171
207
|
replicas: rule.replicas,
|
|
@@ -223,6 +259,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
223
259
|
packages: triggers.comment.packages,
|
|
224
260
|
image: triggers.comment.image,
|
|
225
261
|
skillsDir: triggers.comment.skillsDir,
|
|
262
|
+
secrets: triggers.comment.secrets,
|
|
263
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
226
264
|
instructions: triggers.comment.instructions,
|
|
227
265
|
resume: triggers.comment.resume,
|
|
228
266
|
replicas: triggers.comment.replicas,
|
|
@@ -318,6 +356,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
318
356
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
319
357
|
image: rule.image,
|
|
320
358
|
skillsDir: rule.skillsDir,
|
|
359
|
+
secrets: rule.secrets,
|
|
360
|
+
secretsProfile: rule.secretsProfile,
|
|
321
361
|
instructions: rule.instructions,
|
|
322
362
|
resume: rule.resume,
|
|
323
363
|
replicas: rule.replicas,
|
|
@@ -347,3 +387,135 @@ function buildPrTarget(pr) {
|
|
|
347
387
|
if (pr.base) target.base = { ref: pr.base.ref };
|
|
348
388
|
return target;
|
|
349
389
|
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Issue close path (issue #231). `matched.number` is the CLOSED ITEM's number, not the rule's
|
|
393
|
+
* narrowing -- an unnarrowed rule fires for any issue, and the decision record must still name which
|
|
394
|
+
* one spent it, or a once disarm is unexplainable back to an item.
|
|
395
|
+
*/
|
|
396
|
+
function routeIssueClose(subset, triggers, closerAuthorized) {
|
|
397
|
+
const number = subset.issue?.number;
|
|
398
|
+
// Integer or refuse, the PR arm's missing-object guard sharpened for BOTH arms: parseSubset always
|
|
399
|
+
// fabricates `subset.issue` as an object, so object presence proves nothing here, and an UNNARROWED
|
|
400
|
+
// rule matches on the action alone -- without this line a signed body replayed under a swapped
|
|
401
|
+
// event header enqueues a paid job whose target has no number at all (and a crafted string number
|
|
402
|
+
// would ride verbatim into event.json). GitHub only ever sends integers; anything else is a shape
|
|
403
|
+
// this route must not spend on.
|
|
404
|
+
if (!Number.isInteger(number)) {
|
|
405
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
406
|
+
}
|
|
407
|
+
return routeClose(
|
|
408
|
+
triggers.issue,
|
|
409
|
+
number,
|
|
410
|
+
closerAuthorized,
|
|
411
|
+
(rule) => ({ index: rule.index, type: "issue", action: CLOSE_ACTION, number, ...(rule.once === true && { once: true }) }),
|
|
412
|
+
() => ({ type: "issue", number, title: subset.issue?.title, body: subset.issue?.body }),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* PR close path (issue #231), the same guard shape routePullRequest opens with: no pull_request
|
|
418
|
+
* object is a malformed delivery, refused before any rule is consulted. `matched` carries no number
|
|
419
|
+
* here -- the target does, exactly as on every other pull_request match.
|
|
420
|
+
*/
|
|
421
|
+
function routePrClose(subset, triggers, closerAuthorized) {
|
|
422
|
+
const pr = subset.pull_request;
|
|
423
|
+
// Object AND integer number, the issue arm's rule: a pull_request object without an integer
|
|
424
|
+
// number is the same malformed delivery wearing a shape, and the dedup key would otherwise read
|
|
425
|
+
// `#undefined`. One reason for both cases -- to an operator they are one fact.
|
|
426
|
+
if (pr === null || typeof pr !== "object" || !Number.isInteger(pr.number)) {
|
|
427
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
428
|
+
}
|
|
429
|
+
return routeClose(
|
|
430
|
+
triggers.prClose,
|
|
431
|
+
pr.number,
|
|
432
|
+
closerAuthorized,
|
|
433
|
+
(rule) => ({ index: rule.index, type: "pull_request", action: CLOSE_ACTION, ...(rule.once === true && { once: true }) }),
|
|
434
|
+
() => buildPrTarget(pr),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The shared close route: one body for both arms, driving the ONE rule derivation in ./close.mjs --
|
|
440
|
+
* the same findCloseRule call `wantsCloserAuthority` makes, so "which close does a rule want" can
|
|
441
|
+
* never drift between the receiver's pre-lookup question and this routing answer.
|
|
442
|
+
*
|
|
443
|
+
* `once` rides `matched` only when literally true, mirroring how every optional job field is absent
|
|
444
|
+
* rather than present-and-false: matched is the downstream disarm signal, and its consumers test
|
|
445
|
+
* presence, not truthiness.
|
|
446
|
+
*/
|
|
447
|
+
function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
448
|
+
const found = findCloseRule(rules, CLOSE_ACTION, number);
|
|
449
|
+
if (found.rule === undefined) {
|
|
450
|
+
return { enqueue: false, reason: found.reason };
|
|
451
|
+
}
|
|
452
|
+
// The authority gate sits INSIDE the route and AFTER the match, deliberately. The receiver performs
|
|
453
|
+
// the closer's permission lookup only for a delivery some close rule actually wants (that is what
|
|
454
|
+
// `wantsCloserAuthority` exists for), so a gate BEFORE the rule loop would emit `closer-not-allowed`
|
|
455
|
+
// for closes no rule matches -- a security token no lookup ever backed, telling an operator an
|
|
456
|
+
// authority decision was made about a delivery nobody ever resolved. A close nothing wants is
|
|
457
|
+
// `no-matching-close-trigger`, whoever closed it.
|
|
458
|
+
//
|
|
459
|
+
// Strict `!== true`, never truthiness: the value is the receiver's RESOLVED answer, and anything
|
|
460
|
+
// else reaching here -- undefined from an unwired caller, an indeterminate lookup, a "true" string
|
|
461
|
+
// or a count from a parse bug -- is a wiring fault that must fail CLOSED, the direction every gate
|
|
462
|
+
// in this file already takes (CONST-TRIGGER-AUTHOR-GATE).
|
|
463
|
+
if (closerAuthorized !== true) {
|
|
464
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
465
|
+
}
|
|
466
|
+
const rule = found.rule;
|
|
467
|
+
return {
|
|
468
|
+
enqueue: true,
|
|
469
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
470
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
471
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
472
|
+
image: rule.image,
|
|
473
|
+
skillsDir: rule.skillsDir,
|
|
474
|
+
secrets: rule.secrets,
|
|
475
|
+
secretsProfile: rule.secretsProfile,
|
|
476
|
+
instructions: rule.instructions,
|
|
477
|
+
resume: rule.resume,
|
|
478
|
+
replicas: rule.replicas,
|
|
479
|
+
matched: matchedFor(rule),
|
|
480
|
+
target: targetFor(),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Does this forge group arm any close trigger at all? Pure, for the receiver/poller arm: whether the
|
|
486
|
+
* closer-authority machinery is worth wiring up for a delivery stream is a per-group fact, and
|
|
487
|
+
* deriving it anywhere else would be a second spelling of "which groups are the close groups".
|
|
488
|
+
*/
|
|
489
|
+
export function hasCloseTriggers(group) {
|
|
490
|
+
return group?.issue?.length > 0 || group?.prClose?.length > 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Should the receiver spend a permission lookup on this delivery before calling `filter`? Pure, and
|
|
495
|
+
* the SINGLE derivation shared with the route above: it calls the same findCloseRule over the same
|
|
496
|
+
* groups, so the pre-lookup question and the routing answer cannot drift -- a delivery never costs a
|
|
497
|
+
* lookup the route then ignores, and never routes a close the lookup never gated (./close.mjs's
|
|
498
|
+
* header names exactly this hazard).
|
|
499
|
+
*/
|
|
500
|
+
export function wantsCloserAuthority(eventName, subset, group, selfId) {
|
|
501
|
+
// Filter's own step-0/1 guards, replicated FIRST and in the same order (fail-closed identity, then
|
|
502
|
+
// the unconditional bot-loop guard): the harness closing its own issue -- the natural last act of
|
|
503
|
+
// the very flow a close trigger arms -- must not cost a lookup, and an INDETERMINATE lookup on a
|
|
504
|
+
// self-delivery would 503 (so: redeliver, retry, loop) traffic the filter would only ever drop as
|
|
505
|
+
// `self`.
|
|
506
|
+
if (typeof subset?.sender?.id !== "number") return false;
|
|
507
|
+
if (subset.sender.id === selfId) return false;
|
|
508
|
+
if (eventName === "issues" && subset.action === CLOSE_ACTION) {
|
|
509
|
+
// The routes' own shape guards, replicated for the same one-derivation reason as the step-0/1
|
|
510
|
+
// guards above: an UNNARROWED rule matches an undefined number, so without these lines a
|
|
511
|
+
// degenerate payload costs a token mint and a lookup the route then drops -- the exact "lookup
|
|
512
|
+
// the route ignores" this function exists to make impossible.
|
|
513
|
+
if (!Number.isInteger(subset.issue?.number)) return false;
|
|
514
|
+
return findCloseRule(group?.issue, CLOSE_ACTION, subset.issue.number).rule !== undefined;
|
|
515
|
+
}
|
|
516
|
+
if (eventName === "pull_request" && subset.action === CLOSE_ACTION) {
|
|
517
|
+
if (subset.pull_request === null || typeof subset.pull_request !== "object" || !Number.isInteger(subset.pull_request.number)) return false;
|
|
518
|
+
return findCloseRule(group?.prClose, CLOSE_ACTION, subset.pull_request.number).rule !== undefined;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -77,7 +77,10 @@ export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch })
|
|
|
77
77
|
try {
|
|
78
78
|
body = await res.json();
|
|
79
79
|
} catch (err) {
|
|
80
|
-
|
|
80
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
81
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
82
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
83
|
+
return { indeterminate: "collaborator permission lookup returned unparseable JSON" };
|
|
81
84
|
}
|
|
82
85
|
const permission = body?.permission;
|
|
83
86
|
if (typeof permission !== "string" || permission === "") {
|
package/src/forgejo-subset.mjs
CHANGED
|
@@ -44,6 +44,11 @@ const ISSUE_ACTIONS = {
|
|
|
44
44
|
opened: "opened",
|
|
45
45
|
reopened: "reopened",
|
|
46
46
|
label_updated: "labeled",
|
|
47
|
+
// The close trigger (issue #231) made `closed` actionable, so it moved OUT of IGNORED_ACTIONS
|
|
48
|
+
// below. It sits in BOTH maps deliberately: the maps are selected by EVENT NAME, so `closed` on
|
|
49
|
+
// `issues` and `closed` on `pull_request` are two different routes sharing a spelling, not one
|
|
50
|
+
// word listed twice -- the never-in-two rule below is about a map versus the ignored set.
|
|
51
|
+
closed: "closed",
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
const PR_ACTIONS = {
|
|
@@ -51,12 +56,19 @@ const PR_ACTIONS = {
|
|
|
51
56
|
reopened: "reopened",
|
|
52
57
|
label_updated: "labeled",
|
|
53
58
|
synchronized: "synchronize",
|
|
59
|
+
closed: "closed", // issue #231 -- see the note on ISSUE_ACTIONS
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Recognised, and deliberately not actionable. Named so the drop reason can say which it was.
|
|
64
|
+
*
|
|
65
|
+
* DISJOINT from both action maps, and that is an invariant rather than an accident: `mapAction`
|
|
66
|
+
* would happily route a word that also sat here, and this set's claim of "ignored" would then be a
|
|
67
|
+
* lie the drop reason repeats to an operator. `closed` lived here until issue #231 made closes
|
|
68
|
+
* routable; it MOVED into the maps rather than gaining a twin.
|
|
69
|
+
*/
|
|
57
70
|
const IGNORED_ACTIONS = new Set([
|
|
58
71
|
"label_cleared", // removing a label must never start a paid run
|
|
59
|
-
"closed",
|
|
60
72
|
"edited",
|
|
61
73
|
"assigned",
|
|
62
74
|
"unassigned",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a GitHub CLOSER's repository permission -- the enforcement half of `CONST-TRIGGER-AUTHOR-GATE`'s
|
|
3
|
+
* close arm on the GitHub side.
|
|
4
|
+
*
|
|
5
|
+
* Every other GitHub route reads an association straight off the payload and pays no lookup. A close
|
|
6
|
+
* cannot: an issue's own author can close it with no write access whatsoever, and on a pull request
|
|
7
|
+
* `author_association` names the AUTHOR, a different person from the closer -- the review inversion a
|
|
8
|
+
* second time. The payload carries NO association for the closer at all, so the closer is resolved by
|
|
9
|
+
* login through `GET /repos/{owner}/{repo}/collaborators/{username}/permission`, whose legacy
|
|
10
|
+
* `permission` field answers `"admin"|"write"|"read"|"none"`. `admin` and `write` are exactly the levels
|
|
11
|
+
* that can push a branch, which is the property the constitution actually requires.
|
|
12
|
+
*
|
|
13
|
+
* The shape of this module is the Forgejo resolver's, for the same three reasons, restated rather than
|
|
14
|
+
* cross-referenced:
|
|
15
|
+
* - it runs in the RECEIVER, between verification and the gate, so `filter` stays pure, total and
|
|
16
|
+
* offline-testable -- a fetch inside the gate would make the security-critical decision untestable
|
|
17
|
+
* without a server;
|
|
18
|
+
* - it runs AFTER verification -- and, unlike every sibling resolver, only for a close delivery an
|
|
19
|
+
* armed close rule actually matches (`wantsCloserAuthority`), so every other GitHub path stays
|
|
20
|
+
* payload-only and lookup-free, and an unauthenticated flood cannot make this project call GitHub;
|
|
21
|
+
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
22
|
+
* answers. A 404 is determinate and refuses; anything unrecognised is INDETERMINATE and the receiver
|
|
23
|
+
* answers 503, so GitHub redelivers and the stable `X-GitHub-Delivery` GUID dedups the retry.
|
|
24
|
+
* Collapsing indeterminate to "deny" would drop real work during an outage behind a 204 that looks
|
|
25
|
+
* exactly like a stranger being correctly refused.
|
|
26
|
+
*
|
|
27
|
+
* One difference from every sibling: there is no operator token in config to close over. The GitHub arm
|
|
28
|
+
* holds the boot-time auth object instead, so the credential is MINTED per call. On the App source the
|
|
29
|
+
* caller asks the mint to scope it to the one repository AND narrow it to metadata:read, so what this
|
|
30
|
+
* function holds cannot write even if leaked; on pat/gh no narrowing exists (the operator's standing
|
|
31
|
+
* token is what it is, and it already lives in this process's env), so what the wiring buys there is
|
|
32
|
+
* one read with a credential the receiver held anyway. Never a job credential on any source, and no
|
|
33
|
+
* container ever receives it (`CONST-TOKEN-SCOPED-PER-JOB`, whose per-job wording start.mjs's header
|
|
34
|
+
* records). Closes are rare, so per-delivery minting is the recorded cost; the poller's token cache is
|
|
35
|
+
* the fallback if that changes.
|
|
36
|
+
*
|
|
37
|
+
* The username is required (the endpoint takes no numeric id) and is never logged or returned. One
|
|
38
|
+
* honest bound on that claim, shared with every sibling resolver: the network-throw arm returns
|
|
39
|
+
* `fetchFailureReason(err)`, and undici's error messages carry the HOST, never the request path --
|
|
40
|
+
* so the guarantee on that one arm rests on undici's phrasing rather than a fixed token, exactly as
|
|
41
|
+
* it does in the forgejo/gitlab/azure resolvers. Every arm this module authors itself is a fixed
|
|
42
|
+
* string.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
46
|
+
|
|
47
|
+
/** GitHub's API root. Not configurable: this arm serves github.com, the forges with a host knob have their own resolvers. */
|
|
48
|
+
const API_ROOT = "https://api.github.com";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
52
|
+
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
53
|
+
* draws. Two members is the COMPLETE honest mapping, not a shortcut: GitHub folds `maintain` into `write`
|
|
54
|
+
* and `triage` into `read` in this legacy field, so every role that can push already reads as one of
|
|
55
|
+
* these. The response's richer `role_name` is deliberately not read -- it can name Ultimate custom roles
|
|
56
|
+
* this code has no table to rank, and ranking them wrongly fails OPEN.
|
|
57
|
+
*/
|
|
58
|
+
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the resolver. `mintToken` is the boot auth object's own minter (it takes a job-shaped `{ repo }`
|
|
62
|
+
* and scopes the token to it); `fetchFn` is injected so the whole module is testable offline.
|
|
63
|
+
*
|
|
64
|
+
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
65
|
+
* -- the same shape every forge's resolver returns.
|
|
66
|
+
*/
|
|
67
|
+
export function makeResolveGitHubAuthority({ mintToken, fetchFn = fetch }) {
|
|
68
|
+
return async function resolveAuthority(repoFullName, login) {
|
|
69
|
+
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
70
|
+
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
71
|
+
// the answer would be attributed to the wrong repository or the wrong person. Whitespace joins the
|
|
72
|
+
// refused set here because a GitHub login can never carry it, so its presence is a malformed payload,
|
|
73
|
+
// not a user. Determinate refusals, before the mint: nothing is asked about, so nothing is spent.
|
|
74
|
+
const repo = typeof repoFullName === "string" ? repoFullName.split("/") : [];
|
|
75
|
+
// The repo halves get the login's charset discipline PLUS the dot-segment refusal: a half of
|
|
76
|
+
// exactly "." or ".." would URL-normalize the request onto a different endpoint than the one
|
|
77
|
+
// this function believes it is asking (a repo NAME may contain dots -- "next.js" is real -- so
|
|
78
|
+
// only the two pure dot segments are refused, never dots inside a name).
|
|
79
|
+
const badHalf = (h) => !h || h === "." || h === ".." || /[/?#\s]/.test(h);
|
|
80
|
+
if (repo.length !== 2 || badHalf(repo[0]) || badHalf(repo[1]) || typeof login !== "string" || login === "" || /[/?#\s]/.test(login)) {
|
|
81
|
+
// Not a lookup failure -- the payload never named a repository and an actor we could ask about.
|
|
82
|
+
return { authorized: false };
|
|
83
|
+
}
|
|
84
|
+
// Minted per call, repo-scoped (the auth object's minter reads `job.repo`). A mint failure is
|
|
85
|
+
// INDETERMINATE -- the closer's standing was never established -- and the reason is a fixed token,
|
|
86
|
+
// never the thrown message: configError texts name auth sources and key paths, which have no
|
|
87
|
+
// business in a per-delivery log line.
|
|
88
|
+
let token;
|
|
89
|
+
try {
|
|
90
|
+
token = await mintToken({ repo: repoFullName });
|
|
91
|
+
} catch {
|
|
92
|
+
return { indeterminate: "token-mint-failed" };
|
|
93
|
+
}
|
|
94
|
+
const url = `${API_ROOT}/repos/${encodeURIComponent(repo[0])}/${encodeURIComponent(repo[1])}/collaborators/${encodeURIComponent(login)}/permission`;
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
// `redirect: "error"` so a 30x on this path cannot silently send the token somewhere else -- the
|
|
98
|
+
// same rule the Forgejo and GitLab resolvers apply.
|
|
99
|
+
res = await fetchFn(url, {
|
|
100
|
+
headers: {
|
|
101
|
+
accept: "application/vnd.github+json",
|
|
102
|
+
"x-github-api-version": "2022-11-28",
|
|
103
|
+
authorization: `Bearer ${token}`,
|
|
104
|
+
},
|
|
105
|
+
redirect: "error",
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { indeterminate: fetchFailureReason(err) };
|
|
109
|
+
}
|
|
110
|
+
if (res.status === 404) {
|
|
111
|
+
// Unknown user or unknown repository -- determinate, and refused. NOT the usual non-collaborator
|
|
112
|
+
// answer: see the `permission: "none"` note below.
|
|
113
|
+
return { authorized: false };
|
|
114
|
+
}
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
// Status only. What is NOT here: the response body -- a GitHub error body can echo the request,
|
|
117
|
+
// and the request carried the token.
|
|
118
|
+
return { indeterminate: `status-${res.status}` };
|
|
119
|
+
}
|
|
120
|
+
let body;
|
|
121
|
+
try {
|
|
122
|
+
body = await res.json();
|
|
123
|
+
} catch {
|
|
124
|
+
// A fixed token, deliberately WITHOUT the parse error's message -- a divergence from the Forgejo
|
|
125
|
+
// resolver worth its own line: V8's JSON.parse errors quote the offending input, and the input
|
|
126
|
+
// here is the response body, which must never reach a returned string.
|
|
127
|
+
return { indeterminate: "collaborator permission lookup returned unparseable JSON" };
|
|
128
|
+
}
|
|
129
|
+
const permission = body?.permission;
|
|
130
|
+
if (typeof permission !== "string" || permission === "") {
|
|
131
|
+
// A 200 whose shape we do not recognise is not a refusal. Answering `false` here would turn an
|
|
132
|
+
// upstream schema change into a silent, permanent refusal of every close trigger.
|
|
133
|
+
return { indeterminate: "collaborator permission lookup returned no permission string" };
|
|
134
|
+
}
|
|
135
|
+
// The NORMAL answer for a non-collaborator is a 200 with `permission: "none"` -- GitHub answers the
|
|
136
|
+
// question for any visible user rather than 404ing strangers -- so this line, not the 404 arm above,
|
|
137
|
+
// is where most unauthorized closers are refused.
|
|
138
|
+
return { authorized: WRITE_PERMISSIONS.has(permission) };
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/gitlab-members.mjs
CHANGED
|
@@ -78,7 +78,10 @@ export function makeResolveAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
|
78
78
|
try {
|
|
79
79
|
body = await res.json();
|
|
80
80
|
} catch (err) {
|
|
81
|
-
|
|
81
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
82
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
83
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
84
|
+
return { indeterminate: "members lookup returned unparseable JSON" };
|
|
82
85
|
}
|
|
83
86
|
const level = body?.access_level;
|
|
84
87
|
if (!Number.isInteger(level)) {
|