From d9d3514647f4deeb0666385939152027e4d3dc3c Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 21 Apr 2026 10:58:25 -0700 Subject: [PATCH] fix(gitea-bot): mint PAT via Basic auth, not Sudo header Gitea's POST /users/{name}/tokens is explicitly Basic-auth only; neither the admin token nor Sudo header is accepted. Keep the random password we generate at createUser time and pass it straight into createAccessTokenFor as Basic auth. For bots that already exist from a half-failed previous provision run, reset their password via PATCH /admin/users/{name} so we can Basic-auth as them and mint a fresh token. Made-with: Cursor --- lib/gitea.ts | 54 +++++++++++++++++++++++++++++++++++++++++------ lib/workspaces.ts | 20 +++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) 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'], });