Files
vibn-frontend/lib/coolify.ts
Mark Henderson b6eaa85733 fix(tenancy): stop leaking workspace-level Coolify services across projects
CRITICAL: every Vibn project was rendering every other project's
services in the same workspace (Twenty CRM, n8n, all databases,
all secrets). Tenancy was effectively broken — cross-project data
exposure inside a workspace.

Root cause:
  - Coolify's POST /projects validates `description` against a strict
    allowlist (letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`).
  - Our description "Vibn project: <name> (workspace: <slug>)" contains
    two colons. Every project-create on Coolify returned 422.
  - lib/projects.ts caught that 422 and fell back to
    `workspace.coolify_project_uuid` so deploys "weren't blocked."
  - That UUID is shared by every Vibn project in the workspace, so
    listServicesInProject(coolifyProjectUuid) returned the union of
    all projects' services, applications, and databases for any
    project in the workspace. The Product, Hosting, and Infrastructure
    tabs all rendered cross-tenant data as if it were the current
    project's.

Fixes (defense in depth — fix at every layer):

  1. lib/coolify.ts createProject(): sanitize the description against
     Coolify's allowlist at the boundary so no caller can ever ship
     a description that 422s. Replaces disallowed chars with `-`,
     collapses runs, caps at 255 chars.

  2. lib/projects.ts ensureProjectCoolifyProject():
     - Pre-sanitize the description we pass (belt + suspenders).
     - Detect when `stored === workspace.coolify_project_uuid` (the
       legacy bad state) and re-provision a dedicated project.
     - REMOVE the workspace-UUID fallback on create failure. A 422
       now leaves coolifyProjectUuid null and the UI shows an empty
       state, which is correct: better to surface "no resources" than
       to lie about which project owns what.
     - Export sanitizeCoolifyDescription helper for reuse.

  3. /api/projects/[projectId]/anatomy/route.ts: SELF-HEAL on every
     read. If the project's stored Coolify UUID matches the
     workspace's UUID, we treat it as missing, re-provision a
     dedicated Coolify project on the fly (idempotent — reuses the
     existing one if found by name), persist the new UUID, and
     continue serving with the corrected scope. If provisioning
     fails we fall back to undefined, NOT the workspace UUID, so
     no cross-tenant data ever surfaces again.

The self-heal means existing already-broken projects will fix
themselves on the next page load — no manual data migration needed.

Made-with: Cursor
2026-04-29 17:16:33 -07:00

1349 lines
48 KiB
TypeScript

/**
* Coolify API client for Vibn project provisioning.
*
* Used server-side only. Credentials from env vars:
* COOLIFY_URL — e.g. https://coolify.vibnai.com
* COOLIFY_API_TOKEN — admin bearer token
* COOLIFY_DEFAULT_SERVER_UUID — which Coolify server workspaces deploy to
* COOLIFY_DEFAULT_DESTINATION_UUID — Docker destination on that server
*/
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'https://coolify.vibnai.com';
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
const COOLIFY_DEFAULT_SERVER_UUID =
process.env.COOLIFY_DEFAULT_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc';
const COOLIFY_DEFAULT_DESTINATION_UUID =
process.env.COOLIFY_DEFAULT_DESTINATION_UUID ?? 'zkogkggkw0wg40gccks80oo0';
export interface CoolifyProject {
uuid: string;
name: string;
description?: string;
environments?: Array<{ id: number; uuid: string; name: string; project_id: number }>;
}
/** All database flavors Coolify v4 can provision. */
export type CoolifyDatabaseType =
| 'postgresql'
| 'mysql'
| 'mariadb'
| 'mongodb'
| 'redis'
| 'keydb'
| 'dragonfly'
| 'clickhouse';
export interface CoolifyDatabase {
uuid: string;
name: string;
type?: string;
status: string;
internal_db_url?: string;
external_db_url?: string;
is_public?: boolean;
public_port?: number;
project_uuid?: string;
environment_id?: number;
environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } };
}
export interface CoolifyApplication {
uuid: string;
name: string;
status: string;
fqdn?: string;
domains?: string;
git_repository?: string;
git_branch?: string;
project_uuid?: string;
environment_id?: number;
environment_name?: string;
environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } };
build_pack?: string;
}
/**
* Coolify env var, as returned by GET /applications/{uuid}/envs.
*
* NOTE on build-time vars: Coolify removed `is_build_time` from the
* **write** schema some time ago. The flag is now a derived read-only
* attribute (`is_buildtime`, one word) computed from whether the var
* is referenced as a Dockerfile ARG. `is_build_time` (underscored) is
* kept here only to tolerate very old read responses — never send it
* on POST/PATCH. See `COOLIFY_ENV_WRITE_FIELDS` below.
*/
export interface CoolifyEnvVar {
uuid?: string;
key: string;
value: string;
is_preview?: boolean;
/** @deprecated read-only, derived server-side. Do not send on write. */
is_build_time?: boolean;
/** Newer one-word spelling of the same derived read-only flag. */
is_buildtime?: boolean;
is_runtime?: boolean;
is_literal?: boolean;
is_multiline?: boolean;
is_shown_once?: boolean;
is_shared?: boolean;
}
/**
* The only fields Coolify v4 accepts on POST/PATCH /applications/{uuid}/envs.
* Any other field (notably `is_build_time`) triggers a 422
* "This field is not allowed." Build-time vs runtime is no longer a
* writable flag — Coolify infers it at build time.
*
* Source of truth:
* https://coolify.io/docs/api-reference/api/operations/update-env-by-application-uuid
* https://coolify.io/docs/api-reference/api/operations/create-env-by-application-uuid
*/
const COOLIFY_ENV_WRITE_FIELDS = [
'key',
'value',
'is_preview',
'is_literal',
'is_multiline',
'is_shown_once',
] as const;
type CoolifyEnvWritePayload = {
key: string;
value: string;
is_preview?: boolean;
is_literal?: boolean;
is_multiline?: boolean;
is_shown_once?: boolean;
};
function toCoolifyEnvWritePayload(env: CoolifyEnvVar): CoolifyEnvWritePayload {
const src = env as unknown as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of COOLIFY_ENV_WRITE_FIELDS) {
const v = src[k];
if (v !== undefined) out[k] = v;
}
return out as CoolifyEnvWritePayload;
}
export interface CoolifyPrivateKey {
uuid: string;
name: string;
description?: string;
fingerprint?: string;
}
export interface CoolifyServer {
uuid: string;
name: string;
ip: string;
is_reachable?: boolean;
settings?: { wildcard_domain?: string | null };
}
export interface CoolifyDeployment {
uuid: string;
status: string;
created_at?: string;
finished_at?: string;
commit?: string;
}
async function coolifyFetch(path: string, options: RequestInit = {}) {
const url = `${COOLIFY_URL}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
...(options.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Coolify API error ${res.status} on ${path}: ${text}`);
}
if (res.status === 204) return null;
return res.json();
}
// ──────────────────────────────────────────────────
// Projects
// ──────────────────────────────────────────────────
export async function listProjects(): Promise<CoolifyProject[]> {
return coolifyFetch('/projects');
}
export async function createProject(name: string, description?: string): Promise<CoolifyProject> {
// Coolify validates the description against a narrow allowlist:
// letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`.
// Anything else (colon, semicolon, brackets, pipe, …) errors out with
// a 422 — and a 422 here used to silently fall back to the workspace
// project, leaking services across tenants. Sanitize defensively so
// every caller is safe.
const cleaned = description
? description
.replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-")
.replace(/-{2,}/g, "-")
.trim()
.slice(0, 255)
: undefined;
return coolifyFetch('/projects', {
method: 'POST',
body: JSON.stringify({ name, description: cleaned }),
});
}
export async function getProject(uuid: string): Promise<CoolifyProject> {
return coolifyFetch(`/projects/${uuid}`);
}
export async function deleteProject(uuid: string): Promise<void> {
await coolifyFetch(`/projects/${uuid}`, { method: 'DELETE' });
}
// ──────────────────────────────────────────────────
// Servers
// ──────────────────────────────────────────────────
export async function listServers(): Promise<CoolifyServer[]> {
return coolifyFetch('/servers');
}
// ──────────────────────────────────────────────────
// Private keys (SSH deploy keys)
// ──────────────────────────────────────────────────
export async function listPrivateKeys(): Promise<CoolifyPrivateKey[]> {
return coolifyFetch('/security/keys');
}
export async function createPrivateKey(opts: {
name: string;
privateKeyPem: string;
description?: string;
}): Promise<CoolifyPrivateKey> {
return coolifyFetch('/security/keys', {
method: 'POST',
body: JSON.stringify({
name: opts.name,
private_key: opts.privateKeyPem,
description: opts.description ?? '',
}),
});
}
export async function deletePrivateKey(uuid: string): Promise<void> {
await coolifyFetch(`/security/keys/${uuid}`, { method: 'DELETE' });
}
// ──────────────────────────────────────────────────
// Databases (all 8 types)
// ──────────────────────────────────────────────────
/** Shared context used by every database-create call. */
export interface CoolifyDbCreateContext {
projectUuid: string;
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
instantDeploy?: boolean;
}
export interface CreateDatabaseOpts extends CoolifyDbCreateContext {
type: CoolifyDatabaseType;
name: string;
description?: string;
image?: string;
isPublic?: boolean;
publicPort?: number;
/** Type-specific credentials / config */
credentials?: Record<string, unknown>;
/** Resource limits */
limits?: { memory?: string; cpus?: string };
}
/**
* Create a database of the requested type. Dispatches to the right
* POST /databases/{type} endpoint and packs the right credential
* fields for each flavor.
*/
export async function createDatabase(opts: CreateDatabaseOpts): Promise<{ uuid: string }> {
const {
type, name,
projectUuid,
serverUuid = COOLIFY_DEFAULT_SERVER_UUID,
environmentName = 'production',
destinationUuid = COOLIFY_DEFAULT_DESTINATION_UUID,
instantDeploy = true,
description,
image,
isPublic,
publicPort,
credentials = {},
limits = {},
} = opts;
const common: Record<string, unknown> = {
project_uuid: projectUuid,
server_uuid: serverUuid,
environment_name: environmentName,
destination_uuid: destinationUuid,
name,
description,
image,
is_public: isPublic,
public_port: publicPort,
instant_deploy: instantDeploy,
limits_memory: limits.memory,
limits_cpus: limits.cpus,
};
return coolifyFetch(`/databases/${type}`, {
method: 'POST',
body: JSON.stringify(stripUndefined({ ...common, ...credentials })),
});
}
export async function listDatabases(): Promise<CoolifyDatabase[]> {
return coolifyFetch('/databases');
}
export async function getDatabase(uuid: string): Promise<CoolifyDatabase> {
return coolifyFetch(`/databases/${uuid}`);
}
export async function updateDatabase(uuid: string, patch: Record<string, unknown>): Promise<{ uuid: string }> {
return coolifyFetch(`/databases/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(stripUndefined(patch)),
});
}
export async function deleteDatabase(
uuid: string,
opts: {
deleteConfigurations?: boolean;
deleteVolumes?: boolean;
dockerCleanup?: boolean;
deleteConnectedNetworks?: boolean;
} = {}
): Promise<void> {
const q = new URLSearchParams();
if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations));
if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes));
if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup));
if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks));
const qs = q.toString();
await coolifyFetch(`/databases/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' });
}
export async function startDatabase(uuid: string): Promise<void> {
await coolifyFetch(`/databases/${uuid}/start`, { method: 'POST' });
}
export async function stopDatabase(uuid: string): Promise<void> {
await coolifyFetch(`/databases/${uuid}/stop`, { method: 'POST' });
}
export async function restartDatabase(uuid: string): Promise<void> {
await coolifyFetch(`/databases/${uuid}/restart`, { method: 'POST' });
}
// ──────────────────────────────────────────────────
// Applications
// ──────────────────────────────────────────────────
export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose' | 'dockerimage';
export interface CreatePrivateDeployKeyAppOpts {
projectUuid: string;
privateKeyUuid: string;
gitRepository: string; // SSH URL: git@git.vibnai.com:vibn-mark/repo.git
gitBranch?: string;
portsExposes: string; // "3000"
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
buildPack?: CoolifyBuildPack;
name?: string;
description?: string;
domains?: string; // comma-separated FQDNs
isAutoDeployEnabled?: boolean;
isForceHttpsEnabled?: boolean;
instantDeploy?: boolean;
installCommand?: string;
buildCommand?: string;
startCommand?: string;
baseDirectory?: string;
dockerfileLocation?: string;
manualWebhookSecretGitea?: string;
}
export async function createPrivateDeployKeyApp(
opts: CreatePrivateDeployKeyAppOpts
): Promise<{ uuid: string }> {
const body = stripUndefined({
project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
environment_name: opts.environmentName ?? 'production',
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
private_key_uuid: opts.privateKeyUuid,
git_repository: opts.gitRepository,
git_branch: opts.gitBranch ?? 'main',
build_pack: opts.buildPack ?? 'nixpacks',
ports_exposes: opts.portsExposes,
name: opts.name,
description: opts.description,
domains: opts.domains,
is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true,
is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
instant_deploy: opts.instantDeploy ?? true,
install_command: opts.installCommand,
build_command: opts.buildCommand,
start_command: opts.startCommand,
base_directory: opts.baseDirectory,
dockerfile_location: opts.dockerfileLocation,
manual_webhook_secret_gitea: opts.manualWebhookSecretGitea,
});
return coolifyFetch('/applications/private-deploy-key', {
method: 'POST',
body: JSON.stringify(body),
});
}
export interface CreatePublicAppOpts {
projectUuid: string;
gitRepository: string; // https URL (can embed basic-auth creds for private repos)
gitBranch?: string;
portsExposes: string;
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
buildPack?: CoolifyBuildPack;
name?: string;
description?: string;
domains?: string;
isAutoDeployEnabled?: boolean;
isForceHttpsEnabled?: boolean;
instantDeploy?: boolean;
installCommand?: string;
buildCommand?: string;
startCommand?: string;
baseDirectory?: string;
dockerfileLocation?: string;
dockerComposeLocation?: string;
manualWebhookSecretGitea?: string;
}
export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> {
const body = stripUndefined({
project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
environment_name: opts.environmentName ?? 'production',
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
git_repository: opts.gitRepository,
git_branch: opts.gitBranch ?? 'main',
build_pack: opts.buildPack ?? 'nixpacks',
ports_exposes: opts.portsExposes,
name: opts.name,
description: opts.description,
domains: opts.domains,
is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true,
is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
instant_deploy: opts.instantDeploy ?? true,
install_command: opts.installCommand,
build_command: opts.buildCommand,
start_command: opts.startCommand,
base_directory: opts.baseDirectory,
dockerfile_location: opts.dockerfileLocation,
docker_compose_location: opts.dockerComposeLocation,
manual_webhook_secret_gitea: opts.manualWebhookSecretGitea,
});
return coolifyFetch('/applications/public', {
method: 'POST',
body: JSON.stringify(body),
});
}
// ──────────────────────────────────────────────────
// Repo-free app creation (Docker image / raw compose)
// ──────────────────────────────────────────────────
export interface CreateDockerImageAppOpts {
projectUuid: string;
image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine"
name?: string;
portsExposes?: string; // default "80"
domains?: string;
description?: string;
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
isForceHttpsEnabled?: boolean;
instantDeploy?: boolean;
}
export async function createDockerImageApp(
opts: CreateDockerImageAppOpts,
): Promise<{ uuid: string }> {
const body = stripUndefined({
project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
environment_name: opts.environmentName ?? 'production',
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
docker_registry_image_name: opts.image,
name: opts.name,
description: opts.description,
ports_exposes: opts.portsExposes ?? '80',
domains: opts.domains,
is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
instant_deploy: opts.instantDeploy ?? false,
});
return coolifyFetch('/applications/dockerimage', {
method: 'POST',
body: JSON.stringify(body),
});
}
export interface CreateDockerComposeAppOpts {
projectUuid: string;
composeRaw: string; // raw docker-compose YAML as a string
name?: string;
description?: string;
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
isForceHttpsEnabled?: boolean;
instantDeploy?: boolean;
/**
* Map compose service(s) to public domain(s) after creation.
* Array of { service, domain } pairs. The first entry becomes the
* primary public URL.
*/
composeDomains?: Array<{ service: string; domain: string }>;
}
/**
* Create an inline Docker Compose stack.
*
* Coolify's /applications/dockercompose endpoint creates a **Service**
* resource (not an Application). Services have their own API:
* /services/{uuid} GET/PATCH
* /services/{uuid}/envs GET/POST
* /services/{uuid}/start POST
* /services/{uuid}/stop POST
* /services/{uuid} DELETE
* The UUID returned here is a service UUID; callers should treat it as
* such and use the service helpers below for lifecycle operations.
*/
export async function createDockerComposeApp(
opts: CreateDockerComposeAppOpts,
): Promise<{ uuid: string; resourceType: 'service' }> {
const body = stripUndefined({
project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
environment_name: opts.environmentName ?? 'production',
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
name: opts.name,
description: opts.description,
docker_compose_raw: Buffer.from(opts.composeRaw, 'utf8').toString('base64'),
instant_deploy: opts.instantDeploy ?? false,
});
const created = await coolifyFetch('/applications/dockercompose', {
method: 'POST',
body: JSON.stringify(body),
}) as { uuid: string };
return { uuid: created.uuid, resourceType: 'service' };
}
// ── Coolify Services API ─────────────────────────────────────────────
export async function startService(uuid: string): Promise<void> {
await coolifyFetch(`/services/${uuid}/start`, { method: 'POST' });
}
export async function stopService(uuid: string): Promise<void> {
await coolifyFetch(`/services/${uuid}/stop`, { method: 'POST' });
}
export interface ServiceEnvVar { key: string; value: string; is_preview?: boolean; is_literal?: boolean; }
export async function listServiceEnvs(uuid: string): Promise<ServiceEnvVar[]> {
return coolifyFetch(`/services/${uuid}/envs`);
}
export async function bulkUpsertServiceEnvs(
uuid: string,
envs: ServiceEnvVar[],
): Promise<void> {
await coolifyFetch(`/services/${uuid}/envs/bulk`, {
method: 'PUT',
body: JSON.stringify({ data: envs }),
});
}
export async function upsertServiceEnv(
uuid: string,
env: ServiceEnvVar,
): Promise<void> {
// Coolify auto-creates env entries (with empty values) for every
// ${VAR} reference in the compose file at service-creation time.
// POST to /envs returns "already exists" for those — we must use
// PATCH to update them. Try POST first, fall back to PATCH.
try {
await coolifyFetch(`/services/${uuid}/envs`, {
method: 'POST',
body: JSON.stringify(env),
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('already exists')) {
await coolifyFetch(`/services/${uuid}/envs`, {
method: 'PATCH',
body: JSON.stringify(env),
});
return;
}
throw err;
}
}
export async function listAllServices(): Promise<Array<Record<string, unknown>>> {
return coolifyFetch('/services') as Promise<Array<Record<string, unknown>>>;
}
export async function updateApplication(
uuid: string,
patch: Record<string, unknown>
): Promise<{ uuid: string }> {
return coolifyFetch(`/applications/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(stripUndefined(patch)),
});
}
export async function setApplicationDomains(
uuid: string,
domains: string[],
opts: {
forceOverride?: boolean;
/**
* Build pack of the target app. Required to dispatch to the right
* Coolify field:
* - dockercompose → docker_compose_domains (per-service JSON)
* - everything else → domains (comma-separated string)
* If omitted we GET the app and detect it.
*/
buildPack?: string;
/**
* For compose apps only: which compose service should receive the
* public domain(s). Defaults to 'server' (matches Twenty, Plane,
* Cal.com). Ignored for non-compose apps.
*/
composeService?: string;
} = {}
): Promise<{ uuid: string }> {
// Coolify validates each entry as a URL, so bare hostnames need a scheme.
const normalized = domains.map(d => {
const trimmed = d.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
});
let buildPack = opts.buildPack;
if (!buildPack) {
const app = await getApplication(uuid);
buildPack = (app.build_pack ?? 'nixpacks') as string;
}
// ── Compose apps: set per-service domains ──────────────────────────
// Coolify hard-rejects the top-level `domains` field for dockercompose
// (HTTP 422: "Use docker_compose_domains instead"). Domains live per
// compose service — we default to the `server` service, which matches
// the majority of self-hostable apps (Twenty, Plane, Cal.com, etc.).
if (buildPack === 'dockercompose') {
const service = (opts.composeService ?? 'server').trim();
// Coolify accepts an array of {name, domain}; ONE entry per service.
// Multiple domains → comma-join into the single service's `domain`
// field (Coolify splits on comma internally).
const payload = [{ name: service, domain: normalized.join(',') }];
return updateApplication(uuid, { docker_compose_domains: payload });
}
// ── Single-container apps: top-level `domains` (maps to fqdn) ──────
// Coolify maps `domains` → the DB `fqdn` column, but only when the
// destination server has `proxy.type=TRAEFIK`/`CADDY` AND
// `is_build_server=false` (Server::isProxyShouldRun() returns true).
// If either is misconfigured, the PATCH silently drops the field
// (200 OK but fqdn unchanged). Fix that in Coolify's server config,
// not here. Sending `fqdn` directly returns 422.
return updateApplication(uuid, {
domains: normalized.join(','),
force_domain_override: opts.forceOverride ?? true,
is_force_https_enabled: true,
});
}
export async function deleteApplication(
uuid: string,
opts: {
deleteConfigurations?: boolean;
deleteVolumes?: boolean;
dockerCleanup?: boolean;
deleteConnectedNetworks?: boolean;
} = {}
): Promise<void> {
const q = new URLSearchParams();
if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations));
if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes));
if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup));
if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks));
const qs = q.toString();
await coolifyFetch(`/applications/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' });
}
export async function listApplications(): Promise<CoolifyApplication[]> {
return coolifyFetch('/applications');
}
export async function deployApplication(uuid: string, opts: { force?: boolean } = {}): Promise<{ deployment_uuid: string }> {
// Coolify v4 exposes deploy as POST /deploy?uuid=...&force=...
// The older /applications/{uuid}/deploy path is a 404 on current
// Coolify releases.
const q = new URLSearchParams({ uuid });
if (opts.force) q.set('force', 'true');
const res = await coolifyFetch(`/deploy?${q.toString()}`, { method: 'POST' });
// Response shape: { deployments: [{ deployment_uuid, ... }] } or direct object.
const first = Array.isArray(res?.deployments) ? res.deployments[0] : res;
return {
deployment_uuid: first?.deployment_uuid ?? first?.uuid ?? '',
};
}
export async function getApplication(uuid: string): Promise<CoolifyApplication> {
return coolifyFetch(`/applications/${uuid}`);
}
export async function startApplication(uuid: string): Promise<void> {
await coolifyFetch(`/applications/${uuid}/start`, { method: 'POST' });
}
export async function stopApplication(uuid: string): Promise<void> {
await coolifyFetch(`/applications/${uuid}/stop`, { method: 'POST' });
}
export async function restartApplication(uuid: string): Promise<void> {
await coolifyFetch(`/applications/${uuid}/restart`, { method: 'POST' });
}
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
}
/**
* Coolify's "runtime logs" endpoint. Returns `{ logs: "…" }` for simple
* Dockerfile/nixpacks apps; returns an empty string for `dockercompose`
* apps (Coolify v4 doesn't know which of the compose services to tail).
* Use coolify-logs.getApplicationRuntimeLogs for the compose-aware path.
*/
export async function getApplicationRuntimeLogsFromApi(
uuid: string,
lines = 200,
): Promise<{ logs: string }> {
return coolifyFetch(`/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`);
}
export async function listApplicationDeployments(uuid: string): Promise<CoolifyDeployment[]> {
// Coolify v4 nests this under /deployments/applications/{uuid}
// and returns { count, deployments }. Normalize to a flat array.
const raw = await coolifyFetch(`/deployments/applications/${uuid}?take=50`);
if (Array.isArray(raw)) return raw as CoolifyDeployment[];
return (raw?.deployments ?? []) as CoolifyDeployment[];
}
// ──────────────────────────────────────────────────
// Legacy monorepo helper (still used by older flows)
// ──────────────────────────────────────────────────
export async function createMonorepoAppService(opts: {
projectUuid: string;
appName: string;
gitRepo: string;
gitBranch?: string;
domain: string;
serverUuid?: string;
environmentName?: string;
}): Promise<CoolifyApplication> {
const {
projectUuid, appName, gitRepo,
gitBranch = 'main',
domain,
serverUuid = COOLIFY_DEFAULT_SERVER_UUID,
environmentName = 'production',
} = opts;
return coolifyFetch(`/applications`, {
method: 'POST',
body: JSON.stringify({
project_uuid: projectUuid,
name: appName,
git_repository: gitRepo,
git_branch: gitBranch,
server_uuid: serverUuid,
environment_name: environmentName,
build_pack: 'nixpacks',
build_command: `pnpm install && turbo run build --filter=${appName}`,
start_command: `turbo run start --filter=${appName}`,
ports_exposes: '3000',
fqdn: `https://${domain}`,
}),
});
}
// ──────────────────────────────────────────────────
// Environment variables
// ──────────────────────────────────────────────────
export async function listApplicationEnvs(uuid: string): Promise<CoolifyEnvVar[]> {
return coolifyFetch(`/applications/${uuid}/envs`);
}
export async function upsertApplicationEnv(
uuid: string,
env: CoolifyEnvVar & { is_preview?: boolean }
): Promise<CoolifyEnvVar> {
// Strip any read-only/derived fields (`is_build_time`, `is_buildtime`,
// `is_runtime`, `is_shared`, `uuid`) before sending — Coolify returns
// 422 "This field is not allowed." for anything outside the write
// schema. See COOLIFY_ENV_WRITE_FIELDS.
const payload = toCoolifyEnvWritePayload(env);
try {
return await coolifyFetch(`/applications/${uuid}/envs`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('404') || msg.includes('405')) {
return coolifyFetch(`/applications/${uuid}/envs`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
throw err;
}
}
export async function deleteApplicationEnv(uuid: string, key: string): Promise<void> {
await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
}
// ──────────────────────────────────────────────────
// Services (raw Coolify service resources)
// ──────────────────────────────────────────────────
export interface CoolifyService {
uuid: string;
name: string;
status?: string;
/** Coolify template slug the service was provisioned from, e.g. "pocketbase". */
service_type?: string;
project_uuid?: string;
environment_id?: number;
environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } };
}
export async function listServices(): Promise<CoolifyService[]> {
return coolifyFetch('/services');
}
export async function getService(uuid: string): Promise<CoolifyService> {
return coolifyFetch(`/services/${uuid}`);
}
export async function createService(opts: {
projectUuid: string;
type: string; // e.g. "pocketbase", "authentik", "zitadel"
name: string;
description?: string;
serverUuid?: string;
environmentName?: string;
destinationUuid?: string;
instantDeploy?: boolean;
}): Promise<{ uuid: string }> {
const body = stripUndefined({
project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
environment_name: opts.environmentName ?? 'production',
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
type: opts.type,
name: opts.name,
description: opts.description,
instant_deploy: opts.instantDeploy ?? true,
});
return coolifyFetch('/services', { method: 'POST', body: JSON.stringify(body) });
}
/**
* Patch a Coolify Service with arbitrary fields. Used for things like
* `urls` (custom domains), `name`, `description`, etc.
*/
export async function updateService(
uuid: string,
patch: Record<string, unknown>,
): Promise<{ uuid: string }> {
return coolifyFetch(`/services/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(stripUndefined(patch)),
});
}
/**
* Set custom URLs on a Coolify Service. Coolify auto-assigns sslip.io
* domains at service-creation time; this PATCH replaces them with the
* caller-supplied FQDN.
*
* Each entry in `urls` maps a compose service name (the YAML key) to a
* full URL. For most one-click templates the primary HTTP service is
* named after the template slug (e.g. template "twenty" → service
* "twenty"). If a template uses a different name, callers should pass
* the explicit mapping.
*
* Reasoning behind the shape: Coolify expects
* { urls: [{ name: "twenty", url: "https://my.example.com" }, ...] }
* where `name` is the docker-compose service key. Passing only `url`
* silently no-ops.
*/
export async function setServiceDomains(
uuid: string,
urls: Array<{ name: string; url: string }>,
): Promise<{ uuid: string }> {
const normalized = urls.map(u => ({
name: u.name,
url: /^https?:\/\//i.test(u.url.trim()) ? u.url.trim() : `https://${u.url.trim()}`,
}));
return updateService(uuid, { urls: normalized });
}
// ── Coolify Service Templates (one-click catalog) ────────────────────
//
// Coolify maintains a curated catalog of 320+ deployable services
// (CRMs, AI tools, CMSes, dashboards, databases, etc). Each entry has
// a slug ("twenty", "n8n", "supabase"), a slogan, tags, a logo path
// and a base64-encoded docker-compose.yml.
//
// Coolify itself does NOT expose this catalog over its REST API — the
// Laravel helper `get_service_templates()` reads it directly from
// `/var/www/html/templates/service-templates.json`. To avoid SSH-ing
// into the Coolify host every time, we fetch the canonical JSON from
// the upstream repo and cache it in memory for an hour.
//
// Source of truth:
// https://github.com/coollabsio/coolify/blob/main/templates/service-templates.json
export interface CoolifyServiceTemplate {
/** Slug used as the `type` in `POST /services` (e.g. "twenty"). */
slug: string;
/** One-line marketing description from upstream. */
slogan?: string;
/** Free-form tags ("crm", "ai", "wiki", …). */
tags?: string[];
/** Coarse category (often missing). */
category?: string;
/** Path under coollabsio/coolify-static/svgs (no full URL). */
logo?: string;
/** Default HTTP port the primary service listens on. */
port?: number;
/** Documentation URL provided by the template author. */
documentation?: string;
/** Minimum Coolify version required. */
minversion?: string;
}
const TEMPLATES_URL =
process.env.COOLIFY_TEMPLATES_URL ??
'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json';
let templatesCache: { fetchedAt: number; data: Record<string, CoolifyServiceTemplate> } | null = null;
const TEMPLATES_TTL_MS = 60 * 60 * 1000;
/**
* Fetch the full Coolify service template catalog. In-memory cache for
* one hour because the upstream JSON is ~1MB and only changes when
* Coolify ships new templates.
*/
export async function listServiceTemplates(opts: { force?: boolean } = {}): Promise<Record<string, CoolifyServiceTemplate>> {
const now = Date.now();
if (!opts.force && templatesCache && now - templatesCache.fetchedAt < TEMPLATES_TTL_MS) {
return templatesCache.data;
}
const res = await fetch(TEMPLATES_URL, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch service templates: ${res.status} ${res.statusText}`);
const raw = await res.json() as Record<string, Record<string, unknown>>;
const data: Record<string, CoolifyServiceTemplate> = {};
for (const [slug, t] of Object.entries(raw)) {
data[slug] = {
slug,
slogan: typeof t.slogan === 'string' ? t.slogan : undefined,
tags: Array.isArray(t.tags) ? t.tags.filter((x): x is string => typeof x === 'string') : undefined,
category: typeof t.category === 'string' ? t.category : undefined,
logo: typeof t.logo === 'string' ? t.logo : undefined,
// Coolify's catalog stores port as either a number (e.g. 3000)
// or a numeric string (e.g. "3000") — handle both.
port: typeof t.port === 'number'
? t.port
: typeof t.port === 'string' && /^\d+$/.test(t.port.trim())
? Number(t.port.trim())
: undefined,
documentation: typeof t.documentation === 'string' ? t.documentation : undefined,
minversion: typeof t.minversion === 'string' ? t.minversion : undefined,
};
}
templatesCache = { fetchedAt: now, data };
return data;
}
/**
* Search the template catalog by slug, slogan, or tag (case-insensitive
* substring match). Returns at most `limit` matches sorted by:
* 1. exact slug match
* 2. slug starts-with
* 3. tag match
* 4. slogan substring
*/
export async function searchServiceTemplates(
query: string,
opts: { limit?: number; tag?: string } = {},
): Promise<CoolifyServiceTemplate[]> {
const all = await listServiceTemplates();
const q = query.trim().toLowerCase();
const tagFilter = opts.tag?.trim().toLowerCase();
const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
const scored: Array<{ score: number; t: CoolifyServiceTemplate }> = [];
for (const t of Object.values(all)) {
if (tagFilter && !(t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter))) continue;
let score = 0;
const slug = t.slug.toLowerCase();
const slogan = (t.slogan ?? '').toLowerCase();
const tags = (t.tags ?? []).map(x => x.toLowerCase());
if (!q) {
score = 1; // tag-only filter — include everything that passed
} else if (slug === q) score = 1000;
else if (slug.startsWith(q)) score = 500;
else if (slug.includes(q)) score = 300;
else if (tags.includes(q)) score = 200;
else if (tags.some(x => x.includes(q))) score = 100;
else if (slogan.includes(q)) score = 50;
if (score > 0) scored.push({ score, t });
}
scored.sort((a, b) => b.score - a.score || a.t.slug.localeCompare(b.t.slug));
return scored.slice(0, limit).map(s => s.t);
}
export async function deleteService(
uuid: string,
opts: {
deleteConfigurations?: boolean;
deleteVolumes?: boolean;
dockerCleanup?: boolean;
deleteConnectedNetworks?: boolean;
} = {}
): Promise<void> {
const q = new URLSearchParams();
if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations));
if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes));
if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup));
if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks));
const qs = q.toString();
await coolifyFetch(`/services/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' });
}
// ──────────────────────────────────────────────────
// Tenant helpers — every endpoint that returns an app/db/service runs
// through one of these so cross-project access is impossible.
//
// Coolify v4's /applications, /databases and /services list endpoints do NOT
// include `project_uuid` on the returned resources. The authoritative link is
// `environment_id`, which we match against the project's environments (fetched
// via /projects/{uuid}).
//
// For efficient listing per project, we use `/projects/{uuid}/{envName}` which
// returns the resources scoped to that project+environment in one call.
// ──────────────────────────────────────────────────
export class TenantError extends Error {
status = 403 as const;
}
function envIdOf(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService
): number | null {
return (
(typeof resource.environment_id === 'number' ? resource.environment_id : null) ??
(resource.environment && typeof resource.environment.id === 'number'
? resource.environment.id
: null)
);
}
function explicitProjectUuidOf(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService
): string | null {
return (
resource.project_uuid ??
resource.environment?.project_uuid ??
resource.environment?.project?.uuid ??
null
);
}
/** Fetch the set of environment IDs that belong to a project. */
async function projectEnvIds(projectUuid: string): Promise<Set<number>> {
const project = await getProject(projectUuid);
const ids = new Set<number>();
for (const env of project.environments ?? []) {
if (typeof env.id === 'number') ids.add(env.id);
}
return ids;
}
async function ensureResourceInProject(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
resourceKind: string,
expectedProjectUuid: string
): Promise<void> {
const explicit = explicitProjectUuidOf(resource);
if (explicit && explicit === expectedProjectUuid) return;
if (explicit && explicit !== expectedProjectUuid) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`
);
}
const envId = envIdOf(resource);
if (envId == null) {
throw new TenantError(
`${resourceKind} ${resource.uuid} has no environment_id; cannot verify project ${expectedProjectUuid}`
);
}
const envIds = await projectEnvIds(expectedProjectUuid);
if (!envIds.has(envId)) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`
);
}
}
export async function getApplicationInProject(
appUuid: string,
expectedProjectUuid: string
): Promise<CoolifyApplication> {
const app = await getApplication(appUuid);
await ensureResourceInProject(app, 'Application', expectedProjectUuid);
return app;
}
export async function getDatabaseInProject(
dbUuid: string,
expectedProjectUuid: string
): Promise<CoolifyDatabase> {
const db = await getDatabase(dbUuid);
await ensureResourceInProject(db, 'Database', expectedProjectUuid);
return db;
}
export async function getServiceInProject(
serviceUuid: string,
expectedProjectUuid: string
): Promise<CoolifyService> {
const svc = await getService(serviceUuid);
await ensureResourceInProject(svc, 'Service', expectedProjectUuid);
return svc;
}
// ──────────────────────────────────────────────────
// Workspace-set-aware variants
// ──────────────────────────────────────────────────
// Each Vibn workspace owns multiple Coolify projects (the legacy
// `vibn-ws-{slug}` plus one per Vibn project: `vibn-{slug}-{project-slug}`).
// These helpers verify a resource belongs to ANY of the workspace's owned
// projects — used by single-resource MCP tools (apps.get/delete/exec/etc.)
// so they keep working after we shifted to per-project Coolify projects.
async function ensureResourceInWorkspaceProjects(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
resourceKind: string,
ownedProjectUuids: Set<string>,
): Promise<void> {
if (ownedProjectUuids.size === 0) {
throw new TenantError(`${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`);
}
const explicit = explicitProjectUuidOf(resource);
if (explicit && ownedProjectUuids.has(explicit)) return;
if (explicit && !ownedProjectUuids.has(explicit)) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to this workspace`,
);
}
const envId = envIdOf(resource);
if (envId == null) {
throw new TenantError(
`${resourceKind} ${resource.uuid} has no environment_id; cannot verify workspace membership`,
);
}
// Build env_id → project_uuid map from all owned projects (parallel fetch).
const projects = await Promise.allSettled(
Array.from(ownedProjectUuids).map((uuid) => getProject(uuid)),
);
const allowedEnvIds = new Set<number>();
for (const r of projects) {
if (r.status === 'fulfilled') {
for (const env of r.value.environments ?? []) {
if (typeof env.id === 'number') allowedEnvIds.add(env.id);
}
}
}
if (!allowedEnvIds.has(envId)) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to this workspace`,
);
}
}
export async function getApplicationInWorkspace(
appUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyApplication> {
const app = await getApplication(appUuid);
await ensureResourceInWorkspaceProjects(app, 'Application', ownedProjectUuids);
return app;
}
export async function getDatabaseInWorkspace(
dbUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyDatabase> {
const db = await getDatabase(dbUuid);
await ensureResourceInWorkspaceProjects(db, 'Database', ownedProjectUuids);
return db;
}
export async function getServiceInWorkspace(
serviceUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyService> {
const svc = await getService(serviceUuid);
await ensureResourceInWorkspaceProjects(svc, 'Service', ownedProjectUuids);
return svc;
}
/**
* Response shape of GET /projects/{uuid}/{envName}.
* Coolify splits databases by flavor across sibling arrays.
*/
interface CoolifyProjectEnvResources {
id: number;
uuid: string;
name: string;
applications?: CoolifyApplication[];
services?: CoolifyService[];
postgresqls?: CoolifyDatabase[];
mysqls?: CoolifyDatabase[];
mariadbs?: CoolifyDatabase[];
mongodbs?: CoolifyDatabase[];
redis?: CoolifyDatabase[];
keydbs?: CoolifyDatabase[];
dragonflies?: CoolifyDatabase[];
clickhouses?: CoolifyDatabase[];
}
/** Maps Coolify's plural endpoint key → the engine label we want to
* surface on every CoolifyDatabase record. Coolify's flattened
* per-resource shape doesn't include `type`, so we derive it from
* whichever array we pulled the row out of. */
const DB_ARRAY_KEYS_TO_TYPE: Array<{ key: keyof CoolifyProjectEnvResources; type: string }> = [
{ key: 'postgresqls', type: 'postgresql' },
{ key: 'mysqls', type: 'mysql' },
{ key: 'mariadbs', type: 'mariadb' },
{ key: 'mongodbs', type: 'mongodb' },
{ key: 'redis', type: 'redis' },
{ key: 'keydbs', type: 'keydb' },
{ key: 'dragonflies', type: 'dragonfly' },
{ key: 'clickhouses', type: 'clickhouse' },
];
async function getProjectEnvResources(
projectUuid: string,
envName: string
): Promise<CoolifyProjectEnvResources> {
return coolifyFetch(`/projects/${projectUuid}/${encodeURIComponent(envName)}`);
}
async function forEachEnv<T>(
projectUuid: string,
collect: (envResources: CoolifyProjectEnvResources) => T[]
): Promise<T[]> {
const project = await getProject(projectUuid);
const out: T[] = [];
for (const env of project.environments ?? []) {
const envResources = await getProjectEnvResources(projectUuid, env.name);
out.push(...collect(envResources));
}
return out;
}
export async function listApplicationsInProject(
projectUuid: string
): Promise<CoolifyApplication[]> {
return forEachEnv(projectUuid, r => r.applications ?? []);
}
export async function listDatabasesInProject(
projectUuid: string
): Promise<CoolifyDatabase[]> {
return forEachEnv(projectUuid, r => {
const out: CoolifyDatabase[] = [];
for (const { key, type } of DB_ARRAY_KEYS_TO_TYPE) {
const arr = r[key];
if (!Array.isArray(arr)) continue;
for (const db of arr as CoolifyDatabase[]) {
// Always tag with the engine we derived from the array key —
// Coolify itself doesn't set `type` on the individual record.
out.push({ ...db, type: db.type ?? type });
}
}
return out;
});
}
export async function listServicesInProject(
projectUuid: string
): Promise<CoolifyService[]> {
return forEachEnv(projectUuid, r => r.services ?? []);
}
/** @deprecated Use getApplicationInProject / ensureResourceInProject instead. */
export function projectUuidOf(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService
): string | null {
return explicitProjectUuidOf(resource);
}
// ──────────────────────────────────────────────────
// util
// ──────────────────────────────────────────────────
function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
const out: Partial<T> = {};
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined) (out as Record<string, unknown>)[k] = v;
}
return out;
}
export { COOLIFY_DEFAULT_SERVER_UUID, COOLIFY_DEFAULT_DESTINATION_UUID };