feat: Gitea auto-provisioning and webhook context sync

- Add lib/gitea.ts: Gitea API client (createRepo, createWebhook,
  deleteRepo, verifyWebhookSignature)
- Add lib/coolify.ts: Coolify API client (projects, databases,
  applications, deployments)
- Update api/projects/create: auto-creates a private Gitea repo and
  registers a webhook on every new project; stores giteaRepo,
  giteaRepoUrl, giteaCloneUrl, giteaSshUrl, giteaWebhookId in project
  data; Gitea errors are non-fatal so project creation still succeeds
- Add api/webhooks/gitea: handles push, pull_request, issues events;
  verifies HMAC signature; updates contextSnapshot on project record
- Add api/webhooks/coolify: handles deployment status events; updates
  contextSnapshot.lastDeployment on project record

Requires env vars: GITEA_API_URL, GITEA_API_TOKEN, GITEA_ADMIN_USER,
GITEA_WEBHOOK_SECRET, COOLIFY_URL, COOLIFY_API_TOKEN

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-18 14:48:46 -08:00
parent 1f13d4ef74
commit 373bcee8c1
5 changed files with 650 additions and 4 deletions

162
lib/coolify.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Coolify API client for Vibn project provisioning.
*
* Used server-side only. Credentials from env vars:
* COOLIFY_URL — e.g. http://34.19.250.135:8000
* COOLIFY_API_TOKEN — admin bearer token
*/
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000';
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
export interface CoolifyProject {
uuid: string;
name: string;
description?: string;
}
export interface CoolifyDatabase {
uuid: string;
name: string;
type: string;
status: string;
internal_db_url?: string;
external_db_url?: string;
}
export interface CoolifyApplication {
uuid: string;
name: string;
status: string;
fqdn?: string;
git_repository?: string;
git_branch?: string;
}
async function coolifyFetch(path: string, options: RequestInit = {}) {
const url = `${COOLIFY_URL}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
...(options.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Coolify API error ${res.status} on ${path}: ${text}`);
}
if (res.status === 204) return null;
return res.json();
}
// ──────────────────────────────────────────────────
// Projects
// ──────────────────────────────────────────────────
export async function listProjects(): Promise<CoolifyProject[]> {
return coolifyFetch('/projects');
}
export async function createProject(name: string, description?: string): Promise<CoolifyProject> {
return coolifyFetch('/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
}
export async function getProject(uuid: string): Promise<CoolifyProject> {
return coolifyFetch(`/projects/${uuid}`);
}
export async function deleteProject(uuid: string): Promise<void> {
await coolifyFetch(`/projects/${uuid}`, { method: 'DELETE' });
}
// ──────────────────────────────────────────────────
// Databases
// ──────────────────────────────────────────────────
type DBType = 'postgresql' | 'mysql' | 'mariadb' | 'redis' | 'mongodb' | 'keydb';
export async function createDatabase(opts: {
projectUuid: string;
name: string;
type: DBType;
serverUuid?: string;
environmentName?: string;
}): Promise<CoolifyDatabase> {
const { projectUuid, name, type, serverUuid = '0', environmentName = 'production' } = opts;
return coolifyFetch(`/databases`, {
method: 'POST',
body: JSON.stringify({
project_uuid: projectUuid,
name,
type,
server_uuid: serverUuid,
environment_name: environmentName,
}),
});
}
export async function getDatabase(uuid: string): Promise<CoolifyDatabase> {
return coolifyFetch(`/databases/${uuid}`);
}
export async function deleteDatabase(uuid: string): Promise<void> {
await coolifyFetch(`/databases/${uuid}`, { method: 'DELETE' });
}
// ──────────────────────────────────────────────────
// Applications
// ──────────────────────────────────────────────────
export async function createApplication(opts: {
projectUuid: string;
name: string;
gitRepo: string; // e.g. "https://git.vibnai.com/mark/taskmaster.git"
gitBranch?: string;
serverUuid?: string;
environmentName?: string;
buildPack?: string; // nixpacks, static, dockerfile
ports?: string; // e.g. "3000"
}): Promise<CoolifyApplication> {
const {
projectUuid, name, gitRepo,
gitBranch = 'main',
serverUuid = '0',
environmentName = 'production',
buildPack = 'nixpacks',
ports = '3000',
} = opts;
return coolifyFetch(`/applications`, {
method: 'POST',
body: JSON.stringify({
project_uuid: projectUuid,
name,
git_repository: gitRepo,
git_branch: gitBranch,
server_uuid: serverUuid,
environment_name: environmentName,
build_pack: buildPack,
ports_exposes: ports,
}),
});
}
export async function deployApplication(uuid: string): Promise<{ deployment_uuid: string }> {
return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' });
}
export async function getApplication(uuid: string): Promise<CoolifyApplication> {
return coolifyFetch(`/applications/${uuid}`);
}
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
}

166
lib/gitea.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* 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 under the admin user (or a specified owner).
*/
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;
return giteaFetch(`/user/repos`, {
method: 'POST',
body: JSON.stringify({
name,
description,
private: isPrivate,
auto_init,
default_branch: 'main',
}),
});
}
/**
* 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;
}
export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;