Files
mesh-catalog/modules/gitea/packages-account.ts
T
jschoubben 4492bc3013 CAT-G2: the packages token is renewed on the forge's refusal, said as a finding, never on a timer
The Go bundle raises packages-token-refused in the module's findings state when the forge
answers 401 to a write; the watcher mints afresh on that key's event and clears it (lead's
ruling on A3n, map-onto-how-the-mesh-is-built: nothing waits on a timer).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:47:26 +01:00

114 lines
6.8 KiB
TypeScript

// The package seats write as an account of their own, never the administrator (design 51: each holder keeps its write
// side as an own secret beside a read-only tool token; the forge module's is `write:package`, its tool token
// `read:package`).
//
// The Go bundle (cmd/package-registry) serves both package seats' verbs: it reads the registry with the tools' token
// (token.ts, which carries `read:package`) and writes (a publish, a remove, retention's deletion) with this one:
//
// - **the account**: `mesh-packages`, plain, made by the module with the administrator's login as tools-account.ts
// makes `mesh-tools`; its password random, sent once and kept nowhere.
// - **the team**: `mesh-packages` in the organisation that owns the registry, writing packages and nothing else:
// no code, no issues, no pull requests, no administration.
// - **the token**: minted for the account by the administrator's login with the one scope `write:package`, kept at
// 0600 in the module's runtime state as `packages-token`, its scopes line beside it saying whose it is
// (MintedToken: one mint at a time across the bundle's processes, re-minted when the kept one is gone or other).
//
// The watcher mints it at its start (keepPackagesToken). No timer looks at it after that: the Go bundle mints nothing,
// and when the forge refuses its write it raises the finding PACKAGES_TOKEN_REFUSED in the module's findings state; the
// watcher, watching that key, looks again (a refused token is minted afresh) and clears the finding (renewOnRefusal).
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { MintedToken } from "./token.js";
import { TOOLS_ORG, type ToolsForge } from "./tools-account.js";
/** The account the package seats write as (internal/forgehttp PackagesAccount). */
export const PACKAGES_ACCOUNT = "mesh-packages";
/** Its team in the registry's organisation, and the token's name in the account's list. */
export const PACKAGES_TEAM = "mesh-packages";
export const PACKAGES_TOKEN_NAME = "mesh-packages";
/** The one scope the write side needs; reads go with the tools' token. */
export const PACKAGES_TOKEN_SCOPES: readonly string[] = ["write:package"];
/** Where the token is kept in the module's runtime state (the manifest's MESH_GITEA_PACKAGES_TOKEN_FILE). */
export const PACKAGES_TOKEN_FILE = "packages-token";
/** What the team grants: write on the organisation's packages, and no more. */
export const PACKAGES_TEAM_UNITS = {
units_map: { "repo.packages": "write" },
includes_all_repositories: false,
can_create_org_repo: false,
} as const;
/** Make the packages account, plain, a member of its team. Its password is set afresh each time and kept nowhere. */
export async function setUpPackagesAccount(forge: ToolsForge, account = PACKAGES_ACCOUNT, password: () => string): Promise<void> {
await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true);
const team = await forge.ensureTeamWith(TOOLS_ORG, PACKAGES_TEAM, { name: PACKAGES_TEAM, ...PACKAGES_TEAM_UNITS });
await forge.addUserToTeam(team, account);
}
/** The packages account's token, minted by the administrator's login from what the manifest hands the watcher. */
export function packagesToken(url: string, env: NodeJS.ProcessEnv = process.env, log?: (line: string) => void): MintedToken {
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(`the package seats' write token cannot be minted: ${missing.join(", ")} not set by the manifest`);
const account = env.MESH_GITEA_PACKAGES_USER || PACKAGES_ACCOUNT;
if (account === admin) throw new Error(`MESH_GITEA_PACKAGES_USER names the admin account "${admin}"; the package seats write as an account of their own`);
return new MintedToken({
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, PACKAGES_TOKEN_FILE), account,
name: PACKAGES_TOKEN_NAME, scopes: PACKAGES_TOKEN_SCOPES, log,
setUp: async (password) => {
const { GiteaAdmin } = await import("./client.js");
await setUpPackagesAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
},
});
}
/**
* One look at the packages token: mint it when none is kept, then ask the forge one read the scope covers (the
* organisation's package list); a 401 renews it. Answers what was done, for the watcher's line.
*/
export async function keepPackagesToken(source: MintedToken, url: string, owner = TOOLS_ORG, fetchImpl: typeof fetch = fetch): Promise<string> {
const token = await source.current();
const look = (t: string) => fetchImpl(`${url.replace(/\/+$/, "")}/api/v1/packages/${encodeURIComponent(owner)}?limit=1`,
{ headers: { Authorization: `token ${t}`, Accept: "application/json" } });
const res = await look(token);
if (res.status === 401 || MintedToken.lacksScope(res.status, await res.text())) {
const fresh = await source.renew(token);
const again = await look(fresh);
if (!again.ok) throw new Error(`the forge answered ${again.status} to the renewed package token's read`);
return "the forge rejected the package seats' write token; minted afresh";
}
if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`);
return "the package seats' write token holds";
}
/** The finding the Go bundle raises in the module's findings state when the forge refuses the write token (cmd/package-registry PackagesTokenRefusedKey). */
export const PACKAGES_TOKEN_REFUSED = "seat.gitea.packages-token-refused_write";
/** One change of the findings state, as its watch hands it. */
export interface FindingChange { readonly key: string; readonly op: "put" | "delete" }
/**
* The watcher's answer to the Go bundle's refusal: on the finding's put, look at the token (a refused one is minted
* afresh) and clear the finding; a look that fails leaves the finding standing, said. Other keys are not its own.
*/
export function renewOnRefusal(source: MintedToken, url: string, findings: { delete(key: string): Promise<void> },
log: (line: string) => void, fetchImpl: typeof fetch = fetch): (change: FindingChange) => Promise<void> {
return async (change) => {
if (change.op !== "put" || change.key !== PACKAGES_TOKEN_REFUSED) return;
try {
log(await keepPackagesToken(source, url, TOOLS_ORG, fetchImpl));
} catch (err) {
log(`the package seats' write token was refused and could not be minted afresh; the finding stands — ${err instanceof Error ? err.message : err}`);
return;
}
await findings.delete(change.key);
};
}