/** * GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace * POST /api/workspaces/[slug]/apps — create a new app from a Gitea repo * * Auth: session OR `Bearer vibn_sk_...`. The workspace's * `coolify_project_uuid` acts as the tenant boundary — any app whose * Coolify project uuid doesn't match is filtered out even if the * token issuer accidentally had wider reach. * * POST body: * { * repo: string, // "my-api" or "{org}/my-api" * branch?: string, // default: "main" * name?: string, // default: derived from repo * ports?: string, // default: "3000" * buildPack?: "nixpacks"|"static"|"dockerfile"|"dockercompose" * domain?: string, // default: {app}.{workspace}.vibnai.com * envs?: Record * instantDeploy?: boolean, // default: true * } */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { listApplicationsInProject, projectUuidOf, createPrivateDeployKeyApp, upsertApplicationEnv, getApplication, deployApplication, } from '@/lib/coolify'; import { slugify, workspaceAppFqdn, toDomainsString, isDomainUnderWorkspace, giteaSshUrl, } from '@/lib/naming'; import { getRepo } from '@/lib/gitea'; export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); if (principal instanceof NextResponse) return principal; const ws = principal.workspace; if (!ws.coolify_project_uuid) { return NextResponse.json( { error: 'Workspace has no Coolify project yet', apps: [] }, { status: 503 } ); } try { const apps = await listApplicationsInProject(ws.coolify_project_uuid); return NextResponse.json({ workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid }, apps: apps.map(a => ({ uuid: a.uuid, name: a.name, status: a.status, fqdn: a.fqdn ?? null, domains: a.domains ?? null, gitRepository: a.git_repository ?? null, gitBranch: a.git_branch ?? null, projectUuid: projectUuidOf(a), })), }); } catch (err) { return NextResponse.json( { error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) }, { status: 502 } ); } } export async function POST( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); if (principal instanceof NextResponse) return principal; const ws = principal.workspace; if ( !ws.coolify_project_uuid || !ws.coolify_private_key_uuid || !ws.gitea_org ) { return NextResponse.json( { error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' }, { status: 503 } ); } type Body = { repo?: string; branch?: string; name?: string; ports?: string; buildPack?: 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose'; domain?: string; envs?: Record; instantDeploy?: boolean; description?: string; baseDirectory?: string; installCommand?: string; buildCommand?: string; startCommand?: string; dockerfileLocation?: string; }; let body: Body = {}; try { body = (await request.json()) as Body; } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } if (!body.repo || typeof body.repo !== 'string') { return NextResponse.json({ error: 'Missing "repo" field' }, { status: 400 }); } // Accept either "repo-name" (assumed in workspace org) or "org/repo". const parts = body.repo.replace(/\.git$/, '').split('/'); const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org; const repoName = parts.length === 2 ? parts[1] : parts[0]; if (repoOrg !== ws.gitea_org) { return NextResponse.json( { error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` }, { status: 403 } ); } if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { return NextResponse.json({ error: 'Invalid repo name' }, { status: 400 }); } // Verify the repo actually exists in Gitea (fail fast). const repo = await getRepo(repoOrg, repoName); if (!repo) { return NextResponse.json( { error: `Repo ${repoOrg}/${repoName} not found in Gitea` }, { status: 404 } ); } const appName = slugify(body.name ?? repoName); const fqdn = body.domain ? body.domain.replace(/^https?:\/\//, '') : workspaceAppFqdn(ws.slug, appName); if (!isDomainUnderWorkspace(fqdn, ws.slug)) { return NextResponse.json( { error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` }, { status: 403 } ); } try { const created = await createPrivateDeployKeyApp({ projectUuid: ws.coolify_project_uuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, privateKeyUuid: ws.coolify_private_key_uuid, gitRepository: giteaSshUrl(repoOrg, repoName), gitBranch: body.branch ?? repo.default_branch ?? 'main', portsExposes: body.ports ?? '3000', buildPack: body.buildPack ?? 'nixpacks', name: appName, description: body.description ?? `AI-created from ${repoOrg}/${repoName}`, domains: toDomainsString([fqdn]), isAutoDeployEnabled: true, isForceHttpsEnabled: true, // We defer the first deploy until envs are attached so they // show up in the initial build. instantDeploy: false, baseDirectory: body.baseDirectory, installCommand: body.installCommand, buildCommand: body.buildCommand, startCommand: body.startCommand, dockerfileLocation: body.dockerfileLocation, }); // Attach env vars (best-effort — don't fail the whole create on one bad key). if (body.envs && typeof body.envs === 'object') { for (const [key, value] of Object.entries(body.envs)) { if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; try { await upsertApplicationEnv(created.uuid, { key, value }); } catch (e) { console.warn('[apps.POST] upsert env failed', key, e); } } } // Now kick off the first deploy (unless the caller opted out). let deploymentUuid: string | null = null; if (body.instantDeploy !== false) { try { const dep = await deployApplication(created.uuid); deploymentUuid = dep.deployment_uuid ?? null; } catch (e) { console.warn('[apps.POST] initial deploy failed', e); } } // Return a hydrated object (status / urls) for the UI. const app = await getApplication(created.uuid); return NextResponse.json( { uuid: app.uuid, name: app.name, status: app.status, domain: fqdn, url: `https://${fqdn}`, gitRepository: app.git_repository ?? null, gitBranch: app.git_branch ?? null, deploymentUuid, }, { status: 201 } ); } catch (err) { return NextResponse.json( { error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) }, { status: 502 } ); } }