@edgehero/pi-dispatch-receiver 4.3.0 → 4.3.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 +2 -2
- package/src/config.mjs +19 -1
- package/src/forgejo-members.mjs +41 -8
- package/src/start.mjs +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch trigger edge: verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks, or polls GitHub with no public URL, and queues one pi coding agent job per event (per replica, if set) for the worker.",
|
|
6
6
|
"keywords": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@octokit/webhooks": "14.2.0",
|
|
51
|
-
"@edgehero/pi-dispatch": "^4.3.
|
|
51
|
+
"@edgehero/pi-dispatch": "^4.3.1",
|
|
52
52
|
"bullmq": "5.80.4",
|
|
53
53
|
"ioredis": "5.11.1"
|
|
54
54
|
}
|
package/src/config.mjs
CHANGED
|
@@ -184,6 +184,16 @@ function loadGitLabConfig(env) {
|
|
|
184
184
|
* `FORGEJO_BOT_ID` is optional and exists for one reason: a repository-scoped token cannot call
|
|
185
185
|
* `GET /user` (see worker/src/forgejo-identity.mjs). Without it, following the token-scoping advice would
|
|
186
186
|
* make the receiver unable to identify itself, and identity is not optional here.
|
|
187
|
+
*
|
|
188
|
+
* `FORGEJO_PERMISSION_TOKEN` is optional and is the author gate's alone (OQ-043). Forgejo answers a lookup of
|
|
189
|
+
* ANOTHER user's repository permission only to a site admin or a repository admin, so whichever token asks must
|
|
190
|
+
* belong to one. FORGEJO_TOKEN is also every forgejo job's token, so making ITS account an admin hands each job
|
|
191
|
+
* a token that can lift branch protection. This variable lets a DIFFERENT account, an admin of the repositories,
|
|
192
|
+
* answer that one GET with a `read:repository` token, while the job's account stays a `write` collaborator.
|
|
193
|
+
* Read here and nowhere else: it never reaches the worker's job env, and the worker refuses it in PI_FORWARD_ENV
|
|
194
|
+
* and run.secrets (WORKER_ONLY_SECRET_VARS). Unset (or blank), the lookup falls back to FORGEJO_TOKEN, which is
|
|
195
|
+
* the behaviour before it existed and works only if that account is an admin; `lookupTokenVar` names which one
|
|
196
|
+
* is in use, so the boot line and a 403's reason can say so.
|
|
187
197
|
*/
|
|
188
198
|
function loadForgejoConfig(env) {
|
|
189
199
|
const secret = env.FORGEJO_WEBHOOK_SECRET;
|
|
@@ -206,7 +216,15 @@ function loadForgejoConfig(env) {
|
|
|
206
216
|
if (typeof token !== "string" || token.trim() === "") {
|
|
207
217
|
throw configError("FORGEJO_TOKEN is required for forgejo triggers -- the receiver resolves the actor's repository permission before it may enqueue");
|
|
208
218
|
}
|
|
209
|
-
|
|
219
|
+
const permissionToken = typeof env.FORGEJO_PERMISSION_TOKEN === "string" ? env.FORGEJO_PERMISSION_TOKEN.trim() : "";
|
|
220
|
+
return {
|
|
221
|
+
secret,
|
|
222
|
+
token,
|
|
223
|
+
apiUrl: apiUrl.trim(),
|
|
224
|
+
botId,
|
|
225
|
+
lookupToken: permissionToken !== "" ? permissionToken : token,
|
|
226
|
+
lookupTokenVar: permissionToken !== "" ? "FORGEJO_PERMISSION_TOKEN" : "FORGEJO_TOKEN",
|
|
227
|
+
};
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
/**
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Forgejo has no `author_association`; the concept is GitHub's. What it has instead is
|
|
6
6
|
* `GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission`, which answers
|
|
7
|
-
* `{ permission: "admin"|"write"|"read"|"none" }`. `admin` and `write` are the honest
|
|
8
|
-
* `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
-
* the constitution actually requires.
|
|
7
|
+
* `{ permission: "owner"|"admin"|"write"|"read"|"none" }`. `owner`, `admin` and `write` are the honest
|
|
8
|
+
* analogue of `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
+
* property the constitution actually requires. `owner` is not hypothetical and not rare: measured on
|
|
10
|
+
* Forgejo 14.0.5, the owner of a personal repository asking about themselves gets
|
|
11
|
+
* `{"permission":"owner","role_name":"owner",...}`, and a set without it dropped every trigger they set
|
|
12
|
+
* as `author-not-allowed`.
|
|
10
13
|
*
|
|
11
14
|
* The shape of this module is the GitLab resolver's, for the same three reasons, and they are worth
|
|
12
15
|
* restating rather than cross-referencing:
|
|
@@ -31,18 +34,31 @@ const API_PREFIX = "/api/v1";
|
|
|
31
34
|
/**
|
|
32
35
|
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
33
36
|
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
34
|
-
* draws.
|
|
37
|
+
* draws. These are the strings of Forgejo's access modes (none, read, write, admin, owner); `owner` is what
|
|
38
|
+
* a personal repository's owner is answered with (measured, see the header).
|
|
35
39
|
*/
|
|
36
|
-
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
40
|
+
const WRITE_PERMISSIONS = new Set(["owner", "admin", "write"]);
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* What a 403 tells the operator to fix, by which variable's token asked. Fixed strings, never the body.
|
|
44
|
+
* With the dedicated lookup token, its account is the one that must be an admin. With the fallback, the right repair
|
|
45
|
+
* is NOT to make the job's account an admin (that hands every job a token able to lift branch protection, OQ-043) but
|
|
46
|
+
* to give the lookup its own token.
|
|
47
|
+
*/
|
|
48
|
+
const FORBIDDEN_REASON = Object.freeze({
|
|
49
|
+
FORGEJO_PERMISSION_TOKEN: "collaborator permission lookup returned 403 (the account behind FORGEJO_PERMISSION_TOKEN must be an admin or the owner of the repository, and the token must carry read:repository)",
|
|
50
|
+
FORGEJO_TOKEN: "collaborator permission lookup returned 403 (FORGEJO_TOKEN's account is not an admin of the repository: set FORGEJO_PERMISSION_TOKEN to a read:repository token of an account that is, docs/forgejo.md)",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the resolver. `token` is the lookup token (FORGEJO_PERMISSION_TOKEN, or FORGEJO_TOKEN as the fallback) and
|
|
55
|
+
* `tokenVar` names which, for a 403's reason; `fetchFn` is injected so the whole module is testable offline.
|
|
41
56
|
*
|
|
42
57
|
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
43
58
|
* -- the same shape every forge's resolver returns.
|
|
44
59
|
*/
|
|
45
|
-
export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
60
|
+
export function makeResolveForgejoAuthority({ apiUrl, token, tokenVar = "FORGEJO_TOKEN", fetchFn = fetch }) {
|
|
61
|
+
const forbidden = FORBIDDEN_REASON[tokenVar] ?? FORBIDDEN_REASON.FORGEJO_TOKEN;
|
|
46
62
|
return async function resolveAuthority(repoFullName, login) {
|
|
47
63
|
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
48
64
|
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
@@ -68,6 +84,23 @@ export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch })
|
|
|
68
84
|
// a refusal rather than a failure.
|
|
69
85
|
return { authorized: false };
|
|
70
86
|
}
|
|
87
|
+
if (res.status === 403) {
|
|
88
|
+
// A CONFIGURATION error, named as one. Forgejo lets only a site admin, a repository admin, or the
|
|
89
|
+
// user themselves read a permission (measured on 14.0.5: a `write` collaborator's token gets 403
|
|
90
|
+
// {"message":"Only admins can query all permissions, repo admins can query all repo permissions,
|
|
91
|
+
// collaborators can query only their own"}; the same account raised to repo admin gets 200). The
|
|
92
|
+
// other 403s this route can give are the token's scope (`token does not have at least one of
|
|
93
|
+
// required scope(s)`, `token scope is limited to public repos`), so the dedicated token's reason names its scope too.
|
|
94
|
+
// Still indeterminate, and that is a decision, not an oversight. A retry cannot heal it, but the
|
|
95
|
+
// retry costs nothing (no token is minted and nothing spends before this answer), while the
|
|
96
|
+
// alternative is worse in the way this file's header names: a determinate refusal answers 204, which
|
|
97
|
+
// Forgejo records as a successful delivery, so the one place the operator looks would show every
|
|
98
|
+
// trigger "delivered" while nothing ran. A 503 marks each delivery failed there, the log line below
|
|
99
|
+
// names the repair, and a delivery that failed this way still describes real work to redo once the
|
|
100
|
+
// account is fixed. The body is still not read: the fixed reason is enough, and a body is not ours
|
|
101
|
+
// to log. The reason names the variable whose token asked (FORBIDDEN_REASON).
|
|
102
|
+
return { indeterminate: forbidden };
|
|
103
|
+
}
|
|
71
104
|
if (!res.ok) {
|
|
72
105
|
// Note what is NOT here: the response body. A Forgejo error body can echo the request, and the
|
|
73
106
|
// request carried the token.
|
package/src/start.mjs
CHANGED
|
@@ -211,10 +211,14 @@ export async function startReceiver(
|
|
|
211
211
|
if (cfg.forgejo) {
|
|
212
212
|
const forgejoSelfId = await retryIdentity(() => resolveForgejoSelfIdFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token, botId: cfg.forgejo.botId }), { forge: "forgejo", ...retryOpts });
|
|
213
213
|
log({ event: "self_identity", forge: "forgejo", id: forgejoSelfId, source: cfg.forgejo.botId ? "FORGEJO_BOT_ID" : "api" });
|
|
214
|
+
// The author gate's lookup token, by NAME only (OQ-043): FORGEJO_PERMISSION_TOKEN when set, else FORGEJO_TOKEN,
|
|
215
|
+
// whose account must then be a repository admin. Identity above stays FORGEJO_TOKEN's: it is the account the
|
|
216
|
+
// jobs post as, which the bot-loop guard must recognise.
|
|
217
|
+
log({ event: "forgejo_permission_token", source: cfg.forgejo.lookupTokenVar });
|
|
214
218
|
forgejo = {
|
|
215
219
|
secret: cfg.forgejo.secret,
|
|
216
220
|
selfId: forgejoSelfId,
|
|
217
|
-
resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.
|
|
221
|
+
resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.lookupToken, tokenVar: cfg.forgejo.lookupTokenVar }),
|
|
218
222
|
};
|
|
219
223
|
}
|
|
220
224
|
|