/** * Gitea API client for Vibn project provisioning. * * Used server-side only. Credentials come from env vars: * GITEA_API_URL — e.g. https://git.vibnai.com * GITEA_API_TOKEN — admin token * GITEA_ADMIN_USER — default owner for repos (e.g. "mark") */ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ''; const GITEA_ADMIN_USER = process.env.GITEA_ADMIN_USER ?? 'mark'; export interface GiteaRepo { id: number; name: string; full_name: string; html_url: string; clone_url: string; ssh_url: string; private: boolean; default_branch: string; } export interface GiteaWebhook { id: number; type: string; active: boolean; config: { url: string; content_type: string; secret?: string; }; } async function giteaFetch(path: string, options: RequestInit = {}) { const url = `${GITEA_API_URL}/api/v1${path}`; const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `token ${GITEA_API_TOKEN}`, ...(options.headers ?? {}), }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Gitea API error ${res.status} on ${path}: ${text}`); } if (res.status === 204) return null; return res.json(); } /** * Create a new repo. By default creates under the admin user. * Pass `owner` to create under a specific user OR org — when the owner * is an org (or any user other than the token holder), Gitea requires * the org-scoped endpoint `/orgs/{owner}/repos`. */ export async function createRepo( name: string, opts: { description?: string; private?: boolean; owner?: string; auto_init?: boolean } = {} ): Promise { const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts; const body = JSON.stringify({ name, description, private: isPrivate, auto_init, default_branch: 'main', }); // Token-owner repos use /user/repos; everything else (orgs, other users) // must go through /orgs/{owner}/repos. const path = owner === GITEA_ADMIN_USER ? `/user/repos` : `/orgs/${owner}/repos`; return giteaFetch(path, { method: 'POST', body }); } // ────────────────────────────────────────────────── // Organizations (per-workspace tenancy) // ────────────────────────────────────────────────── export interface GiteaOrg { id: number; username: string; // org name (Gitea uses "username" for orgs too) full_name: string; description?: string; visibility: 'public' | 'private' | 'limited'; } /** * Create a Gitea organization. Requires the admin token to have * permission to create orgs. */ export async function createOrg(opts: { name: string; fullName?: string; description?: string; visibility?: 'public' | 'private' | 'limited'; }): Promise { const { name, fullName = name, description = '', visibility = 'private' } = opts; return giteaFetch(`/orgs`, { method: 'POST', body: JSON.stringify({ username: name, full_name: fullName, description, visibility, repo_admin_change_team_access: true, }), }); } export async function getOrg(name: string): Promise { try { return await giteaFetch(`/orgs/${name}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('404')) return null; throw err; } } /** * Add a Gitea user to an org's "Owners" team (full access to the org). * Falls back to the org's default team when "Owners" cannot be located. */ export async function addOrgOwner(orgName: string, username: string): Promise { const teams = (await giteaFetch(`/orgs/${orgName}/teams`)) as Array<{ id: number; name: string }>; const owners = teams.find(t => t.name.toLowerCase() === 'owners') ?? teams[0]; if (!owners) throw new Error(`No teams found for org ${orgName}`); await giteaFetch(`/teams/${owners.id}/members/${username}`, { method: 'PUT' }); } export async function getUser(username: string): Promise<{ id: number; login: string } | null> { try { return await giteaFetch(`/users/${username}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('404')) return null; throw err; } } // ────────────────────────────────────────────────── // Admin: create users + mint PATs + team management // (used for per-workspace bot provisioning) // ────────────────────────────────────────────────── export interface GiteaUser { id: number; login: string; full_name?: string; email?: string; } /** * Create a Gitea user via the admin API. Requires the root admin token * to have the site-admin bit. `must_change_password: false` because the * bot never logs in interactively — only the PAT is used. */ export async function createUser(opts: { username: string; email: string; password: string; fullName?: string; }): Promise { return giteaFetch(`/admin/users`, { method: 'POST', body: JSON.stringify({ username: opts.username, email: opts.email, password: opts.password, full_name: opts.fullName ?? opts.username, must_change_password: false, send_notify: false, source_id: 0, }), }); } /** * 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, password, name, scopes = [ 'write:repository', 'write:issue', 'write:user', 'write:organization', ], } = 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: { '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 { id: number; name: string; permission?: string; } export async function listOrgTeams(orgName: string): Promise { return giteaFetch(`/orgs/${orgName}/teams`); } /** * Create a new team inside an org with a scoped permission. * `permission`: "read" | "write" | "admin" | "owner". * Used to give bot users less than full owner access. */ export async function createOrgTeam(opts: { org: string; name: string; description?: string; permission?: 'read' | 'write' | 'admin'; includesAllRepos?: boolean; }): Promise { const { org, name, description = '', permission = 'write', includesAllRepos = true, } = opts; return giteaFetch(`/orgs/${org}/teams`, { method: 'POST', body: JSON.stringify({ name, description, permission, includes_all_repositories: includesAllRepos, can_create_org_repo: true, units: [ 'repo.code', 'repo.issues', 'repo.pulls', 'repo.releases', 'repo.wiki', 'repo.ext_wiki', 'repo.ext_issues', 'repo.projects', 'repo.actions', 'repo.packages', ], }), }); } /** Add a user to a team by id. */ export async function addOrgTeamMember(teamId: number, username: string): Promise { await giteaFetch(`/teams/${teamId}/members/${username}`, { method: 'PUT' }); } /** * Ensure a team exists on an org with the requested permission, and * that `username` is a member of it. Idempotent. */ export async function ensureOrgTeamMembership(opts: { org: string; teamName: string; permission?: 'read' | 'write' | 'admin'; username: string; }): Promise { const { org, teamName, permission = 'write', username } = opts; const teams = await listOrgTeams(org); let team = teams.find(t => t.name.toLowerCase() === teamName.toLowerCase()); if (!team) { team = await createOrgTeam({ org, name: teamName, description: `Vibn ${teamName} team`, permission, }); } await addOrgTeamMember(team.id, username); return team; } // ────────────────────────────────────────────────── // Admin: SSH keys on a user (for Coolify deploy-key flow) // ────────────────────────────────────────────────── export interface GiteaSshKey { id: number; key: string; title: string; fingerprint?: string; read_only?: boolean; } /** * Register an SSH public key under a target user via the admin API. * The resulting key gives anyone holding the matching private key the * same repo-read access as the user (bounded by that user's team * memberships — for bots, usually read/write on one org only). */ export async function adminAddUserSshKey(opts: { username: string; title: string; key: string; // OpenSSH-format public key, e.g. "ssh-ed25519 AAAAC3... comment" readOnly?: boolean; }): Promise { return giteaFetch(`/admin/users/${opts.username}/keys`, { method: 'POST', body: JSON.stringify({ title: opts.title, key: opts.key, read_only: opts.readOnly ?? false, }), }); } /** * List SSH keys for a user (admin view). */ export async function adminListUserSshKeys(username: string): Promise { return giteaFetch(`/users/${username}/keys`); } /** * Delete an SSH key by id (owned by a user). Used when rotating keys. */ export async function adminDeleteUserSshKey(keyId: number): Promise { await giteaFetch(`/admin/users/keys/${keyId}`, { method: 'DELETE' }); } /** * Get an existing repo. */ export async function getRepo(owner: string, repo: string): Promise { try { return await giteaFetch(`/repos/${owner}/${repo}`); } catch { return null; } } /** * Delete a repo (used for project cleanup). */ export async function deleteRepo(owner: string, repo: string): Promise { await giteaFetch(`/repos/${owner}/${repo}`, { method: 'DELETE' }); } /** * Register a webhook on a repo that fires on push, PR, and issue events. * * @param owner Repo owner (user or org) * @param repo Repo name * @param webhookUrl Target URL — should include projectId as query param * @param secret Shared secret for payload signature verification */ export async function createWebhook( owner: string, repo: string, webhookUrl: string, secret: string ): Promise { return giteaFetch(`/repos/${owner}/${repo}/hooks`, { method: 'POST', body: JSON.stringify({ type: 'gitea', active: true, events: ['push', 'pull_request', 'issues', 'issue_comment'], config: { url: webhookUrl, content_type: 'json', secret, }, }), }); } /** * List webhooks on a repo. */ export async function listWebhooks(owner: string, repo: string): Promise { return giteaFetch(`/repos/${owner}/${repo}/hooks`); } /** * Delete a webhook. */ export async function deleteWebhook(owner: string, repo: string, hookId: number): Promise { await giteaFetch(`/repos/${owner}/${repo}/hooks/${hookId}`, { method: 'DELETE' }); } /** * Verify the X-Gitea-Signature-256 header on an incoming webhook payload. * Returns true if the signature matches. */ export async function verifyWebhookSignature( body: string, signature: string, secret: string ): Promise { if (!signature?.startsWith('sha256=')) return false; const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sigBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(body)); const expected = 'sha256=' + Array.from(new Uint8Array(sigBytes)) .map(b => b.toString(16).padStart(2, '0')) .join(''); return expected === signature; } /** * Push a single file to a repo via the Gitea contents API. * Creates the file if it doesn't exist; updates it if it does. */ export async function giteaPushFile( owner: string, repo: string, path: string, content: string, message: string, branch = 'main', ): Promise { const encoded = Buffer.from(content).toString('base64'); // Check if file already exists to get its SHA (required for updates) let sha: string | undefined; try { const existing = await giteaFetch(`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`); sha = (existing as any)?.sha; } catch { // File doesn't exist — create it } await giteaFetch(`/repos/${owner}/${repo}/contents/${path}`, { method: sha ? 'PUT' : 'POST', body: JSON.stringify({ message, content: encoded, branch, ...(sha ? { sha } : {}) }), }); } export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;