/** * 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; const out: Record = {}; 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 { return coolifyFetch('/projects'); } export async function createProject(name: string, description?: string): Promise { return coolifyFetch('/projects', { method: 'POST', body: JSON.stringify({ name, description }), }); } export async function getProject(uuid: string): Promise { return coolifyFetch(`/projects/${uuid}`); } export async function deleteProject(uuid: string): Promise { await coolifyFetch(`/projects/${uuid}`, { method: 'DELETE' }); } // ────────────────────────────────────────────────── // Servers // ────────────────────────────────────────────────── export async function listServers(): Promise { return coolifyFetch('/servers'); } // ────────────────────────────────────────────────── // Private keys (SSH deploy keys) // ────────────────────────────────────────────────── export async function listPrivateKeys(): Promise { return coolifyFetch('/security/keys'); } export async function createPrivateKey(opts: { name: string; privateKeyPem: string; description?: string; }): Promise { 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 { 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; /** 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 = { 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 { return coolifyFetch('/databases'); } export async function getDatabase(uuid: string): Promise { return coolifyFetch(`/databases/${uuid}`); } export async function updateDatabase(uuid: string, patch: Record): 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 { 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 { await coolifyFetch(`/databases/${uuid}/start`, { method: 'POST' }); } export async function stopDatabase(uuid: string): Promise { await coolifyFetch(`/databases/${uuid}/stop`, { method: 'POST' }); } export async function restartDatabase(uuid: string): Promise { 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 }>; } export async function createDockerComposeApp( opts: CreateDockerComposeAppOpts, ): Promise<{ uuid: string }> { // NOTE: the /applications/dockercompose endpoint has a restricted // field allowlist vs the PATCH endpoint. It rejects `build_pack` // (hardcoded to "dockercompose"), `is_force_https_enabled`, and // `docker_compose_domains` — those must be set via PATCH afterward. 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, // Coolify requires docker_compose_raw to be base64-encoded 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 }; // Coolify creates apps asynchronously. Wait briefly before PATCHing // so the record is committed to the DB. if (opts.composeDomains && opts.composeDomains.length > 0) { await new Promise(r => setTimeout(r, 2500)); await coolifyFetch(`/applications/${created.uuid}`, { method: 'PATCH', body: JSON.stringify(stripUndefined({ is_force_https_enabled: opts.isForceHttpsEnabled ?? true, docker_compose_domains: JSON.stringify( opts.composeDomains.map(({ service, domain }) => ({ name: service, domain: `https://${domain.replace(/^https?:\/\//, '')}`, })), ), })), }); } return created; } export async function updateApplication( uuid: string, patch: Record ): 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 { 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 { 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 { return coolifyFetch(`/applications/${uuid}`); } export async function startApplication(uuid: string): Promise { await coolifyFetch(`/applications/${uuid}/start`, { method: 'POST' }); } export async function stopApplication(uuid: string): Promise { await coolifyFetch(`/applications/${uuid}/stop`, { method: 'POST' }); } export async function restartApplication(uuid: string): Promise { 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 { // 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 { 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 { return coolifyFetch(`/applications/${uuid}/envs`); } export async function upsertApplicationEnv( uuid: string, env: CoolifyEnvVar & { is_preview?: boolean } ): Promise { // 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 { 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 { return coolifyFetch('/services'); } export async function getService(uuid: string): Promise { 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) }); } export async function deleteService( uuid: string, opts: { deleteConfigurations?: boolean; deleteVolumes?: boolean; dockerCleanup?: boolean; deleteConnectedNetworks?: boolean; } = {} ): Promise { 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> { const project = await getProject(projectUuid); const ids = new Set(); 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 { 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 { const app = await getApplication(appUuid); await ensureResourceInProject(app, 'Application', expectedProjectUuid); return app; } export async function getDatabaseInProject( dbUuid: string, expectedProjectUuid: string ): Promise { const db = await getDatabase(dbUuid); await ensureResourceInProject(db, 'Database', expectedProjectUuid); return db; } export async function getServiceInProject( serviceUuid: string, expectedProjectUuid: string ): Promise { const svc = await getService(serviceUuid); await ensureResourceInProject(svc, 'Service', expectedProjectUuid); 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[]; } const DB_ARRAY_KEYS: Array = [ 'postgresqls', 'mysqls', 'mariadbs', 'mongodbs', 'redis', 'keydbs', 'dragonflies', 'clickhouses', ]; async function getProjectEnvResources( projectUuid: string, envName: string ): Promise { return coolifyFetch(`/projects/${projectUuid}/${encodeURIComponent(envName)}`); } async function forEachEnv( projectUuid: string, collect: (envResources: CoolifyProjectEnvResources) => T[] ): Promise { 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 { return forEachEnv(projectUuid, r => r.applications ?? []); } export async function listDatabasesInProject( projectUuid: string ): Promise { return forEachEnv(projectUuid, r => { const out: CoolifyDatabase[] = []; for (const k of DB_ARRAY_KEYS) { const arr = r[k]; if (Array.isArray(arr)) out.push(...(arr as CoolifyDatabase[])); } return out; }); } export async function listServicesInProject( projectUuid: string ): Promise { 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>(obj: T): Partial { const out: Partial = {}; for (const [k, v] of Object.entries(obj)) { if (v !== undefined) (out as Record)[k] = v; } return out; } export { COOLIFY_DEFAULT_SERVER_UUID, COOLIFY_DEFAULT_DESTINATION_UUID };