The forge mints its own API token with the admin account the vault delivers #49

Merged
jschoubben merged 2 commits from fix/gitea-mints-its-token into main 2026-09-24 08:41:14 +00:00
9 changed files with 625 additions and 28 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # 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 # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # 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 --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
+37 -20
View File
@@ -4,6 +4,7 @@
// does. // does.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
/** A repository, trimmed to what the mesh cares about. */ /** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo { export interface GiteaRepo {
@@ -53,44 +54,60 @@ function meshConfig(file?: string): Record<string, string> {
export class GiteaClient { export class GiteaClient {
readonly baseUrl: string; readonly baseUrl: string;
private cachedUsername: string | null = null; private readonly tokens: TokenSource;
constructor( /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */
url: string, constructor(url: string, token: string | TokenSource) {
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, ""); 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 / * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own
* MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the * name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order:
* URL, to the forge's loopback port. A token is required — without one there is no authenticated * one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else
* call to make, so this throws rather than hand back a client that fails on first use. * 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 { static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); 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 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; const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); if (configured) return new GiteaClient(url, new ConfiguredToken(configured));
return new GiteaClient(url, token); 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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, { let token = await this.tokens.current();
...options, let res = await this.send(path, options, token);
headers: { if (res.status === 401) {
"Content-Type": "application/json", token = await this.tokens.renew(token);
Authorization: `token ${this.token}`, res = await this.send(path, options, token);
...(options.headers as Record<string, string> | undefined), }
},
});
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;
const text = await res.text(); const text = await res.text();
return (text ? JSON.parse(text) : null) as T; return (text ? JSON.parse(text) : null) as T;
} }
private send(path: string, options: RequestInit, token: string): Promise<Response> {
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined),
},
});
}
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
* Path is relative to /api/v1. */ * Path is relative to /api/v1. */
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
+17 -2
View File
@@ -16,7 +16,9 @@
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js"; 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; let gitea: GiteaClient | null = null;
try { try {
gitea = GiteaClient.fromEnv(); gitea = GiteaClient.fromEnv();
@@ -49,8 +51,21 @@ async function pollRepos(client: GiteaClient): Promise<void> {
if (gitea) { if (gitea) {
const client = 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<void>, everyMs: number): void => { const tick = (fn: () => Promise<void>, 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); setInterval(run, everyMs);
run(); run();
}; };
+9 -1
View File
@@ -71,6 +71,12 @@
"path": "/var/lib/mesh/gitea", "path": "/var/lib/mesh/gitea",
"mode": "0700" "mode": "0700"
}, },
{
"id": "runtime-state",
"type": "directory",
"path": "/var/lib/mesh/gitea/state",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -161,7 +167,8 @@
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"/var/lib/gitea/grants:/var/lib/gitea/grants: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": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -169,6 +176,7 @@
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json", "MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json" "MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
}, },
"artifact": "runtime", "artifact": "runtime",
+4
View File
@@ -4,6 +4,10 @@
"description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "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": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
+300
View File
@@ -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<string, string>;
admins: Map<string, string>;
close(): Promise<void>;
}
function fakeForge(): Promise<Forge> {
const forge = {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // 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<any> =>
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);
});
+250
View File
@@ -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<string>;
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */
renew(rejected: string): Promise<string>;
}
/** 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<string> {
return this.token;
}
async renew(): Promise<string> {
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<string> | 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<string, MintedToken>();
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<string> {
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<string> {
// 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<string> {
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<string> {
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);
}
+6 -3
View File
@@ -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 // The tools exist when the client has a way to a token: one configured, or the admin account to mint
// failing the whole runtime. // 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) => { registerModuleTools("gitea", (env) => {
try { try {
return getGiteaTools(GiteaClient.fromEnv(env)); return getGiteaTools(GiteaClient.fromEnv(env));
} catch { } catch (err) {
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
return []; return [];
} }
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": 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"]
} }