diff --git a/lib/gitea.ts b/lib/gitea.ts index e1045f8..10ca3a5 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -183,22 +183,62 @@ export async function createUser(opts: { } /** - * Mint a Personal Access Token on behalf of another user. This uses - * Gitea's `Sudo` header — only works when GITEA_API_TOKEN is a site-admin - * token. Returns the plaintext token string, which must be stored - * encrypted (Gitea never shows it again). + * Mint a Personal Access Token for a user. + * + * Gitea's `POST /users/{username}/tokens` endpoint is explicitly Basic-auth + * only: neither an admin token nor the `Sudo` header work. So we require + * the target user's password (we know it — we just created the user with + * a random one and can discard it right after). + * + * Returns the plaintext token (`sha1` field) — store it encrypted; + * Gitea never shows it again. */ export async function createAccessTokenFor(opts: { username: string; + password: string; name: string; scopes?: string[]; }): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> { - const { username, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts; - return giteaFetch(`/users/${username}/tokens`, { + const { username, password, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts; + const basic = Buffer.from(`${username}:${password}`).toString('base64'); + const url = `${GITEA_API_URL}/api/v1/users/${username}/tokens`; + const res = await fetch(url, { method: 'POST', - headers: { Sudo: username }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${basic}`, + }, body: JSON.stringify({ name, scopes }), }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Gitea token-mint error ${res.status} on /users/${username}/tokens: ${text}`); + } + return res.json(); +} + +/** + * Admin: edit an existing user. Used during re-provisioning to reset + * the bot's password so we can Basic-auth as it and mint a fresh PAT. + */ +export async function adminEditUser(opts: { + username: string; + password?: string; + fullName?: string; + email?: string; +}): Promise { + const body: Record = { + source_id: 0, + // Editing requires specifying login name explicitly. + login_name: opts.username, + }; + if (opts.password) body.password = opts.password; + if (opts.fullName) body.full_name = opts.fullName; + if (opts.email) body.email = opts.email; + return giteaFetch(`/admin/users/${opts.username}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); } export interface GiteaTeam { diff --git a/lib/workspaces.ts b/lib/workspaces.ts index 9c0458c..d7263ec 100644 --- a/lib/workspaces.ts +++ b/lib/workspaces.ts @@ -28,6 +28,7 @@ import { createUser, createAccessTokenFor, ensureOrgTeamMembership, + adminEditUser, } from '@/lib/gitea'; import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box'; @@ -254,17 +255,29 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom try { const wantBot = giteaBotUsernameFor(workspace.slug); - // 1. Ensure the bot user exists. + // 1. Ensure the bot user exists. We *always* generate a fresh + // random password — if the user already exists, we can't + // retrieve its password to auth with, so we'll fail at the + // token-mint step. The `partial` provision status + error + // message tells the operator to delete the bot user in Gitea + // (or reset its password) and re-run. Since this only hurts + // re-provisioning after a half-failed run, we also try to + // reset the password via the admin API in that case. + const password = `bot-${randomBytes(24).toString('base64url')}`; + let existingBot = await getUser(wantBot); if (!existingBot) { const created = await createUser({ username: wantBot, email: giteaBotEmailFor(workspace.slug), - // Password is never used (only the PAT is), but Gitea requires one. - password: `bot-${randomBytes(24).toString('base64url')}`, + password, fullName: `Vibn bot (${workspace.slug})`, }); existingBot = { id: created.id, login: created.login }; + } else { + // Existing bot from a half-failed previous run — reset its + // password so we can basic-auth as it below. + await adminEditUser({ username: wantBot, password }); } botUsername = existingBot.login; botUserId = existingBot.id; @@ -282,6 +295,7 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom if (!botTokenEncrypted) { const pat = await createAccessTokenFor({ username: botUsername, + password, name: `vibn-${workspace.slug}-${Date.now().toString(36)}`, scopes: ['write:repository', 'write:issue', 'write:user'], });