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;
|
|
}
|