Coolify was failing all Gitea clones with "Permission denied (publickey)"
because the helper container's SSH hits git.vibnai.com:22 (Ubuntu host
sshd, which doesn't know Gitea keys), while Gitea's builtin SSH is on
host port 22222 (not publicly reachable).
Rather than fight the SSH topology, switch every Vibn-provisioned app
to clone over HTTPS with the workspace bot's PAT embedded in the URL.
The PAT is already stored encrypted per workspace and scoped to that
org, so this gives equivalent isolation with zero SSH dependency.
Changes:
- lib/naming.ts: add giteaHttpsUrl() + redactGiteaHttpsUrl(); mark
giteaSshUrl() as deprecated-for-deploys with a comment.
- lib/coolify.ts: extend CreatePublicAppOpts with install/build/start
commands, base_directory, dockerfile_location, docker_compose_location,
manual_webhook_secret_gitea so it's at parity with the SSH variant.
- app/api/mcp/route.ts:
- apps.create now uses createPublicApp(giteaHttpsUrl(...)) and pulls
the bot PAT via getWorkspaceBotCredentials(). No more private-
deploy-key path for new apps.
- apps.update adds git_commit_sha + docker_compose_location to the
whitelist.
- New apps.rewire_git tool: re-points an app's git_repository at the
canonical HTTPS+PAT URL. Unblocks older apps stuck on SSH URLs
and provides a path for PAT rotation without rebuilding the app.
- lib/gitea.ts: createUser() now issues an immediate PATCH to set
active: true. Gitea's admin-create endpoint creates users as inactive
by default, and inactive users fail permission checks even though
they're org members. GiteaUser gains optional `active` field.
- scripts/activate-workspace-bots.ts: idempotent backfill that flips
active=true for any existing workspace bot that was created before
this fix. Safe to re-run.
- AI_CAPABILITIES.md: document apps.rewire_git; clarify apps.create
uses HTTPS+PAT (no SSH).
Already unblocked in prod for the mark workspace:
- vibn-bot-mark activated.
- twenty-crm's git_repository PATCHed to HTTPS+PAT form; git clone
now succeeds (remaining unrelated error: docker-compose file path).
Made-with: Cursor
525 lines
15 KiB
TypeScript
525 lines
15 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;
|
|
active?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const created = await 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,
|
|
}),
|
|
});
|
|
|
|
// Gitea's admin-create endpoint returns users with `active=false` by
|
|
// default (a quirk of the admin API — UI-created users skip email
|
|
// verification but API-created ones don't). Inactive users fail
|
|
// permission checks and cannot clone private repos, so we flip the
|
|
// flag immediately via a PATCH. Idempotent: a second call is a noop.
|
|
try {
|
|
await giteaFetch(`/admin/users/${opts.username}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
source_id: 0,
|
|
login_name: opts.username,
|
|
active: true,
|
|
}),
|
|
});
|
|
(created as GiteaUser).active = true;
|
|
} catch (err) {
|
|
console.warn('[gitea] failed to activate bot user', opts.username, err);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* 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;
|