/** * Vibn MCP HTTP bridge. * * Authenticates via a workspace-scoped `vibn_sk_...` token (session * cookies also work for browser debugging). Every tool call is * executed inside the bound workspace's tenant boundary — Coolify * requests verify the app's project uuid, and git credentials are * pinned to the workspace's Gitea org/bot. * * Exposed tools are a stable subset of the Vibn REST API so agents * have one well-typed entry point regardless of deployment host. * * Protocol notes: * - This is a thin, JSON-over-HTTP MCP shim. The `mcp.json` in a * user's Cursor config points at this URL and stores the bearer * token. We keep the shape compatible with MCP clients that * speak `{ action, params }` calls. */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces'; import { ensureWorkspaceGcsProvisioned, getWorkspaceGcsState, getWorkspaceGcsHmacCredentials, } from '@/lib/workspace-gcs'; import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage'; import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; import { execInCoolifyApp } from '@/lib/coolify-exec'; import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh'; import { listContainersForApp } from '@/lib/coolify-containers'; import { deployApplication, getApplicationInProject, listApplicationDeployments, listApplicationEnvs, listApplicationsInProject, projectUuidOf, TenantError, upsertApplicationEnv, deleteApplicationEnv, // Phase 4 ── create/update/delete + domains + databases + services createPublicApp, createDockerImageApp, createDockerComposeApp, 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 { giteaHttpsUrl, isDomainUnderWorkspace, slugify, toDomainsString, workspaceAppFqdn, } from '@/lib/naming'; const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; // ────────────────────────────────────────────────── // Capability descriptor // ────────────────────────────────────────────────── export async function GET() { return NextResponse.json({ name: 'vibn-mcp', version: '2.3.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', description: 'Workspace-scoped token minted at /settings. Every tool call is ' + 'automatically restricted to the workspace the token belongs to.', }, capabilities: { tools: { supported: true, available: [ 'workspace.describe', 'gitea.credentials', 'projects.list', 'projects.get', 'apps.list', 'apps.get', 'apps.create', 'apps.update', 'apps.rewire_git', 'apps.delete', 'apps.deploy', 'apps.deployments', 'apps.domains.list', 'apps.domains.set', 'apps.logs', 'apps.exec', 'apps.volumes.list', 'apps.volumes.wipe', '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', 'domains.search', 'domains.list', 'domains.get', 'domains.register', 'domains.attach', 'storage.describe', 'storage.provision', 'storage.inject_env', ], }, }, documentation: 'https://vibnai.com/docs/mcp', }); } // ────────────────────────────────────────────────── // Tool dispatcher // ────────────────────────────────────────────────── export async function POST(request: Request) { const principal = await requireWorkspacePrincipal(request); if (principal instanceof NextResponse) return principal; let body: { action?: string; tool?: string; params?: Record }; try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } // Accept either `{ action, params }` or `{ tool, params }` shapes. const action = (body.tool ?? body.action ?? '') as string; const params = (body.params ?? {}) as Record; try { switch (action) { case 'workspace.describe': return NextResponse.json({ result: describeWorkspace(principal) }); case 'gitea.credentials': return await toolGiteaCredentials(principal); case 'projects.list': return await toolProjectsList(principal); case 'projects.get': return await toolProjectsGet(principal, params); case 'apps.list': return await toolAppsList(principal); case 'apps.get': return await toolAppsGet(principal, params); case 'apps.deploy': return await toolAppsDeploy(principal, params); case 'apps.deployments': return await toolAppsDeployments(principal, params); case 'apps.envs.list': return await toolAppsEnvsList(principal, params); case 'apps.envs.upsert': return await toolAppsEnvsUpsert(principal, params); 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.rewire_git': return await toolAppsRewireGit(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 'apps.logs': return await toolAppsLogs(principal, params); case 'apps.exec': return await toolAppsExec(principal, params); case 'apps.volumes.list': return await toolAppsVolumesList(principal, params); case 'apps.volumes.wipe': return await toolAppsVolumesWipe(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); case 'domains.search': return await toolDomainsSearch(principal, params); case 'domains.list': return await toolDomainsList(principal); case 'domains.get': return await toolDomainsGet(principal, params); case 'domains.register': return await toolDomainsRegister(principal, params); case 'domains.attach': return await toolDomainsAttach(principal, params); case 'storage.describe': return await toolStorageDescribe(principal); case 'storage.provision': return await toolStorageProvision(principal); case 'storage.inject_env': return await toolStorageInjectEnv(principal, params); default: return NextResponse.json( { error: `Unknown tool "${action}"` }, { status: 404 } ); } } catch (err) { if (err instanceof TenantError) { return NextResponse.json({ error: err.message }, { status: 403 }); } console.error('[mcp] tool failed', action, err); return NextResponse.json( { error: 'Tool execution failed', details: err instanceof Error ? err.message : String(err) }, { status: 500 } ); } } // ────────────────────────────────────────────────── // Tool implementations // ────────────────────────────────────────────────── type Principal = Extract< Awaited>, { source: 'session' | 'api_key' } >; function describeWorkspace(principal: Principal) { const w = principal.workspace; return { slug: w.slug, name: w.name, coolifyProjectUuid: w.coolify_project_uuid, giteaOrg: w.gitea_org, giteaBotUsername: w.gitea_bot_username, provisionStatus: w.provision_status, provisionError: w.provision_error, principal: { source: principal.source, apiKeyId: principal.apiKeyId ?? null }, }; } async function toolGiteaCredentials(principal: Principal) { let ws = principal.workspace; if (!ws.gitea_bot_token_encrypted || !ws.gitea_org) { ws = await ensureWorkspaceProvisioned(ws); } const creds = getWorkspaceBotCredentials(ws); if (!creds) { return NextResponse.json( { error: 'Workspace has no Gitea bot yet', provisionStatus: ws.provision_status }, { status: 503 } ); } const apiBase = GITEA_API_URL.replace(/\/$/, ''); const host = new URL(apiBase).host; return NextResponse.json({ result: { org: creds.org, username: creds.username, token: creds.token, apiBase, host, cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`, }, }); } async function toolProjectsList(principal: Principal) { const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>( `SELECT id, data, created_at, updated_at FROM fs_projects WHERE vibn_workspace_id = $1 OR workspace = $2 ORDER BY created_at DESC`, [principal.workspace.id, principal.workspace.slug] ); return NextResponse.json({ result: rows.map(r => ({ id: r.id, name: r.data?.name ?? null, repo: r.data?.repoName ?? null, giteaRepo: r.data?.giteaRepo ?? null, coolifyAppUuid: r.data?.coolifyAppUuid ?? null, createdAt: r.created_at, updatedAt: r.updated_at, })), }); } async function toolProjectsGet(principal: Principal, params: Record) { const projectId = String(params.projectId ?? params.id ?? '').trim(); if (!projectId) { return NextResponse.json({ error: 'Param "projectId" is required' }, { status: 400 }); } const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>( `SELECT id, data, created_at, updated_at FROM fs_projects WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`, [projectId, principal.workspace.id, principal.workspace.slug] ); if (rows.length === 0) { return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 }); } const r = rows[0]; return NextResponse.json({ result: { id: r.id, data: r.data, createdAt: r.created_at, updatedAt: r.updated_at }, }); } function requireCoolifyProject(principal: Principal): string | NextResponse { const projectUuid = principal.workspace.coolify_project_uuid; if (!projectUuid) { return NextResponse.json( { error: 'Workspace has no Coolify project yet' }, { status: 503 } ); } return projectUuid; } async function toolAppsList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const apps = await listApplicationsInProject(projectUuid); return NextResponse.json({ result: apps.map(a => ({ uuid: a.uuid, name: a.name, status: a.status, fqdn: a.fqdn ?? null, gitRepository: a.git_repository ?? null, gitBranch: a.git_branch ?? null, projectUuid: projectUuidOf(a), })), }); } async function toolAppsGet(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); return NextResponse.json({ result: app }); } async function toolAppsDeploy(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 { deployment_uuid } = await deployApplication(appUuid); return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid } }); } async function toolAppsDeployments(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 deployments = await listApplicationDeployments(appUuid); return NextResponse.json({ result: deployments }); } /** * Runtime logs for a Coolify app. Compose-aware: * - Dockerfile/nixpacks apps → Coolify's `/applications/{uuid}/logs` * - Compose apps → SSH into Coolify host, `docker logs` per service * * Params: * uuid – app uuid (required) * service – compose service filter (optional) * lines – tail lines per container, default 200, max 5000 */ async function toolAppsLogs(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 linesRaw = Number(params.lines ?? 200); const lines = Number.isFinite(linesRaw) ? linesRaw : 200; const serviceRaw = params.service; const service = typeof serviceRaw === 'string' && serviceRaw.trim() ? serviceRaw.trim() : undefined; const result = await getApplicationRuntimeLogs(appUuid, { lines, service }); return NextResponse.json({ result }); } /** * apps.exec — run a one-shot command inside an app container. * * Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller * provides `uuid`, an optional `service` (required for compose apps * with >1 container), and a `command` string. Output is capped at * 1MB by default and 10-minute wall-clock timeout. * * Note: the command is NOT parsed or validated. It's executed as a * single shell invocation inside the container via `sh -lc`. This is * deliberate — this tool is the platform's trust-the-agent escape * hatch for migrations, CLI invocations, and ad-hoc debugging. It's * authenticated per-workspace (tenant check above) and rate-limited * by the SSH session's timeout/byte caps. */ async function toolAppsExec(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; if (!isCoolifySshConfigured()) { return NextResponse.json( { error: 'apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.', }, { status: 501 }, ); } const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const command = typeof params.command === 'string' ? params.command : ''; if (!appUuid || !command) { return NextResponse.json( { error: 'Params "uuid" and "command" are required' }, { status: 400 }, ); } await getApplicationInProject(appUuid, projectUuid); const service = typeof params.service === 'string' && params.service.trim() ? params.service.trim() : undefined; const user = typeof params.user === 'string' && params.user.trim() ? params.user.trim() : undefined; const workdir = typeof params.workdir === 'string' && params.workdir.trim() ? params.workdir.trim() : undefined; const timeoutMs = Number.isFinite(Number(params.timeout_ms)) ? Number(params.timeout_ms) : undefined; const maxBytes = Number.isFinite(Number(params.max_bytes)) ? Number(params.max_bytes) : undefined; try { const result = await execInCoolifyApp({ appUuid, service, command, user, workdir, timeoutMs, maxBytes, }); return NextResponse.json({ result }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return NextResponse.json({ error: msg }, { status: 400 }); } } // ── Volume tools ──────────────────────────────────────────────────────── /** * apps.volumes.list — list Docker volumes that belong to an app. * Returns name, size (bytes), and which containers are currently * using each volume (if any are running). */ async function toolAppsVolumesList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 }); } 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 res = await runOnCoolifyHost( `docker volume ls --filter name=${sq(appUuid)} --format '{{.Name}}' | xargs -r -I{} sh -c 'echo "{}|$(docker volume inspect {} --format "{{.UsageData.Size}}" 2>/dev/null || echo -1)"'`, { timeoutMs: 12_000 }, ); if (res.code !== 0) { return NextResponse.json({ error: `docker volume ls failed: ${res.stderr.trim()}` }, { status: 502 }); } const volumes = res.stdout .split('\n') .map(l => l.trim()) .filter(Boolean) .map(l => { const [name, sizeStr] = l.split('|'); const sizeBytes = parseInt(sizeStr ?? '-1', 10); return { name, sizeBytes: isNaN(sizeBytes) ? -1 : sizeBytes }; }); return NextResponse.json({ result: { volumes } }); } /** * apps.volumes.wipe — destroy a Docker volume for this app. * * This is a destructive, irreversible operation. The agent MUST pass * `confirm: ""` exactly matching the volume name, to * prevent accidents. All containers using the volume are stopped and * removed first (Coolify will restart them on the next deploy). * * Typical use: wipe a stale Postgres data volume before redeploying * so the database is initialised fresh. */ async function toolAppsVolumesWipe(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 }); } const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const volumeName = String(params.volume ?? '').trim(); const confirm = String(params.confirm ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); if (!volumeName) return NextResponse.json({ error: 'Param "volume" is required (exact volume name from apps.volumes.list)' }, { status: 400 }); if (confirm !== volumeName) { return NextResponse.json( { error: `Param "confirm" must equal the exact volume name "${volumeName}" to proceed` }, { status: 400 }, ); } // Security check: volume must belong to this app (name must contain the uuid) if (!volumeName.includes(appUuid)) { return NextResponse.json( { error: `Volume "${volumeName}" does not appear to belong to app ${appUuid}` }, { status: 403 }, ); } await getApplicationInProject(appUuid, projectUuid); // Stop + remove all containers using this volume, then remove the volume const cmd = [ // Stop and remove containers for this app (they'll be recreated on next deploy) `CONTAINERS=$(docker ps -a --filter name=${sq(appUuid)} --format '{{.Names}}')`, `[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker stop -t 10 || true`, `[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker rm -f || true`, // Remove the volume `docker volume rm ${sq(volumeName)}`, `echo "done"`, ].join(' && '); const res = await runOnCoolifyHost(cmd, { timeoutMs: 30_000 }); if (res.code !== 0 || !res.stdout.includes('done')) { return NextResponse.json( { error: `Volume removal failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.trim()}` }, { status: 502 }, ); } return NextResponse.json({ result: { wiped: volumeName, message: 'Volume removed. Trigger apps.deploy to restart the app with a fresh volume.', }, }); } function sq(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } async function toolAppsEnvsList(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 envs = await listApplicationEnvs(appUuid); return NextResponse.json({ result: envs }); } async function toolAppsEnvsUpsert(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; const value = typeof params.value === 'string' ? params.value : ''; if (!appUuid || !key) { return NextResponse.json( { error: 'Params "uuid" and "key" are required' }, { status: 400 } ); } await getApplicationInProject(appUuid, projectUuid); // Coolify v4 rejects `is_build_time` on POST/PATCH (it's a derived // read-only flag now). Silently drop it here so agents that still send // it don't get a surprise 422. See lib/coolify.ts upsertApplicationEnv // for the hard enforcement at the network boundary. const result = await upsertApplicationEnv(appUuid, { key, value, is_preview: !!params.is_preview, is_literal: !!params.is_literal, is_multiline: !!params.is_multiline, is_shown_once: !!params.is_shown_once, }); const body: Record = { result }; if (params.is_build_time !== undefined) { body.warnings = [ 'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.', ]; } return NextResponse.json(body); } async function toolAppsEnvsDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; if (!appUuid || !key) { return NextResponse.json( { error: 'Params "uuid" and "key" are required' }, { status: 400 } ); } await getApplicationInProject(appUuid, projectUuid); await deleteApplicationEnv(appUuid, key); return NextResponse.json({ result: { ok: true, key } }); } // ────────────────────────────────────────────────── // Phase 4: apps create/update/delete + domains // ────────────────────────────────────────────────── /** * apps.create — three distinct pathways depending on what you pass: * * 1. Gitea repo (existing behaviour — for user-owned custom apps) * Required: repo * Optional: branch, buildPack, ports, domain, envs, … * * 2. Docker image from a registry (no repo, no build) * Required: image e.g. "nginx:alpine", "twentyhq/twenty:1.23.0" * Optional: name, domain, ports, envs * * 3. Inline Docker Compose YAML (no repo, no build) * Required: composeRaw (the full docker-compose.yml contents as a string) * Optional: name, domain, composeDomains, envs * * Pathways 2 and 3 do NOT create a Gitea repo. They deploy directly * from Docker Hub / any public registry, or from the raw YAML you * supply. Use these for third-party apps (Twenty, Directus, Cal.com…). * * Use pathway 1 for user's own code that lives in the workspace's * Gitea org. */ async function toolAppsCreate(principal: Principal, params: Record) { const ws = principal.workspace; if (!ws.coolify_project_uuid) { return NextResponse.json( { error: 'Workspace not fully provisioned (need Coolify project)' }, { status: 503 } ); } const commonOpts = { projectUuid: ws.coolify_project_uuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, isForceHttpsEnabled: true, instantDeploy: false, }; // ── Pathway 2: Docker image ─────────────────────────────────────────── if (params.image) { const image = String(params.image).trim(); const appName = slugify(String(params.name ?? image.split('/').pop()?.split(':')[0] ?? 'app')); const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; const created = await createDockerImageApp({ ...commonOpts, image, name: appName, portsExposes: String(params.ports ?? '80'), domains: toDomainsString([fqdn]), description: params.description ? String(params.description) : undefined, }); await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); } // ── Pathway 3: Inline Docker Compose ───────────────────────────────── if (params.composeRaw) { const composeRaw = String(params.composeRaw).trim(); const appName = slugify(String(params.name ?? 'app')); const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; // composeDomains: array of { service, domain } or derive from fqdn const composeDomains: Array<{ service: string; domain: string }> = Array.isArray(params.composeDomains) && params.composeDomains.length > 0 ? params.composeDomains : [{ service: params.composeService ?? 'server', domain: fqdn }]; const created = await createDockerComposeApp({ ...commonOpts, composeRaw, name: appName, description: params.description ? String(params.description) : undefined, composeDomains, }); await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); } // ── Pathway 1: Gitea repo (original behaviour) ──────────────────────── if (!ws.gitea_org) { return NextResponse.json( { error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `image` or `composeRaw` instead of `repo`.' }, { status: 503 } ); } const botCreds = getWorkspaceBotCredentials(ws); if (!botCreds) { return NextResponse.json( { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, { status: 503 } ); } const repoIn = String(params.repo ?? '').trim(); if (!repoIn) { return NextResponse.json( { error: 'One of `repo`, `image`, or `composeRaw` 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 = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; const created = await createPublicApp({ ...commonOpts, gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token), 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, dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined, dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined, baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined, }); const dep = await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, deploymentUuid: dep, }, }); } /** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */ function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse { const fqdn = String(domainParam ?? '').trim() ? String(domainParam).replace(/^https?:\/\//, '') : workspaceAppFqdn(slug, appName); if (!isDomainUnderWorkspace(fqdn, slug)) { return NextResponse.json( { error: `Domain ${fqdn} must end with .${slug}.vibnai.com` }, { status: 403 } ); } return fqdn; } /** Upsert envs then optionally trigger deploy. Returns deploymentUuid or null. */ async function applyEnvsAndDeploy( appUuid: string, params: Record, ): Promise { 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(appUuid, { key: k, value: String(v) }); } catch (e) { console.warn('[mcp apps.create] upsert env failed', k, e); } } } if (params.instantDeploy === false) return null; try { const dep = await deployApplication(appUuid); return dep.deployment_uuid ?? null; } catch (e) { console.warn('[mcp apps.create] first deploy failed', e); return null; } } 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', 'git_commit_sha', 'build_pack', 'ports_exposes', 'install_command', 'build_command', 'start_command', 'base_directory', 'dockerfile_location', 'docker_compose_location', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', ]); // ── Control params (never forwarded to Coolify) ───────────────────── // `uuid`/`appUuid` identify the target; we've consumed them already. const control = new Set(['uuid', 'appUuid', 'patch']); // ── Fields we deliberately DO NOT forward from apps.update ───────── // Each maps to a different tool; silently dropping them the way we // used to caused real live-test bugs (PATCH returns ok, nothing // persists, agent thinks it worked). const redirected: Record = { fqdn: 'apps.domains.set', domains: 'apps.domains.set', docker_compose_domains: 'apps.domains.set', git_repository: 'apps.rewire_git', }; // Support both the flat `{ uuid, name, description, ... }` shape and // the explicit `{ uuid, patch: { name, description, ... } }` shape. const source: Record = params.patch && typeof params.patch === 'object' && !Array.isArray(params.patch) ? (params.patch as Record) : params; const patch: Record = {}; const ignored: string[] = []; const rerouted: Array<{ field: string; use: string }> = []; for (const [k, v] of Object.entries(source)) { if (v === undefined) continue; if (control.has(k) && source === params) continue; if (redirected[k]) { rerouted.push({ field: k, use: redirected[k] }); continue; } if (allowed.has(k)) { patch[k] = v; continue; } ignored.push(k); } if (Object.keys(patch).length === 0) { return NextResponse.json( { error: rerouted.length ? 'No updatable fields in params. Some fields must be set via other tools — see `rerouted`.' : 'No updatable fields in params. See `ignored` and `allowed`.', rerouted, ignored, allowed: [...allowed], }, { status: 400 }, ); } await updateApplication(appUuid, patch); return NextResponse.json({ result: { ok: true, uuid: appUuid, applied: Object.keys(patch), // Non-empty `ignored`/`rerouted` are NOT errors but callers need to // see them; silently dropping unrecognised keys was the original // "fqdn returns ok but doesn't persist" false-positive. ...(ignored.length ? { ignored } : {}), ...(rerouted.length ? { rerouted } : {}), }, }); } /** * Re-point an app's git_repository at the workspace's canonical * HTTPS+PAT clone URL. Useful to recover older apps that were created * with SSH URLs (which don't work on this Gitea topology), or to * rotate the bot PAT embedded in the URL after a credential cycle. * The repo name is inferred from the current URL unless `repo` is * passed explicitly (in `owner/name` form). */ async function toolAppsRewireGit(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 ws = principal.workspace; const botCreds = getWorkspaceBotCredentials(ws); if (!botCreds) { return NextResponse.json( { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, { status: 503 } ); } const app = await getApplicationInProject(appUuid, projectUuid); let repoOrg: string; let repoName: string; if (params.repo) { const parts = String(params.repo).replace(/\.git$/, '').split('/'); if (parts.length !== 2) { return NextResponse.json({ error: 'Param "repo" must be "owner/name"' }, { status: 400 }); } [repoOrg, repoName] = parts; } else { const m = (app.git_repository ?? '').match( /(?:git@[^:]+:|https?:\/\/(?:[^/]+@)?[^/]+\/)([^/]+)\/([^/.]+)(?:\.git)?$/ ); if (!m) { return NextResponse.json( { error: 'Could not infer repo from current git_repository; pass repo="owner/name"' }, { status: 400 } ); } [, repoOrg, repoName] = m; } if (repoOrg !== ws.gitea_org) { return NextResponse.json( { error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` }, { status: 403 } ); } const newUrl = giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token); await updateApplication(appUuid, { git_repository: newUrl }); return NextResponse.json({ result: { ok: true, uuid: appUuid, repo: `${repoOrg}/${repoName}`, gitUrlScheme: 'https+pat', }, }); } 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 }); } const app = 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); } const buildPack = (app.build_pack ?? 'nixpacks') as string; const composeService = typeof params.service === 'string' && params.service.trim() ? params.service.trim() : typeof params.composeService === 'string' && params.composeService.trim() ? params.composeService.trim() : undefined; await setApplicationDomains(appUuid, normalized, { forceOverride: true, buildPack, composeService, }); return NextResponse.json({ result: { uuid: appUuid, domains: normalized, buildPack, routedTo: buildPack === 'dockercompose' ? { field: 'docker_compose_domains', service: composeService ?? 'server' } : { field: 'domains' }, }, }); } // ────────────────────────────────────────────────── // 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 } }, }); } // ────────────────────────────────────────────────── // Phase 5.1: domains (OpenSRS) // ────────────────────────────────────────────────── async function toolDomainsSearch(principal: Principal, params: Record) { const namesIn = Array.isArray(params.names) ? params.names : [params.name]; const names = namesIn .filter((x: unknown): x is string => typeof x === 'string' && x.trim().length > 0) .map((s: string) => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '')); if (names.length === 0) { return NextResponse.json({ error: 'Params { names: string[] } or { name: string } required' }, { status: 400 }); } const period = typeof params.period === 'number' && params.period > 0 ? params.period : 1; const { checkDomain } = await import('@/lib/opensrs'); const results = await Promise.all(names.map(async (name: string) => { try { const r = await checkDomain(name, period); return { domain: name, available: r.available, price: r.price ?? null, currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'), period: r.period ?? period, }; } catch (err) { return { domain: name, available: false, error: err instanceof Error ? err.message : String(err) }; } })); return NextResponse.json({ result: { mode: process.env.OPENSRS_MODE ?? 'test', results } }); } async function toolDomainsList(principal: Principal) { const { listDomainsForWorkspace } = await import('@/lib/domains'); const rows = await listDomainsForWorkspace(principal.workspace.id); return NextResponse.json({ result: rows.map(r => ({ id: r.id, domain: r.domain, tld: r.tld, status: r.status, registeredAt: r.registered_at, expiresAt: r.expires_at, periodYears: r.period_years, dnsProvider: r.dns_provider, })), }); } async function toolDomainsGet(principal: Principal, params: Record) { const name = String(params.domain ?? params.name ?? '').trim().toLowerCase(); if (!name) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); const { getDomainForWorkspace } = await import('@/lib/domains'); const row = await getDomainForWorkspace(principal.workspace.id, name); if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); return NextResponse.json({ result: { id: row.id, domain: row.domain, tld: row.tld, status: row.status, registrarOrderId: row.registrar_order_id, periodYears: row.period_years, registeredAt: row.registered_at, expiresAt: row.expires_at, dnsProvider: row.dns_provider, dnsZoneId: row.dns_zone_id, dnsNameservers: row.dns_nameservers, }, }); } async function toolDomainsRegister(principal: Principal, params: Record) { const raw = String(params.domain ?? '').toLowerCase().trim() .replace(/^https?:\/\//, '').replace(/\/+$/, ''); if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) { return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 }); } if (!params.contact || typeof params.contact !== 'object') { return NextResponse.json({ error: '`contact` object is required (see /api/workspaces/[slug]/domains POST schema)' }, { status: 400 }); } const { domainTld: tldOf, minPeriodFor, registerDomain, OpenSrsError } = await import('@/lib/opensrs'); const tld = tldOf(raw); if (tld === 'ca' && !params.ca) { return NextResponse.json({ error: '.ca requires `ca.cprCategory` and `ca.legalType`' }, { status: 400 }); } const period = minPeriodFor(tld, typeof params.period === 'number' ? params.period : 1); const { createDomainIntent, getDomainForWorkspace, markDomainFailed, markDomainRegistered, recordDomainEvent, } = await import('@/lib/domains'); let intent = await getDomainForWorkspace(principal.workspace.id, raw); if (intent && intent.status === 'active') { return NextResponse.json({ error: `Domain ${raw} is already registered`, domainId: intent.id }, { status: 409 }); } if (!intent) { intent = await createDomainIntent({ workspaceId: principal.workspace.id, domain: raw, createdBy: principal.userId, periodYears: period, whoisPrivacy: params.whoisPrivacy ?? true, }); } await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.attempt', payload: { period, via: 'mcp', mode: process.env.OPENSRS_MODE ?? 'test' }, }); try { const result = await registerDomain({ domain: raw, period, contact: params.contact, nameservers: params.nameservers, whoisPrivacy: params.whoisPrivacy ?? true, ca: params.ca, }); const updated = await markDomainRegistered({ domainId: intent.id, registrarOrderId: result.orderId, registrarUsername: result.regUsername, registrarPassword: result.regPassword, periodYears: period, pricePaidCents: null, priceCurrency: process.env.OPENSRS_CURRENCY ?? 'CAD', registeredAt: new Date(), expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000), }); await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.success', payload: { orderId: result.orderId, period, via: 'mcp' }, }); return NextResponse.json({ result: { ok: true, mode: process.env.OPENSRS_MODE ?? 'test', domain: { id: updated.id, domain: updated.domain, status: updated.status, registrarOrderId: updated.registrar_order_id, expiresAt: updated.expires_at, }, }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); await markDomainFailed(intent.id, message); if (err instanceof OpenSrsError) { return NextResponse.json({ error: 'Registration failed', registrarCode: err.code, details: err.message }, { status: 502 }); } return NextResponse.json({ error: 'Registration failed', details: message }, { status: 500 }); } } async function toolDomainsAttach(principal: Principal, params: Record) { const apex = String(params.domain ?? params.name ?? '').trim().toLowerCase(); if (!apex) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); const { getDomainForWorkspace } = await import('@/lib/domains'); const row = await getDomainForWorkspace(principal.workspace.id, apex); if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); const { attachDomain, AttachError } = await import('@/lib/domain-attach'); try { const result = await attachDomain(principal.workspace, row, { appUuid: typeof params.appUuid === 'string' ? params.appUuid : undefined, ip: typeof params.ip === 'string' ? params.ip : undefined, cname: typeof params.cname === 'string' ? params.cname : undefined, subdomains: Array.isArray(params.subdomains) ? params.subdomains : undefined, updateRegistrarNs: params.updateRegistrarNs !== false, }); return NextResponse.json({ result: { ok: true, domain: { id: result.domain.id, domain: result.domain.domain, dnsProvider: result.domain.dns_provider, dnsZoneId: result.domain.dns_zone_id, dnsNameservers: result.domain.dns_nameservers, }, zone: result.zone, records: result.records, registrarNsUpdate: result.registrarNsUpdate, coolifyUpdate: result.coolifyUpdate, }, }); } catch (err) { if (err instanceof AttachError) { return NextResponse.json( { error: err.message, tag: err.tag, ...(err.extra ?? {}) }, { status: err.status }, ); } console.error('[mcp domains.attach] unexpected', err); return NextResponse.json( { error: 'Attach failed', details: err instanceof Error ? err.message : String(err) }, { status: 500 }, ); } } // ────────────────────────────────────────────────── // Phase 5.3: Object storage (GCS via S3-compatible HMAC) // ────────────────────────────────────────────────── /** * Shape of the S3-compatible credentials we expose to agents. * * The HMAC *secret* is never returned here — only the access id and * the bucket/region/endpoint. Use `storage.inject_env` to push the * full `{accessId, secret}` pair into a Coolify app's env vars * server-side, where the secret never leaves our network. */ function describeWorkspaceStorage(ws: { slug: string; gcs_default_bucket_name: string | null; gcs_hmac_access_id: string | null; gcp_service_account_email: string | null; gcp_provision_status: string | null; }) { return { status: ws.gcp_provision_status ?? 'pending', bucket: ws.gcs_default_bucket_name, region: VIBN_GCS_LOCATION, endpoint: 'https://storage.googleapis.com', accessKeyId: ws.gcs_hmac_access_id, serviceAccountEmail: ws.gcp_service_account_email, note: 'S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. ' + 'The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.', }; } async function toolStorageDescribe(principal: Principal) { const ws = await getWorkspaceGcsState(principal.workspace.id); if (!ws) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); } return NextResponse.json({ result: describeWorkspaceStorage(ws) }); } async function toolStorageProvision(principal: Principal) { const result = await ensureWorkspaceGcsProvisioned(principal.workspace); return NextResponse.json({ result }); } /** * Inject the workspace's storage credentials into a Coolify app as * env vars, so the app can reach the bucket with any S3 SDK. The * HMAC secret is read server-side and written directly to Coolify — * it never transits through the agent or our API response. * * Envs written (all tagged is_shown_once so Coolify hides the secret * in the UI after first render): * STORAGE_ENDPOINT = https://storage.googleapis.com * STORAGE_REGION = northamerica-northeast1 * STORAGE_BUCKET = vibn-ws-{slug}-{rand} * STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id) * STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once) * STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS) * * Agents can override the env var prefix via `params.prefix` * (e.g. "S3_" for apps that expect AWS-style names). */ async function toolStorageInjectEnv(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 prefix = String(params.prefix ?? 'STORAGE_'); if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) { return NextResponse.json( { error: 'Param "prefix" must be uppercase ASCII (letters, digits, underscores)' }, { status: 400 }, ); } const ws = await getWorkspaceGcsState(principal.workspace.id); if (!ws) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); if (ws.gcp_provision_status !== 'ready' || !ws.gcs_default_bucket_name) { return NextResponse.json( { error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`, }, { status: 409 }, ); } const creds = getWorkspaceGcsHmacCredentials(ws); if (!creds) { return NextResponse.json( { error: 'Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.' }, { status: 409 }, ); } const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [ { key: `${prefix}ENDPOINT`, value: 'https://storage.googleapis.com' }, { key: `${prefix}REGION`, value: VIBN_GCS_LOCATION }, { key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name }, { key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId }, { key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true }, { key: `${prefix}FORCE_PATH_STYLE`, value: 'true' }, ]; const written: string[] = []; const failed: Array<{ key: string; error: string }> = []; for (const e of entries) { try { await upsertApplicationEnv(appUuid, { key: e.key, value: e.value, is_shown_once: e.shownOnce ?? false, }); written.push(e.key); } catch (err) { failed.push({ key: e.key, error: err instanceof Error ? err.message : String(err) }); } } return NextResponse.json({ result: { uuid: appUuid, prefix, written, failed: failed.length ? failed : undefined, bucket: ws.gcs_default_bucket_name, }, }); }