diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index c8e5c28..aa86e75 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -95,6 +95,13 @@ export class GiteaClient { if (res.status === 401) { token = await this.tokens.renew(token); res = await this.send(path, options, token); + } else if (res.status === 403) { + // A kept token minted before a scope was added lacks it. The forge says so; the source + // re-mints with the whole list and the call is retried once. Any other 403 stays a 403. + const text = await res.text(); + if (!MintedToken.lacksScope(res.status, text)) throw new Error(`Gitea API ${path}: 403 ${text}`); + token = await this.tokens.renew(token); + res = await this.send(path, options, token); } if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (res.status === 204) return null as T; diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index c634378..75a5bd2 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -29,6 +29,7 @@ interface Forge { mints: number; lastScopes: string[] | null; tokens: Map; + scopesOf: Map; admins: Map; close(): Promise; } @@ -85,6 +86,20 @@ function fakeForge(): Promise { } return json(res, 405, { message: "method not allowed" }); } + if (url.pathname === "/api/v1/repos/search") { + // The client lists through the search endpoint since 2026-09-28 (the forge's whole view); + // it sits under `repository`, which write:repository covers. + const h = req.headers.authorization ?? ""; + const value = h.startsWith("token ") ? h.slice(6) : ""; + if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" }); + if (!covers(forge.scopesOf.get(value) ?? [], "read:repository")) { + return json(res, 403, { message: `token does not have at least one of required scope(s), required=[read:repository]` }); + } + return json(res, 200, { + ok: true, + data: [{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }], + }); + } if (url.pathname === "/api/v1/user/repos") { const h = req.headers.authorization ?? ""; const value = h.startsWith("token ") ? h.slice(6) : ""; @@ -101,6 +116,21 @@ function fakeForge(): Promise { { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, ]); } + const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/); + if (adminUser && req.method === "PATCH") { + const h = req.headers.authorization ?? ""; + const value = h.startsWith("token ") ? h.slice(6) : ""; + if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" }); + if (!covers(forge.scopesOf.get(value) ?? [], "write:admin")) { + return json(res, 403, { + message: `token does not have at least one of required scope(s), required=[write:admin]`, + }); + } + const login = decodeURIComponent(adminUser[1]); + if (login === "untouchable") return json(res, 403, { message: "user untouchable may not be edited" }); + const patch = await body(req); + return json(res, 200, { login, is_admin: patch?.admin === true }); + } return json(res, 404, { message: "no such route in the fake" }); }); return new Promise((resolve) => { @@ -111,6 +141,7 @@ function fakeForge(): Promise { get mints() { return forge.mints; }, get lastScopes() { return forge.lastScopes; }, tokens: forge.tokens, + scopesOf: forge.scopesOf, admins: forge.admins, close: () => new Promise((r) => server.close(() => r())), }); @@ -152,14 +183,14 @@ function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient { const forge = await fakeForge(); after(() => forge.close()); -test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => { +test("first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only", async () => { const { env, file, logs } = await delivered(forge); const repos = await minted(env, logs).listRepos(); assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(forge.mints, 1); - assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); + assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); const token = forge.tokens.get("mesh-tools")!; assert.equal(await readFile(file, "utf8"), token + "\n"); @@ -197,6 +228,34 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n")); }); +test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => { + const { env, file, logs } = await delivered(forge); + const client = minted(env, logs); + await client.listRepos(); + const before = forge.mints; + const old = forge.tokens.get("mesh-tools")!; + forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build + + const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", { + method: "PATCH", + body: JSON.stringify({ admin: true }), + }); + + assert.equal(user.is_admin, true); + assert.equal(forge.mints, before + 1); + assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); + assert.notEqual(forge.tokens.get("mesh-tools"), old); + assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n"); + assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n")); + // A 403 that is not about scopes is the forge's answer, not a reason to mint. + const again = forge.mints; + await assert.rejects( + client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }), + /403 .*untouchable/, + ); + assert.equal(forge.mints, again); +}); + test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => { const { env, file, logs } = await delivered(forge); await minted(env, logs).listRepos(); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index e511eb9..fb1e53a 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -34,15 +34,21 @@ export const TOKEN_NAME = "mesh-tools"; * It sits under the `user` category despite listing repositories, not `repository` * — confirmed against the running forge (1.27.3), which answered * `required=[read:user]` to a token carrying only the other two. - * Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover. + * write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making + * the builder's login a site admin so every repository the mesh may build is + * clonable (novox/hq 229). Nothing under /orgs or write:user. + * + * A token kept from before a scope was added lacks it: the forge answers such a call with + * `403 token does not have at least one of required scope(s)`, and the client treats that like a + * 401 — the source re-mints by name, with the whole list, and the call is retried once. */ -export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; +export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"]; /** Where a client's token comes from, and what to do when the forge says it is wrong. */ export interface TokenSource { /** The token to authenticate with now; minted, read or configured. */ current(): Promise; - /** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */ + /** The forge answered 401 to `rejected`, or 403 for a scope it lacks. A fresh token, or a plain error when there is nothing to renew with. */ renew(rejected: string): Promise; } @@ -170,6 +176,11 @@ export class MintedToken implements TokenSource { return this.mint("the forge rejected the kept token — minting a fresh one"); } + /** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */ + static lacksScope(status: number, body: string): boolean { + return status === 403 && /required scope/i.test(body); + } + /** One mint at a time: concurrent first calls share it, rather than each minting its own. */ private mint(why: string): Promise { if (this.inflight === null) {