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
504 lines
14 KiB
TypeScript
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;
|