Per-project Coolify project isolation (Stage 1)

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
This commit is contained in:
2026-04-27 19:02:43 -07:00
parent ddc5c37a8e
commit 1a686c2a23
5 changed files with 268 additions and 41 deletions

View File

@@ -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}

View File

@@ -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<string, any> = {}) {
// 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<Record<string, unknown>>),
Promise.allSettled(targetUuids.map((uuid) => getProject(uuid))),
]);
const appList = apps.status === 'fulfilled' ? apps.value : [];
const projectEnvIds = new Set<number>(
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<Record<string, unknown>>)
: []
).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<number, string>();
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<Record<string, unknown>>) || [];
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<string, any>)
);
}
// 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,

View File

@@ -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

View File

@@ -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.' },

142
lib/projects.ts Normal file
View File

@@ -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<string | null> {
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<string | null> {
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<Set<string>> {
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<string>();
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;
}