@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "4.3.0",
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.0",
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
- return { secret, token, apiUrl: apiUrl.trim(), botId };
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
  /**
@@ -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 analogue of
8
- * `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the property
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
- * Build the resolver. `token` is the same operator-supplied access token the worker uses; `fetchFn` is
40
- * injected so the whole module is testable offline.
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.token }),
221
+ resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.lookupToken, tokenVar: cfg.forgejo.lookupTokenVar }),
218
222
  };
219
223
  }
220
224