// The token the forge's tools and watcher authenticate with — and where it comes from. // // Nobody configures it. The forge is raised by the mesh, so there is no operator holding a token to // paste in, and pasting one into settings would put a secret in the inventory in plaintext. What // the mesh does deliver is the admin account: a login the manifest names and a password the vault // minted and the host unsealed into a file (novox/hq ADR 0086). That account is enough to mint a // token, so the module mints its own (hq issue 100, the forge's tools): // // - at first use, when none is kept: POST /users/{admin}/tokens over basic auth, with the two // scopes the tools and the watcher need, and no more; // - kept in the module's own state, a 0600 file, and read back on the next start — the forge // hands a token's value out exactly once, so a token not kept is a token lost; // - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not // a fault: the forge's data was restored from a predecessor and the token the file names never // existed there. // // An explicitly configured token still wins, and is never minted over: if it is rejected, that is // reported, not repaired — somebody chose it. // // The token is never logged. Lines say that one was minted, reused or renewed, and where it is // kept; never what it is. import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; /** The name the token carries in the forge's own list — one per mesh runtime, found by name. */ export const TOKEN_NAME = "mesh-tools"; /** * The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens): * write:repository — create/delete repositories, pull requests (list/get/open/merge); * write:issue — issues, comments, labels; * read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call. * 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. */ export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; /** 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. */ renew(rejected: string): Promise; } /** A token somebody set — in settings or the environment. Never minted over. */ export class ConfiguredToken implements TokenSource { constructor(private readonly token: string) {} async current(): Promise { return this.token; } async renew(): Promise { throw new Error( "the forge rejected the configured Gitea token (401). It was set explicitly (settings or MESH_GITEA_TOKEN), " + "so the module does not mint over it — fix it, or unset it and the module mints its own", ); } } /** The forge would not take the admin account: it is missing, or its password is not the one the mesh holds. */ export class AdminRefused extends Error { constructor(admin: string, status: number) { super( `the forge refused the admin account "${admin}" (${status}) — it does not exist there, or its password is not ` + `the one the vault delivered. The admin-bootstrap step creates it on a forge the mesh raised; a forge whose data ` + `came from a predecessor does not have it. Create "${admin}" on the forge with the delivered password and the ` + `token is minted on the next call — the tools stay registered and the watcher keeps trying`, ); this.name = "AdminRefused"; } } export interface MintedTokenOptions { /** The forge, e.g. http://127.0.0.1:3000. */ readonly url: string; /** The admin login the manifest names. */ readonly admin: string; /** The file the host unsealed the admin password into (ADR 0086). Read at mint time, so a rotation takes. */ readonly passwordFile: string; /** Where the token is kept: a 0600 file in the module's own state. */ readonly file: string; readonly name?: string; readonly scopes?: readonly string[]; readonly log?: (line: string) => void; readonly fetch?: typeof fetch; } /** The token the module mints for itself, kept in its state and renewed when the forge rejects it. */ export class MintedToken implements TokenSource { private held: string | null = null; private readFile = false; private inflight: Promise | null = null; private readonly name: string; private readonly scopes: readonly string[]; private readonly log: (line: string) => void; private readonly fetchImpl: typeof fetch; constructor(private readonly opts: MintedTokenOptions) { this.name = opts.name ?? TOKEN_NAME; this.scopes = opts.scopes ?? TOKEN_SCOPES; this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`)); this.fetchImpl = opts.fetch ?? fetch; } /** * Build from the runtime's environment: the forge's URL, the admin login and password file the * manifest hands the runtime, and the module's state directory (MESH_GITEA_STATE_DIR, a directory * the runtime mounts writable). Throws, naming what is missing, rather than hand back a source * that cannot mint. */ static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken { const opts = MintedToken.optionsFromEnv(url, env); // One source per kept file in a process. The watcher and the tools entrypoint both build a // client in the same runtime; two sources over one file would each renew on a 401 and drop the // other's token by name, forever. Shared, a renewal is one renewal. const shared = MintedToken.shared.get(opts.file); if (shared) return shared; const source = new MintedToken(opts); MintedToken.shared.set(opts.file, source); return source; } private static readonly shared = new Map(); private static optionsFromEnv(url: string, env: NodeJS.ProcessEnv): MintedTokenOptions { const admin = env.MESH_GITEA_ADMIN_USER; const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE; const stateDir = env.MESH_GITEA_STATE_DIR; const missing = [ admin ? null : "MESH_GITEA_ADMIN_USER", passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE", stateDir ? null : "MESH_GITEA_STATE_DIR", ].filter((v): v is string => v !== null); if (missing.length) { throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`); } return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") }; } async current(): Promise { if (this.held !== null) return this.held; if (!this.readFile) { this.readFile = true; const kept = this.read(); if (kept !== null) { this.held = kept; this.log(`reusing the token kept at ${this.opts.file}`); return kept; } } return this.mint("no token kept — minting one"); } async renew(rejected: string): Promise { // Another caller already renewed while this one was in flight with the old token. if (this.held !== null && this.held !== rejected) return this.held; // Or another process did, and kept it: use what is kept before minting over it. const kept = this.read(); if (kept !== null && kept !== rejected) { this.held = kept; this.log(`the forge rejected the token held; the kept one at ${this.opts.file} is newer — reusing it`); return kept; } this.held = null; return this.mint("the forge rejected the kept token — minting a fresh one"); } /** 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) { this.log(why); this.inflight = this.doMint().finally(() => { this.inflight = null; }); } return this.inflight; } private read(): string | null { try { const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, ""); return token.length ? token : null; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error(`cannot read the kept Gitea token at ${this.opts.file}: ${(err as Error).message}`); } } /** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */ private keep(token: string): void { mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 }); const tmp = `${this.opts.file}.tmp`; writeFileSync(tmp, token + "\n", { mode: 0o600 }); chmodSync(tmp, 0o600); renameSync(tmp, this.opts.file); } private async doMint(): Promise { let password: string; try { password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, ""); } catch (err) { throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`); } const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64"); const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`; const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => { const res = await this.fetchImpl(tokens + path, { method, headers: { "Content-Type": "application/json", Authorization: authorization }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const text = await res.text(); let parsed: any = null; if (text) { try { parsed = JSON.parse(text); } catch { parsed = text; } } return { status: res.status, body: parsed }; }; let res = await call("POST", "", { name: this.name, scopes: this.scopes }); if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status); if (res.status === 400 || res.status === 422) { // The forge still holds a token by this name whose value we no longer have — the kept file // went while the forge's data stayed. It is ours to replace: drop it by name and mint again. this.log(`the forge already holds a token named "${this.name}" — replacing it`); const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`); if (dropped.status !== 204 && dropped.status !== 404) { throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`); } res = await call("POST", "", { name: this.name, scopes: this.scopes }); } if (res.status !== 201 && res.status !== 200) { throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`); } const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null; if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`); this.keep(token); this.held = token; this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`); return token; } } function detail(body: unknown): string { return typeof body === "string" ? body : JSON.stringify(body); }