Files
vibn-frontend/lib/projects.ts
Mark Henderson 1a686c2a23 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
2026-04-27 19:02:43 -07:00

143 lines
4.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}`;
}
/**
* 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;
}