feat(mcp): per-resource Vibn-project ownership + backfill endpoint

Stage 3 of per-project Coolify isolation. Adds an authoritative ownership
table so apps_list { projectId } returns ONLY the resources actually owned
by that Vibn project, even when multiple Vibn projects share a single
Coolify project (the legacy workspace-level vibn-ws-{slug}).

- New table fs_project_resources (project_id, resource_uuid, type, workspace).
  Auto-created on first use.
- lib/projects.ts: linkResourceToProject / unlinkResource /
  getProjectResourceUuids / getProjectIdForResource helpers.
- apps_list { projectId }: when the project's coolifyProjectUuid equals the
  legacy workspace project, restrict results to explicitly-linked resources.
  When it has a dedicated Coolify project, return everything in that project.
- apps_create / databases_create: auto-link the newly-created resource to
  the requesting Vibn project.
- apps_delete / databases_delete / services_delete: unlink on success.
- projects_get → possibleDeployments: prefer explicit links; fuzzy-match
  fallback only fires when no link table entry exists yet.
- POST /api/projects/backfill-isolation: idempotent migration that mints a
  dedicated Coolify project for every Vibn project AND records existing
  coolifyServiceUuid/coolifyAppUuid/coolifyDatabaseUuid links. Resolves
  the "Twenty CRM project shows n8n" bug for legacy projects without
  needing to physically move services in Coolify.

Made-with: Cursor
This commit is contained in:
2026-04-27 19:33:07 -07:00
parent 766352ec00
commit 769fbdcba2
3 changed files with 295 additions and 9 deletions

View File

@@ -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<string, any>
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<string, any>
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<string, any>
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<Record<string, unknown>>) || [];
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<string, any> = {}) {
// 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<string, string> = 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<string, any> =
.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<string, any> =
resourceType: 'application',
coolifyProjectUuid: (a as any)._coolifyProjectUuid as string,
})),
...serviceList.map((s) => {
...filteredServices.map((s) => {
const apps = (s.applications as Array<Record<string, unknown>>) || [];
const publicApp = apps.find((a) => a.fqdn);
return {
@@ -980,6 +1015,20 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
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<string, any>)
// 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<string, any>)
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<string, any>)
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<string, any>)
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<string, any>)
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<string,
instantDeploy: params.instantDeploy !== false,
});
const db = await getDatabaseInWorkspace(uuid, ownedUuids);
if (params.projectId) {
try {
await linkResourceToProject(String(params.projectId), ws.slug, uuid, 'database');
} catch (e) {
console.warn('[mcp databases.create] linkResourceToProject failed', e);
}
}
return NextResponse.json({
result: {
uuid: db.uuid,
@@ -2042,6 +2103,7 @@ async function toolDatabasesDelete(principal: Principal, params: Record<string,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
await unlinkResource(uuid).catch((e) => 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<string, any>)
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 } },
});

View File

@@ -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<ProjectRow>(
`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,
});
}

View File

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