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:
@@ -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