CRITICAL: every Vibn project was rendering every other project's
services in the same workspace (Twenty CRM, n8n, all databases,
all secrets). Tenancy was effectively broken — cross-project data
exposure inside a workspace.
Root cause:
- Coolify's POST /projects validates `description` against a strict
allowlist (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`).
- Our description "Vibn project: <name> (workspace: <slug>)" contains
two colons. Every project-create on Coolify returned 422.
- lib/projects.ts caught that 422 and fell back to
`workspace.coolify_project_uuid` so deploys "weren't blocked."
- That UUID is shared by every Vibn project in the workspace, so
listServicesInProject(coolifyProjectUuid) returned the union of
all projects' services, applications, and databases for any
project in the workspace. The Product, Hosting, and Infrastructure
tabs all rendered cross-tenant data as if it were the current
project's.
Fixes (defense in depth — fix at every layer):
1. lib/coolify.ts createProject(): sanitize the description against
Coolify's allowlist at the boundary so no caller can ever ship
a description that 422s. Replaces disallowed chars with `-`,
collapses runs, caps at 255 chars.
2. lib/projects.ts ensureProjectCoolifyProject():
- Pre-sanitize the description we pass (belt + suspenders).
- Detect when `stored === workspace.coolify_project_uuid` (the
legacy bad state) and re-provision a dedicated project.
- REMOVE the workspace-UUID fallback on create failure. A 422
now leaves coolifyProjectUuid null and the UI shows an empty
state, which is correct: better to surface "no resources" than
to lie about which project owns what.
- Export sanitizeCoolifyDescription helper for reuse.
3. /api/projects/[projectId]/anatomy/route.ts: SELF-HEAL on every
read. If the project's stored Coolify UUID matches the
workspace's UUID, we treat it as missing, re-provision a
dedicated Coolify project on the fly (idempotent — reuses the
existing one if found by name), persist the new UUID, and
continue serving with the corrected scope. If provisioning
fails we fall back to undefined, NOT the workspace UUID, so
no cross-tenant data ever surfaces again.
The self-heal means existing already-broken projects will fix
themselves on the next page load — no manual data migration needed.
Made-with: Cursor
263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
/**
|
|
* 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<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;
|
|
|
|
// 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<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;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Map<string, ResourceType>> {
|
|
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<string, ResourceType>();
|
|
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<string | null> {
|
|
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;
|
|
}
|