diff --git a/modules/gitea/Dockerfile b/modules/gitea/Dockerfile index 0c0eabf..59f134d 100644 --- a/modules/gitea/Dockerfile +++ b/modules/gitea/Dockerfile @@ -23,7 +23,7 @@ COPY . . # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # symlinks to a launcher that requires its library relatively — resolved away when the base image # was assembled. -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index aadb4cc..983186e 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -4,6 +4,7 @@ // does. import { readFileSync } from "node:fs"; +import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { @@ -53,44 +54,60 @@ function meshConfig(file?: string): Record { export class GiteaClient { readonly baseUrl: string; - private cachedUsername: string | null = null; + private readonly tokens: TokenSource; - constructor( - url: string, - private readonly token: string, - ) { + /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */ + constructor(url: string, token: string | TokenSource) { this.baseUrl = url.replace(/\/+$/, ""); + this.tokens = typeof token === "string" ? new ConfiguredToken(token) : token; } /** - * Build from the module's resolved environment. URL and token come from MESH_GITEA_URL / - * MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the - * URL, to the forge's loopback port. A token is required — without one there is no authenticated - * call to make, so this throws rather than hand back a client that fails on first use. + * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own + * name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order: + * one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else + * one the module mints for itself with the admin account the vault delivered and keeps in its own + * state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing, + * rather than hand back a client that fails on first use. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; - const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; - if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); - return new GiteaClient(url, token); + const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + if (configured) return new GiteaClient(url, new ConfiguredToken(configured)); + return new GiteaClient(url, MintedToken.fromEnv(url, env)); } + /** + * One authenticated call. A 401 is the forge saying the token is not one it knows — the case + * after the forge's data was restored, or after somebody revoked it — so the source is asked to + * renew once and the call is repeated with the new token. A configured token has nothing to renew + * with, and its source says so. + */ private async request(path: string, options: RequestInit = {}): Promise { - const res = await fetch(`${this.baseUrl}/api/v1${path}`, { - ...options, - headers: { - "Content-Type": "application/json", - Authorization: `token ${this.token}`, - ...(options.headers as Record | undefined), - }, - }); + let token = await this.tokens.current(); + let res = await this.send(path, options, token); + if (res.status === 401) { + 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; const text = await res.text(); return (text ? JSON.parse(text) : null) as T; } + private send(path: string, options: RequestInit, token: string): Promise { + return fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `token ${token}`, + ...(options.headers as Record | undefined), + }, + }); + } + /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. * Path is relative to /api/v1. */ async api(path: string, options: RequestInit = {}): Promise { diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 2731819..f7847bb 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -16,7 +16,9 @@ import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient } from "./client.js"; -// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime. +// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is +// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints +// or reuses the token, so the runtime's start also shows what it did about it. let gitea: GiteaClient | null = null; try { gitea = GiteaClient.fromEnv(); @@ -49,8 +51,21 @@ async function pollRepos(client: GiteaClient): Promise { if (gitea) { const client = gitea; + // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up + // yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line. + let failing: string | null = null; const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`)); + const run = (): void => + void fn() + .then(() => { + if (failing !== null) console.log("[gitea] watching again"); + failing = null; + }) + .catch((err) => { + const why = err instanceof Error ? err.message : String(err); + if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); + failing = why; + }); setInterval(run, everyMs); run(); }; diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 4702a9d..d40084e 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -71,6 +71,12 @@ "path": "/var/lib/mesh/gitea", "mode": "0700" }, + { + "id": "runtime-state", + "type": "directory", + "path": "/var/lib/mesh/gitea/state", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -161,7 +167,8 @@ "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", "/var/lib/gitea/grants:/var/lib/gitea/grants:ro", - "/var/lib/gitea/admin.secret:/run/secrets/admin:ro" + "/var/lib/gitea/admin.secret:/run/secrets/admin:ro", + "/var/lib/mesh/gitea/state:/run/state" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", @@ -169,6 +176,7 @@ "MESH_GITEA_CONFIG_FILE": "/run/config/config.json", "MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", + "MESH_GITEA_STATE_DIR": "/run/state", "MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json" }, "artifact": "runtime", diff --git a/modules/gitea/package.json b/modules/gitea/package.json index fe629bf..04e8df1 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -4,6 +4,10 @@ "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", "type": "module", "private": true, + "scripts": { + "build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts new file mode 100644 index 0000000..4327cd6 --- /dev/null +++ b/modules/gitea/test/token.test.ts @@ -0,0 +1,300 @@ +// What holds the module to its own token (token.ts; hq issue 100, the forge's tools): minted with +// the delivered admin account on the first call and kept at 0600, reused on the next start, minted +// afresh when the forge rejects it or the kept file is gone, and a refused admin account reported in +// plain words rather than crash-looped. A configured token still wins. And the tools register once +// there is a way to a token at all — before one exists. +// +// The forge is a fake: the four routes the module touches, with the same status codes gitea gives. +// Run against the compiled module (npm test builds first), the way the runtime loads it. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { collectTools } from "@novox/mesh-sdk/tools"; +import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js"; +import { GiteaClient } from "../dist/client.js"; +import "../dist/tools/index.js"; + +const ADMIN = "mesh-admin"; +const PASSWORD = "the-vault-minted-this"; + +// ---- A fake forge: what the module sends, and what gitea would answer. ---- + +interface Forge { + url: string; + mints: number; + lastScopes: string[] | null; + tokens: Map; + admins: Map; + close(): Promise; +} + +function fakeForge(): Promise { + const forge = { + mints: 0, + lastScopes: null as string[] | null, + tokens: new Map(), // name -> value + admins: new Map([[ADMIN, PASSWORD]]), + }; + const json = (res: ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(body === null ? "" : JSON.stringify(body)); + }; + const body = (req: IncomingMessage): Promise => + new Promise((resolve) => { + let text = ""; + req.on("data", (c) => (text += c)); + req.on("end", () => resolve(text ? JSON.parse(text) : null)); + }); + const basic = (req: IncomingMessage): string | null => { + const h = req.headers.authorization ?? ""; + if (!h.startsWith("Basic ")) return null; + const [user, pass] = Buffer.from(h.slice(6), "base64").toString().split(":"); + return forge.admins.get(user) === pass ? user : null; + }; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://fake"); + const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/); + if (tokens) { + const user = basic(req); + if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" }); + if (req.method === "POST") { + const { name, scopes } = await body(req); + if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" }); + forge.mints++; + forge.lastScopes = scopes; + const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`; + forge.tokens.set(name, sha1); + return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) }); + } + if (req.method === "DELETE" && tokens[2]) { + const name = decodeURIComponent(tokens[2]); + if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" }); + forge.tokens.delete(name); + return json(res, 204, null); + } + return json(res, 405, { message: "method not allowed" }); + } + if (url.pathname === "/api/v1/user/repos") { + 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" }); + return json(res, 200, [ + { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, + ]); + } + return json(res, 404, { message: "no such route in the fake" }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + resolve({ + url: `http://127.0.0.1:${port}`, + get mints() { return forge.mints; }, + get lastScopes() { return forge.lastScopes; }, + tokens: forge.tokens, + admins: forge.admins, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +// ---- What the runtime's environment gives the module. ---- + +async function delivered(forge: Forge): Promise<{ env: NodeJS.ProcessEnv; file: string; logs: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "gitea-")); + const passwordFile = join(dir, "admin.secret"); + await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 }); + const state = join(dir, "state"); + return { + env: { + MESH_GITEA_URL: forge.url, + MESH_GITEA_ADMIN_USER: ADMIN, + MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile, + MESH_GITEA_STATE_DIR: state, + }, + file: join(state, "token"), + logs: [], + }; +} + +/** A client as a fresh process would build it: a new source over the kept file, its log captured. */ +function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient { + const source = new MintedToken({ + url: env.MESH_GITEA_URL!, + admin: env.MESH_GITEA_ADMIN_USER!, + passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!, + file: join(env.MESH_GITEA_STATE_DIR!, "token"), + log: (l) => logs.push(l), + }); + return new GiteaClient(env.MESH_GITEA_URL!, source); +} + +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 () => { + 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"]); + assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); + const token = forge.tokens.get("mesh-tools")!; + assert.equal(await readFile(file, "utf8"), token + "\n"); + assert.equal((await stat(file)).mode & 0o777, 0o600); + // Said that it minted, and where it keeps it — never what it is. + assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n")); + assert.ok(logs.every((l) => !l.includes(token) && !l.includes(PASSWORD)), logs.join("\n")); +}); + +test("second start: reuses the kept token, mints nothing", async () => { + const { env, logs } = await delivered(forge); + await minted(env, logs).listRepos(); + const before = forge.mints; + + const again: string[] = []; + await minted(env, again).listRepos(); + + assert.equal(forge.mints, before); + assert.ok(again.some((l) => l.startsWith("reusing the token kept at")), again.join("\n")); + assert.ok(again.every((l) => !l.includes(forge.tokens.get("mesh-tools")!)), again.join("\n")); +}); + +test("the forge rejects the kept token (its data was restored): minted afresh, once, 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; + + forge.tokens.clear(); // the forge no longer knows any token — a restore from the predecessor + const repos = await client.listRepos(); + + assert.equal(repos.length, 1); + assert.equal(forge.mints, before + 1); + 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")); +}); + +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(); + const before = forge.mints; + await rm(file); + + const repos = await minted(env, logs).listRepos(); + + assert.equal(repos.length, 1); + assert.equal(forge.mints, before + 1); + assert.equal([...forge.tokens.keys()].filter((n) => n === "mesh-tools").length, 1); + assert.ok(logs.some((l) => l.includes('already holds a token named "mesh-tools"')), logs.join("\n")); +}); + +test("concurrent first calls share one mint", async () => { + const { env, logs } = await delivered(forge); + const client = minted(env, logs); + const before = forge.mints; + + await Promise.all([client.listRepos(), client.listRepos(), client.listRepos()]); + + assert.equal(forge.mints, before + 1); +}); + +test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => { + const { env, file, logs } = await delivered(forge); + forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there + try { + const client = minted(env, logs); + const before = forge.mints; + + await assert.rejects(client.listRepos(), (err: unknown) => { + assert.ok(err instanceof AdminRefused, String(err)); + assert.match(err.message, /refused the admin account "mesh-admin" \(401\)/); + assert.match(err.message, /admin-bootstrap step creates it/); + assert.match(err.message, /came from a predecessor/); + assert.ok(!err.message.includes(PASSWORD)); + return true; + }); + await assert.rejects(client.listRepos(), AdminRefused); + assert.equal(forge.mints, before); + await assert.rejects(stat(file), /ENOENT/); + + // The account appears (the operator created it): the very next call mints and works. + forge.admins.set(ADMIN, PASSWORD); + assert.equal((await client.listRepos()).length, 1); + assert.equal(forge.mints, before + 1); + } finally { + forge.admins.set(ADMIN, PASSWORD); + } +}); + +test("one process shares one source per kept file — the watcher and the tools never renew against each other", async () => { + const { env } = await delivered(forge); + assert.equal(MintedToken.fromEnv(env.MESH_GITEA_URL!, env), MintedToken.fromEnv(env.MESH_GITEA_URL!, env)); +}); + +test("a second process finds the token the first renewed, and reuses it instead of minting over it", async () => { + const { env, logs } = await delivered(forge); + const first = minted(env, logs); + const second = minted(env, logs); + await first.listRepos(); + await second.listRepos(); // both hold the same kept token + const before = forge.mints; + + forge.tokens.clear(); + await first.listRepos(); // renews: one mint + await second.listRepos(); // rejected too — but the kept file already carries the renewed one + + assert.equal(forge.mints, before + 1); + assert.ok(logs.some((l) => l.includes("is newer — reusing it")), logs.join("\n")); +}); + +test("a configured token wins, and is reported rather than minted over when the forge rejects it", async () => { + const { env } = await delivered(forge); + const before = forge.mints; + + const client = GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" }); + + await assert.rejects(client.listRepos(), /rejected the configured Gitea token \(401\)/); + assert.equal(forge.mints, before); +}); + +test("nothing to mint with and no token: the client says what is missing", async () => { + assert.throws( + () => GiteaClient.fromEnv({ MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN }), + /set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/, + ); +}); + +test("the tools register once there is a way to a token, and the first call mints it", async () => { + const { env } = await delivered(forge); + const before = forge.mints; + + const withAdmin = collectTools(env).find((c) => c.module === "gitea")!; + const withNothing = collectTools({}).find((c) => c.module === "gitea")!; + + assert.equal(withNothing.tools.length, 0); + assert.deepEqual( + withAdmin.tools.map((t) => t.name), + [ + "gitea_list_repos", "gitea_create_repo", "gitea_delete_repo", + "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment", + "gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request", + "gitea_list_labels", "gitea_create_label", + "gitea_api", + ], + ); + assert.equal(forge.mints, before, "registering must not mint — the forge may not be up yet"); + + const result = (await withAdmin.tools.find((t) => t.name === "gitea_list_repos")!.run({})) as { repos: unknown[] }; + assert.equal(result.repos.length, 1); + assert.equal(forge.mints, before + 1); +}); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts new file mode 100644 index 0000000..732391b --- /dev/null +++ b/modules/gitea/token.ts @@ -0,0 +1,250 @@ +// 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 — list/create/delete repositories, pull requests (list/get/open/merge), and + * the watcher's /user/repos poll; + * write:issue — issues, comments, labels. + * Nothing under /admin, /orgs or /users — the escape-hatch tool reaches only what these two cover. + */ +export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue"]; + +/** 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); +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 485521e..7e77484 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -295,12 +295,15 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { ]; } -// The tools exist only when a token can be found; without one, gitea contributes none rather than -// failing the whole runtime. +// The tools exist when the client has a way to a token: one configured, or the admin account to mint +// one with (token.ts). The mint itself happens on the first call, not here — a contributor is +// synchronous, and a forge not yet answering must not keep the runtime from serving. Without either +// way, gitea contributes none rather than failing the whole runtime, and says why. registerModuleTools("gitea", (env) => { try { return getGiteaTools(GiteaClient.fromEnv(env)); - } catch { + } catch (err) { + console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`); return []; } }); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 51f4046..49f42ec 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] + "include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] }