The runtime beside the forge served 0 tools: GiteaClient.fromEnv required a token
(settings or MESH_GITEA_TOKEN), nobody had one to give — the mesh raised the forge —
and putting one in settings would store a secret in plaintext in the inventory. So
the fifteen tools registered nothing and the watcher logged "not watching".
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 (ADR 0086). That is
enough to mint a token, so the module does (hq issue 100, the forge's tools):
POST /users/{admin}/tokens over basic auth, scoped to write:repository and
write:issue — the least the tools and the repo watcher need — kept at 0600 in the
module's own state (/var/lib/mesh/gitea/state, a new directory resource the runtime
mounts writable), read back on the next start, and minted afresh when the forge
answers 401 to it or the kept file is gone. A forge whose data came from the
predecessor has no mesh-admin: that is reported in plain words on every poll until
it clears, once per reason, not crash-looped. A configured token still wins and is
never minted over.
The mint happens on the first call, not at registration: a contributor is
synchronous, and a forge not yet answering must not keep the runtime from serving.
One source per kept file in a process, or the watcher and the tools would each
renew on a 401 and drop the other's token by name.
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
// 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);
|
|
});
|