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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
142
lib/projects.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user