From 0797717bc1fab22b538d2d4db9447977d0cc37cf Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 21 Apr 2026 12:04:59 -0700 Subject: [PATCH] Phase 4: AI-driven app/database/auth lifecycle Workspace-owned deploy infra so AI agents can create and destroy Coolify resources without ever touching the root admin token. vibn_workspaces + coolify_server_uuid, coolify_destination_uuid + coolify_environment_name (default "production") + coolify_private_key_uuid, gitea_bot_ssh_key_id ensureWorkspaceProvisioned + generates an ed25519 keypair per workspace + pushes pubkey to the Gitea bot user (read/write scoped by team) + registers privkey in Coolify as a reusable deploy key New endpoints under /api/workspaces/[slug]/ apps/ POST (private-deploy-key from Gitea repo) apps/[uuid] PATCH, DELETE?confirm= apps/[uuid]/domains GET, PATCH (policy: *.{ws}.vibnai.com only) databases/ GET, POST (8 types incl. postgres, clickhouse, dragonfly) databases/[uuid] GET, PATCH, DELETE?confirm= auth/ GET, POST (Pocketbase, Authentik, Keycloak, Pocket-ID, Logto, Supertokens) auth/[uuid] DELETE?confirm= MCP (/api/mcp) gains 15 new tools that mirror the REST surface and enforce the same workspace tenancy + delete-confirm guard. Safety: destructive ops require ?confirm=; volumes are kept by default (pass delete_volumes=true to drop). Made-with: Cursor --- app/api/admin/migrate/route.ts | 17 + app/api/mcp/route.ts | 470 ++++++++++++++- .../[slug]/apps/[uuid]/domains/route.ts | 114 ++++ .../workspaces/[slug]/apps/[uuid]/route.ts | 144 ++++- app/api/workspaces/[slug]/apps/route.ts | 186 +++++- .../workspaces/[slug]/auth/[uuid]/route.ts | 92 +++ app/api/workspaces/[slug]/auth/route.ts | 174 ++++++ .../[slug]/databases/[uuid]/route.ts | 158 +++++ app/api/workspaces/[slug]/databases/route.ts | 161 +++++ lib/coolify.ts | 570 +++++++++++++++--- lib/gitea.ts | 48 ++ lib/naming.ts | 69 +++ lib/ssh-keys.ts | 80 +++ lib/workspaces.ts | 109 +++- 14 files changed, 2274 insertions(+), 118 deletions(-) create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts create mode 100644 app/api/workspaces/[slug]/auth/[uuid]/route.ts create mode 100644 app/api/workspaces/[slug]/auth/route.ts create mode 100644 app/api/workspaces/[slug]/databases/[uuid]/route.ts create mode 100644 app/api/workspaces/[slug]/databases/route.ts create mode 100644 lib/naming.ts create mode 100644 lib/ssh-keys.ts diff --git a/app/api/admin/migrate/route.ts b/app/api/admin/migrate/route.ts index c9cc630..3de244a 100644 --- a/app/api/admin/migrate/route.ts +++ b/app/api/admin/migrate/route.ts @@ -198,6 +198,23 @@ export async function POST(req: NextRequest) { `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`, `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`, `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`, + + // ── Phase 4: workspace-owned deploy infra ──────────────────────── + // Lets AI agents create Coolify applications/databases/services + // against a Gitea repo the bot can read, routed to the right + // server and Docker destination, and exposed under the workspace's + // own subdomain namespace. + // + // coolify_server_uuid — which Coolify server the workspace deploys to + // coolify_destination_uuid — Docker network / destination on that server + // coolify_environment_name — Coolify environment (default "production") + // coolify_private_key_uuid — workspace-wide SSH deploy key (Coolify-side UUID) + // gitea_bot_ssh_key_id — Gitea key id for the matching public key (for rotation) + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_server_uuid TEXT`, + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_destination_uuid TEXT`, + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`, + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`, + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`, ]; for (const stmt of statements) { diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 8999661..139e460 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -30,8 +30,31 @@ import { TenantError, upsertApplicationEnv, deleteApplicationEnv, + // Phase 4 ── create/update/delete + domains + databases + services + createPrivateDeployKeyApp, + updateApplication, + deleteApplication, + setApplicationDomains, + listDatabasesInProject, + createDatabase, + getDatabaseInProject, + updateDatabase, + deleteDatabase, + listServicesInProject, + createService, + getServiceInProject, + deleteService, + type CoolifyDatabaseType, } from '@/lib/coolify'; import { query } from '@/lib/db-postgres'; +import { getRepo } from '@/lib/gitea'; +import { + giteaSshUrl, + isDomainUnderWorkspace, + slugify, + toDomainsString, + workspaceAppFqdn, +} from '@/lib/naming'; const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; @@ -42,7 +65,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.0.0', + version: '2.1.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -60,11 +83,24 @@ export async function GET() { 'projects.get', 'apps.list', 'apps.get', + 'apps.create', + 'apps.update', + 'apps.delete', 'apps.deploy', 'apps.deployments', + 'apps.domains.list', + 'apps.domains.set', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', + 'databases.list', + 'databases.create', + 'databases.get', + 'databases.update', + 'databases.delete', + 'auth.list', + 'auth.create', + 'auth.delete', ], }, }, @@ -126,6 +162,35 @@ export async function POST(request: Request) { case 'apps.envs.delete': return await toolAppsEnvsDelete(principal, params); + case 'apps.create': + return await toolAppsCreate(principal, params); + case 'apps.update': + return await toolAppsUpdate(principal, params); + case 'apps.delete': + return await toolAppsDelete(principal, params); + case 'apps.domains.list': + return await toolAppsDomainsList(principal, params); + case 'apps.domains.set': + return await toolAppsDomainsSet(principal, params); + + case 'databases.list': + return await toolDatabasesList(principal); + case 'databases.create': + return await toolDatabasesCreate(principal, params); + case 'databases.get': + return await toolDatabasesGet(principal, params); + case 'databases.update': + return await toolDatabasesUpdate(principal, params); + case 'databases.delete': + return await toolDatabasesDelete(principal, params); + + case 'auth.list': + return await toolAuthList(principal); + case 'auth.create': + return await toolAuthCreate(principal, params); + case 'auth.delete': + return await toolAuthDelete(principal, params); + default: return NextResponse.json( { error: `Unknown tool "${action}"` }, @@ -351,3 +416,406 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record) { + 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 } + ); + } + + const repoIn = String(params.repo ?? '').trim(); + if (!repoIn) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 }); + + const parts = repoIn.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 } + ); + } + const repo = await getRepo(repoOrg, repoName); + if (!repo) { + return NextResponse.json({ error: `Repo ${repoOrg}/${repoName} not found in Gitea` }, { status: 404 }); + } + + const appName = slugify(String(params.name ?? repoName)); + const fqdn = String(params.domain ?? '').trim() + ? String(params.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 } + ); + } + + 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: String(params.branch ?? repo.default_branch ?? 'main'), + portsExposes: String(params.ports ?? '3000'), + buildPack: (params.buildPack as any) ?? 'nixpacks', + name: appName, + domains: toDomainsString([fqdn]), + isAutoDeployEnabled: true, + isForceHttpsEnabled: true, + instantDeploy: false, + }); + + // Attach envs + if (params.envs && typeof params.envs === 'object') { + for (const [k, v] of Object.entries(params.envs as Record)) { + if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue; + try { + await upsertApplicationEnv(created.uuid, { key: k, value: String(v) }); + } catch (e) { + console.warn('[mcp apps.create] upsert env failed', k, e); + } + } + } + + let deploymentUuid: string | null = null; + if (params.instantDeploy !== false) { + try { + const dep = await deployApplication(created.uuid); + deploymentUuid = dep.deployment_uuid ?? null; + } catch (e) { + console.warn('[mcp apps.create] first deploy failed', e); + } + } + + return NextResponse.json({ + result: { + uuid: created.uuid, + name: appName, + domain: fqdn, + url: `https://${fqdn}`, + deploymentUuid, + }, + }); +} + +async function toolAppsUpdate(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + + await getApplicationInProject(appUuid, projectUuid); + + const allowed = new Set([ + 'name', 'description', 'git_branch', 'build_pack', 'ports_exposes', + 'install_command', 'build_command', 'start_command', + 'base_directory', 'dockerfile_location', + 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', + ]); + const patch: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (allowed.has(k) && v !== undefined) patch[k] = v; + } + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 }); + } + await updateApplication(appUuid, patch); + return NextResponse.json({ result: { ok: true, uuid: appUuid } }); +} + +async function toolAppsDelete(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + + const app = await getApplicationInProject(appUuid, projectUuid); + const confirm = String(params.confirm ?? ''); + if (confirm !== app.name) { + return NextResponse.json( + { error: 'Confirmation required', hint: `Pass confirm=${app.name} to delete` }, + { status: 409 } + ); + } + const deleteVolumes = params.deleteVolumes === true; + await deleteApplication(appUuid, { + deleteConfigurations: true, + deleteVolumes, + deleteConnectedNetworks: true, + dockerCleanup: true, + }); + return NextResponse.json({ + result: { ok: true, deleted: { uuid: appUuid, name: app.name, volumesKept: !deleteVolumes } }, + }); +} + +async function toolAppsDomainsList(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + const app = await getApplicationInProject(appUuid, projectUuid); + const raw = (app.domains ?? app.fqdn ?? '') as string; + const list = raw + .split(/[,\s]+/) + .map(s => s.trim()) + .filter(Boolean) + .map(s => s.replace(/^https?:\/\//, '').replace(/\/+$/, '')); + return NextResponse.json({ result: { uuid: appUuid, domains: list } }); +} + +async function toolAppsDomainsSet(principal: Principal, params: Record) { + const ws = principal.workspace; + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + const domainsIn = Array.isArray(params.domains) ? params.domains : []; + if (!appUuid || domainsIn.length === 0) { + return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 }); + } + await getApplicationInProject(appUuid, projectUuid); + const normalized: string[] = []; + for (const d of domainsIn) { + if (typeof d !== 'string' || !d.trim()) continue; + const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); + if (!isDomainUnderWorkspace(clean, ws.slug)) { + return NextResponse.json( + { error: `Domain ${clean} must end with .${ws.slug}.vibnai.com` }, + { status: 403 } + ); + } + normalized.push(clean); + } + await setApplicationDomains(appUuid, normalized, { forceOverride: true }); + return NextResponse.json({ result: { uuid: appUuid, domains: normalized } }); +} + +// ────────────────────────────────────────────────── +// Phase 4: databases +// ────────────────────────────────────────────────── + +const DB_TYPES: readonly CoolifyDatabaseType[] = [ + 'postgresql', 'mysql', 'mariadb', 'mongodb', + 'redis', 'keydb', 'dragonfly', 'clickhouse', +]; + +async function toolDatabasesList(principal: Principal) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const dbs = await listDatabasesInProject(projectUuid); + return NextResponse.json({ + result: dbs.map(d => ({ + uuid: d.uuid, + name: d.name, + type: d.type ?? null, + status: d.status, + isPublic: d.is_public ?? false, + publicPort: d.public_port ?? null, + })), + }); +} + +async function toolDatabasesCreate(principal: Principal, params: Record) { + const ws = principal.workspace; + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType; + if (!DB_TYPES.includes(type)) { + return NextResponse.json( + { error: `Param "type" must be one of: ${DB_TYPES.join(', ')}` }, + { status: 400 } + ); + } + const name = slugify(String(params.name ?? `${type}-${Date.now().toString(36)}`)); + const { uuid } = await createDatabase({ + type, + name, + description: params.description ? String(params.description) : undefined, + projectUuid, + serverUuid: ws.coolify_server_uuid ?? undefined, + environmentName: ws.coolify_environment_name, + destinationUuid: ws.coolify_destination_uuid ?? undefined, + isPublic: params.isPublic === true, + publicPort: typeof params.publicPort === 'number' ? params.publicPort : undefined, + image: params.image ? String(params.image) : undefined, + credentials: params.credentials && typeof params.credentials === 'object' ? params.credentials : {}, + limits: params.limits && typeof params.limits === 'object' ? params.limits : undefined, + instantDeploy: params.instantDeploy !== false, + }); + const db = await getDatabaseInProject(uuid, projectUuid); + return NextResponse.json({ + result: { + uuid: db.uuid, + name: db.name, + type: db.type ?? type, + status: db.status, + internalUrl: db.internal_db_url ?? null, + externalUrl: db.external_db_url ?? null, + }, + }); +} + +async function toolDatabasesGet(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const uuid = String(params.uuid ?? '').trim(); + if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + const db = await getDatabaseInProject(uuid, projectUuid); + return NextResponse.json({ + result: { + uuid: db.uuid, + name: db.name, + type: db.type ?? null, + status: db.status, + isPublic: db.is_public ?? false, + publicPort: db.public_port ?? null, + internalUrl: db.internal_db_url ?? null, + externalUrl: db.external_db_url ?? null, + }, + }); +} + +async function toolDatabasesUpdate(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const uuid = String(params.uuid ?? '').trim(); + if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + await getDatabaseInProject(uuid, projectUuid); + const allowed = new Set(['name', 'description', 'is_public', 'public_port', 'image', 'limits_memory', 'limits_cpus']); + const patch: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (allowed.has(k) && v !== undefined) patch[k] = v; + } + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 }); + } + await updateDatabase(uuid, patch); + return NextResponse.json({ result: { ok: true, uuid } }); +} + +async function toolDatabasesDelete(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const uuid = String(params.uuid ?? '').trim(); + if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + const db = await getDatabaseInProject(uuid, projectUuid); + const confirm = String(params.confirm ?? ''); + if (confirm !== db.name) { + return NextResponse.json( + { error: 'Confirmation required', hint: `Pass confirm=${db.name} to delete` }, + { status: 409 } + ); + } + const deleteVolumes = params.deleteVolumes === true; + await deleteDatabase(uuid, { + deleteConfigurations: true, + deleteVolumes, + deleteConnectedNetworks: true, + dockerCleanup: true, + }); + return NextResponse.json({ + result: { ok: true, deleted: { uuid, name: db.name, volumesKept: !deleteVolumes } }, + }); +} + +// ────────────────────────────────────────────────── +// Phase 4: auth providers (Coolify services, curated allowlist) +// ────────────────────────────────────────────────── + +const AUTH_PROVIDERS_MCP: Record = { + pocketbase: 'pocketbase', + authentik: 'authentik', + keycloak: 'keycloak', + 'keycloak-with-postgres': 'keycloak-with-postgres', + 'pocket-id': 'pocket-id', + 'pocket-id-with-postgresql': 'pocket-id-with-postgresql', + logto: 'logto', + 'supertokens-with-postgresql': 'supertokens-with-postgresql', +}; + +async function toolAuthList(principal: Principal) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const all = await listServicesInProject(projectUuid); + const slugs = new Set(Object.values(AUTH_PROVIDERS_MCP)); + return NextResponse.json({ + result: { + providers: all + .filter(s => { + for (const slug of slugs) { + if (s.name === slug || s.name.startsWith(`${slug}-`)) return true; + } + return false; + }) + .map(s => ({ uuid: s.uuid, name: s.name, status: s.status ?? null })), + allowedProviders: Object.keys(AUTH_PROVIDERS_MCP), + }, + }); +} + +async function toolAuthCreate(principal: Principal, params: Record) { + const ws = principal.workspace; + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const key = String(params.provider ?? '').toLowerCase().trim(); + const coolifyType = AUTH_PROVIDERS_MCP[key]; + if (!coolifyType) { + return NextResponse.json( + { + error: `Unsupported provider "${key}"`, + allowed: Object.keys(AUTH_PROVIDERS_MCP), + }, + { status: 400 } + ); + } + const name = slugify(String(params.name ?? key)); + const { uuid } = await createService({ + projectUuid, + type: coolifyType, + name, + description: params.description ? String(params.description) : undefined, + serverUuid: ws.coolify_server_uuid ?? undefined, + environmentName: ws.coolify_environment_name, + destinationUuid: ws.coolify_destination_uuid ?? undefined, + instantDeploy: params.instantDeploy !== false, + }); + const svc = await getServiceInProject(uuid, projectUuid); + return NextResponse.json({ + result: { uuid: svc.uuid, name: svc.name, provider: key, status: svc.status ?? null }, + }); +} + +async function toolAuthDelete(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const uuid = String(params.uuid ?? '').trim(); + if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + const svc = await getServiceInProject(uuid, projectUuid); + const confirm = String(params.confirm ?? ''); + if (confirm !== svc.name) { + return NextResponse.json( + { error: 'Confirmation required', hint: `Pass confirm=${svc.name} to delete` }, + { status: 409 } + ); + } + const deleteVolumes = params.deleteVolumes === true; + await deleteService(uuid, { + deleteConfigurations: true, + deleteVolumes, + deleteConnectedNetworks: true, + dockerCleanup: true, + }); + return NextResponse.json({ + result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } }, + }); +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts new file mode 100644 index 0000000..ef77774 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts @@ -0,0 +1,114 @@ +/** + * GET /api/workspaces/[slug]/apps/[uuid]/domains — list current domains + * PATCH /api/workspaces/[slug]/apps/[uuid]/domains — replace domain set + * + * Body: { domains: string[] } — each must end with .{workspace}.vibnai.com. + * We enforce workspace-subdomain policy here to prevent AI-driven + * hijacking of other workspaces' subdomains. + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { + getApplicationInProject, + setApplicationDomains, + TenantError, +} from '@/lib/coolify'; +import { + isDomainUnderWorkspace, + parseDomainsString, + workspaceAppFqdn, + slugify, +} from '@/lib/naming'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + try { + const app = await getApplicationInProject(uuid, ws.coolify_project_uuid); + return NextResponse.json({ + uuid: app.uuid, + name: app.name, + domains: parseDomainsString(app.domains ?? app.fqdn ?? ''), + workspaceDomainSuffix: `${ws.slug}.vibnai.com`, + }); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json({ error: 'App not found' }, { status: 404 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + let app; + try { + app = await getApplicationInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json({ error: 'App not found' }, { status: 404 }); + } + + let body: { domains?: string[] } = {}; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const raw = Array.isArray(body.domains) ? body.domains : []; + if (raw.length === 0) { + return NextResponse.json({ error: '`domains` must be a non-empty array' }, { status: 400 }); + } + + // Normalize + policy-check. + const normalized: string[] = []; + for (const d of raw) { + if (typeof d !== 'string' || !d.trim()) continue; + const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); + if (!isDomainUnderWorkspace(clean, ws.slug)) { + return NextResponse.json( + { + error: `Domain ${clean} is not allowed; must end with .${ws.slug}.vibnai.com`, + hint: `Use ${workspaceAppFqdn(ws.slug, slugify(app.name))}`, + }, + { status: 403 } + ); + } + normalized.push(clean); + } + + try { + await setApplicationDomains(uuid, normalized, { forceOverride: true }); + return NextResponse.json({ ok: true, uuid, domains: normalized }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify domain update failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/route.ts index f42dd86..cfcd49e 100644 --- a/app/api/workspaces/[slug]/apps/[uuid]/route.ts +++ b/app/api/workspaces/[slug]/apps/[uuid]/route.ts @@ -1,13 +1,23 @@ /** - * GET /api/workspaces/[slug]/apps/[uuid] + * GET /api/workspaces/[slug]/apps/[uuid] — app details + * PATCH /api/workspaces/[slug]/apps/[uuid] — update fields (name/branch/build config) + * DELETE /api/workspaces/[slug]/apps/[uuid]?confirm= + * — destroy app. Volumes kept by default. * - * Single Coolify app details. Verifies the app's project uuid matches - * the workspace's before returning anything. + * All verify the app's project uuid matches the workspace's before + * acting. DELETE additionally requires `?confirm=` + * to prevent AI-driven accidents. */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; -import { getApplicationInProject, projectUuidOf, TenantError } from '@/lib/coolify'; +import { + getApplicationInProject, + projectUuidOf, + TenantError, + updateApplication, + deleteApplication, +} from '@/lib/coolify'; export async function GET( request: Request, @@ -29,6 +39,7 @@ export async function GET( name: app.name, status: app.status, fqdn: app.fqdn ?? null, + domains: app.domains ?? null, gitRepository: app.git_repository ?? null, gitBranch: app.git_branch ?? null, projectUuid: projectUuidOf(app), @@ -43,3 +54,128 @@ export async function GET( ); } } + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + // Verify tenancy first (400-style fail fast on cross-tenant access). + try { + await getApplicationInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json({ error: 'App not found' }, { status: 404 }); + } + + let body: Record = {}; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + // Whitelist which Coolify fields we expose to AI-level callers. + // Domains are managed via the dedicated /domains subroute. + const allowed = new Set([ + 'name', + 'description', + 'git_branch', + 'build_pack', + 'ports_exposes', + 'install_command', + 'build_command', + 'start_command', + 'base_directory', + 'dockerfile_location', + 'is_auto_deploy_enabled', + 'is_force_https_enabled', + 'static_image', + ]); + const patch: Record = {}; + for (const [k, v] of Object.entries(body)) { + if (allowed.has(k) && v !== undefined) patch[k] = v; + } + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 }); + } + + try { + await updateApplication(uuid, patch); + return NextResponse.json({ ok: true, uuid }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + // Resolve the app and verify tenancy. + let app; + try { + app = await getApplicationInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json({ error: 'App not found' }, { status: 404 }); + } + + // Require `?confirm=` to prevent accidental destroys. + const url = new URL(request.url); + const confirm = url.searchParams.get('confirm'); + if (confirm !== app.name) { + return NextResponse.json( + { + error: 'Confirmation required', + hint: `Pass ?confirm=${app.name} to delete this app`, + }, + { status: 409 } + ); + } + + // Default: preserve volumes (user data). Caller can opt in. + const deleteVolumes = url.searchParams.get('delete_volumes') === 'true'; + const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false'; + const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false'; + const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false'; + + try { + await deleteApplication(uuid, { + deleteConfigurations, + deleteVolumes, + deleteConnectedNetworks, + dockerCleanup, + }); + return NextResponse.json({ ok: true, deleted: { uuid, name: app.name, volumesKept: !deleteVolumes } }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/app/api/workspaces/[slug]/apps/route.ts b/app/api/workspaces/[slug]/apps/route.ts index 84786f5..efa4610 100644 --- a/app/api/workspaces/[slug]/apps/route.ts +++ b/app/api/workspaces/[slug]/apps/route.ts @@ -1,15 +1,43 @@ /** - * GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace + * 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 } from '@/lib/coolify'; +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, @@ -36,6 +64,7 @@ export async function GET( 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), @@ -48,3 +77,156 @@ export async function GET( ); } } + +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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/auth/[uuid]/route.ts b/app/api/workspaces/[slug]/auth/[uuid]/route.ts new file mode 100644 index 0000000..b322cf5 --- /dev/null +++ b/app/api/workspaces/[slug]/auth/[uuid]/route.ts @@ -0,0 +1,92 @@ +/** + * GET /api/workspaces/[slug]/auth/[uuid] — provider details + * DELETE /api/workspaces/[slug]/auth/[uuid]?confirm= + * Volumes KEPT by default (don't blow away user accounts). + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { + getServiceInProject, + deleteService, + projectUuidOf, + TenantError, +} from '@/lib/coolify'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + try { + const svc = await getServiceInProject(uuid, ws.coolify_project_uuid); + return NextResponse.json({ + uuid: svc.uuid, + name: svc.name, + status: svc.status ?? null, + projectUuid: projectUuidOf(svc), + }); + } catch (err) { + if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 }); + return NextResponse.json({ error: 'Provider not found' }, { status: 404 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + let svc; + try { + svc = await getServiceInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 }); + return NextResponse.json({ error: 'Provider not found' }, { status: 404 }); + } + + const url = new URL(request.url); + const confirm = url.searchParams.get('confirm'); + if (confirm !== svc.name) { + return NextResponse.json( + { error: 'Confirmation required', hint: `Pass ?confirm=${svc.name}` }, + { status: 409 } + ); + } + + const deleteVolumes = url.searchParams.get('delete_volumes') === 'true'; + + try { + await deleteService(uuid, { + deleteConfigurations: true, + deleteVolumes, + deleteConnectedNetworks: true, + dockerCleanup: true, + }); + return NextResponse.json({ + ok: true, + deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes }, + }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/app/api/workspaces/[slug]/auth/route.ts b/app/api/workspaces/[slug]/auth/route.ts new file mode 100644 index 0000000..fdf47c4 --- /dev/null +++ b/app/api/workspaces/[slug]/auth/route.ts @@ -0,0 +1,174 @@ +/** + * Workspace authentication providers. + * + * GET /api/workspaces/[slug]/auth — list auth-provider services + * POST /api/workspaces/[slug]/auth — provision one of the vetted providers + * + * AI-callers can only create providers from an allowlist — we deliberately + * skip the rest of Coolify's ~300 one-click templates so this endpoint + * stays focused on "auth for my app". The allowlist: + * + * pocketbase — lightweight (SQLite-backed) auth + data + * authentik — feature-rich self-hosted IDP + * keycloak — industry-standard OIDC/SAML + * keycloak-with-postgres + * pocket-id — passkey-first OIDC + * pocket-id-with-postgresql + * logto — dev-first IDP + * supertokens-with-postgresql — session/auth backend + * + * (Zitadel is not on Coolify's service catalog — callers that ask for + * it get a descriptive 400 so the AI knows to pick a supported one.) + * + * POST body: + * { provider: "pocketbase", name?: "auth" } + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { + listServicesInProject, + createService, + getService, + projectUuidOf, +} from '@/lib/coolify'; +import { slugify } from '@/lib/naming'; + +/** + * Vetted auth-provider service ids. Keys are what callers pass as + * `provider`; values are the Coolify service-template slugs. + */ +const AUTH_PROVIDERS: Record = { + pocketbase: 'pocketbase', + authentik: 'authentik', + keycloak: 'keycloak', + 'keycloak-with-postgres': 'keycloak-with-postgres', + 'pocket-id': 'pocket-id', + 'pocket-id-with-postgresql': 'pocket-id-with-postgresql', + logto: 'logto', + 'supertokens-with-postgresql': 'supertokens-with-postgresql', +}; + +/** Anything in this set is Coolify-supported but not an auth provider (used for filtering the list view). */ +const AUTH_PROVIDER_SLUGS = new Set(Object.values(AUTH_PROVIDERS)); + +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', providers: [] }, + { status: 503 } + ); + } + + try { + const all = await listServicesInProject(ws.coolify_project_uuid); + // Coolify returns all services — we narrow to ones whose name + // contains an auth-provider slug. We also return the full list so + // callers can see non-auth services without a separate endpoint. + return NextResponse.json({ + workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid }, + providers: all + .filter(s => AUTH_PROVIDER_SLUGS.has(deriveTypeFromName(s.name))) + .map(s => ({ + uuid: s.uuid, + name: s.name, + status: s.status ?? null, + provider: deriveTypeFromName(s.name), + projectUuid: projectUuidOf(s), + })), + allowedProviders: Object.keys(AUTH_PROVIDERS), + }); + } 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) { + return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 }); + } + + let body: { provider?: string; name?: string; description?: string; instantDeploy?: boolean } = {}; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const providerKey = (body.provider ?? '').toLowerCase().trim(); + const coolifyType = AUTH_PROVIDERS[providerKey]; + if (!coolifyType) { + return NextResponse.json( + { + error: `Unsupported provider "${providerKey}". Allowed: ${Object.keys(AUTH_PROVIDERS).join(', ')}`, + hint: 'Zitadel is not on Coolify v4 service catalog — use keycloak or authentik instead.', + }, + { status: 400 } + ); + } + + const name = slugify(body.name ?? providerKey); + + try { + const created = await createService({ + projectUuid: ws.coolify_project_uuid, + type: coolifyType, + name, + description: body.description ?? `AI-provisioned ${providerKey} for ${ws.slug}`, + serverUuid: ws.coolify_server_uuid ?? undefined, + environmentName: ws.coolify_environment_name, + destinationUuid: ws.coolify_destination_uuid ?? undefined, + instantDeploy: body.instantDeploy ?? true, + }); + + const svc = await getService(created.uuid); + return NextResponse.json( + { + uuid: svc.uuid, + name: svc.name, + provider: providerKey, + status: svc.status ?? null, + }, + { status: 201 } + ); + } catch (err) { + return NextResponse.json( + { error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} + +/** + * Coolify names services "{type}-{random-suffix}" when auto-named. We + * recover the provider slug by stripping the trailing `-\w+` if any + * and matching against our allowlist. Falls back to empty string. + */ +function deriveTypeFromName(name: string): string { + const candidates = Object.values(AUTH_PROVIDERS).sort((a, b) => b.length - a.length); + for (const slug of candidates) { + if (name === slug || name.startsWith(`${slug}-`) || name.startsWith(`${slug}_`)) { + return slug; + } + } + return ''; +} diff --git a/app/api/workspaces/[slug]/databases/[uuid]/route.ts b/app/api/workspaces/[slug]/databases/[uuid]/route.ts new file mode 100644 index 0000000..9bbdbde --- /dev/null +++ b/app/api/workspaces/[slug]/databases/[uuid]/route.ts @@ -0,0 +1,158 @@ +/** + * GET /api/workspaces/[slug]/databases/[uuid] — database details (incl. URLs) + * PATCH /api/workspaces/[slug]/databases/[uuid] — update fields + * DELETE /api/workspaces/[slug]/databases/[uuid]?confirm= + * Volumes KEPT by default (data). Pass &delete_volumes=true to drop. + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { + getDatabaseInProject, + updateDatabase, + deleteDatabase, + projectUuidOf, + TenantError, +} from '@/lib/coolify'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + try { + const db = await getDatabaseInProject(uuid, ws.coolify_project_uuid); + return NextResponse.json({ + uuid: db.uuid, + name: db.name, + type: db.type ?? null, + status: db.status, + isPublic: db.is_public ?? false, + publicPort: db.public_port ?? null, + internalUrl: db.internal_db_url ?? null, + externalUrl: db.external_db_url ?? null, + projectUuid: projectUuidOf(db), + }); + } catch (err) { + if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 }); + return NextResponse.json({ error: 'Database not found' }, { status: 404 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + try { + await getDatabaseInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 }); + return NextResponse.json({ error: 'Database not found' }, { status: 404 }); + } + + let body: Record = {}; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const allowed = new Set([ + 'name', + 'description', + 'is_public', + 'public_port', + 'image', + 'limits_memory', + 'limits_cpus', + ]); + const patch: Record = {}; + for (const [k, v] of Object.entries(body)) { + if (allowed.has(k) && v !== undefined) patch[k] = v; + } + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 }); + } + + try { + await updateDatabase(uuid, patch); + return NextResponse.json({ ok: true, uuid }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = 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' }, { status: 503 }); + } + + let db; + try { + db = await getDatabaseInProject(uuid, ws.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 }); + return NextResponse.json({ error: 'Database not found' }, { status: 404 }); + } + + const url = new URL(request.url); + const confirm = url.searchParams.get('confirm'); + if (confirm !== db.name) { + return NextResponse.json( + { error: 'Confirmation required', hint: `Pass ?confirm=${db.name}` }, + { status: 409 } + ); + } + + // Default: preserve volumes (it's a database — user data lives there). + const deleteVolumes = url.searchParams.get('delete_volumes') === 'true'; + const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false'; + const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false'; + const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false'; + + try { + await deleteDatabase(uuid, { + deleteConfigurations, + deleteVolumes, + deleteConnectedNetworks, + dockerCleanup, + }); + return NextResponse.json({ + ok: true, + deleted: { uuid, name: db.name, volumesKept: !deleteVolumes }, + }); + } catch (err) { + return NextResponse.json( + { error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/app/api/workspaces/[slug]/databases/route.ts b/app/api/workspaces/[slug]/databases/route.ts new file mode 100644 index 0000000..1cad830 --- /dev/null +++ b/app/api/workspaces/[slug]/databases/route.ts @@ -0,0 +1,161 @@ +/** + * GET /api/workspaces/[slug]/databases — list databases in this workspace + * POST /api/workspaces/[slug]/databases — provision a new database + * + * Supported `type` values (all that Coolify v4 can deploy): + * postgresql | mysql | mariadb | mongodb | redis | keydb | dragonfly | clickhouse + * + * POST body: + * { + * type: "postgresql", + * name?: "my-db", + * isPublic?: true, // expose a host port for remote clients + * publicPort?: 5433, + * image?: "postgres:16", + * credentials?: { ... } // type-specific (e.g. postgres_user) + * limits?: { memory?: "1G", cpus?: "1" }, + * } + * + * Tenancy: every returned record is filtered to the workspace's own + * Coolify project UUID. + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { + listDatabasesInProject, + createDatabase, + getDatabase, + projectUuidOf, + type CoolifyDatabaseType, +} from '@/lib/coolify'; +import { slugify } from '@/lib/naming'; + +const SUPPORTED_TYPES: readonly CoolifyDatabaseType[] = [ + 'postgresql', + 'mysql', + 'mariadb', + 'mongodb', + 'redis', + 'keydb', + 'dragonfly', + 'clickhouse', +]; + +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', databases: [] }, + { status: 503 } + ); + } + + try { + const dbs = await listDatabasesInProject(ws.coolify_project_uuid); + return NextResponse.json({ + workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid }, + databases: dbs.map(d => ({ + uuid: d.uuid, + name: d.name, + type: d.type ?? null, + status: d.status, + isPublic: d.is_public ?? false, + publicPort: d.public_port ?? null, + projectUuid: projectUuidOf(d), + })), + }); + } 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) { + return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 }); + } + + type Body = { + type?: string; + name?: string; + description?: string; + isPublic?: boolean; + publicPort?: number; + image?: string; + credentials?: Record; + limits?: { memory?: string; cpus?: string }; + instantDeploy?: boolean; + }; + let body: Body = {}; + try { + body = (await request.json()) as Body; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const type = body.type as CoolifyDatabaseType | undefined; + if (!type || !SUPPORTED_TYPES.includes(type)) { + return NextResponse.json( + { error: `\`type\` must be one of: ${SUPPORTED_TYPES.join(', ')}` }, + { status: 400 } + ); + } + + const name = slugify(body.name ?? `${type}-${Date.now().toString(36)}`); + + try { + const created = await createDatabase({ + type, + name, + description: body.description, + projectUuid: ws.coolify_project_uuid, + serverUuid: ws.coolify_server_uuid ?? undefined, + environmentName: ws.coolify_environment_name, + destinationUuid: ws.coolify_destination_uuid ?? undefined, + isPublic: body.isPublic, + publicPort: body.publicPort, + image: body.image, + credentials: body.credentials, + limits: body.limits, + instantDeploy: body.instantDeploy ?? true, + }); + + const db = await getDatabase(created.uuid); + return NextResponse.json( + { + uuid: db.uuid, + name: db.name, + type: db.type ?? type, + status: db.status, + isPublic: db.is_public ?? false, + publicPort: db.public_port ?? null, + internalUrl: db.internal_db_url ?? null, + externalUrl: db.external_db_url ?? null, + }, + { status: 201 } + ); + } catch (err) { + return NextResponse.json( + { error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/lib/coolify.ts b/lib/coolify.ts index 0daddec..85f821f 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -2,30 +2,48 @@ * 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 + * COOLIFY_URL — e.g. https://coolify.vibnai.com + * COOLIFY_API_TOKEN — admin bearer token + * COOLIFY_DEFAULT_SERVER_UUID — which Coolify server workspaces deploy to + * COOLIFY_DEFAULT_DESTINATION_UUID — Docker destination on that server */ -const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000'; +const COOLIFY_URL = process.env.COOLIFY_URL ?? 'https://coolify.vibnai.com'; const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? ''; +const COOLIFY_DEFAULT_SERVER_UUID = + process.env.COOLIFY_DEFAULT_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc'; +const COOLIFY_DEFAULT_DESTINATION_UUID = + process.env.COOLIFY_DEFAULT_DESTINATION_UUID ?? 'zkogkggkw0wg40gccks80oo0'; export interface CoolifyProject { uuid: string; name: string; description?: string; + environments?: Array<{ id: number; uuid: string; name: string; project_id: number }>; } +/** All database flavors Coolify v4 can provision. */ +export type CoolifyDatabaseType = + | 'postgresql' + | 'mysql' + | 'mariadb' + | 'mongodb' + | 'redis' + | 'keydb' + | 'dragonfly' + | 'clickhouse'; + export interface CoolifyDatabase { uuid: string; name: string; - type: string; + type?: string; status: string; internal_db_url?: string; external_db_url?: string; - /** When true, Coolify publishes a host port for remote connections */ is_public?: boolean; - /** Host port mapped to 5432 inside the container */ public_port?: number; + project_uuid?: string; + environment?: { project_uuid?: string; project?: { uuid?: string } }; } export interface CoolifyApplication { @@ -33,11 +51,11 @@ export interface CoolifyApplication { name: string; status: string; fqdn?: string; + domains?: string; git_repository?: string; git_branch?: string; project_uuid?: string; environment_name?: string; - /** Coolify sometimes nests these under an `environment` object */ environment?: { project_uuid?: string; project?: { uuid?: string } }; } @@ -52,6 +70,29 @@ export interface CoolifyEnvVar { is_shown_once?: boolean; } +export interface CoolifyPrivateKey { + uuid: string; + name: string; + description?: string; + fingerprint?: string; +} + +export interface CoolifyServer { + uuid: string; + name: string; + ip: string; + is_reachable?: boolean; + settings?: { wildcard_domain?: string | null }; +} + +export interface CoolifyDeployment { + uuid: string; + status: string; + created_at?: string; + finished_at?: string; + commit?: string; +} + async function coolifyFetch(path: string, options: RequestInit = {}) { const url = `${COOLIFY_URL}/api/v1${path}`; const res = await fetch(url, { @@ -67,7 +108,6 @@ async function coolifyFetch(path: string, options: RequestInit = {}) { 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(); } @@ -96,82 +136,331 @@ export async function deleteProject(uuid: string): Promise { } // ────────────────────────────────────────────────── -// Databases +// Servers // ────────────────────────────────────────────────── -type DBType = 'postgresql' | 'mysql' | 'mariadb' | 'redis' | 'mongodb' | 'keydb'; +export async function listServers(): Promise { + return coolifyFetch('/servers'); +} -export async function createDatabase(opts: { - projectUuid: string; +// ────────────────────────────────────────────────── +// Private keys (SSH deploy keys) +// ────────────────────────────────────────────────── + +export async function listPrivateKeys(): Promise { + return coolifyFetch('/security/keys'); +} + +export async function createPrivateKey(opts: { name: string; - type: DBType; - serverUuid?: string; - environmentName?: string; -}): Promise { - const { projectUuid, name, type, serverUuid = '0', environmentName = 'production' } = opts; - - return coolifyFetch(`/databases`, { + privateKeyPem: string; + description?: string; +}): Promise { + return coolifyFetch('/security/keys', { method: 'POST', body: JSON.stringify({ - project_uuid: projectUuid, - name, - type, - server_uuid: serverUuid, - environment_name: environmentName, + name: opts.name, + private_key: opts.privateKeyPem, + description: opts.description ?? '', }), }); } +export async function deletePrivateKey(uuid: string): Promise { + await coolifyFetch(`/security/keys/${uuid}`, { method: 'DELETE' }); +} + +// ────────────────────────────────────────────────── +// Databases (all 8 types) +// ────────────────────────────────────────────────── + +/** Shared context used by every database-create call. */ +export interface CoolifyDbCreateContext { + projectUuid: string; + serverUuid?: string; + environmentName?: string; + destinationUuid?: string; + instantDeploy?: boolean; +} + +export interface CreateDatabaseOpts extends CoolifyDbCreateContext { + type: CoolifyDatabaseType; + name: string; + description?: string; + image?: string; + isPublic?: boolean; + publicPort?: number; + /** Type-specific credentials / config */ + credentials?: Record; + /** Resource limits */ + limits?: { memory?: string; cpus?: string }; +} + +/** + * Create a database of the requested type. Dispatches to the right + * POST /databases/{type} endpoint and packs the right credential + * fields for each flavor. + */ +export async function createDatabase(opts: CreateDatabaseOpts): Promise<{ uuid: string }> { + const { + type, name, + projectUuid, + serverUuid = COOLIFY_DEFAULT_SERVER_UUID, + environmentName = 'production', + destinationUuid = COOLIFY_DEFAULT_DESTINATION_UUID, + instantDeploy = true, + description, + image, + isPublic, + publicPort, + credentials = {}, + limits = {}, + } = opts; + + const common: Record = { + project_uuid: projectUuid, + server_uuid: serverUuid, + environment_name: environmentName, + destination_uuid: destinationUuid, + name, + description, + image, + is_public: isPublic, + public_port: publicPort, + instant_deploy: instantDeploy, + limits_memory: limits.memory, + limits_cpus: limits.cpus, + }; + + return coolifyFetch(`/databases/${type}`, { + method: 'POST', + body: JSON.stringify(stripUndefined({ ...common, ...credentials })), + }); +} + +export async function listDatabases(): Promise { + return coolifyFetch('/databases'); +} + export async function getDatabase(uuid: string): Promise { return coolifyFetch(`/databases/${uuid}`); } -export async function deleteDatabase(uuid: string): Promise { - await coolifyFetch(`/databases/${uuid}`, { method: 'DELETE' }); +export async function updateDatabase(uuid: string, patch: Record): Promise<{ uuid: string }> { + return coolifyFetch(`/databases/${uuid}`, { + method: 'PATCH', + body: JSON.stringify(stripUndefined(patch)), + }); +} + +export async function deleteDatabase( + uuid: string, + opts: { + deleteConfigurations?: boolean; + deleteVolumes?: boolean; + dockerCleanup?: boolean; + deleteConnectedNetworks?: boolean; + } = {} +): Promise { + const q = new URLSearchParams(); + if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + const qs = q.toString(); + await coolifyFetch(`/databases/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); +} + +export async function startDatabase(uuid: string): Promise { + await coolifyFetch(`/databases/${uuid}/start`, { method: 'POST' }); +} + +export async function stopDatabase(uuid: string): Promise { + await coolifyFetch(`/databases/${uuid}/stop`, { method: 'POST' }); +} + +export async function restartDatabase(uuid: string): Promise { + await coolifyFetch(`/databases/${uuid}/restart`, { method: 'POST' }); } // ────────────────────────────────────────────────── // Applications // ────────────────────────────────────────────────── -export async function createApplication(opts: { +export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose'; + +export interface CreatePrivateDeployKeyAppOpts { projectUuid: string; - name: string; - gitRepo: string; // e.g. "https://git.vibnai.com/mark/taskmaster.git" + privateKeyUuid: string; + gitRepository: string; // SSH URL: git@git.vibnai.com:vibn-mark/repo.git gitBranch?: string; + portsExposes: string; // "3000" serverUuid?: string; environmentName?: string; - buildPack?: string; // nixpacks, static, dockerfile - ports?: string; // e.g. "3000" -}): Promise { - const { - projectUuid, name, gitRepo, - gitBranch = 'main', - serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc', - environmentName = 'production', - buildPack = 'nixpacks', - ports = '3000', - } = opts; + destinationUuid?: string; + buildPack?: CoolifyBuildPack; + name?: string; + description?: string; + domains?: string; // comma-separated FQDNs + isAutoDeployEnabled?: boolean; + isForceHttpsEnabled?: boolean; + instantDeploy?: boolean; + installCommand?: string; + buildCommand?: string; + startCommand?: string; + baseDirectory?: string; + dockerfileLocation?: string; + manualWebhookSecretGitea?: string; +} - return coolifyFetch(`/applications`, { +export async function createPrivateDeployKeyApp( + opts: CreatePrivateDeployKeyAppOpts +): Promise<{ uuid: string }> { + const body = stripUndefined({ + project_uuid: opts.projectUuid, + server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, + environment_name: opts.environmentName ?? 'production', + destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, + private_key_uuid: opts.privateKeyUuid, + git_repository: opts.gitRepository, + git_branch: opts.gitBranch ?? 'main', + build_pack: opts.buildPack ?? 'nixpacks', + ports_exposes: opts.portsExposes, + name: opts.name, + description: opts.description, + domains: opts.domains, + is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true, + is_force_https_enabled: opts.isForceHttpsEnabled ?? true, + instant_deploy: opts.instantDeploy ?? true, + install_command: opts.installCommand, + build_command: opts.buildCommand, + start_command: opts.startCommand, + base_directory: opts.baseDirectory, + dockerfile_location: opts.dockerfileLocation, + manual_webhook_secret_gitea: opts.manualWebhookSecretGitea, + }); + return coolifyFetch('/applications/private-deploy-key', { 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, - }), + body: JSON.stringify(body), }); } -/** - * Create a Coolify service for one app inside a Turborepo monorepo. - * Build command uses `turbo run build --filter` to target just that app. - */ +export interface CreatePublicAppOpts { + projectUuid: string; + gitRepository: string; // https URL + gitBranch?: string; + portsExposes: string; + serverUuid?: string; + environmentName?: string; + destinationUuid?: string; + buildPack?: CoolifyBuildPack; + name?: string; + description?: string; + domains?: string; + isAutoDeployEnabled?: boolean; + isForceHttpsEnabled?: boolean; + instantDeploy?: boolean; +} + +export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> { + const body = stripUndefined({ + project_uuid: opts.projectUuid, + server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, + environment_name: opts.environmentName ?? 'production', + destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, + git_repository: opts.gitRepository, + git_branch: opts.gitBranch ?? 'main', + build_pack: opts.buildPack ?? 'nixpacks', + ports_exposes: opts.portsExposes, + name: opts.name, + description: opts.description, + domains: opts.domains, + is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true, + is_force_https_enabled: opts.isForceHttpsEnabled ?? true, + instant_deploy: opts.instantDeploy ?? true, + }); + return coolifyFetch('/applications/public', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function updateApplication( + uuid: string, + patch: Record +): Promise<{ uuid: string }> { + return coolifyFetch(`/applications/${uuid}`, { + method: 'PATCH', + body: JSON.stringify(stripUndefined(patch)), + }); +} + +export async function setApplicationDomains( + uuid: string, + domains: string[], + opts: { forceOverride?: boolean } = {} +): Promise<{ uuid: string }> { + return updateApplication(uuid, { + domains: domains.join(','), + force_domain_override: opts.forceOverride ?? true, + is_force_https_enabled: true, + }); +} + +export async function deleteApplication( + uuid: string, + opts: { + deleteConfigurations?: boolean; + deleteVolumes?: boolean; + dockerCleanup?: boolean; + deleteConnectedNetworks?: boolean; + } = {} +): Promise { + const q = new URLSearchParams(); + if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + const qs = q.toString(); + await coolifyFetch(`/applications/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); +} + +export async function listApplications(): Promise { + return coolifyFetch('/applications'); +} + +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 startApplication(uuid: string): Promise { + await coolifyFetch(`/applications/${uuid}/start`, { method: 'POST' }); +} + +export async function stopApplication(uuid: string): Promise { + await coolifyFetch(`/applications/${uuid}/stop`, { method: 'POST' }); +} + +export async function restartApplication(uuid: string): Promise { + await coolifyFetch(`/applications/${uuid}/restart`, { method: 'POST' }); +} + +export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> { + return coolifyFetch(`/deployments/${deploymentUuid}/logs`); +} + +export async function listApplicationDeployments(uuid: string): Promise { + return coolifyFetch(`/applications/${uuid}/deployments`); +} + +// ────────────────────────────────────────────────── +// Legacy monorepo helper (still used by older flows) +// ────────────────────────────────────────────────── + export async function createMonorepoAppService(opts: { projectUuid: string; appName: string; @@ -185,10 +474,9 @@ export async function createMonorepoAppService(opts: { projectUuid, appName, gitRepo, gitBranch = 'main', domain, - serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc', + serverUuid = COOLIFY_DEFAULT_SERVER_UUID, environmentName = 'production', } = opts; - return coolifyFetch(`/applications`, { method: 'POST', body: JSON.stringify({ @@ -207,32 +495,6 @@ export async function createMonorepoAppService(opts: { }); } -export async function listApplications(): Promise { - return coolifyFetch('/applications'); -} - -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`); -} - -export async function listApplicationDeployments(uuid: string): Promise> { - return coolifyFetch(`/applications/${uuid}/deployments`); -} - // ────────────────────────────────────────────────── // Environment variables // ────────────────────────────────────────────────── @@ -245,8 +507,6 @@ export async function upsertApplicationEnv( uuid: string, env: CoolifyEnvVar & { is_preview?: boolean } ): Promise { - // Coolify accepts PATCH for updates and POST for creates. We try - // PATCH first (idempotent upsert on key), fall back to POST. try { return await coolifyFetch(`/applications/${uuid}/envs`, { method: 'PATCH', @@ -271,27 +531,82 @@ export async function deleteApplicationEnv(uuid: string, key: string): Promise { + return coolifyFetch('/services'); +} + +export async function getService(uuid: string): Promise { + return coolifyFetch(`/services/${uuid}`); +} + +export async function createService(opts: { + projectUuid: string; + type: string; // e.g. "pocketbase", "authentik", "zitadel" + name: string; + description?: string; + serverUuid?: string; + environmentName?: string; + destinationUuid?: string; + instantDeploy?: boolean; +}): Promise<{ uuid: string }> { + const body = stripUndefined({ + project_uuid: opts.projectUuid, + server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, + environment_name: opts.environmentName ?? 'production', + destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, + type: opts.type, + name: opts.name, + description: opts.description, + instant_deploy: opts.instantDeploy ?? true, + }); + return coolifyFetch('/services', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function deleteService( + uuid: string, + opts: { + deleteConfigurations?: boolean; + deleteVolumes?: boolean; + dockerCleanup?: boolean; + deleteConnectedNetworks?: boolean; + } = {} +): Promise { + const q = new URLSearchParams(); + if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + const qs = q.toString(); + await coolifyFetch(`/services/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); +} + +// ────────────────────────────────────────────────── +// Tenant helpers — every endpoint that returns an app/db/service runs +// through one of these so cross-project access is impossible. +// ────────────────────────────────────────────────── + +export function projectUuidOf( + resource: CoolifyApplication | CoolifyDatabase | CoolifyService +): string | null { return ( - app.project_uuid ?? - app.environment?.project_uuid ?? - app.environment?.project?.uuid ?? + resource.project_uuid ?? + resource.environment?.project_uuid ?? + resource.environment?.project?.uuid ?? null ); } -/** - * Fetch an application AND verify it lives in the expected Coolify - * project. Throws a `TenantError` when the app is cross-tenant so - * callers can translate to HTTP 403. - */ export class TenantError extends Error { status = 403 as const; } @@ -310,10 +625,65 @@ export async function getApplicationInProject( return app; } -/** List applications that belong to the given Coolify project. */ +export async function getDatabaseInProject( + dbUuid: string, + expectedProjectUuid: string +): Promise { + const db = await getDatabase(dbUuid); + const actualProject = projectUuidOf(db); + if (!actualProject || actualProject !== expectedProjectUuid) { + throw new TenantError( + `Database ${dbUuid} does not belong to project ${expectedProjectUuid}` + ); + } + return db; +} + +export async function getServiceInProject( + serviceUuid: string, + expectedProjectUuid: string +): Promise { + const svc = await getService(serviceUuid); + const actualProject = projectUuidOf(svc); + if (!actualProject || actualProject !== expectedProjectUuid) { + throw new TenantError( + `Service ${serviceUuid} does not belong to project ${expectedProjectUuid}` + ); + } + return svc; +} + export async function listApplicationsInProject( projectUuid: string ): Promise { const all = await listApplications(); return all.filter(a => projectUuidOf(a) === projectUuid); } + +export async function listDatabasesInProject( + projectUuid: string +): Promise { + const all = await listDatabases(); + return all.filter(d => projectUuidOf(d) === projectUuid); +} + +export async function listServicesInProject( + projectUuid: string +): Promise { + const all = await listServices(); + return all.filter(s => projectUuidOf(s) === projectUuid); +} + +// ────────────────────────────────────────────────── +// util +// ────────────────────────────────────────────────── + +function stripUndefined>(obj: T): Partial { + const out: Partial = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) (out as Record)[k] = v; + } + return out; +} + +export { COOLIFY_DEFAULT_SERVER_UUID, COOLIFY_DEFAULT_DESTINATION_UUID }; diff --git a/lib/gitea.ts b/lib/gitea.ts index 00c8a9d..4abd7cf 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -334,6 +334,54 @@ export async function ensureOrgTeamMembership(opts: { 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 { + 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 { + 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 { + await giteaFetch(`/admin/users/keys/${keyId}`, { method: 'DELETE' }); +} + /** * Get an existing repo. */ diff --git a/lib/naming.ts b/lib/naming.ts new file mode 100644 index 0000000..46034bf --- /dev/null +++ b/lib/naming.ts @@ -0,0 +1,69 @@ +/** + * Canonical name + domain derivation for workspace-scoped resources. + * + * AI-generated Coolify apps live under a single subdomain namespace + * per workspace: + * + * https://{app-slug}.{workspace-slug}.vibnai.com + * + * e.g. `api.mark.vibnai.com` for the `api` app in workspace `mark`. + * + * The DNS record `*.vibnai.com` (or its subdomain) must resolve to + * the Coolify server. Traefik picks up the Host header and Coolify's + * per-app ACME handshake provisions a Let's Encrypt cert per FQDN. + */ + +const VIBN_BASE_DOMAIN = process.env.VIBN_BASE_DOMAIN ?? 'vibnai.com'; +const SLUG_STRIP = /[^a-z0-9-]+/g; + +/** Lowercase, dash-sanitize a free-form name into a DNS-safe slug. */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(SLUG_STRIP, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'app'; +} + +/** + * The default public FQDN for an app inside a workspace, given the + * workspace's slug (e.g. `mark`) and an app slug (e.g. `my-api`). + * + * workspaceAppFqdn('mark', 'my-api') === 'my-api.mark.vibnai.com' + */ +export function workspaceAppFqdn(workspaceSlug: string, appSlug: string): string { + return `${appSlug}.${workspaceSlug}.${VIBN_BASE_DOMAIN}`; +} + +/** `https://{fqdn}` — what Coolify's `domains` field expects. */ +export function toDomainsString(fqdns: string[]): string { + return fqdns.map(f => (f.startsWith('http') ? f : `https://${f}`)).join(','); +} + +/** Parse a Coolify `domains` CSV back into bare FQDNs. */ +export function parseDomainsString(domains: string | null | undefined): string[] { + if (!domains) return []; + return domains + .split(/[,\s]+/) + .map(d => d.trim()) + .filter(Boolean) + .map(d => d.replace(/^https?:\/\//, '').replace(/\/+$/, '')); +} + +/** Guard against cross-workspace or disallowed domains. */ +export function isDomainUnderWorkspace(fqdn: string, workspaceSlug: string): boolean { + const f = fqdn.replace(/^https?:\/\//, '').toLowerCase(); + return f === `${workspaceSlug}.${VIBN_BASE_DOMAIN}` || f.endsWith(`.${workspaceSlug}.${VIBN_BASE_DOMAIN}`); +} + +/** + * Build a Gitea SSH clone URL for a repo in a workspace's org. + * Matches what Coolify's `private-deploy-key` flow expects. + */ +export function giteaSshUrl(org: string, repo: string, giteaHost = 'git.vibnai.com'): string { + return `git@${giteaHost}:${org}/${repo}.git`; +} + +export { VIBN_BASE_DOMAIN }; diff --git a/lib/ssh-keys.ts b/lib/ssh-keys.ts new file mode 100644 index 0000000..2165ad5 --- /dev/null +++ b/lib/ssh-keys.ts @@ -0,0 +1,80 @@ +/** + * ed25519 SSH keypair generation for per-workspace Coolify deploy keys. + * + * We generate once at provisioning time: + * - public key in OpenSSH format (for Gitea's SSH keys API) + * - private key in PKCS8 PEM (Coolify accepts this via /security/keys) + * + * Keys live in memory just long enough to be pushed to Gitea and Coolify; + * neither is ever persisted by Vibn directly (Coolify holds the private + * key, Gitea holds the public key). We keep the Coolify key uuid and the + * Gitea key id on vibn_workspaces so we can rotate them later. + */ + +import { generateKeyPairSync, createPublicKey, type KeyObject } from 'crypto'; + +export interface Ed25519Keypair { + /** PKCS8 PEM — what Coolify's POST /security/keys wants. */ + privateKeyPem: string; + /** OpenSSH public-key line: "ssh-ed25519 AAAA… " */ + publicKeyOpenSsh: string; + /** SHA256 fingerprint string (without the trailing "=") */ + fingerprint: string; +} + +export function generateEd25519Keypair(comment = ''): Ed25519Keypair { + const { privateKey, publicKey } = generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + const pubKeyObj = createPublicKey(publicKey); + const publicKeyOpenSsh = ed25519PublicKeyToOpenSsh(pubKeyObj, comment); + return { + privateKeyPem: privateKey, + publicKeyOpenSsh, + fingerprint: opensshFingerprint(publicKeyOpenSsh), + }; +} + +/** + * Convert a Node ed25519 public KeyObject into an OpenSSH-format line. + * Wire format for the base64 blob: + * uint32 len=11 | "ssh-ed25519" | uint32 len=32 | raw-pubkey-bytes + */ +function ed25519PublicKeyToOpenSsh(publicKey: KeyObject, comment: string): string { + const jwk = publicKey.export({ format: 'jwk' }) as { x?: string }; + if (!jwk.x) { + throw new Error('public key has no jwk.x component — not ed25519?'); + } + const rawKey = Buffer.from(jwk.x, 'base64url'); + if (rawKey.length !== 32) { + throw new Error(`expected 32-byte ed25519 pubkey, got ${rawKey.length}`); + } + const keyTypeBytes = Buffer.from('ssh-ed25519'); + const blob = Buffer.concat([ + uint32BE(keyTypeBytes.length), + keyTypeBytes, + uint32BE(rawKey.length), + rawKey, + ]).toString('base64'); + const tail = comment ? ` ${comment}` : ''; + return `ssh-ed25519 ${blob}${tail}`; +} + +function uint32BE(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n); + return b; +} + +/** + * OpenSSH `ssh-keygen -lf`-style SHA256 fingerprint of a public key line. + */ +function opensshFingerprint(publicKeyLine: string): string { + const b64 = publicKeyLine.trim().split(/\s+/)[1]; + if (!b64) return ''; + const raw = Buffer.from(b64, 'base64'); + const { createHash } = require('crypto') as typeof import('crypto'); + const fp = createHash('sha256').update(raw).digest('base64'); + return `SHA256:${fp.replace(/=+$/, '')}`; +} diff --git a/lib/workspaces.ts b/lib/workspaces.ts index e5daa49..6ac6744 100644 --- a/lib/workspaces.ts +++ b/lib/workspaces.ts @@ -19,7 +19,12 @@ import { randomBytes } from 'crypto'; import { query, queryOne } from '@/lib/db-postgres'; -import { createProject as createCoolifyProject } from '@/lib/coolify'; +import { + createProject as createCoolifyProject, + createPrivateKey as createCoolifyPrivateKey, + COOLIFY_DEFAULT_SERVER_UUID, + COOLIFY_DEFAULT_DESTINATION_UUID, +} from '@/lib/coolify'; import { createOrg, getOrg, @@ -29,8 +34,11 @@ import { createAccessTokenFor, ensureOrgTeamMembership, adminEditUser, + adminAddUserSshKey, + adminListUserSshKeys, } from '@/lib/gitea'; import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box'; +import { generateEd25519Keypair } from '@/lib/ssh-keys'; export interface VibnWorkspace { id: string; @@ -39,10 +47,15 @@ export interface VibnWorkspace { owner_user_id: string; coolify_project_uuid: string | null; coolify_team_id: number | null; + coolify_server_uuid: string | null; + coolify_destination_uuid: string | null; + coolify_environment_name: string; + coolify_private_key_uuid: string | null; gitea_org: string | null; gitea_bot_username: string | null; gitea_bot_user_id: number | null; gitea_bot_token_encrypted: string | null; + gitea_bot_ssh_key_id: number | null; provision_status: 'pending' | 'partial' | 'ready' | 'error'; provision_error: string | null; created_at: Date; @@ -183,11 +196,18 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom workspace.coolify_project_uuid && workspace.gitea_org && workspace.gitea_bot_username && - workspace.gitea_bot_token_encrypted; + workspace.gitea_bot_token_encrypted && + workspace.coolify_private_key_uuid && + workspace.gitea_bot_ssh_key_id; if (fullyProvisioned) return workspace; let coolifyUuid = workspace.coolify_project_uuid; let giteaOrg = workspace.gitea_org; + let coolifyServerUuid = workspace.coolify_server_uuid ?? COOLIFY_DEFAULT_SERVER_UUID; + let coolifyDestinationUuid = + workspace.coolify_destination_uuid ?? COOLIFY_DEFAULT_DESTINATION_UUID; + let coolifyPrivateKeyUuid = workspace.coolify_private_key_uuid; + let giteaBotSshKeyId = workspace.gitea_bot_ssh_key_id; const errors: string[] = []; // ── Coolify Project ──────────────────────────────────────────────── @@ -323,20 +343,83 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom } } - const allReady = !!(coolifyUuid && giteaOrg && botUsername && botTokenEncrypted); + // ── Per-workspace SSH deploy keypair ────────────────────────────── + // Used by Coolify to clone private Gitea repos this workspace owns. + // We generate the keypair in-process, push the public key to Gitea + // (under the bot user so its team memberships scope repo access), + // and register the private key in Coolify. Neither key is ever + // persisted directly by Vibn — we only keep their ids. + if (botUsername && (!coolifyPrivateKeyUuid || !giteaBotSshKeyId)) { + try { + const comment = `vibn-${workspace.slug}@${botUsername}`; + const kp = generateEd25519Keypair(comment); + + // Register public key on Gitea bot if not already present. + if (!giteaBotSshKeyId) { + // Protect against double-adding on re-provisioning by looking + // for an existing key with our canonical title first. + const title = `vibn-${workspace.slug}-coolify`; + let existingId: number | null = null; + try { + const keys = await adminListUserSshKeys(botUsername); + existingId = keys.find(k => k.title === title)?.id ?? null; + } catch { + /* list failure is non-fatal */ + } + if (existingId) { + giteaBotSshKeyId = existingId; + } else { + const added = await adminAddUserSshKey({ + username: botUsername, + title, + key: kp.publicKeyOpenSsh, + readOnly: false, + }); + giteaBotSshKeyId = added.id; + } + } + + // Register private key in Coolify if not already present. + if (!coolifyPrivateKeyUuid) { + const created = await createCoolifyPrivateKey({ + name: `vibn-${workspace.slug}-gitea`, + description: `Workspace ${workspace.slug} Gitea deploy key (${kp.fingerprint})`, + privateKeyPem: kp.privateKeyPem, + }); + coolifyPrivateKeyUuid = created.uuid; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`ssh-key: ${msg}`); + console.error('[workspaces] SSH deploy-key provisioning failed', workspace.slug, msg); + } + } + + const allReady = !!( + coolifyUuid && + giteaOrg && + botUsername && + botTokenEncrypted && + coolifyPrivateKeyUuid && + giteaBotSshKeyId + ); const status: VibnWorkspace['provision_status'] = allReady ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; const updated = await query( `UPDATE vibn_workspaces - SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), - gitea_org = COALESCE($3, gitea_org), - gitea_bot_username = COALESCE($4, gitea_bot_username), - gitea_bot_user_id = COALESCE($5, gitea_bot_user_id), - gitea_bot_token_encrypted= COALESCE($6, gitea_bot_token_encrypted), - provision_status = $7, - provision_error = $8, - updated_at = now() + SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), + gitea_org = COALESCE($3, gitea_org), + gitea_bot_username = COALESCE($4, gitea_bot_username), + gitea_bot_user_id = COALESCE($5, gitea_bot_user_id), + gitea_bot_token_encrypted = COALESCE($6, gitea_bot_token_encrypted), + coolify_server_uuid = COALESCE($7, coolify_server_uuid), + coolify_destination_uuid = COALESCE($8, coolify_destination_uuid), + coolify_private_key_uuid = COALESCE($9, coolify_private_key_uuid), + gitea_bot_ssh_key_id = COALESCE($10, gitea_bot_ssh_key_id), + provision_status = $11, + provision_error = $12, + updated_at = now() WHERE id = $1 RETURNING *`, [ @@ -346,6 +429,10 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom botUsername, botUserId, botTokenEncrypted, + coolifyServerUuid, + coolifyDestinationUuid, + coolifyPrivateKeyUuid, + giteaBotSshKeyId, status, errors.length ? errors.join('; ') : null, ]