Merge pull request 'The forge mints its own API token with the admin account the vault delivers' (#49) from fix/gitea-mints-its-token into main
This commit was merged in pull request #49.
This commit is contained in:
@@ -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}
|
||||
|
||||
+37
-20
@@ -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<string, string> {
|
||||
|
||||
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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${this.token}`,
|
||||
...(options.headers as Record<string, string> | 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<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.
|
||||
* Path is relative to /api/v1. */
|
||||
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
+17
-2
@@ -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<void> {
|
||||
|
||||
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<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);
|
||||
run();
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user