From 1a686c2a23b5e5cb4fccf1adf4a25616b9cf6f3c Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 27 Apr 2026 19:02:43 -0700 Subject: [PATCH] Per-project Coolify project isolation (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Vibn project now gets its OWN Coolify project named vibn-{workspace-slug}-{project-slug}. All apps/databases/services deployed for the project land inside that Coolify project, giving us clean grouping, cascading delete, and per-project domain namespaces. Changes: - New lib/projects.ts: ensureProjectCoolifyProject (idempotent create/lookup), getProjectCoolifyUuid, getOwnedCoolifyProjectUuids - /api/projects/create: pre-insert row, mint per-project Coolify project, then complete the row with productData (preserves the coolifyProjectUuid that was just set) - apps.list (MCP): without projectId, aggregates across ALL workspace-owned Coolify projects; with projectId, scopes to that project's Coolify project. Returns coolifyProjectUuid on each result so the AI knows where things live. - apps.create (MCP): accepts projectId; auto-mints the Vibn project's Coolify project on first deploy if missing - apps_list/apps_create tool defs: projectId param surfaced - System prompt: Project as first-class — planning + live as facets of ONE thing, never as separate worlds. AI told to always pass projectId on apps_create. Stage 2 (next): set-aware ensureResourceInProject across all single-resource MCP tools (apps.get/delete/exec/etc.) and cascading delete via projects.delete. Made-with: Cursor --- app/api/chat/route.ts | 14 +-- app/api/mcp/route.ts | 106 ++++++++++++++++------- app/api/projects/create/route.ts | 36 ++++++-- lib/ai/vibn-tools.ts | 11 ++- lib/projects.ts | 142 +++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 41 deletions(-) create mode 100644 lib/projects.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a7964632..8112c2ac 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -62,12 +62,16 @@ function buildSystemPrompt(projects: any[], workspace: string): string { return `You are Vibn AI, an expert product and infrastructure assistant embedded in the Vibn platform. You are talking to the owner of the "${workspace}" workspace. -## Architecture (important — read carefully) -Vibn has two separate concepts: -1. **Projects** (in the Vibn DB) — planning/concept records. They store a product vision, status, and metadata. They are NOT running services. -2. **Apps / Services** (in Coolify) — actual live deployments. These are what have domains and real endpoints. Call \`apps_list\` to see what is actually running. +## How Vibn is structured +- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects. +- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project so all its apps + databases + services are grouped together. A project has both: + - Planning side: name, vision/objectives, requirements (from \`projects_get\`) + - Live side: deployed apps + services (in \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`) + These are facets of ONE thing — never describe them as separate. -When a user asks what's live, call \`apps_list\` — not \`projects_get\`. Project records from \`projects_get\` describe what the user *wants* to build, not what is deployed. +When the user asks about a project, call \`projects_get\` first — it returns both the planning details and the linked deployments. Use \`apps_list { projectId }\` to drill into running services for a specific project, or \`apps_list\` (no args) to see everything in the workspace. + +When deploying with \`apps_create\`, ALWAYS pass \`projectId\` so the new app/service lands inside the right project's isolated Coolify namespace. If the user hasn't specified a project, ask them which one. ## Current workspace projects ${projectsText} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index ba9eaa90..78f53199 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -20,6 +20,11 @@ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces'; +import { + ensureProjectCoolifyProject, + getProjectCoolifyUuid, + getOwnedCoolifyProjectUuids, +} from '@/lib/projects'; import { ensureWorkspaceGcsProvisioned, getWorkspaceGcsState, @@ -188,7 +193,7 @@ export async function POST(request: Request) { return await toolProjectsGet(principal, params); case 'apps.list': - return await toolAppsList(principal); + return await toolAppsList(principal, params); case 'apps.get': return await toolAppsGet(principal, params); @@ -487,40 +492,60 @@ function requireCoolifyProject(principal: Principal): string | NextResponse { return projectUuid; } -async function toolAppsList(principal: Principal) { - const projectUuid = requireCoolifyProject(principal); - if (projectUuid instanceof NextResponse) return projectUuid; +async function toolAppsList(principal: Principal, params: Record = {}) { + // Determine which Coolify projects to scan: + // - If `projectId` is given, scope to that single Vibn project's Coolify project. + // - Otherwise, aggregate across ALL Coolify projects owned by the workspace + // (per-project + the legacy workspace-level one). + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); + let targetUuids: string[]; + if (params.projectId) { + const projectCoolify = await getProjectCoolifyUuid(String(params.projectId), principal.workspace); + if (!projectCoolify) { + return NextResponse.json({ error: `Project ${params.projectId} not found in this workspace` }, { status: 404 }); + } + targetUuids = [projectCoolify]; + } else { + targetUuids = Array.from(ownedUuids); + if (targetUuids.length === 0 && principal.workspace.coolify_project_uuid) { + targetUuids = [principal.workspace.coolify_project_uuid]; + } + } - // Fetch Applications and Services in parallel. - // Services are compose stacks created via the composeRaw pathway; - // they live at /services not /applications. - // Coolify's /services response does NOT include a `project` field — services - // belong to environments, and environments belong to projects. So we resolve - // the project's environment IDs first, then filter services by environment_id. - const [apps, allServices, project] = await Promise.allSettled([ - listApplicationsInProject(projectUuid), - listAllServices(), - getProject(projectUuid), + if (targetUuids.length === 0) { + return NextResponse.json({ result: [] }); + } + + // Fetch apps + services in parallel; services need env_id → project_uuid resolution. + const [appsResults, allServicesRes, projectsResults] = await Promise.all([ + Promise.allSettled(targetUuids.map((uuid) => listApplicationsInProject(uuid))), + listAllServices().catch(() => [] as Array>), + Promise.allSettled(targetUuids.map((uuid) => getProject(uuid))), ]); - const appList = apps.status === 'fulfilled' ? apps.value : []; - const projectEnvIds = new Set( - project.status === 'fulfilled' - ? (project.value.environments ?? []).map((e) => e.id) + const appList = appsResults.flatMap((r, i) => + r.status === 'fulfilled' + ? r.value.map((a) => ({ ...a, _coolifyProjectUuid: targetUuids[i] })) : [], ); - const serviceList = (allServices.status === 'fulfilled' && Array.isArray(allServices.value) - ? (allServices.value as Array>) - : [] - ).filter((s) => { - const envId = typeof s.environment_id === 'number' ? s.environment_id : Number(s.environment_id); - return projectEnvIds.has(envId); + // Build env_id → coolify_project_uuid map for service filtering + const envToProject = new Map(); + projectsResults.forEach((r, i) => { + if (r.status === 'fulfilled') { + for (const env of r.value.environments ?? []) { + envToProject.set(env.id, targetUuids[i]); + } + } }); + const serviceList = (Array.isArray(allServicesRes) ? allServicesRes : []) + .filter((s) => envToProject.has(Number(s.environment_id))) + .map((s) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! })); + return NextResponse.json({ result: [ - ...appList.map(a => ({ + ...appList.map((a) => ({ uuid: a.uuid, name: a.name, status: a.status, @@ -528,10 +553,9 @@ async function toolAppsList(principal: Principal) { gitRepository: a.git_repository ?? null, gitBranch: a.git_branch ?? null, resourceType: 'application', + coolifyProjectUuid: (a as any)._coolifyProjectUuid as string, })), ...serviceList.map((s) => { - // Try to extract a usable URL from the service's applications array - // (compose stacks expose their public service's fqdn there). const apps = (s.applications as Array>) || []; const publicApp = apps.find((a) => a.fqdn); return { @@ -541,7 +565,8 @@ async function toolAppsList(principal: Principal) { fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null, gitRepository: null, gitBranch: null, - resourceType: 'service', + resourceType: 'service' as const, + coolifyProjectUuid: (s as any)._coolifyProjectUuid as string, }; }), ], @@ -913,8 +938,31 @@ async function toolAppsCreate(principal: Principal, params: Record) ); } + // Resolve which Coolify project to deploy into: + // - If params.projectId given, use that Vibn project's per-project Coolify project + // (auto-mint it if not already provisioned). + // - Otherwise fall back to the workspace's legacy Coolify project for back-compat. + let targetCoolifyProjectUuid = ws.coolify_project_uuid; + if (params.projectId) { + const projectId = String(params.projectId); + const projectRow = await queryOne<{ id: string; data: any; slug: string }>( + `SELECT id, data, slug FROM fs_projects + WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`, + [projectId, ws.id, ws.slug], + ); + if (!projectRow) { + return NextResponse.json({ error: `Project ${projectId} not found in this workspace` }, { status: 404 }); + } + const projectName = projectRow.data?.productName || projectRow.data?.name || projectRow.slug; + const ensured = await ensureProjectCoolifyProject(projectId, ws, { + projectSlug: projectRow.slug, + projectName, + }); + if (ensured) targetCoolifyProjectUuid = ensured; + } + const commonOpts = { - projectUuid: ws.coolify_project_uuid, + projectUuid: targetCoolifyProjectUuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index c1fdfc1c..0eed6236 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -6,6 +6,7 @@ import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPO import { pushTurborepoScaffold } from '@/lib/scaffold'; import { createMonorepoAppService } from '@/lib/coolify'; import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; +import { ensureProjectCoolifyProject } from '@/lib/projects'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; @@ -181,9 +182,25 @@ export async function POST(request: Request) { name: string; path: string; coolifyServiceUuid: string | null; domain: string | null; }> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null })); - // The workspace's Coolify Project IS our team boundary. All Vibn - // projects for a workspace share one Coolify Project namespace. - const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid; + // Each Vibn project gets its OWN Coolify Project under the workspace. + // Naming: `vibn-{workspace-slug}-{project-slug}`. Falls back to the + // workspace's legacy Coolify Project UUID if Coolify provisioning fails, + // so apps still deploy (with degraded isolation). + // + // Note: ensureProjectCoolifyProject reads the row, but we INSERT the row + // further below. To break the chicken-and-egg we insert a minimal row + // first, then provision Coolify, then complete the row. + await query( + `INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id) + VALUES ($1, '{}'::jsonb, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING`, + [projectId, firebaseUserId, workspace, slug, vibnWorkspace.id], + ); + const coolifyProjectUuid: string | null = await ensureProjectCoolifyProject( + projectId, + vibnWorkspace, + { projectSlug: slug, projectName }, + ); if (giteaCloneUrl && coolifyProjectUuid) { for (const app of provisionedApps) { @@ -259,9 +276,18 @@ export async function POST(request: Request) { updatedAt: now, }; + // Update the row we pre-inserted above with the full project data. + // We merge with existing data so the coolifyProjectUuid set by + // ensureProjectCoolifyProject() above is preserved. await query(` - INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id) - VALUES ($1, $2::jsonb, $3, $4, $5, $6) + UPDATE fs_projects + SET data = data || $2::jsonb, + user_id = $3, + workspace = $4, + slug = $5, + vibn_workspace_id = $6, + updated_at = NOW() + WHERE id = $1 `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]); // Associate any unlinked sessions for this workspace path diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts index a4ed0375..ad48ae3a 100644 --- a/lib/ai/vibn-tools.ts +++ b/lib/ai/vibn-tools.ts @@ -50,8 +50,14 @@ export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'apps_list', - description: 'List all live applications and services deployed in the Coolify workspace. Use this (not projects_list) when the user asks what is running or what has a domain.', - parameters: { type: 'OBJECT', properties: {}, required: [] }, + description: 'List live applications and services. Without projectId, lists everything across the workspace. With projectId, scopes to that single Vibn project.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'Optional Vibn project ID to scope the list to one project.' }, + }, + required: [], + }, }, { name: 'apps_get', @@ -75,6 +81,7 @@ Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`, parameters: { type: 'OBJECT', properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID to deploy this app under. STRONGLY RECOMMENDED — gives the app its own isolated Coolify project so all related resources (databases, services) are grouped together and can be lifecycle-managed as one unit. If omitted, the app lands in the workspace\'s shared/legacy Coolify project.' }, name: { type: 'STRING', description: 'App name (slug-friendly, e.g. "my-crm"). Required for all pathways.' }, domain: { type: 'STRING', description: 'Custom subdomain (e.g. "crm.mark.vibnai.com"). Optional — auto-generated if omitted.' }, template: { type: 'STRING', description: 'Coolify one-click template slug (e.g. "twenty", "n8n", "wordpress"). Use apps_templates_search to find the slug.' }, diff --git a/lib/projects.ts b/lib/projects.ts new file mode 100644 index 00000000..3fff8540 --- /dev/null +++ b/lib/projects.ts @@ -0,0 +1,142 @@ +/** + * Vibn projects — per-project resource isolation. + * + * Each Vibn project lives inside a Workspace and owns its OWN Coolify Project + * (named `vibn-{workspace-slug}-{project-slug}`). All apps, databases, and + * services deployed for the project land inside that Coolify project, giving + * us: + * - clean grouping in the Coolify UI + * - cascading delete (drop project → drop all its resources) + * - per-project billing/usage attribution + * - per-project domain namespace (`*.{project-slug}.{workspace-slug}.vibnai.com`) + * + * The mapping is stored on fs_projects.data.coolifyProjectUuid. Helpers below + * are idempotent — safe to call repeatedly. + */ + +import { query, queryOne } from '@/lib/db-postgres'; +import { createProject as createCoolifyProject, listProjects as listCoolifyProjects } from '@/lib/coolify'; +import type { VibnWorkspace } from '@/lib/workspaces'; + +export interface VibnProjectRow { + id: string; + data: any; + workspace: string; + slug: string; + user_id: string; + vibn_workspace_id: string | null; + created_at: Date; + updated_at: Date; +} + +/** Coolify Project name we use for a Vibn project. */ +export function coolifyProjectNameForVibnProject(workspaceSlug: string, projectSlug: string): string { + return `vibn-${workspaceSlug}-${projectSlug}`; +} + +/** + * Idempotently ensure the given Vibn project has its own Coolify Project. + * Returns the Coolify project UUID. Persists it to fs_projects.data.coolifyProjectUuid. + * + * - If already stored, returns immediately. + * - If not stored, looks up by name in Coolify (handles re-runs after a + * half-failed previous create) and either reuses or creates fresh. + * - Falls back to the workspace's legacy `vibn-ws-{slug}` project on Coolify + * failure so deploys aren't blocked. + */ +export async function ensureProjectCoolifyProject( + projectId: string, + workspace: VibnWorkspace, + opts: { projectSlug: string; projectName?: string }, +): Promise { + const row = await queryOne<{ data: any }>( + `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId], + ); + if (!row) return null; + + const stored = row.data?.coolifyProjectUuid as string | undefined; + if (stored) return stored; + + const wantName = coolifyProjectNameForVibnProject(workspace.slug, opts.projectSlug); + + let coolifyUuid: string | null = null; + try { + // First check if it already exists (could happen if a previous create call + // succeeded on Coolify but failed before persisting back to fs_projects). + const all = await listCoolifyProjects(); + const existing = all.find((p) => p.name === wantName); + if (existing) { + coolifyUuid = existing.uuid; + } else { + const created = await createCoolifyProject( + wantName, + `Vibn project: ${opts.projectName || opts.projectSlug} (workspace: ${workspace.slug})`, + ); + coolifyUuid = created.uuid; + } + } catch (err) { + console.error( + '[projects] Failed to provision Coolify project for', + projectId, + err instanceof Error ? err.message : String(err), + ); + // Fall back to the workspace's legacy Coolify project so the user can + // still deploy. Lifecycle isolation is degraded but functionality works. + coolifyUuid = workspace.coolify_project_uuid; + } + + if (coolifyUuid) { + await query( + `UPDATE fs_projects + SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text), + updated_at = NOW() + WHERE id = $1`, + [projectId, coolifyUuid], + ); + } + + return coolifyUuid; +} + +/** + * Resolve the Coolify project UUID for a given Vibn project ID, scoped to + * the workspace. Returns null if the project doesn't exist or doesn't belong + * to the workspace. + */ +export async function getProjectCoolifyUuid( + projectId: string, + workspace: VibnWorkspace, +): Promise { + const row = await queryOne<{ data: any }>( + `SELECT data + FROM fs_projects + WHERE id = $1 + AND (vibn_workspace_id = $2 OR workspace = $3) + LIMIT 1`, + [projectId, workspace.id, workspace.slug], + ); + if (!row) return null; + return (row.data?.coolifyProjectUuid as string) || null; +} + +/** + * Return the COMPLETE set of Coolify project UUIDs owned by this workspace — + * the workspace's legacy `vibn-ws-{slug}` project PLUS every per-Vibn-project + * Coolify project that has been provisioned. Used by the tenant safety gate + * so an API-key principal can touch any of its workspace's resources but + * absolutely nothing outside. + */ +export async function getOwnedCoolifyProjectUuids(workspace: VibnWorkspace): Promise> { + const rows = await query<{ uuid: string }>( + `SELECT DISTINCT data->>'coolifyProjectUuid' AS uuid + FROM fs_projects + WHERE (vibn_workspace_id = $1 OR workspace = $2) + AND data->>'coolifyProjectUuid' IS NOT NULL`, + [workspace.id, workspace.slug], + ); + const set = new Set(); + for (const r of rows) if (r.uuid) set.add(r.uuid); + if (workspace.coolify_project_uuid) set.add(workspace.coolify_project_uuid); + return set; +}