gitea: the tools' token carries write:admin, and a kept token is re-minted when it lacks a scope
The forge's own users are the mesh's to settle — making the builder's login a site admin so private repos build (hq 229) — and the tools' token had no write:admin. A token kept from before a scope was added lacks it, so the client now treats the forge's 403 "required scope" like a 401: the source re-mints by name with the whole list and retries once. The fake forge in the tests learns /repos/search, which the client has used since 2026-09-28 and which had left 9 of the 11 token tests failing on main.
This commit is contained in:
@@ -95,6 +95,13 @@ export class GiteaClient {
|
|||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
token = await this.tokens.renew(token);
|
token = await this.tokens.renew(token);
|
||||||
res = await this.send(path, options, 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.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||||
if (res.status === 204) return null as T;
|
if (res.status === 204) return null as T;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Forge {
|
|||||||
mints: number;
|
mints: number;
|
||||||
lastScopes: string[] | null;
|
lastScopes: string[] | null;
|
||||||
tokens: Map<string, string>;
|
tokens: Map<string, string>;
|
||||||
|
scopesOf: Map<string, string[]>;
|
||||||
admins: Map<string, string>;
|
admins: Map<string, string>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,20 @@ function fakeForge(): Promise<Forge> {
|
|||||||
}
|
}
|
||||||
return json(res, 405, { message: "method not allowed" });
|
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") {
|
if (url.pathname === "/api/v1/user/repos") {
|
||||||
const h = req.headers.authorization ?? "";
|
const h = req.headers.authorization ?? "";
|
||||||
const value = h.startsWith("token ") ? h.slice(6) : "";
|
const value = h.startsWith("token ") ? h.slice(6) : "";
|
||||||
@@ -101,6 +116,21 @@ function fakeForge(): Promise<Forge> {
|
|||||||
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
|
{ 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 json(res, 404, { message: "no such route in the fake" });
|
||||||
});
|
});
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -111,6 +141,7 @@ function fakeForge(): Promise<Forge> {
|
|||||||
get mints() { return forge.mints; },
|
get mints() { return forge.mints; },
|
||||||
get lastScopes() { return forge.lastScopes; },
|
get lastScopes() { return forge.lastScopes; },
|
||||||
tokens: forge.tokens,
|
tokens: forge.tokens,
|
||||||
|
scopesOf: forge.scopesOf,
|
||||||
admins: forge.admins,
|
admins: forge.admins,
|
||||||
close: () => new Promise((r) => server.close(() => r())),
|
close: () => new Promise((r) => server.close(() => r())),
|
||||||
});
|
});
|
||||||
@@ -152,14 +183,14 @@ function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
|
|||||||
const forge = await fakeForge();
|
const forge = await fakeForge();
|
||||||
after(() => forge.close());
|
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 { env, file, logs } = await delivered(forge);
|
||||||
|
|
||||||
const repos = await minted(env, logs).listRepos();
|
const repos = await minted(env, logs).listRepos();
|
||||||
|
|
||||||
assert.equal(repos[0]?.full_name, "novox/hq");
|
assert.equal(repos[0]?.full_name, "novox/hq");
|
||||||
assert.equal(forge.mints, 1);
|
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]);
|
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
|
||||||
const token = forge.tokens.get("mesh-tools")!;
|
const token = forge.tokens.get("mesh-tools")!;
|
||||||
assert.equal(await readFile(file, "utf8"), token + "\n");
|
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"));
|
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 () => {
|
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);
|
const { env, file, logs } = await delivered(forge);
|
||||||
await minted(env, logs).listRepos();
|
await minted(env, logs).listRepos();
|
||||||
|
|||||||
+14
-3
@@ -34,15 +34,21 @@ export const TOKEN_NAME = "mesh-tools";
|
|||||||
* It sits under the `user` category despite listing repositories, not `repository`
|
* It sits under the `user` category despite listing repositories, not `repository`
|
||||||
* — confirmed against the running forge (1.27.3), which answered
|
* — confirmed against the running forge (1.27.3), which answered
|
||||||
* `required=[read:user]` to a token carrying only the other two.
|
* `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. */
|
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
|
||||||
export interface TokenSource {
|
export interface TokenSource {
|
||||||
/** The token to authenticate with now; minted, read or configured. */
|
/** The token to authenticate with now; minted, read or configured. */
|
||||||
current(): Promise<string>;
|
current(): Promise<string>;
|
||||||
/** 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<string>;
|
renew(rejected: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +176,11 @@ export class MintedToken implements TokenSource {
|
|||||||
return this.mint("the forge rejected the kept token — minting a fresh one");
|
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. */
|
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
|
||||||
private mint(why: string): Promise<string> {
|
private mint(why: string): Promise<string> {
|
||||||
if (this.inflight === null) {
|
if (this.inflight === null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user