diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 995d4e71..96c19271 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -24,6 +24,9 @@ import { ensureProjectCoolifyProject, getProjectCoolifyUuid, getOwnedCoolifyProjectUuids, + getProjectResourceUuids, + linkResourceToProject, + unlinkResource, } from '@/lib/projects'; import { ensureWorkspaceGcsProvisioned, @@ -403,6 +406,12 @@ async function toolProjectsGet(principal: Principal, params: Record const linkedUuid = d.coolifyAppUuid || d.coolifyServiceUuid || null; const projectUuid = principal.workspace.coolify_project_uuid; + + // Authoritative source: explicit project↔resource links from fs_project_resources. + // The fuzzy-match below is only a fallback for legacy projects that haven't + // been backfilled yet. + const explicitLinks = await getProjectResourceUuids(r.id); + if (projectUuid) { try { const [appsRes, servicesRes, projectRes] = await Promise.allSettled([ @@ -433,8 +442,11 @@ async function toolProjectsGet(principal: Principal, params: Record return tokens.some((t: string) => n.includes(t)); }; + const hasExplicit = explicitLinks.size > 0; for (const a of apps) { - if (matches(a.name) || a.uuid === linkedUuid) { + const isLinked = explicitLinks.has(a.uuid) || a.uuid === linkedUuid; + // If we have explicit links, ONLY include linked resources. Otherwise fall back to fuzzy match. + if (isLinked || (!hasExplicit && matches(a.name))) { possibleDeployments.push({ uuid: a.uuid, name: a.name, @@ -447,7 +459,8 @@ async function toolProjectsGet(principal: Principal, params: Record for (const s of services) { const name = String(s.name ?? ''); const uuid = String(s.uuid); - if (matches(name) || uuid === linkedUuid) { + const isLinked = explicitLinks.has(uuid) || uuid === linkedUuid; + if (isLinked || (!hasExplicit && matches(name))) { const subApps = (s.applications as Array>) || []; const publicApp = subApps.find((a) => a.fqdn); possibleDeployments.push({ @@ -493,18 +506,33 @@ function requireCoolifyProject(principal: Principal): string | NextResponse { } async function toolAppsList(principal: Principal, params: Record = {}) { - // Determine which Coolify projects to scan: - // - If `projectId` is given, scope to that single Vibn project's Coolify project. - // - Otherwise, aggregate across ALL Coolify projects owned by the workspace - // (per-project + the legacy workspace-level one). + // Resolve which Coolify projects to scan AND which resource UUIDs (if any) + // are explicitly linked to a single Vibn project via fs_project_resources. + // + // Semantics: + // - No `projectId` → return everything in every Coolify project owned by the workspace. + // - `projectId` + dedicated → return everything in that Vibn project's dedicated Coolify project, + // plus any extra resources explicitly linked. + // - `projectId` + legacy → ONLY explicitly-linked resources (the dedicated project equals + // shared the legacy workspace project, so a raw scan would leak unrelated + // services like an unrelated n8n deployment). const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); let targetUuids: string[]; + let explicitLinked: Map = new Map(); // resource_uuid → resource_type + let restrictToExplicit = false; if (params.projectId) { const projectCoolify = await getProjectCoolifyUuid(String(params.projectId), principal.workspace); if (!projectCoolify) { return NextResponse.json({ error: `Project ${params.projectId} not found in this workspace` }, { status: 404 }); } - targetUuids = [projectCoolify]; + explicitLinked = await getProjectResourceUuids(String(params.projectId)); + const legacyUuid = principal.workspace.coolify_project_uuid; + if (legacyUuid && projectCoolify === legacyUuid) { + restrictToExplicit = true; + targetUuids = [projectCoolify]; + } else { + targetUuids = [projectCoolify]; + } } else { targetUuids = Array.from(ownedUuids); if (targetUuids.length === 0 && principal.workspace.coolify_project_uuid) { @@ -543,9 +571,16 @@ async function toolAppsList(principal: Principal, params: Record = .filter((s: any) => envToProject.has(Number(s.environment_id))) .map((s: any) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! })); + const filteredApps = restrictToExplicit + ? appList.filter((a) => explicitLinked.has(a.uuid)) + : appList; + const filteredServices = restrictToExplicit + ? serviceList.filter((s) => explicitLinked.has(String(s.uuid))) + : serviceList; + return NextResponse.json({ result: [ - ...appList.map((a) => ({ + ...filteredApps.map((a) => ({ uuid: a.uuid, name: a.name, status: a.status, @@ -555,7 +590,7 @@ async function toolAppsList(principal: Principal, params: Record = resourceType: 'application', coolifyProjectUuid: (a as any)._coolifyProjectUuid as string, })), - ...serviceList.map((s) => { + ...filteredServices.map((s) => { const apps = (s.applications as Array>) || []; const publicApp = apps.find((a) => a.fqdn); return { @@ -980,6 +1015,20 @@ async function toolAppsCreate(principal: Principal, params: Record) instantDeploy: false, }; + // Record the Vibn-project ↔ Coolify-resource link so apps.list { projectId } + // can surface it even when multiple Vibn projects share a Coolify project + // (e.g. legacy workspace project still hosting a hand-deployed n8n alongside + // a project-bound Twenty CRM). + const linkIfRequested = async (uuid: string, type: 'application' | 'service' | 'database') => { + if (params.projectId) { + try { + await linkResourceToProject(String(params.projectId), ws.slug, uuid, type); + } catch (e) { + console.warn('[mcp apps.create] linkResourceToProject failed', e); + } + } + }; + // ── Pathway 4: Coolify one-click template ───────────────────────────── // Most reliable path for popular third-party apps. Coolify maintains // a curated catalog at templates/service-templates.json — each entry @@ -1023,6 +1072,7 @@ async function toolAppsCreate(principal: Principal, params: Record) // intermittent issues and we want to set the FQDN + envs first. instantDeploy: false, }); + await linkIfRequested(created.uuid, 'service'); // Coolify auto-assigns sslip.io URLs. Replace them with the // user's FQDN, INCLUDING the required upstream port — see comment @@ -1103,6 +1153,7 @@ async function toolAppsCreate(principal: Principal, params: Record) domains: toDomainsString([fqdn]), description: params.description ? String(params.description) : undefined, }); + await linkIfRequested(created.uuid, 'application'); await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); @@ -1121,6 +1172,7 @@ async function toolAppsCreate(principal: Principal, params: Record) name: appName, description: params.description ? String(params.description) : undefined, }); + await linkIfRequested(created.uuid, 'service'); // Services use /services/{uuid}/envs — upsert each env var if (params.envs && typeof params.envs === 'object') { @@ -1232,6 +1284,7 @@ async function toolAppsCreate(principal: Principal, params: Record) dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined, baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined, }); + await linkIfRequested(created.uuid, 'application'); const dep = await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ @@ -1845,6 +1898,7 @@ async function toolAppsDelete(principal: Principal, params: Record) deleteConnectedNetworks: true, dockerCleanup: true, }); + await unlinkResource(appUuid).catch((e) => console.warn('[mcp apps.delete] unlink failed', e)); return NextResponse.json({ result: { ok: true, deleted: { uuid: appUuid, name: app.name, volumesKept: !deleteVolumes } }, }); @@ -1969,6 +2023,13 @@ async function toolDatabasesCreate(principal: Principal, params: Record console.warn('[mcp databases.delete] unlink failed', e)); return NextResponse.json({ result: { ok: true, deleted: { uuid, name: db.name, volumesKept: !deleteVolumes } }, }); @@ -2137,6 +2199,7 @@ async function toolAuthDelete(principal: Principal, params: Record) deleteConnectedNetworks: true, dockerCleanup: true, }); + await unlinkResource(uuid).catch((e) => console.warn('[mcp services.delete] unlink failed', e)); return NextResponse.json({ result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } }, }); diff --git a/app/api/projects/backfill-isolation/route.ts b/app/api/projects/backfill-isolation/route.ts new file mode 100644 index 00000000..92b6db5e --- /dev/null +++ b/app/api/projects/backfill-isolation/route.ts @@ -0,0 +1,131 @@ +/** + * Backfill endpoint: per-Vibn-project Coolify isolation. + * + * For each Vibn project in the caller's workspace, this: + * 1. Mints a dedicated `vibn-{ws}-{slug}` Coolify project (idempotent). + * 2. Records the project's existing linked Coolify resource (coolifyAppUuid + * / coolifyServiceUuid) in fs_project_resources. + * + * After backfill, `apps_list { projectId }` will only surface the user's + * actually-owned resources for that project, even when multiple projects + * legacy-share a single Coolify project. + * + * Safe to re-run; everything is idempotent. + */ + +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { query } from '@/lib/db-postgres'; +import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; +import { + ensureProjectCoolifyProject, + ensureProjectResourcesTable, + linkResourceToProject, + type ResourceType, +} from '@/lib/projects'; + +interface ProjectRow { + id: string; + slug: string; + data: any; +} + +interface BackfillReport { + projectId: string; + projectName: string; + beforeCoolifyProjectUuid: string | null; + afterCoolifyProjectUuid: string | null; + linkedResources: Array<{ uuid: string; type: ResourceType }>; + warnings: string[]; +} + +export async function POST() { + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const email = session.user.email; + + // Resolve workspace + load all of the user's projects. + const users = await query<{ id: string }>( + `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, + [email], + ); + if (users.length === 0) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + const firebaseUserId = users[0].id; + + const ws = await getOrCreateProvisionedWorkspace({ + userId: firebaseUserId, + email, + displayName: session.user.name ?? email, + }); + if (!ws) { + return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 }); + } + + await ensureProjectResourcesTable(); + + const projects = await query( + `SELECT id, slug, data + FROM fs_projects + WHERE vibn_workspace_id = $1 OR workspace = $2 + ORDER BY created_at ASC`, + [ws.id, ws.slug], + ); + + const reports: BackfillReport[] = []; + + for (const p of projects) { + const data = p.data || {}; + const projectName = data.productName || data.name || data.title || p.slug; + const before = (data.coolifyProjectUuid as string) || null; + const warnings: string[] = []; + + let after = before; + try { + // ensureProjectCoolifyProject is a no-op when already set. + const ensured = await ensureProjectCoolifyProject(p.id, ws, { + projectSlug: p.slug, + projectName, + }); + if (ensured) after = ensured; + } catch (err: any) { + warnings.push(`coolify provision failed: ${err?.message ?? err}`); + } + + // Record any pre-existing single-resource link. + const linked: Array<{ uuid: string; type: ResourceType }> = []; + const candidate: Array<[string | undefined, ResourceType]> = [ + [data.coolifyServiceUuid, 'service'], + [data.coolifyAppUuid, 'application'], + [data.coolifyDatabaseUuid, 'database'], + ]; + for (const [uuid, type] of candidate) { + if (typeof uuid === 'string' && uuid.length > 0) { + try { + await linkResourceToProject(p.id, ws.slug, uuid, type); + linked.push({ uuid, type }); + } catch (err: any) { + warnings.push(`link ${type}=${uuid} failed: ${err?.message ?? err}`); + } + } + } + + reports.push({ + projectId: p.id, + projectName, + beforeCoolifyProjectUuid: before, + afterCoolifyProjectUuid: after, + linkedResources: linked, + warnings, + }); + } + + return NextResponse.json({ + workspace: ws.slug, + processed: reports.length, + reports, + }); +} diff --git a/lib/projects.ts b/lib/projects.ts index 3fff8540..2cfd9158 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -140,3 +140,95 @@ export async function getOwnedCoolifyProjectUuids(workspace: VibnWorkspace): Pro 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; +}