/** * GET /api/projects/[projectId]/anatomy * * Single-fetch shape consumed by the Product / Hosting / Infrastructure * tabs. Keeping it one endpoint keeps page transitions cheap and avoids * fan-out. * * Conceptual model (locked Apr 28 2026): * Product = "what makes up the thing you're shipping" * → codebases (Gitea repos) + images (Coolify services * backed by an upstream Docker image, e.g. Twenty CRM, * n8n). Both are first-class product surfaces. * → vibn-dev-* containers are NOT shown — the dev * container is the AI's workshop, not the product. * * Hosting = "where does it live and how does it get there" * → unified `live` list of running endpoints (each item * carries source = "repo" | "image", attached domains, * and last build/deploy status inline) + `previews` * (dev container preview URLs). * → no separate Build, Domains, or Services categories. * * Infrastructure = TODO (placeholder). */ import { NextResponse } from "next/server"; import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; import { listApplications, listApplicationDeployments, listApplicationEnvs, listServicesInProject, listServiceEnvs, listDatabasesInProject, listProjects as listCoolifyProjects, createProject as createCoolifyProject, type CoolifyApplication, type CoolifyService, type CoolifyDatabase, } from "@/lib/coolify"; import { getWorkspaceGcsState } from "@/lib/workspace-gcs"; import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage"; import { sortDevPreviewsFrontendFirst } from "@/lib/dev-preview-priority"; const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com"; const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ""; // ────────────────────────────────────────────────── // Types // ────────────────────────────────────────────────── interface Codebase { id: string; label: string; path: string; hint?: string; } interface ProductImage { uuid: string; name: string; /** "twentycrm/twenty" */ image: string; /** "v1.15" — empty string when not pinned */ version: string; serviceType?: string; /** Coolify service status, surfaced so the Product tile can show a dot */ status?: string; } interface BuildSummary { status: string; createdAt?: string; finishedAt?: string; commit?: string; } interface LiveEndpoint { uuid: string; name: string; /** repo = built from Gitea, image = pulled docker image (Coolify service) */ source: "repo" | "image"; /** "apps/web" or "twentycrm/twenty:v1.15" */ sourceLabel: string; status: string; /** primary host (no scheme) when one exists */ fqdn?: string; /** all attached hosts */ domains: string[]; branch?: string; buildPack?: string; /** Last finished deployment, only for source = "repo" */ lastBuild?: BuildSummary; /** In-flight deployment (queued / in_progress / building). Present * iff Coolify has an active deploy not yet marked finished. The * status pill flips to "Deploying" the moment this is non-null. */ inFlightBuild?: BuildSummary; } interface Preview { id: string; name: string; /** Last start command — helps debug preview readiness failures */ command?: string; port: number; url: string; state: string; startedAt: string; } /** A Coolify database resource attached to this project. */ interface InfraDatabase { uuid: string; name: string; type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse status: string; isPublic: boolean; publicPort?: number; /** "host:port" for the in-cluster reachable DB (no creds). */ internalAddress?: string; /** Stable env-var key apps should set to consume this DB * (DATABASE_URL for SQL, REDIS_URL for Redis, etc.). */ consumerEnvKey: string; } /** A non-database third-party provider detected by env-var pattern. * Coolify doesn't model these natively, so we infer them from the * keys present in app + service env vars. */ interface InfraProvider { /** Stable id used by the UI for selection */ id: string; category: "auth" | "email" | "payments" | "llm" | "storage"; vendor: string; // "Stripe", "Resend", "OpenAI", … /** Where the env keys for this provider live */ attachments: Array<{ resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; keys: string[]; // matching env var keys (values redacted) }>; } /** Workspace-bundled S3 (GCS) storage that Vibn provisions for each * workspace. Same record across every project in the workspace — we * surface it on each project's Infrastructure tab so users can see * what's available without going to settings. */ interface BundledStorage { status: "ready" | "pending" | "partial" | "error" | "unprovisioned"; bucketName?: string; hmacAccessId?: string; region?: string; errorMessage?: string; } interface InfraSecretSummary { /** Total number of env vars across every app + service in the project */ total: number; /** Per-resource breakdown for drill-down. Includes the actual env-var * KEYS (never values) so the Secrets detail pane can show what's * set. Values are intentionally excluded from this surface — to * read or rotate them, route through apps.envs.* / services.envs.* * MCP tools which audit-log and tenant-scope every access. */ byResource: Array<{ resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; count: number; keys: string[]; }>; } interface Anatomy { project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string; }; codebasesReason?: "no_repo" | "empty_repo"; product: { codebases: Codebase[]; images: ProductImage[]; }; hosting: { live: LiveEndpoint[]; previews: Preview[]; }; infrastructure: { databases: InfraDatabase[]; providers: InfraProvider[]; bundledStorage: BundledStorage; secrets: InfraSecretSummary; }; } // ────────────────────────────────────────────────── // Gitea (codebase discovery) // ────────────────────────────────────────────────── interface GiteaItem { name: string; path: string; type: "file" | "dir" | "symlink"; } async function giteaList( repo: string, path: string, ): Promise { const encoded = path ? encodeURIComponent(path).replace(/%2F/g, "/") : ""; const res = await fetch( `${GITEA_API_URL}/api/v1/repos/${repo}/contents/${encoded}`, { headers: { Authorization: `token ${GITEA_API_TOKEN}` }, next: { revalidate: 30 }, }, ); if (res.status === 404) return null; if (!res.ok) throw new Error(`Gitea ${res.status} listing ${repo}/${path}`); const data = await res.json(); return Array.isArray(data) ? (data as GiteaItem[]) : null; } async function discoverCodebases(giteaRepo: string): Promise<{ codebases: Codebase[]; reason?: "empty_repo"; }> { const root = await giteaList(giteaRepo, ""); if (!root) return { codebases: [], reason: "empty_repo" }; const appsDir = root.find( (item) => item.type === "dir" && item.name === "apps", ); let codebases: Codebase[] = []; if (appsDir) { const appsChildren = await giteaList(giteaRepo, "apps"); if (appsChildren) { codebases = appsChildren .filter((item) => item.type === "dir") .map((item) => ({ id: item.name, label: item.name, path: `apps/${item.name}`, })); } } if (codebases.length === 0) { const repoName = giteaRepo.split("/").pop() || "app"; codebases = [ { id: "root", label: repoName, path: "", hint: "Single-codebase project — repository root.", }, ]; } return { codebases }; } // ────────────────────────────────────────────────── // Coolify helpers // ────────────────────────────────────────────────── function normaliseRepoUrl(url: string | undefined): string { if (!url) return ""; let u = url.toLowerCase(); u = u.replace(/^https?:\/\/[^/@]*@/, "https://"); u = u.replace(/\.git$/, ""); return u; } function shortFormOfRepo(url: string | undefined): string { if (!url) return ""; const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, ""); return cleaned.replace(/\.git$/, "").toLowerCase(); } function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean { const target = giteaRepo.toLowerCase(); const appShort = shortFormOfRepo(app.git_repository); if (appShort && appShort === target) return true; return Boolean( app.git_repository && app.git_repository.toLowerCase().includes(target), ); } async function loadRepoApps( giteaRepo: string | undefined, ): Promise { if (!giteaRepo) return []; try { const all = await listApplications(); return all.filter((app) => appMatchesRepo(app, giteaRepo)); } catch (err) { console.error("[anatomy] listApplications failed:", err); return []; } } async function loadProjectServices( coolifyProjectUuid: string | undefined, ): Promise { if (!coolifyProjectUuid) return []; try { return await listServicesInProject(coolifyProjectUuid); } catch (err) { console.error("[anatomy] listServicesInProject failed:", err); return []; } } // Coolify's service-level status aggregates ALL inner apps, including // sidecars that don't define a healthcheck (e.g. Twenty's worker, n8n's // queue runner). Those apps perpetually report `running:unknown` or // `starting:unknown`, which poisons the aggregate even when the // user-facing app is `running:healthy`. // // Fetching the service detail gives us per-app statuses + the // `exclude_from_status` flag. We pick the best status from non-excluded // apps so the project header pill reflects what the user actually // cares about. This costs N extra Coolify GETs per anatomy call (one // per image service) — small N in practice, parallelised below. interface ServiceAppStatus { name: string; status?: string; exclude_from_status?: boolean; fqdn?: string; } async function smartServiceMetaFor( uuid: string, fallbackStatus: string | undefined, serviceLevelFqdn: string | undefined, ): Promise<{ status: string; fqdns: string[] }> { try { const detail = await fetch( `${process.env.COOLIFY_URL}/api/v1/services/${uuid}`, { headers: { Authorization: `Bearer ${process.env.COOLIFY_API_TOKEN}` }, cache: "no-store", }, ); if (!detail.ok) return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn), }; const body = (await detail.json()) as { applications?: ServiceAppStatus[] }; const apps = body.applications ?? []; const userFacing = apps.filter((a) => !a.exclude_from_status); // Fqdn harvesting: for image services Coolify usually leaves the // service-level fqdn null and stores the real domain on the inner // application (e.g. Twenty's `twenty` app). Prefer fqdns from // user-facing apps; fall back to the service-level value. const harvested: string[] = []; for (const a of userFacing) for (const f of fqdnsOf(a.fqdn)) harvested.push(f); const fqdns = harvested.length > 0 ? harvested : fqdnsOf(serviceLevelFqdn); if (userFacing.length === 0) return { status: fallbackStatus ?? "unknown", fqdns }; const ranked = userFacing.map((a) => rankServiceAppStatus(a.status)); let status: string; if (ranked.some((r) => r === "down")) status = "exited"; else if (ranked.some((r) => r === "up")) status = "running:healthy"; else if (ranked.some((r) => r === "transient")) status = "starting:unknown"; else status = fallbackStatus ?? "unknown"; return { status, fqdns }; } catch (err) { console.error(`[anatomy] smartServiceMetaFor(${uuid}) failed:`, err); return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn), }; } } function rankServiceAppStatus( s?: string, ): "up" | "transient" | "down" | "unknown" { const v = (s ?? "").toLowerCase(); if (!v) return "unknown"; if (/^(running|healthy)/.test(v)) return "up"; if ( /^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test( v, ) ) return "transient"; if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(v)) return "down"; return "unknown"; } const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-"); /** Extract image:version from a Coolify docker_compose_raw blob. * Best-effort regex; we only want a sensible label, not perfection. */ function extractImageInfo(svc: CoolifyService): { image: string; version: string; } { const raw = (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ?? ""; const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/); if (!m) return { image: svc.service_type ?? svc.name, version: "" }; const full = m[1]; const at = full.lastIndexOf(":"); if (at <= 0 || full.slice(at).includes("/")) { return { image: full, version: "" }; } return { image: full.slice(0, at), version: full.slice(at + 1) }; } function fqdnsOf(value: string | undefined): string[] { if (!value) return []; return value .split(",") .map((s) => { const trimmed = s.trim().replace(/\/$/, ""); if (!trimmed) return ""; // Strip default ports that won't work through a public reverse // proxy (https on 443, http on 80, and Coolify's internal :3000 // which Traefik bridges to 443 anyway). try { const u = new URL( /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`, ); const host = u.hostname; const port = u.port; const isHttps = u.protocol === "https:"; const showPort = port && !( (isHttps && (port === "443" || port === "3000")) || (!isHttps && port === "80") ); return showPort ? `${host}:${port}` : host; } catch { return trimmed.replace(/^https?:\/\//, ""); } }) .filter(Boolean); } // Heuristic: Coolify auto-generates *.sslip.io URLs as a default // reachable hostname when no custom domain is set. If a user has // configured a real domain, that's what we want to surface in the // header — push sslip.io URLs to the back. function prioritiseFqdns(fqdns: string[]): string[] { const isAuto = (f: string) => /\.sslip\.io(?::|$)/i.test(f) || /\.coolify\.app(?::|$)/i.test(f); return [...fqdns].sort((a, b) => Number(isAuto(a)) - Number(isAuto(b))); } async function lastBuildFor(uuid: string): Promise { try { const deployments = await listApplicationDeployments(uuid); if (!deployments.length) return undefined; const finished = deployments.find((d) => d.finished_at) ?? deployments[0]; return { status: finished.status, finishedAt: finished.finished_at, commit: finished.commit, }; } catch (err) { console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err); return undefined; } } // In-flight deployments — anything queued/building/in_progress that hasn't // finished. Used by the project header status pill so the user sees // "Deploying…" the moment a build kicks off, instead of staring at the // stale "Live" badge until the page is reloaded. async function deploymentActivityFor(uuid: string): Promise<{ lastBuild?: BuildSummary; inFlight?: BuildSummary; }> { try { const deployments = await listApplicationDeployments(uuid); if (!deployments.length) return {}; const isFinished = (s: string) => /^(success|finished|failed|cancelled|error|exited)$/i.test(s); const inFlightDep = deployments.find( (d) => !d.finished_at && !isFinished(d.status ?? ""), ); const finishedDep = deployments.find((d) => d.finished_at) ?? deployments[0]; return { lastBuild: finishedDep ? { status: finishedDep.status, createdAt: finishedDep.created_at, finishedAt: finishedDep.finished_at, commit: finishedDep.commit, } : undefined, inFlight: inFlightDep ? { status: inFlightDep.status, createdAt: inFlightDep.created_at, finishedAt: inFlightDep.finished_at, commit: inFlightDep.commit, } : undefined, }; } catch (err) { console.error(`[anatomy] deploymentActivityFor(${uuid}) failed:`, err); return {}; } } // ────────────────────────────────────────────────── // Infrastructure helpers // ────────────────────────────────────────────────── /** Coolify database type names → friendly normalised label. */ function dbTypeOf(d: CoolifyDatabase): string { const raw = (d.type ?? "").toLowerCase(); if (raw.includes("postgres")) return "postgresql"; if (raw.includes("redis")) return "redis"; if (raw.includes("keydb")) return "keydb"; if (raw.includes("dragonfly")) return "dragonfly"; if (raw.includes("mongo")) return "mongodb"; if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql"; if (raw.includes("clickhouse")) return "clickhouse"; return raw || "database"; } /** Best-effort host:port from the in-cluster URL (creds stripped). */ function parseInternalAddress( internalUrl: string | undefined, ): string | undefined { if (!internalUrl) return undefined; try { const u = new URL(internalUrl); return u.port ? `${u.hostname}:${u.port}` : u.hostname; } catch { // Coolify sometimes returns non-URL formats (e.g. raw mongo conn strings) const m = internalUrl.match(/@([^/]+)\/?/); return m ? m[1] : undefined; } } function consumerKeyFor(type: string): string { if (type === "redis" || type === "keydb" || type === "dragonfly") return "REDIS_URL"; if (type === "mongodb") return "MONGODB_URI"; if (type === "clickhouse") return "CLICKHOUSE_URL"; return "DATABASE_URL"; } async function loadDatabases( coolifyProjectUuid: string | undefined, ): Promise { if (!coolifyProjectUuid) return []; try { const dbs = await listDatabasesInProject(coolifyProjectUuid); return dbs.map((d) => { const type = dbTypeOf(d); return { uuid: d.uuid, name: d.name, type, status: d.status ?? "unknown", isPublic: !!d.is_public, publicPort: d.public_port, internalAddress: parseInternalAddress(d.internal_db_url), consumerEnvKey: consumerKeyFor(type), }; }); } catch (err) { console.error("[anatomy] listDatabasesInProject failed:", err); return []; } } /** * Provider detection rules. Each rule maps a category + vendor to a * regex tested against env-var keys. We deliberately keep the prefix * specific enough to avoid false positives (e.g. `STRIPE_*` not just * `*KEY*`). */ /** * Provider detection rules. Categories surfaced today: Auth, Email, * Payments, LLM. Storage is intentionally NOT auto-detected here — * it's served by the workspace's bundled GCS bucket on the same tab. * * (Earlier iteration also detected SMS, Analytics, Search and * Monitoring categories. Removed Apr 29 2026 to keep the surface * focused on what users are actually plumbing today; rules can be * added back when those categories get real UX.) */ const PROVIDER_RULES: Array<{ category: InfraProvider["category"]; vendor: string; pattern: RegExp; }> = [ // Auth { category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ }, { category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ }, { category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ }, { category: "auth", vendor: "Supabase Auth", pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/, }, { category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ }, { category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ }, { category: "auth", vendor: "Firebase Auth", pattern: /^FIREBASE_(AUTH|API_KEY)/, }, // Email { category: "email", vendor: "Resend", pattern: /^RESEND_/ }, { category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ }, { category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ }, { category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ }, { category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ }, { category: "email", vendor: "Loops", pattern: /^LOOPS_/ }, // Payments { category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/, }, { category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/, }, { category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ }, // LLM (a.k.a. Models) { category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ }, { category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ }, { category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/, }, { category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ }, { category: "llm", vendor: "Cohere", pattern: /^COHERE_/ }, { category: "llm", vendor: "Groq", pattern: /^GROQ_/ }, { category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ }, ]; interface ResourceEnvs { resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; keys: string[]; } async function loadAllEnvs( apps: CoolifyApplication[], services: CoolifyService[], ): Promise { const appPromises = apps.map(async (a): Promise => { try { const envs = await listApplicationEnvs(a.uuid); return { resourceUuid: a.uuid, resourceName: a.name, resourceKind: "app", keys: envs.map((e) => e.key), }; } catch (err) { console.error(`[anatomy] listApplicationEnvs(${a.uuid}) failed:`, err); return { resourceUuid: a.uuid, resourceName: a.name, resourceKind: "app", keys: [], }; } }); const svcPromises = services.map(async (s): Promise => { try { const envs = await listServiceEnvs(s.uuid); return { resourceUuid: s.uuid, resourceName: s.name, resourceKind: "service", keys: envs.map((e) => e.key), }; } catch (err) { console.error(`[anatomy] listServiceEnvs(${s.uuid}) failed:`, err); return { resourceUuid: s.uuid, resourceName: s.name, resourceKind: "service", keys: [], }; } }); return Promise.all([...appPromises, ...svcPromises]); } function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] { // vendor → { category, attachments-by-resource } const byVendor = new Map(); for (const env of allEnvs) { if (env.keys.length === 0) continue; for (const rule of PROVIDER_RULES) { const matches = env.keys.filter((k) => rule.pattern.test(k)); if (matches.length === 0) continue; const id = `${rule.category}:${rule.vendor.toLowerCase().replace(/\s+/g, "-")}`; let entry = byVendor.get(id); if (!entry) { entry = { id, category: rule.category, vendor: rule.vendor, attachments: [], }; byVendor.set(id, entry); } entry.attachments.push({ resourceUuid: env.resourceUuid, resourceName: env.resourceName, resourceKind: env.resourceKind, keys: matches, }); } } return Array.from(byVendor.values()); } function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary { const byResource = allEnvs .filter((e) => e.keys.length > 0) .map((e) => ({ resourceUuid: e.resourceUuid, resourceName: e.resourceName, resourceKind: e.resourceKind, count: e.keys.length, keys: [...e.keys].sort(), })) .sort((a, b) => b.count - a.count); const total = byResource.reduce((sum, r) => sum + r.count, 0); return { total, byResource }; } async function loadBundledStorage( workspaceId: string | undefined, ): Promise { if (!workspaceId) return { status: "unprovisioned" }; try { const ws = await getWorkspaceGcsState(workspaceId); if (!ws) return { status: "unprovisioned" }; return { status: ws.gcp_provision_status ?? "unprovisioned", bucketName: ws.gcs_default_bucket_name ?? undefined, hmacAccessId: ws.gcs_hmac_access_id ?? undefined, region: VIBN_GCS_LOCATION, errorMessage: ws.gcp_provision_error ?? undefined, }; } catch (err) { console.error("[anatomy] getWorkspaceGcsState failed:", err); return { status: "error", errorMessage: err instanceof Error ? err.message : String(err), }; } } async function loadPreviews(projectId: string): Promise { try { const rows = await query<{ id: string; name: string; command: string | null; port: number; preview_url: string; state: string; started_at: string; }>( `SELECT id, name, command, port, preview_url, state, started_at FROM fs_dev_servers WHERE project_id = $1 AND state != 'stopped'`, [projectId], ); // Filter out zombies: if a server is marked 'running' but the URL returns a 50x // Gateway error or times out, the process died. We mark it stopped so the // UI can trigger an auto-restart. const activePreviews: typeof rows = []; await Promise.all( rows.map(async (r) => { if (r.state !== "running") { activePreviews.push(r); return; } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2500); // Fast 2.5s timeout const ping = await fetch(r.preview_url, { method: "HEAD", signal: controller.signal, }); clearTimeout(timeout); // 502/503/504 means Traefik is up but the container isn't answering. // 404 means Traefik doesn't even know about the route. if ( ping.status === 502 || ping.status === 503 || ping.status === 504 || ping.status === 404 ) { console.warn( `[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}). Marking stopped.`, ); await query( `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`, [r.id], ).catch(() => {}); } else { activePreviews.push(r); } } catch (e: any) { // If the fetch completely fails (e.g. timeout, DNS failure), it's dead. console.warn( `[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`, ); await query( `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`, [r.id], ).catch(() => {}); } }), ); return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({ id: r.id, name: r.name, command: r.command ?? undefined, port: r.port, url: r.preview_url, state: r.state, startedAt: r.started_at, })); } catch (err) { if ( err instanceof Error && /relation "fs_dev_servers" does not exist/i.test(err.message) ) { return []; } console.error("[anatomy] fs_dev_servers query failed:", err); return []; } } // ────────────────────────────────────────────────── // Handler // ────────────────────────────────────────────────── export async function GET( _req: Request, { params }: { params: Promise<{ projectId: string }> }, ) { try { const { projectId } = await params; const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const rows = await query<{ data: Record; vibn_workspace_id: string | null; }>( `SELECT p.data, p.vibn_workspace_id FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, session.user.email], ); if (rows.length === 0) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); } const data = rows[0].data; const workspaceId = rows[0].vibn_workspace_id ?? undefined; const giteaRepo = data?.giteaRepo as string | undefined; let coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined; // Self-heal: if a previous bug stored the *workspace*'s Coolify // project UUID on this project (which would surface every other // project's services as if they belonged to this one), wipe it // and re-provision a dedicated Coolify project on the fly. if (coolifyProjectUuid && workspaceId) { try { const wsRow = await query<{ slug: string; coolify_project_uuid: string | null; }>( `SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`, [workspaceId], ); const ws = wsRow[0]; if ( ws && ws.coolify_project_uuid && ws.coolify_project_uuid === coolifyProjectUuid ) { console.warn( "[anatomy] Project", projectId, "had workspace-UUID stored as coolifyProjectUuid — re-provisioning.", ); const projectSlug = (data?.slug as string | undefined) ?? projectId; const projectNm = (data?.productName as string | undefined) ?? projectSlug; const wantName = `vibn-${ws.slug}-${projectSlug}`; try { const all = await listCoolifyProjects(); const existing = all.find((p) => p.name === wantName); const fresh = existing ? existing : await createCoolifyProject( wantName, `Vibn project ${projectNm} - workspace ${ws.slug}`, ); await query( `UPDATE fs_projects SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text), updated_at = NOW() WHERE id = $1`, [projectId, fresh.uuid], ); coolifyProjectUuid = fresh.uuid; } catch (provErr) { console.error("[anatomy] auto-heal provisioning failed:", provErr); coolifyProjectUuid = undefined; // refuse to serve workspace-level resources } } } catch (wsErr) { console.error( "[anatomy] workspace lookup for self-heal failed:", wsErr, ); } } const projectName = (data?.productName as string | undefined) ?? (data?.name as string | undefined) ?? "Project"; const [ codebasesResult, repoApps, allServices, previews, databases, bundledStorage, ] = await Promise.all([ giteaRepo ? discoverCodebases(giteaRepo).catch((err) => { console.error("[anatomy] discoverCodebases failed:", err); return { codebases: [] as Codebase[], reason: "empty_repo" as const, }; }) : Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined, }), loadRepoApps(giteaRepo), loadProjectServices(coolifyProjectUuid), loadPreviews(projectId), loadDatabases(coolifyProjectUuid), loadBundledStorage(workspaceId), ]); // Pull last-build + in-flight build summaries for repo apps in // parallel (small N). In parallel, fan out env-var fetches to drive // provider/secret detection. const [activities, allEnvs] = await Promise.all([ Promise.all(repoApps.map((a) => deploymentActivityFor(a.uuid))), loadAllEnvs( repoApps, allServices.filter((s) => !isDevContainer(s)), ), ]); // Image services (Coolify services minus vibn-dev-*) const imageServices = allServices.filter((s) => !isDevContainer(s)); const productImages: ProductImage[] = imageServices.map((s) => { const { image, version } = extractImageInfo(s); return { uuid: s.uuid, name: s.name, image, version, serviceType: s.service_type, status: s.status, }; }); const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => { const domains = prioritiseFqdns(fqdnsOf(app.fqdn)); return { uuid: app.uuid, name: app.name, source: "repo", sourceLabel: shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"), status: app.status, fqdn: domains[0], domains, branch: app.git_branch, buildPack: app.build_pack, lastBuild: activities[i]?.lastBuild, inFlightBuild: activities[i]?.inFlight, }; }); // Compute smart aggregate statuses + harvest inner-app fqdns for // every image service in parallel — see smartServiceMetaFor. const smartMeta = await Promise.all( imageServices.map((s) => smartServiceMetaFor( s.uuid, s.status, (s as unknown as { fqdn?: string }).fqdn, ), ), ); const liveFromImage: LiveEndpoint[] = imageServices.map((s, i) => { const domains = prioritiseFqdns(smartMeta[i].fqdns); const { image, version } = extractImageInfo(s); return { uuid: s.uuid, name: s.name, source: "image", sourceLabel: version ? `${image}:${version}` : image, status: smartMeta[i].status, fqdn: domains[0], domains, }; }); const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo ? "no_repo" : codebasesResult.reason; const anatomy: Anatomy = { project: { id: projectId, name: projectName, gitea: giteaRepo, coolifyProjectUuid, }, codebasesReason, product: { codebases: codebasesResult.codebases, images: productImages, }, hosting: { live: [...liveFromRepo, ...liveFromImage], previews, }, infrastructure: { databases, providers: detectProviders(allEnvs), bundledStorage, secrets: summariseSecrets(allEnvs), }, }; return NextResponse.json(anatomy); } catch (err) { console.error("[anatomy API]", err); return NextResponse.json( { error: "Failed to build anatomy" }, { status: 500 }, ); } }