From 373bcee8c1d970fbb615794712e8a61524c5cb42 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 18 Feb 2026 14:48:46 -0800 Subject: [PATCH] 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 --- app/api/projects/create/route.ts | 70 ++++++++++- app/api/webhooks/coolify/route.ts | 62 ++++++++++ app/api/webhooks/gitea/route.ts | 194 ++++++++++++++++++++++++++++++ lib/coolify.ts | 162 +++++++++++++++++++++++++ lib/gitea.ts | 166 +++++++++++++++++++++++++ 5 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 app/api/webhooks/coolify/route.ts create mode 100644 app/api/webhooks/gitea/route.ts create mode 100644 lib/coolify.ts create mode 100644 lib/gitea.ts diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 26c7989..b13e12a 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -3,8 +3,13 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; +import { createRepo, createWebhook, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; +const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; +const APP_URL = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? 'https://vibnai.com'; +const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-secret'; + export async function POST(request: Request) { try { const session = await getServerSession(authOptions); @@ -14,7 +19,7 @@ export async function POST(request: Request) { const email = session.user.email; - // Resolve Firebase user ID and workspace from fs_users + // Resolve user record from fs_users const users = await query<{ id: string; data: any }>(` SELECT id, data FROM fs_users WHERE data->>'email' = $1 LIMIT 1 `, [email]); @@ -49,6 +54,44 @@ export async function POST(request: Request) { const projectId = randomUUID(); const now = new Date().toISOString(); + // ────────────────────────────────────────────── + // 1. Provision Gitea repo + // ────────────────────────────────────────────── + let giteaRepo: string | null = null; + let giteaRepoUrl: string | null = null; + let giteaCloneUrl: string | null = null; + let giteaSshUrl: string | null = null; + let giteaWebhookId: number | null = null; + let giteaError: string | null = null; + + try { + const repoName = slug; // e.g. "taskmaster" + const repo = await createRepo(repoName, { + description: `${projectName} — managed by Vibn`, + private: true, + auto_init: true, + }); + + giteaRepo = repo.full_name; // e.g. "mark/taskmaster" + giteaRepoUrl = repo.html_url; // e.g. "https://git.vibnai.com/mark/taskmaster" + giteaCloneUrl = repo.clone_url; + giteaSshUrl = repo.ssh_url; + + // 2. Register webhook on the repo pointing back to Vibn + const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; + const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET); + giteaWebhookId = hook.id; + + console.log(`[API] Gitea repo created: ${giteaRepo}, webhook: ${giteaWebhookId}`); + } catch (err) { + // Non-fatal — log and continue. Project is still created. + giteaError = err instanceof Error ? err.message : String(err); + console.error('[API] Gitea provisioning failed (non-fatal):', giteaError); + } + + // ────────────────────────────────────────────── + // 3. Save project record + // ────────────────────────────────────────────── const projectData = { id: projectId, name: projectName, @@ -77,6 +120,15 @@ export async function POST(request: Request) { phaseStatus: 'not_started', phaseData: {} as ProjectPhaseData, phaseScores: {} as ProjectPhaseScores, + // Gitea provisioning + giteaRepo, + giteaRepoUrl, + giteaCloneUrl, + giteaSshUrl, + giteaWebhookId, + giteaError, + // Context snapshot (kept fresh by webhooks) + contextSnapshot: null, createdAt: now, updatedAt: now, }; @@ -86,7 +138,7 @@ export async function POST(request: Request) { VALUES ($1, $2::jsonb, $3, $4, $5) `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]); - // Associate unlinked sessions for this workspace path + // Associate any unlinked sessions for this workspace path if (workspacePath) { await query(` UPDATE fs_sessions @@ -100,8 +152,18 @@ export async function POST(request: Request) { `, [JSON.stringify(projectId), firebaseUserId, workspacePath]); } - console.log('[API] Created project', projectId, slug); - return NextResponse.json({ success: true, projectId, slug, workspace }); + console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped'); + + return NextResponse.json({ + success: true, + projectId, + slug, + workspace, + gitea: giteaRepo + ? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl } + : null, + giteaError: giteaError ?? undefined, + }); } catch (error) { console.error('[POST /api/projects/create] Error:', error); return NextResponse.json( diff --git a/app/api/webhooks/coolify/route.ts b/app/api/webhooks/coolify/route.ts new file mode 100644 index 0000000..cdbe451 --- /dev/null +++ b/app/api/webhooks/coolify/route.ts @@ -0,0 +1,62 @@ +/** + * POST /api/webhooks/coolify?projectId={projectId} + * + * Receives deployment status events from Coolify. + * Updates the project's contextSnapshot.lastDeployment in Postgres. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db-postgres'; + +export async function POST(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get('projectId'); + if (!projectId) { + return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); + } + + let payload: any; + try { + payload = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const rows = await query<{ id: string; data: any }>( + `SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const project = rows[0]; + const existingSnapshot = project.data?.contextSnapshot ?? {}; + + // Coolify sends status events like: queued, in_progress, finished, failed, cancelled + const status = payload.status ?? payload.data?.status ?? 'unknown'; + const applicationUuid = payload.application_uuid ?? payload.data?.application_uuid; + const deploymentUuid = payload.deployment_uuid ?? payload.data?.deployment_uuid; + const url = payload.fqdn ?? payload.data?.fqdn ?? null; + + const newSnapshot = { + ...existingSnapshot, + lastDeployment: { + status, + applicationUuid, + deploymentUuid, + url, + timestamp: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + }; + + await query(` + UPDATE fs_projects + SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb) + WHERE id = $2 + `, [JSON.stringify(newSnapshot), projectId]); + + console.log(`[webhook/coolify] deploy ${status} for project ${projectId}`); + return NextResponse.json({ ok: true, status, projectId }); +} diff --git a/app/api/webhooks/gitea/route.ts b/app/api/webhooks/gitea/route.ts new file mode 100644 index 0000000..d2c2149 --- /dev/null +++ b/app/api/webhooks/gitea/route.ts @@ -0,0 +1,194 @@ +/** + * POST /api/webhooks/gitea?projectId={projectId} + * + * Receives push, pull_request, issues, and issue_comment events from Gitea. + * Verifies the HMAC signature, then updates the project's contextSnapshot + * in Postgres so the AI always has fresh context at the start of new chats. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db-postgres'; +import { verifyWebhookSignature } from '@/lib/gitea'; + +const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-secret'; + +// ────────────────────────────────────────────────── +// Gitea payload shapes (minimal — we only read what we need) +// ────────────────────────────────────────────────── + +interface GitCommit { + id: string; + message: string; + timestamp: string; + author: { name: string; email: string }; + url: string; +} + +interface PushPayload { + ref: string; + after: string; + commits: GitCommit[]; + repository: { full_name: string; html_url: string }; + pusher: { login: string }; +} + +interface PullRequestPayload { + action: string; // opened, closed, reopened, synchronized + number: number; + pull_request: { + title: string; + html_url: string; + state: string; + merged: boolean; + head: { label: string }; + base: { label: string }; + }; + repository: { full_name: string }; +} + +interface IssuePayload { + action: string; // opened, closed, reopened + issue: { + number: number; + title: string; + html_url: string; + state: string; + body?: string; + labels?: { name: string }[]; + }; + repository: { full_name: string }; +} + +// ────────────────────────────────────────────────── +// Handler +// ────────────────────────────────────────────────── + +export async function POST(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get('projectId'); + if (!projectId) { + return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); + } + + const rawBody = await request.text(); + const signature = request.headers.get('x-gitea-signature-256') ?? ''; + const event = request.headers.get('x-gitea-event') ?? 'unknown'; + + // Verify HMAC signature + const valid = await verifyWebhookSignature(rawBody, signature, GITEA_WEBHOOK_SECRET); + if (!valid) { + console.warn(`[webhook/gitea] Invalid signature for project ${projectId}`); + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Fetch existing project snapshot + const rows = await query<{ id: string; data: any }>( + `SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + + if (rows.length === 0) { + console.warn(`[webhook/gitea] Project not found: ${projectId}`); + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const project = rows[0]; + const existingSnapshot = project.data?.contextSnapshot ?? {}; + + // Build updated snapshot based on event type + const updatedAt = new Date().toISOString(); + let snapshotPatch: Record = { updatedAt }; + + if (event === 'push') { + const push = payload as PushPayload; + const branch = push.ref?.replace('refs/heads/', '') ?? 'unknown'; + const latestCommit = push.commits?.[0]; + + snapshotPatch.lastCommit = latestCommit + ? { + sha: latestCommit.id, + message: latestCommit.message, + author: latestCommit.author?.name, + timestamp: latestCommit.timestamp, + url: latestCommit.url, + } + : existingSnapshot.lastCommit; + + snapshotPatch.currentBranch = branch; + snapshotPatch.recentCommits = [ + ...(push.commits ?? []).slice(0, 5).map(c => ({ + sha: c.id.slice(0, 8), + message: c.message.split('\n')[0], + author: c.author?.name, + timestamp: c.timestamp, + })), + ...(existingSnapshot.recentCommits ?? []), + ].slice(0, 10); + + console.log(`[webhook/gitea] push to ${branch} on project ${projectId}`); + } else if (event === 'pull_request') { + const pr = payload as PullRequestPayload; + const openPRs: any[] = existingSnapshot.openPRs ?? []; + + if (pr.action === 'opened' || pr.action === 'reopened') { + // Add or update + const existing = openPRs.findIndex((p: any) => p.number === pr.number); + const entry = { + number: pr.number, + title: pr.pull_request.title, + url: pr.pull_request.html_url, + state: pr.pull_request.state, + from: pr.pull_request.head.label, + into: pr.pull_request.base.label, + }; + if (existing >= 0) openPRs[existing] = entry; + else openPRs.push(entry); + } else if (pr.action === 'closed') { + // Remove closed/merged PR from open list + const idx = openPRs.findIndex((p: any) => p.number === pr.number); + if (idx >= 0) openPRs.splice(idx, 1); + } + + snapshotPatch.openPRs = openPRs; + console.log(`[webhook/gitea] PR #${pr.number} ${pr.action} on project ${projectId}`); + } else if (event === 'issues') { + const iss = payload as IssuePayload; + const openIssues: any[] = existingSnapshot.openIssues ?? []; + + if (iss.action === 'opened' || iss.action === 'reopened') { + const existing = openIssues.findIndex((i: any) => i.number === iss.issue.number); + const entry = { + number: iss.issue.number, + title: iss.issue.title, + url: iss.issue.html_url, + state: iss.issue.state, + labels: (iss.issue.labels ?? []).map(l => l.name), + }; + if (existing >= 0) openIssues[existing] = entry; + else openIssues.push(entry); + } else if (iss.action === 'closed') { + const idx = openIssues.findIndex((i: any) => i.number === iss.issue.number); + if (idx >= 0) openIssues.splice(idx, 1); + } + + snapshotPatch.openIssues = openIssues; + console.log(`[webhook/gitea] issue #${iss.issue.number} ${iss.action} on project ${projectId}`); + } + + // Merge patch into existing snapshot and persist + const newSnapshot = { ...existingSnapshot, ...snapshotPatch }; + + await query(` + UPDATE fs_projects + SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb) + WHERE id = $2 + `, [JSON.stringify(newSnapshot), projectId]); + + return NextResponse.json({ ok: true, event, projectId }); +} diff --git a/lib/coolify.ts b/lib/coolify.ts new file mode 100644 index 0000000..be35aa4 --- /dev/null +++ b/lib/coolify.ts @@ -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 { + return coolifyFetch('/projects'); +} + +export async function createProject(name: string, description?: string): Promise { + return coolifyFetch('/projects', { + method: 'POST', + body: JSON.stringify({ name, description }), + }); +} + +export async function getProject(uuid: string): Promise { + return coolifyFetch(`/projects/${uuid}`); +} + +export async function deleteProject(uuid: string): Promise { + 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 { + 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 { + return coolifyFetch(`/databases/${uuid}`); +} + +export async function deleteDatabase(uuid: string): Promise { + 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 { + 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 { + return coolifyFetch(`/applications/${uuid}`); +} + +export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> { + return coolifyFetch(`/deployments/${deploymentUuid}/logs`); +} diff --git a/lib/gitea.ts b/lib/gitea.ts new file mode 100644 index 0000000..04ae6dc --- /dev/null +++ b/lib/gitea.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return giteaFetch(`/repos/${owner}/${repo}/hooks`); +} + +/** + * Delete a webhook. + */ +export async function deleteWebhook(owner: string, repo: string, hookId: number): Promise { + 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 { + 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;