/** * 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}`; } /** Coolify's `description` validator only accepts a narrow set of chars * (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`). * Notably colon, semicolon, brackets, and pipe are rejected. We strip * anything else down to a hyphen so create payloads always validate. */ export function sanitizeCoolifyDescription(s: string): string { return s .replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-") .replace(/-{2,}/g, "-") .trim() .slice(0, 255); } /** * 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; // SAFETY: if a prior bug stored the *workspace*'s Coolify project UUID // here, treat it as missing and re-provision. Sharing the workspace // Coolify project across Vibn projects causes services from one // project to bleed into another (we hit this on Apr 29). const isWorkspaceUuid = stored && workspace.coolify_project_uuid && stored === workspace.coolify_project_uuid; if (stored && !isWorkspaceUuid) return stored; if (isWorkspaceUuid) { console.warn( '[projects] Detected workspace-UUID stored as project Coolify UUID for', projectId, '— re-provisioning a dedicated project.' ); } const wantName = coolifyProjectNameForVibnProject(workspace.slug, opts.projectSlug); const description = sanitizeCoolifyDescription( `Vibn project ${opts.projectName || opts.projectSlug} - workspace ${workspace.slug}`, ); 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, description); coolifyUuid = created.uuid; } } catch (err) { console.error( '[projects] Failed to provision Coolify project for', projectId, err instanceof Error ? err.message : String(err), ); // Do NOT fall back to the workspace project. Sharing it across Vibn // projects leaks every project's services into every other project // (the bug this branch was originally written to "soften"). Better // to leave coolifyProjectUuid null — the UI surfaces an empty state // and the user can retry once the underlying error is fixed. coolifyUuid = null; } 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; } // ────────────────────────────────────────────────── // Per-resource project ownership (fs_project_resources) // ────────────────────────────────────────────────── // // Coolify groups resources by `project` natively, but a single Coolify project // can legitimately host multiple Vibn projects (the legacy `vibn-ws-{slug}` // shared project, or hand-grouped scratch projects). We track the // authoritative Vibn-project ↔ Coolify-resource link in our own table so that: // // - `apps_list { projectId }` returns ONLY the resources the user genuinely // owns under that Vibn project, even when the underlying Coolify project // mixes in unrelated services (e.g. Twenty CRM and n8n in the same legacy // workspace project). // - We can backfill legacy deployments without physically moving them in // Coolify (whose API doesn't cleanly support env reassignment). // - Cascading delete becomes a single SQL filter. export type ResourceType = 'application' | 'service' | 'database'; let projectResourcesTableReady = false; export async function ensureProjectResourcesTable(): Promise { if (projectResourcesTableReady) return; await query( `CREATE TABLE IF NOT EXISTS fs_project_resources ( project_id TEXT NOT NULL, workspace TEXT NOT NULL, resource_uuid TEXT NOT NULL, resource_type TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (project_id, resource_uuid) ); CREATE INDEX IF NOT EXISTS fs_project_resources_uuid_idx ON fs_project_resources (resource_uuid); CREATE INDEX IF NOT EXISTS fs_project_resources_workspace_idx ON fs_project_resources (workspace, project_id);`, [], ); projectResourcesTableReady = true; } export async function linkResourceToProject( projectId: string, workspace: string, resourceUuid: string, resourceType: ResourceType, ): Promise { await ensureProjectResourcesTable(); await query( `INSERT INTO fs_project_resources (project_id, workspace, resource_uuid, resource_type) VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, resource_uuid) DO NOTHING`, [projectId, workspace, resourceUuid, resourceType], ); } export async function unlinkResource(resourceUuid: string): Promise { await ensureProjectResourcesTable(); await query(`DELETE FROM fs_project_resources WHERE resource_uuid = $1`, [resourceUuid]); } /** All Coolify resource UUIDs explicitly linked to a Vibn project. */ export async function getProjectResourceUuids( projectId: string, ): Promise> { await ensureProjectResourcesTable(); const rows = await query<{ resource_uuid: string; resource_type: ResourceType }>( `SELECT resource_uuid, resource_type FROM fs_project_resources WHERE project_id = $1`, [projectId], ); const map = new Map(); for (const r of rows) map.set(r.resource_uuid, r.resource_type); return map; } /** * Reverse lookup: which Vibn project does this Coolify resource belong to? * Returns null when no explicit link exists (caller can fall back to * coolifyProjectUuid-based grouping). */ export async function getProjectIdForResource( resourceUuid: string, workspace: string, ): Promise { await ensureProjectResourcesTable(); const row = await queryOne<{ project_id: string }>( `SELECT project_id FROM fs_project_resources WHERE resource_uuid = $1 AND workspace = $2 LIMIT 1`, [resourceUuid, workspace], ); return row?.project_id ?? null; }