diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index c488fb56..e43cd87c 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -33,6 +33,8 @@ import { listServicesInProject, listServiceEnvs, listDatabasesInProject, + listProjects as listCoolifyProjects, + createProject as createCoolifyProject, type CoolifyApplication, type CoolifyService, type CoolifyDatabase, @@ -597,7 +599,53 @@ export async function GET( const data = rows[0].data; const workspaceId = rows[0].vibn_workspace_id ?? undefined; const giteaRepo = data?.giteaRepo as string | undefined; - const coolifyProjectUuid = data?.coolifyProjectUuid 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) ?? diff --git a/lib/coolify.ts b/lib/coolify.ts index 699a541f..c581d43c 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -177,9 +177,22 @@ export async function listProjects(): Promise { } export async function createProject(name: string, description?: string): Promise { + // Coolify validates the description against a narrow allowlist: + // letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`. + // Anything else (colon, semicolon, brackets, pipe, …) errors out with + // a 422 — and a 422 here used to silently fall back to the workspace + // project, leaking services across tenants. Sanitize defensively so + // every caller is safe. + const cleaned = description + ? description + .replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-") + .replace(/-{2,}/g, "-") + .trim() + .slice(0, 255) + : undefined; return coolifyFetch('/projects', { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description: cleaned }), }); } diff --git a/lib/projects.ts b/lib/projects.ts index 2cfd9158..1cbea2e4 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -34,6 +34,18 @@ export function coolifyProjectNameForVibnProject(workspaceSlug: string, projectS 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. @@ -56,9 +68,25 @@ export async function ensureProjectCoolifyProject( if (!row) return null; const stored = row.data?.coolifyProjectUuid as string | undefined; - if (stored) return stored; + + // 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 { @@ -69,10 +97,7 @@ export async function ensureProjectCoolifyProject( if (existing) { coolifyUuid = existing.uuid; } else { - const created = await createCoolifyProject( - wantName, - `Vibn project: ${opts.projectName || opts.projectSlug} (workspace: ${workspace.slug})`, - ); + const created = await createCoolifyProject(wantName, description); coolifyUuid = created.uuid; } } catch (err) { @@ -81,9 +106,12 @@ export async function ensureProjectCoolifyProject( 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; + // 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) {