mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tools' token reached everything the administrator may do, so their boundary rested on their own guards. The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login. The forge shows protection rules only to a repository administrator, so every check reads a branch's protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
471 lines
23 KiB
TypeScript
471 lines
23 KiB
TypeScript
// 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;
|
|
// - minted by one process at a time. The runtime starts each of the bundle's entrypoints as a process
|
|
// of its own (novox/hq ADR 0193): the watcher (index.js) and the tools (tools/index.js) are two
|
|
// processes over one kept file. Two that minted at once left the forge two tokens by one name, which
|
|
// gitea then refuses to delete by name — for ever, and every forge tool with it (the 2026-10-14
|
|
// outage). So a mint holds a lock file beside the kept token, and reads the kept file again once it
|
|
// holds it: what another process minted meanwhile is reused, not minted over;
|
|
// - replaced by id: an old token by this name is found in the account's list and deleted by its id,
|
|
// every one of them, so a name held twice is cleared rather than refused.
|
|
//
|
|
// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's
|
|
// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any
|
|
// token by this name the administrator itself still holds — the token the tools used before. A token kept
|
|
// for another account than the one asked (the scopes line says whose) is minted again at the next start.
|
|
//
|
|
// 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, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
import { randomBytes } from "node:crypto";
|
|
import { dirname, join } from "node:path";
|
|
import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js";
|
|
|
|
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
|
|
export const TOKEN_NAME = "mesh-tools";
|
|
|
|
/**
|
|
* The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens):
|
|
* write:repository — create/delete repositories, pull requests (list/get/open/merge);
|
|
* write:issue — issues, comments, labels;
|
|
* read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call.
|
|
* It sits under the `user` category despite listing repositories, not `repository`
|
|
* — confirmed against the running forge (1.27.3), which answered
|
|
* `required=[read:user]` to a token carrying only the other two.
|
|
*
|
|
* **Not write:admin** (novox/hq ADR 0266). It was here so a person could settle the forge's users through
|
|
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
|
|
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
|
|
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
|
|
* beside it), and the old one is deleted from the forge by its id — so the scope a kept token had does not outlive this.
|
|
*
|
|
* A token kept from before a scope was added lacks it: the forge answers such a call with
|
|
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
|
|
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
|
|
*/
|
|
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
|
|
|
|
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
|
|
export interface TokenSource {
|
|
/** The token to authenticate with now; minted, read or configured. */
|
|
current(): Promise<string>;
|
|
/** The forge answered 401 to `rejected`, or 403 for a scope it lacks. 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[];
|
|
/**
|
|
* The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after
|
|
* `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the
|
|
* admin's own, as it was before.
|
|
*/
|
|
readonly account?: string;
|
|
/** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */
|
|
readonly setUp?: (password: string) => Promise<void>;
|
|
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 account: 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.account = opts.account ?? opts.admin;
|
|
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: two sources in one process would each renew on a 401.
|
|
// This covers one process only. The watcher and the tools are two processes (the runtime starts
|
|
// each entrypoint as its own), and those are kept apart by the mint's lock file (mint, below).
|
|
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(", ")}`);
|
|
}
|
|
// The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it.
|
|
const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT;
|
|
if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`);
|
|
return {
|
|
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account,
|
|
// GiteaAdmin is loaded when first used: client.ts imports this file.
|
|
setUp: async (password) => {
|
|
const { GiteaAdmin } = await import("./client.js");
|
|
await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
|
|
},
|
|
};
|
|
}
|
|
|
|
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.keptScopes() === this.scopesLine()) {
|
|
this.held = kept;
|
|
this.log(`reusing the token kept at ${this.opts.file}`);
|
|
// A retirement of the admin's old token that failed after the mint is tried again, once a process.
|
|
if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`));
|
|
return kept;
|
|
}
|
|
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept);
|
|
}
|
|
return this.mint("no token kept — minting one", null);
|
|
}
|
|
|
|
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", rejected);
|
|
}
|
|
|
|
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
|
|
static lacksScope(status: number, body: string): boolean {
|
|
return status === 403 && /required scope/i.test(body);
|
|
}
|
|
|
|
/**
|
|
* One mint at a time: concurrent first calls in this process share it, and a mint in another process
|
|
* on this machine is waited for (the lock file). Once the lock is held, the kept file is read again:
|
|
* a token there that is not `stale` (the one rejected, or kept with other scopes) and carries the
|
|
* scopes asked for was minted by the process that held the lock before — it is reused.
|
|
*/
|
|
private mint(why: string, stale: string | null): Promise<string> {
|
|
if (this.inflight === null) {
|
|
this.log(why);
|
|
this.inflight = this.locked(async () => {
|
|
const kept = this.read();
|
|
if (kept !== null && kept !== stale && this.keptScopes() === this.scopesLine()) {
|
|
this.held = kept;
|
|
this.log(`another process minted it meanwhile — reusing the token kept at ${this.opts.file}`);
|
|
return kept;
|
|
}
|
|
return this.doMint();
|
|
}).finally(() => {
|
|
this.inflight = null;
|
|
});
|
|
}
|
|
return this.inflight;
|
|
}
|
|
|
|
/** How long a mint may hold the lock before another process takes it as left behind, and how long one waits. */
|
|
static readonly LOCK_STALE_MS = 120_000;
|
|
static readonly LOCK_WAIT_MS = 150_000;
|
|
|
|
/**
|
|
* Run `work` holding the lock file beside the kept token: created exclusively, holding this process's
|
|
* pid, the time and a nonce; removed after, only when it is still ours. A lock whose process is gone,
|
|
* or older than LOCK_STALE_MS, was left by a process that died mid-mint and is taken over — never
|
|
* waited on for ever, which would be the same lock-out by another road.
|
|
*/
|
|
private async locked<T>(work: () => Promise<T>): Promise<T> {
|
|
const lock = `${this.opts.file}.lock`;
|
|
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
|
|
const mine = `${process.pid} ${Date.now()} ${randomBytes(8).toString("hex")}\n`;
|
|
const deadline = Date.now() + MintedToken.LOCK_WAIT_MS;
|
|
for (;;) {
|
|
try {
|
|
const fd = openSync(lock, "wx", 0o600);
|
|
try { writeSync(fd, mine); } finally { closeSync(fd); }
|
|
break;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
throw new Error(`cannot take the mint lock at ${lock}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (this.leftBehind(lock)) {
|
|
this.log(`the mint lock at ${lock} was left by a process that is gone — taking it over`);
|
|
try { unlinkSync(lock); } catch { /* another waiter took it over first */ }
|
|
continue;
|
|
}
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`another process has held the mint lock at ${lock} for over ${MintedToken.LOCK_WAIT_MS / 1000}s — not minting beside it`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 50)));
|
|
}
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
try {
|
|
if (readFileSync(lock, "utf8") === mine) unlinkSync(lock);
|
|
} catch { /* already gone */ }
|
|
}
|
|
}
|
|
|
|
/** Whether the lock's holder is gone: its pid answers no signal, or it is older than LOCK_STALE_MS. */
|
|
private leftBehind(lock: string): boolean {
|
|
let text: string;
|
|
let age: number;
|
|
try {
|
|
text = readFileSync(lock, "utf8");
|
|
age = Date.now() - statSync(lock).mtimeMs;
|
|
} catch {
|
|
return false; // released meanwhile: the next attempt takes it
|
|
}
|
|
if (age > MintedToken.LOCK_STALE_MS) return true;
|
|
const pid = Number(text.split(" ")[0]);
|
|
if (!Number.isInteger(pid) || pid <= 0) return age > 1_000; // half-written, or not ours: give it a moment
|
|
try {
|
|
process.kill(pid, 0);
|
|
return false;
|
|
} catch (err) {
|
|
return (err as NodeJS.ErrnoException).code === "ESRCH";
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */
|
|
private scopesLine(): string {
|
|
const scopes = [...this.scopes].sort().join(",");
|
|
return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`;
|
|
}
|
|
|
|
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
|
private keptScopes(): string {
|
|
try {
|
|
return readFileSync(`${this.opts.file}.scopes`, "utf8").trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. Its scopes beside it. */
|
|
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);
|
|
writeFileSync(`${this.opts.file}.scopes.tmp`, this.scopesLine() + "\n", { mode: 0o600 });
|
|
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
|
}
|
|
|
|
/** One call to the forge's API with the admin login, the password read now. */
|
|
private password(): string {
|
|
try {
|
|
return 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}`);
|
|
}
|
|
}
|
|
|
|
private adminCall(password = this.password()): AdminCall {
|
|
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
|
|
const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`;
|
|
const admin: AdminCall = async (method, path, body) => {
|
|
const res = await this.fetchImpl(api + 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 };
|
|
};
|
|
return admin;
|
|
}
|
|
|
|
/** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */
|
|
private get retireFile(): string {
|
|
return `${this.opts.file}.retire-admin`;
|
|
}
|
|
|
|
/**
|
|
* The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR
|
|
* 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without
|
|
* a token: it is said, remembered beside the kept token, and tried again when a process next reads that token.
|
|
*/
|
|
private async retireAdmin(admin: AdminCall = this.adminCall()): Promise<void> {
|
|
if (this.account === this.opts.admin) return;
|
|
try {
|
|
const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin);
|
|
if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`);
|
|
rmSync(this.retireFile, { force: true });
|
|
} catch (err) {
|
|
writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
private async doMint(): Promise<string> {
|
|
const password = this.password();
|
|
const admin = this.adminCall(password);
|
|
const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`;
|
|
const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body);
|
|
|
|
if (this.account !== this.opts.admin) {
|
|
// The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again
|
|
// at the next call.
|
|
const me = await admin("GET", "/user");
|
|
if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status);
|
|
await this.opts.setUp?.(password);
|
|
}
|
|
|
|
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, or two processes once minted at once and left two. It is
|
|
// ours to replace: find every one by this name in the account's list and delete each by its id
|
|
// (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint.
|
|
await this.dropNamed(admin, tokensOf(this.account), this.account, true);
|
|
res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
|
}
|
|
if (res.status !== 201 && res.status !== 200) {
|
|
throw new Error(`Gitea POST /users/${this.account}/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.account}/tokens: ${res.status} but no token in the reply`);
|
|
|
|
this.keep(token);
|
|
this.held = token;
|
|
this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
|
// Only now, with the account's own token kept, does the admin's old one go.
|
|
await this.retireAdmin(admin);
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice:
|
|
* "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name
|
|
* taken.
|
|
*/
|
|
private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise<number> {
|
|
const held: number[] = [];
|
|
for (let page = 1; page <= 100; page++) {
|
|
const listed = await admin("GET", `${tokens}?page=${page}&limit=50`);
|
|
if (listed.status !== 200 || !Array.isArray(listed.body)) {
|
|
throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`);
|
|
}
|
|
if (listed.body.length === 0) break;
|
|
for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id);
|
|
}
|
|
if (replacing) {
|
|
this.log(held.length === 1
|
|
? `the forge already holds a token named "${this.name}" — replacing it`
|
|
: `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`);
|
|
}
|
|
for (const id of held) {
|
|
const dropped = await admin("DELETE", `${tokens}/${id}`);
|
|
if (dropped.status !== 204 && dropped.status !== 404) {
|
|
throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`);
|
|
}
|
|
}
|
|
return held.length;
|
|
}
|
|
}
|
|
|
|
function detail(body: unknown): string {
|
|
return typeof body === "string" ? body : JSON.stringify(body);
|
|
}
|