Files
vibn-frontend/lib/gitea.ts
Mark Henderson 0797717bc1 Phase 4: AI-driven app/database/auth lifecycle
Workspace-owned deploy infra so AI agents can create and destroy
Coolify resources without ever touching the root admin token.

  vibn_workspaces
    + coolify_server_uuid, coolify_destination_uuid
    + coolify_environment_name (default "production")
    + coolify_private_key_uuid, gitea_bot_ssh_key_id

  ensureWorkspaceProvisioned
    + generates an ed25519 keypair per workspace
    + pushes pubkey to the Gitea bot user (read/write scoped by team)
    + registers privkey in Coolify as a reusable deploy key

  New endpoints under /api/workspaces/[slug]/
    apps/                POST (private-deploy-key from Gitea repo)
    apps/[uuid]          PATCH, DELETE?confirm=<name>
    apps/[uuid]/domains  GET, PATCH (policy: *.{ws}.vibnai.com only)
    databases/           GET, POST (8 types incl. postgres, clickhouse, dragonfly)
    databases/[uuid]     GET, PATCH, DELETE?confirm=<name>
    auth/                GET, POST (Pocketbase, Authentik, Keycloak, Pocket-ID, Logto, Supertokens)
    auth/[uuid]          DELETE?confirm=<name>

  MCP (/api/mcp) gains 15 new tools that mirror the REST surface and
  enforce the same workspace tenancy + delete-confirm guard.

  Safety: destructive ops require ?confirm=<exact-resource-name>; volumes
  are kept by default (pass delete_volumes=true to drop).

Made-with: Cursor
2026-04-21 12:04:59 -07:00

504 lines
14 KiB
TypeScript

/**
* 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<GiteaRepo> {
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<GiteaOrg> {
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<GiteaOrg | null> {
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<void> {
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<GiteaUser> {
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<GiteaUser> {
const body: Record<string, unknown> = {
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<GiteaTeam[]> {
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<GiteaTeam> {
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<void> {
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<GiteaTeam> {
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<GiteaSshKey> {
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<GiteaSshKey[]> {
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<void> {
await giteaFetch(`/admin/users/keys/${keyId}`, { method: 'DELETE' });
}
/**
* Get an existing repo.
*/
export async function getRepo(owner: string, repo: string): Promise<GiteaRepo | null> {
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<void> {
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<GiteaWebhook> {
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<GiteaWebhook[]> {
return giteaFetch(`/repos/${owner}/${repo}/hooks`);
}
/**
* Delete a webhook.
*/
export async function deleteWebhook(owner: string, repo: string, hookId: number): Promise<void> {
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<boolean> {
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<void> {
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;