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:
@@ -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 } },
|
||||
});
|
||||
|
||||
131
app/api/projects/backfill-isolation/route.ts
Normal file
131
app/api/projects/backfill-isolation/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user