Files
vibn-frontend/lib/coolify.ts
Mark Henderson fcd5d03894 fix(apps.create): clone via HTTPS+bot-PAT; activate bot users on creation
Coolify was failing all Gitea clones with "Permission denied (publickey)"
because the helper container's SSH hits git.vibnai.com:22 (Ubuntu host
sshd, which doesn't know Gitea keys), while Gitea's builtin SSH is on
host port 22222 (not publicly reachable).

Rather than fight the SSH topology, switch every Vibn-provisioned app
to clone over HTTPS with the workspace bot's PAT embedded in the URL.
The PAT is already stored encrypted per workspace and scoped to that
org, so this gives equivalent isolation with zero SSH dependency.

Changes:
- lib/naming.ts: add giteaHttpsUrl() + redactGiteaHttpsUrl(); mark
  giteaSshUrl() as deprecated-for-deploys with a comment.
- lib/coolify.ts: extend CreatePublicAppOpts with install/build/start
  commands, base_directory, dockerfile_location, docker_compose_location,
  manual_webhook_secret_gitea so it's at parity with the SSH variant.
- app/api/mcp/route.ts:
  - apps.create now uses createPublicApp(giteaHttpsUrl(...)) and pulls
    the bot PAT via getWorkspaceBotCredentials(). No more private-
    deploy-key path for new apps.
  - apps.update adds git_commit_sha + docker_compose_location to the
    whitelist.
  - New apps.rewire_git tool: re-points an app's git_repository at the
    canonical HTTPS+PAT URL. Unblocks older apps stuck on SSH URLs
    and provides a path for PAT rotation without rebuilding the app.
- lib/gitea.ts: createUser() now issues an immediate PATCH to set
  active: true. Gitea's admin-create endpoint creates users as inactive
  by default, and inactive users fail permission checks even though
  they're org members. GiteaUser gains optional `active` field.
- scripts/activate-workspace-bots.ts: idempotent backfill that flips
  active=true for any existing workspace bot that was created before
  this fix. Safe to re-run.
- AI_CAPABILITIES.md: document apps.rewire_git; clarify apps.create
  uses HTTPS+PAT (no SSH).

Already unblocked in prod for the mark workspace:
- vibn-bot-mark activated.
- twenty-crm's git_repository PATCHed to HTTPS+PAT form; git clone
  now succeeds (remaining unrelated error: docker-compose file path).

Made-with: Cursor
2026-04-23 12:21:00 -07:00

899 lines
30 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 } };
}
/**
* 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> {
return coolifyFetch('/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
}
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';
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),
});
}
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 } = {}
): 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}`;
});
// Coolify API: send `domains` (NOT `fqdn`). The controller maps it to
// the DB's `fqdn` column internally, but only when the destination
// server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false`
// — i.e. when Server::isProxyShouldRun() returns true. If either is
// misconfigured, the controller silently drops the field (PATCH returns
// 200, fqdn unchanged). We hit this on the missinglettr-test app on
// 2026-04-22; the underlying server had proxy.type=null and
// is_build_server=true. Fix is in Coolify server-config (UI/DB), not
// the client. Sending `fqdn` directly is rejected with 422 ("This
// field is not allowed").
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`);
}
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) });
}
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;
}
/**
* 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<keyof CoolifyProjectEnvResources> = [
'postgresqls',
'mysqls',
'mariadbs',
'mongodbs',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
];
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 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<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 };