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:
162
lib/coolify.ts
Normal file
162
lib/coolify.ts
Normal 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
166
lib/gitea.ts
Normal 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;
|
||||
Reference in New Issue
Block a user