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
114 lines
6.8 KiB
TypeScript
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);
|
|
};
|
|
}
|