gitea provides the package registry; the builder gets its npm credential

gitea gains the package-registry provision: serves/receives/grants, an admin
own-secret, a postgres-shaped build, and a provisioner that creates a gitea user
per consumer with the mesh-minted password and seals nothing (hq ADR 0048). The
builder takes its registry credential as an own-secret rather than a resolved
provision, because gitea-as-module needs the base to build its provisioner and so
cannot resolve before the base — a cycle the own-secret avoids.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-16 10:27:26 +02:00
parent abcba14edd
commit 065ddd6d69
6 changed files with 333 additions and 8 deletions
+160
View File
@@ -249,3 +249,163 @@ export class GiteaClient {
};
}
}
/** One raw response the admin client acts on: the status code decides idempotency (a 422/409 on
* create means "already there", a 404 on delete means "already gone"), the body carries ids. */
interface AdminResponse {
readonly status: number;
readonly body: any;
}
/**
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
* by the provisioner (novox/hq ADR 0048/0076).
*
* The token-authenticated {@link GiteaClient} above serves the tools and the event consumer, which
* read repos and open issues. Provisioning is different: it creates and deletes *users* and manages
* org teams — admin-API operations authenticated as the mesh's gitea admin, whose password is a mesh
* own-secret. Basic auth is what the admin API takes, and keeping this separate from GiteaClient
* keeps the two credentials and their two audiences apart.
*
* Every method is idempotent: the reconcile harness calls create repeatedly, so "already exists" is
* success, not an error.
*/
export class GiteaAdmin {
readonly baseUrl: string;
private readonly authorization: string;
constructor(url: string, user: string, password: string) {
this.baseUrl = url.replace(/\/+$/, "");
this.authorization = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}
/**
* Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the forge's
* loopback, since the provisioner shares the host's network), the admin login from
* MESH_GITEA_ADMIN_USER, and the admin password from the file MESH_GITEA_ADMIN_PASSWORD_FILE names
* — the mesh own-secret the host unsealed. Trailing newline trimmed, the way the harness trims a
* sealed secret. Throws rather than hand back a client that fails on first call.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaAdmin {
const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
const user = env.MESH_GITEA_ADMIN_USER;
if (!user) throw new Error("no Gitea admin user — set MESH_GITEA_ADMIN_USER");
const file = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
if (!file) throw new Error("no Gitea admin password file — set MESH_GITEA_ADMIN_PASSWORD_FILE");
const password = readFileSync(file, "utf8").replace(/\n$/, "");
return new GiteaAdmin(url, user, password);
}
/** A single admin-API call. Unlike GiteaClient.request, this returns the status rather than
* throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an
* unexpected status becomes an error, and only where the caller says so. */
private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authorization,
...(options.headers as Record<string, string> | undefined),
},
});
const text = await res.text();
let body: any = null;
if (text) {
try { body = JSON.parse(text); } catch { body = text; }
}
return { status: res.status, body };
}
/** Fail with the forge's own message when a status the caller did not expect comes back. */
private static fail(path: string, res: AdminResponse): never {
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`);
}
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
* create won the race) are all success. */
async ensureOrg(name: string): Promise<void> {
const existing = await this.request(`/orgs/${encodeURIComponent(name)}`);
if (existing.status === 200) return;
const res = await this.request("/orgs", {
method: "POST",
body: JSON.stringify({ username: name, visibility: "private" }),
});
if (res.status === 201 || res.status === 422 || res.status === 409) return;
GiteaAdmin.fail("/orgs", res);
}
/** Ensure the org's package team exists, granting read+write on packages, and return its id. The
* team is found by name if it is already there, created otherwise; a lost create race is resolved
* by re-listing. */
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
const found = await this.findTeam(org, team);
if (found !== null) return found;
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
method: "POST",
body: JSON.stringify({
name: team,
permission: "read",
// Package access is a per-unit grant; the team needs write on the packages unit and nothing
// else. includes_all_repositories keeps the team's repo view whole without widening its
// repo permission beyond read.
units_map: { "repo.packages": packageWrite ? "write" : "read" },
includes_all_repositories: true,
can_create_org_repo: false,
}),
});
if (res.status === 201) return Number(res.body?.id);
if (res.status === 422 || res.status === 409) {
const after = await this.findTeam(org, team);
if (after !== null) return after;
}
return GiteaAdmin.fail(`/orgs/${org}/teams`, res);
}
private async findTeam(org: string, team: string): Promise<number | null> {
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
if (res.status !== 200) return null;
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null;
}
/** Ensure a user exists with exactly this password. Created if absent; if already there, its
* password is patched — so the mesh minting a new secret takes on the next reconcile. */
async ensureUser(username: string, password: string, email: string): Promise<void> {
const res = await this.request("/admin/users", {
method: "POST",
body: JSON.stringify({ username, email, password, must_change_password: false }),
});
if (res.status === 201) return;
if (res.status === 422 || res.status === 409) {
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
// login_name is required by the admin edit endpoint; for a local user it is the username.
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
});
if (patch.status === 200) return;
GiteaAdmin.fail(`/admin/users/${username}`, patch);
}
GiteaAdmin.fail("/admin/users", res);
}
/** Add a user to a team, which also makes them an org member. Idempotent: adding an existing
* member returns 204 again. */
async addUserToTeam(teamId: number, username: string): Promise<void> {
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
method: "PUT",
});
if (res.status === 204 || res.status === 200) return;
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> {
const res = await this.request(`/admin/users/${encodeURIComponent(username)}?purge=true`, {
method: "DELETE",
});
if (res.status === 204 || res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
}