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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user