Files
vibn-frontend/app/api/mcp/route.ts
Mark Henderson 62cb77b5a7 feat(mcp v2.4.1): apps.containers.{up,ps} + auto-fallback for queued-start
Coolify's POST /services/{uuid}/start writes the rendered compose
files but its Laravel queue worker routinely fails to actually
invoke `docker compose up -d`. Until now agents had to SSH to
recover. For an MVP that promises "tell vibn what app you want,
get a URL", that's unacceptable.

- lib/coolify-compose.ts: composeUp/composeDown/composePs over SSH
  via a one-shot docker:cli container that bind-mounts the rendered
  compose dir (works around vibn-logs being in docker group but not
  having read access to /data/coolify/services).
- apps.create (template + composeRaw pathways) now uses
  ensureServiceUp which probes whether Coolify's queue actually
  spawned containers and falls back to direct docker compose up -d
  if not. Result includes startMethod for visibility.
- apps.containers.up / apps.containers.ps exposed as MCP tools for
  recovery scenarios and post-env-change recreations.
- Tenant safety: resolveAppOrService validates uuid against the
  caller's project before touching anything on the host.

Made-with: Cursor
2026-04-23 18:41:42 -07:00

2110 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Vibn MCP HTTP bridge.
*
* Authenticates via a workspace-scoped `vibn_sk_...` token (session
* cookies also work for browser debugging). Every tool call is
* executed inside the bound workspace's tenant boundary — Coolify
* requests verify the app's project uuid, and git credentials are
* pinned to the workspace's Gitea org/bot.
*
* Exposed tools are a stable subset of the Vibn REST API so agents
* have one well-typed entry point regardless of deployment host.
*
* Protocol notes:
* - This is a thin, JSON-over-HTTP MCP shim. The `mcp.json` in a
* user's Cursor config points at this URL and stores the bearer
* token. We keep the shape compatible with MCP clients that
* speak `{ action, params }` calls.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
import {
ensureWorkspaceGcsProvisioned,
getWorkspaceGcsState,
getWorkspaceGcsHmacCredentials,
} from '@/lib/workspace-gcs';
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
import { execInCoolifyApp } from '@/lib/coolify-exec';
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
import { composeUp, composePs, type ResourceKind } from '@/lib/coolify-compose';
import { listContainersForApp } from '@/lib/coolify-containers';
import {
deployApplication,
getApplicationInProject,
listApplicationDeployments,
listApplicationEnvs,
listApplicationsInProject,
projectUuidOf,
TenantError,
upsertApplicationEnv,
deleteApplicationEnv,
// Phase 4 ── create/update/delete + domains + databases + services
createPublicApp,
createDockerImageApp,
createDockerComposeApp,
startService,
getService,
listAllServices,
listServiceEnvs,
upsertServiceEnv,
setServiceDomains,
updateApplication,
deleteApplication,
setApplicationDomains,
listDatabasesInProject,
createDatabase,
getDatabaseInProject,
updateDatabase,
deleteDatabase,
listServicesInProject,
createService,
getServiceInProject,
deleteService,
listServiceTemplates,
searchServiceTemplates,
type CoolifyDatabaseType,
} from '@/lib/coolify';
import { query } from '@/lib/db-postgres';
import { getRepo } from '@/lib/gitea';
import {
giteaHttpsUrl,
isDomainUnderWorkspace,
slugify,
toDomainsString,
workspaceAppFqdn,
} from '@/lib/naming';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
// ──────────────────────────────────────────────────
// Capability descriptor
// ──────────────────────────────────────────────────
export async function GET() {
return NextResponse.json({
name: 'vibn-mcp',
version: '2.4.1',
authentication: {
scheme: 'Bearer',
tokenPrefix: 'vibn_sk_',
description:
'Workspace-scoped token minted at /settings. Every tool call is ' +
'automatically restricted to the workspace the token belongs to.',
},
capabilities: {
tools: {
supported: true,
available: [
'workspace.describe',
'gitea.credentials',
'projects.list',
'projects.get',
'apps.list',
'apps.get',
'apps.create',
'apps.update',
'apps.rewire_git',
'apps.delete',
'apps.deploy',
'apps.deployments',
'apps.domains.list',
'apps.domains.set',
'apps.logs',
'apps.exec',
'apps.volumes.list',
'apps.volumes.wipe',
'apps.containers.up',
'apps.containers.ps',
'apps.templates.list',
'apps.templates.search',
'apps.envs.list',
'apps.envs.upsert',
'apps.envs.delete',
'databases.list',
'databases.create',
'databases.get',
'databases.update',
'databases.delete',
'auth.list',
'auth.create',
'auth.delete',
'domains.search',
'domains.list',
'domains.get',
'domains.register',
'domains.attach',
'storage.describe',
'storage.provision',
'storage.inject_env',
],
},
},
documentation: 'https://vibnai.com/docs/mcp',
});
}
// ──────────────────────────────────────────────────
// Tool dispatcher
// ──────────────────────────────────────────────────
export async function POST(request: Request) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
let body: { action?: string; tool?: string; params?: Record<string, unknown> };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Accept either `{ action, params }` or `{ tool, params }` shapes.
const action = (body.tool ?? body.action ?? '') as string;
const params = (body.params ?? {}) as Record<string, any>;
try {
switch (action) {
case 'workspace.describe':
return NextResponse.json({ result: describeWorkspace(principal) });
case 'gitea.credentials':
return await toolGiteaCredentials(principal);
case 'projects.list':
return await toolProjectsList(principal);
case 'projects.get':
return await toolProjectsGet(principal, params);
case 'apps.list':
return await toolAppsList(principal);
case 'apps.get':
return await toolAppsGet(principal, params);
case 'apps.deploy':
return await toolAppsDeploy(principal, params);
case 'apps.deployments':
return await toolAppsDeployments(principal, params);
case 'apps.envs.list':
return await toolAppsEnvsList(principal, params);
case 'apps.envs.upsert':
return await toolAppsEnvsUpsert(principal, params);
case 'apps.envs.delete':
return await toolAppsEnvsDelete(principal, params);
case 'apps.create':
return await toolAppsCreate(principal, params);
case 'apps.update':
return await toolAppsUpdate(principal, params);
case 'apps.rewire_git':
return await toolAppsRewireGit(principal, params);
case 'apps.delete':
return await toolAppsDelete(principal, params);
case 'apps.domains.list':
return await toolAppsDomainsList(principal, params);
case 'apps.domains.set':
return await toolAppsDomainsSet(principal, params);
case 'apps.logs':
return await toolAppsLogs(principal, params);
case 'apps.exec':
return await toolAppsExec(principal, params);
case 'apps.volumes.list':
return await toolAppsVolumesList(principal, params);
case 'apps.volumes.wipe':
return await toolAppsVolumesWipe(principal, params);
case 'apps.containers.up':
return await toolAppsContainersUp(principal, params);
case 'apps.containers.ps':
return await toolAppsContainersPs(principal, params);
case 'apps.templates.list':
return await toolAppsTemplatesList(params);
case 'apps.templates.search':
return await toolAppsTemplatesSearch(params);
case 'databases.list':
return await toolDatabasesList(principal);
case 'databases.create':
return await toolDatabasesCreate(principal, params);
case 'databases.get':
return await toolDatabasesGet(principal, params);
case 'databases.update':
return await toolDatabasesUpdate(principal, params);
case 'databases.delete':
return await toolDatabasesDelete(principal, params);
case 'auth.list':
return await toolAuthList(principal);
case 'auth.create':
return await toolAuthCreate(principal, params);
case 'auth.delete':
return await toolAuthDelete(principal, params);
case 'domains.search':
return await toolDomainsSearch(principal, params);
case 'domains.list':
return await toolDomainsList(principal);
case 'domains.get':
return await toolDomainsGet(principal, params);
case 'domains.register':
return await toolDomainsRegister(principal, params);
case 'domains.attach':
return await toolDomainsAttach(principal, params);
case 'storage.describe':
return await toolStorageDescribe(principal);
case 'storage.provision':
return await toolStorageProvision(principal);
case 'storage.inject_env':
return await toolStorageInjectEnv(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
{ status: 404 }
);
}
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
console.error('[mcp] tool failed', action, err);
return NextResponse.json(
{ error: 'Tool execution failed', details: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}
// ──────────────────────────────────────────────────
// Tool implementations
// ──────────────────────────────────────────────────
type Principal = Extract<
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
{ source: 'session' | 'api_key' }
>;
function describeWorkspace(principal: Principal) {
const w = principal.workspace;
return {
slug: w.slug,
name: w.name,
coolifyProjectUuid: w.coolify_project_uuid,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
provisionStatus: w.provision_status,
provisionError: w.provision_error,
principal: { source: principal.source, apiKeyId: principal.apiKeyId ?? null },
};
}
async function toolGiteaCredentials(principal: Principal) {
let ws = principal.workspace;
if (!ws.gitea_bot_token_encrypted || !ws.gitea_org) {
ws = await ensureWorkspaceProvisioned(ws);
}
const creds = getWorkspaceBotCredentials(ws);
if (!creds) {
return NextResponse.json(
{ error: 'Workspace has no Gitea bot yet', provisionStatus: ws.provision_status },
{ status: 503 }
);
}
const apiBase = GITEA_API_URL.replace(/\/$/, '');
const host = new URL(apiBase).host;
return NextResponse.json({
result: {
org: creds.org,
username: creds.username,
token: creds.token,
apiBase,
host,
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
},
});
}
async function toolProjectsList(principal: Principal) {
const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>(
`SELECT id, data, created_at, updated_at
FROM fs_projects
WHERE vibn_workspace_id = $1
OR workspace = $2
ORDER BY created_at DESC`,
[principal.workspace.id, principal.workspace.slug]
);
return NextResponse.json({
result: rows.map(r => ({
id: r.id,
name: r.data?.name ?? null,
repo: r.data?.repoName ?? null,
giteaRepo: r.data?.giteaRepo ?? null,
coolifyAppUuid: r.data?.coolifyAppUuid ?? null,
createdAt: r.created_at,
updatedAt: r.updated_at,
})),
});
}
async function toolProjectsGet(principal: Principal, params: Record<string, any>) {
const projectId = String(params.projectId ?? params.id ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'Param "projectId" is required' }, { status: 400 });
}
const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>(
`SELECT id, data, created_at, updated_at
FROM fs_projects
WHERE id = $1
AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 });
}
const r = rows[0];
return NextResponse.json({
result: { id: r.id, data: r.data, createdAt: r.created_at, updatedAt: r.updated_at },
});
}
function requireCoolifyProject(principal: Principal): string | NextResponse {
const projectUuid = principal.workspace.coolify_project_uuid;
if (!projectUuid) {
return NextResponse.json(
{ error: 'Workspace has no Coolify project yet' },
{ status: 503 }
);
}
return projectUuid;
}
async function toolAppsList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
// Fetch Applications and Services in parallel.
// Services are compose stacks created via the composeRaw pathway;
// they live at /services not /applications.
const [apps, allServices] = await Promise.allSettled([
listApplicationsInProject(projectUuid),
listAllServices(),
]);
const appList = apps.status === 'fulfilled' ? apps.value : [];
const serviceList = (allServices.status === 'fulfilled' && Array.isArray(allServices.value)
? (allServices.value as Array<Record<string, unknown>>)
: []
).filter(s => {
const proj = s.project as Record<string, unknown> | undefined;
return proj?.uuid === projectUuid;
});
return NextResponse.json({
result: [
...appList.map(a => ({
uuid: a.uuid,
name: a.name,
status: a.status,
fqdn: a.fqdn ?? null,
gitRepository: a.git_repository ?? null,
gitBranch: a.git_branch ?? null,
resourceType: 'application',
})),
...serviceList.map(s => ({
uuid: String(s.uuid),
name: String(s.name ?? ''),
status: String(s.status ?? 'unknown'),
fqdn: null,
gitRepository: null,
gitBranch: null,
resourceType: 'service',
})),
],
});
}
async function toolAppsGet(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
const app = await getApplicationInProject(appUuid, projectUuid);
return NextResponse.json({ result: app });
}
async function toolAppsDeploy(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
// Try Application deploy first; fall back to Service start
try {
await getApplicationInProject(appUuid, projectUuid);
const { deployment_uuid } = await deployApplication(appUuid);
return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid, resourceType: 'application' } });
} catch (appErr: unknown) {
// Check if it's a Service (compose stack)
try {
const svc = await getService(appUuid);
// Verify it belongs to this workspace's project
const svcProjectUuid = svc.project_uuid
?? svc.environment?.project_uuid
?? svc.environment?.project?.uuid;
if (svcProjectUuid !== projectUuid) {
return NextResponse.json({ error: 'Service not found in this workspace' }, { status: 404 });
}
await startService(appUuid);
return NextResponse.json({ result: { appUuid, resourceType: 'service', message: 'Service start queued' } });
} catch {
// Re-throw original error
throw appErr;
}
}
}
async function toolAppsDeployments(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
const deployments = await listApplicationDeployments(appUuid);
return NextResponse.json({ result: deployments });
}
/**
* Runtime logs for a Coolify app. Compose-aware:
* - Dockerfile/nixpacks apps → Coolify's `/applications/{uuid}/logs`
* - Compose apps → SSH into Coolify host, `docker logs` per service
*
* Params:
* uuid app uuid (required)
* service compose service filter (optional)
* lines tail lines per container, default 200, max 5000
*/
async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
const linesRaw = Number(params.lines ?? 200);
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
const serviceRaw = params.service;
const service = typeof serviceRaw === 'string' && serviceRaw.trim() ? serviceRaw.trim() : undefined;
const result = await getApplicationRuntimeLogs(appUuid, { lines, service });
return NextResponse.json({ result });
}
/**
* apps.exec — run a one-shot command inside an app container.
*
* Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller
* provides `uuid`, an optional `service` (required for compose apps
* with >1 container), and a `command` string. Output is capped at
* 1MB by default and 10-minute wall-clock timeout.
*
* Note: the command is NOT parsed or validated. It's executed as a
* single shell invocation inside the container via `sh -lc`. This is
* deliberate — this tool is the platform's trust-the-agent escape
* hatch for migrations, CLI invocations, and ad-hoc debugging. It's
* authenticated per-workspace (tenant check above) and rate-limited
* by the SSH session's timeout/byte caps.
*/
async function toolAppsExec(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
'apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.',
},
{ status: 501 },
);
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const command = typeof params.command === 'string' ? params.command : '';
if (!appUuid || !command) {
return NextResponse.json(
{ error: 'Params "uuid" and "command" are required' },
{ status: 400 },
);
}
await getApplicationInProject(appUuid, projectUuid);
const service = typeof params.service === 'string' && params.service.trim()
? params.service.trim()
: undefined;
const user = typeof params.user === 'string' && params.user.trim()
? params.user.trim()
: undefined;
const workdir = typeof params.workdir === 'string' && params.workdir.trim()
? params.workdir.trim()
: undefined;
const timeoutMs = Number.isFinite(Number(params.timeout_ms))
? Number(params.timeout_ms)
: undefined;
const maxBytes = Number.isFinite(Number(params.max_bytes))
? Number(params.max_bytes)
: undefined;
try {
const result = await execInCoolifyApp({
appUuid,
service,
command,
user,
workdir,
timeoutMs,
maxBytes,
});
return NextResponse.json({ result });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 400 });
}
}
// ── Volume tools ────────────────────────────────────────────────────────
/**
* apps.volumes.list — list Docker volumes that belong to an app.
* Returns name, size (bytes), and which containers are currently
* using each volume (if any are running).
*/
async function toolAppsVolumesList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 });
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
const res = await runOnCoolifyHost(
`docker volume ls --filter name=${sq(appUuid)} --format '{{.Name}}' | xargs -r -I{} sh -c 'echo "{}|$(docker volume inspect {} --format "{{.UsageData.Size}}" 2>/dev/null || echo -1)"'`,
{ timeoutMs: 12_000 },
);
if (res.code !== 0) {
return NextResponse.json({ error: `docker volume ls failed: ${res.stderr.trim()}` }, { status: 502 });
}
const volumes = res.stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(l => {
const [name, sizeStr] = l.split('|');
const sizeBytes = parseInt(sizeStr ?? '-1', 10);
return { name, sizeBytes: isNaN(sizeBytes) ? -1 : sizeBytes };
});
return NextResponse.json({ result: { volumes } });
}
/**
* apps.volumes.wipe — destroy a Docker volume for this app.
*
* This is a destructive, irreversible operation. The agent MUST pass
* `confirm: "<volume-name>"` exactly matching the volume name, to
* prevent accidents. All containers using the volume are stopped and
* removed first (Coolify will restart them on the next deploy).
*
* Typical use: wipe a stale Postgres data volume before redeploying
* so the database is initialised fresh.
*/
async function toolAppsVolumesWipe(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 });
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const volumeName = String(params.volume ?? '').trim();
const confirm = String(params.confirm ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
if (!volumeName) return NextResponse.json({ error: 'Param "volume" is required (exact volume name from apps.volumes.list)' }, { status: 400 });
if (confirm !== volumeName) {
return NextResponse.json(
{ error: `Param "confirm" must equal the exact volume name "${volumeName}" to proceed` },
{ status: 400 },
);
}
// Security check: volume must belong to this app (name must contain the uuid)
if (!volumeName.includes(appUuid)) {
return NextResponse.json(
{ error: `Volume "${volumeName}" does not appear to belong to app ${appUuid}` },
{ status: 403 },
);
}
await getApplicationInProject(appUuid, projectUuid);
// Stop + remove all containers using this volume, then remove the volume
const cmd = [
// Stop and remove containers for this app (they'll be recreated on next deploy)
`CONTAINERS=$(docker ps -a --filter name=${sq(appUuid)} --format '{{.Names}}')`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker stop -t 10 || true`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker rm -f || true`,
// Remove the volume
`docker volume rm ${sq(volumeName)}`,
`echo "done"`,
].join(' && ');
const res = await runOnCoolifyHost(cmd, { timeoutMs: 30_000 });
if (res.code !== 0 || !res.stdout.includes('done')) {
return NextResponse.json(
{ error: `Volume removal failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.trim()}` },
{ status: 502 },
);
}
return NextResponse.json({
result: {
wiped: volumeName,
message: 'Volume removed. Trigger apps.deploy to restart the app with a fresh volume.',
},
});
}
function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
const envs = await listApplicationEnvs(appUuid);
return NextResponse.json({ result: envs });
}
async function toolAppsEnvsUpsert(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const key = typeof params.key === 'string' ? params.key : '';
const value = typeof params.value === 'string' ? params.value : '';
if (!appUuid || !key) {
return NextResponse.json(
{ error: 'Params "uuid" and "key" are required' },
{ status: 400 }
);
}
await getApplicationInProject(appUuid, projectUuid);
// Coolify v4 rejects `is_build_time` on POST/PATCH (it's a derived
// read-only flag now). Silently drop it here so agents that still send
// it don't get a surprise 422. See lib/coolify.ts upsertApplicationEnv
// for the hard enforcement at the network boundary.
const result = await upsertApplicationEnv(appUuid, {
key,
value,
is_preview: !!params.is_preview,
is_literal: !!params.is_literal,
is_multiline: !!params.is_multiline,
is_shown_once: !!params.is_shown_once,
});
const body: Record<string, unknown> = { result };
if (params.is_build_time !== undefined) {
body.warnings = [
'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.',
];
}
return NextResponse.json(body);
}
async function toolAppsEnvsDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const key = typeof params.key === 'string' ? params.key : '';
if (!appUuid || !key) {
return NextResponse.json(
{ error: 'Params "uuid" and "key" are required' },
{ status: 400 }
);
}
await getApplicationInProject(appUuid, projectUuid);
await deleteApplicationEnv(appUuid, key);
return NextResponse.json({ result: { ok: true, key } });
}
// ──────────────────────────────────────────────────
// Phase 4: apps create/update/delete + domains
// ──────────────────────────────────────────────────
/**
* apps.create — four distinct pathways depending on what you pass:
*
* 1. Gitea repo (for user-owned custom apps)
* Required: repo
* Optional: branch, buildPack, ports, domain, envs, …
*
* 2. Docker image from a registry (no repo, no build)
* Required: image e.g. "nginx:alpine", "twentyhq/twenty:1.23.0"
* Optional: name, domain, ports, envs
*
* 3. Inline Docker Compose YAML (no repo, no build)
* Required: composeRaw (the full docker-compose.yml contents as a string)
* Optional: name, domain, composeDomains, envs
*
* 4. **Coolify one-click template** (RECOMMENDED for popular apps)
* Required: template e.g. "twenty", "n8n", "supabase", "ghost"
* Optional: name, domain, envs
* Discoverable via apps.templates.list / apps.templates.search.
* Coolify ships 320+ vetted templates (CRMs, AI tools, CMSes, etc).
* Each template has battle-tested env defaults, healthchecks, and
* `depends_on` graphs — far more reliable than hand-rolling a
* composeRaw payload for the same app.
*
* Pathway 1 is for code in the workspace's Gitea org. Pathways 2/3/4
* deploy third-party apps without creating a Gitea repo.
*/
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Coolify project)' },
{ status: 503 }
);
}
const commonOpts = {
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isForceHttpsEnabled: true,
instantDeploy: false,
};
// ── Pathway 4: Coolify one-click template ─────────────────────────────
// Most reliable path for popular third-party apps. Coolify maintains
// a curated catalog at templates/service-templates.json — each entry
// has tested env defaults and a working compose graph.
if (params.template) {
const templateSlug = String(params.template).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_-]*$/.test(templateSlug)) {
return NextResponse.json({ error: 'Invalid template slug' }, { status: 400 });
}
// Validate slug exists so we fail fast with a useful error rather
// than relaying Coolify's generic "Service not found".
const catalog = await listServiceTemplates();
if (!catalog[templateSlug]) {
return NextResponse.json({
error: `Unknown template "${templateSlug}". Use apps.templates.search to find valid slugs.`,
}, { status: 404 });
}
const appName = slugify(String(params.name ?? templateSlug));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createService({
projectUuid: commonOpts.projectUuid,
serverUuid: commonOpts.serverUuid,
environmentName: commonOpts.environmentName,
destinationUuid: commonOpts.destinationUuid,
type: templateSlug,
name: appName,
description: params.description ? String(params.description) : undefined,
// Don't ask Coolify to instantly deploy — its queued worker has
// intermittent issues and we want to set the FQDN + envs first.
instantDeploy: false,
});
// Coolify auto-assigns sslip.io URLs. Replace them with the
// user's FQDN. We rebuild the urls array by reading the service
// back to learn the docker-compose service names (template-specific).
let urlsApplied = false;
try {
// Brief settle so the service is fully committed
await new Promise(r => setTimeout(r, 1500));
const svc = await getService(created.uuid) as Record<string, unknown>;
// Coolify stores per-service urls under different shapes across versions:
// - service.fqdn : "https://x.sslip.io,https://y.sslip.io"
// - service.urls : [{ name, url }]
// For simplicity, target the docker-compose service named after
// the template slug (covers ~90% of templates: twenty, n8n, ghost,
// wordpress, etc). Users can adjust later via apps.domains.set.
await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]);
urlsApplied = true;
void svc; // reserved for future heuristic
} catch (e) {
console.warn('[mcp apps.create/template] setServiceDomains failed', e);
}
// Apply user-provided envs (e.g. POSTGRES_PASSWORD overrides)
if (params.envs && typeof params.envs === 'object') {
const envEntries = Object.entries(params.envs as Record<string, unknown>)
.filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k))
.map(([key, value]) => ({ key, value: String(value) }));
for (const env of envEntries) {
try { await upsertServiceEnv(created.uuid, env); }
catch (e) { console.warn('[mcp apps.create/template] upsert env failed', env.key, e); }
}
}
let started = false;
let startMethod: 'coolify-queue' | 'compose-up' | 'failed' = 'failed';
let startDiag = '';
if (params.instantDeploy !== false) {
({ started, startMethod, diag: startDiag } = await ensureServiceUp(created.uuid));
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
resourceType: 'service',
template: templateSlug,
urlsApplied,
started,
startMethod,
...(startDiag ? { startDiag } : {}),
note: started
? 'Containers are up. First boot may take 1-5 min while images finish pulling and migrations run. Use apps.logs to monitor.'
: 'Service created but containers did not start. Call apps.containers.up to retry, or apps.logs to diagnose.',
},
});
}
// ── Pathway 2: Docker image ───────────────────────────────────────────
if (params.image) {
const image = String(params.image).trim();
const appName = slugify(String(params.name ?? image.split('/').pop()?.split(':')[0] ?? 'app'));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createDockerImageApp({
...commonOpts,
image,
name: appName,
portsExposes: String(params.ports ?? '80'),
domains: toDomainsString([fqdn]),
description: params.description ? String(params.description) : undefined,
});
await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } });
}
// ── Pathway 3: Inline Docker Compose (creates a Coolify Service) ────
if (params.composeRaw) {
const composeRaw = String(params.composeRaw).trim();
const appName = slugify(String(params.name ?? 'app'));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createDockerComposeApp({
...commonOpts,
composeRaw,
name: appName,
description: params.description ? String(params.description) : undefined,
});
// Services use /services/{uuid}/envs — upsert each env var
if (params.envs && typeof params.envs === 'object') {
const envEntries = Object.entries(params.envs as Record<string, unknown>)
.filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k))
.map(([key, value]) => ({ key, value: String(value) }));
if (envEntries.length > 0) {
try {
// Wait briefly for Coolify to commit the service to DB
await new Promise(r => setTimeout(r, 2000));
for (const env of envEntries) {
await upsertServiceEnv(created.uuid, env);
}
} catch (e) {
console.warn('[mcp apps.create/composeRaw] upsert service env failed', e);
}
}
}
let started = false;
let startMethod: 'coolify-queue' | 'compose-up' | 'failed' = 'failed';
let startDiag = '';
if (params.instantDeploy !== false) {
({ started, startMethod, diag: startDiag } = await ensureServiceUp(created.uuid));
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
resourceType: 'service',
started,
startMethod,
...(startDiag ? { startDiag } : {}),
note: 'Domain routing for compose services must be configured after initial startup — set SERVER_URL env to the desired URL, then call apps.containers.up to apply.',
},
});
}
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
if (!ws.gitea_org) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `template` (recommended), `image`, or `composeRaw` instead of `repo`.' },
{ status: 503 }
);
}
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
{ error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' },
{ status: 503 }
);
}
const repoIn = String(params.repo ?? '').trim();
if (!repoIn) {
return NextResponse.json(
{ error: 'One of `repo`, `image`, or `composeRaw` is required' },
{ status: 400 }
);
}
const parts = repoIn.replace(/\.git$/, '').split('/');
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
const repoName = parts.length === 2 ? parts[1] : parts[0];
if (repoOrg !== ws.gitea_org) {
return NextResponse.json(
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
{ status: 403 }
);
}
const repo = await getRepo(repoOrg, repoName);
if (!repo) {
return NextResponse.json({ error: `Repo ${repoOrg}/${repoName} not found in Gitea` }, { status: 404 });
}
const appName = slugify(String(params.name ?? repoName));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createPublicApp({
...commonOpts,
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
gitBranch: String(params.branch ?? repo.default_branch ?? 'main'),
portsExposes: String(params.ports ?? '3000'),
buildPack: (params.buildPack as any) ?? 'nixpacks',
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined,
dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined,
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
});
const dep = await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
deploymentUuid: dep,
},
});
}
// ──────────────────────────────────────────────────
// apps.containers.* — direct lifecycle for compose stacks
// ──────────────────────────────────────────────────
//
// These bypass Coolify's queued-start worker (which is unreliable for
// compose Services) and run `docker compose up -d` / `ps` against the
// rendered compose dir on the Coolify host. Used as the recovery
// path when Coolify's start API returns "queued" but no containers
// materialise.
//
// Tenant safety: the uuid is resolved via getApplicationInProject /
// getServiceInProject, so a workspace can't drive containers it
// doesn't own.
/** Resolve a uuid to either an Application or a compose Service in the
* caller's project. Returns the canonical resource kind for
* coolify-compose helpers. NextResponse on policy error / not found. */
async function resolveAppOrService(
principal: Principal,
uuid: string,
): Promise<{ uuid: string; kind: ResourceKind } | NextResponse> {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
try {
await getApplicationInProject(uuid, projectUuid);
return { uuid, kind: 'application' };
} catch (e) {
if (!(e instanceof Error && /404|not found/i.test(e.message))) {
// Tenant errors and other unexpected ones — surface them
if (e instanceof TenantError) return NextResponse.json({ error: e.message }, { status: 403 });
throw e;
}
}
try {
await getServiceInProject(uuid, projectUuid);
return { uuid, kind: 'service' };
} catch (e) {
if (e instanceof TenantError) {
return NextResponse.json({ error: e.message }, { status: 403 });
}
return NextResponse.json({ error: `App or service ${uuid} not found in this workspace` }, { status: 404 });
}
}
/**
* apps.containers.up — `docker compose up -d` against the rendered
* compose dir on the Coolify host.
*
* Use when Coolify's queued-start left the stack in "Created" or
* "no containers" state, or after editing env vars / domains to
* apply the changes (compose env file is regenerated; containers
* need to be recreated to pick it up).
*
* Idempotent — already-running containers are no-op'd. Returns
* `{ ok, code, stdout, stderr, durationMs }` so agents can show the
* user what happened.
*/
async function toolAppsContainersUp(principal: Principal, params: Record<string, any>) {
const uuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.containers.up requires SSH to the Coolify host' }, { status: 501 });
}
const resolved = await resolveAppOrService(principal, uuid);
if (resolved instanceof NextResponse) return resolved;
const t0 = Date.now();
const r = await composeUp(resolved.kind, resolved.uuid, { timeoutMs: 600_000 });
return NextResponse.json({
result: {
ok: r.code === 0,
code: r.code,
stdout: r.stdout.slice(-4000),
stderr: r.stderr.slice(-4000),
truncated: r.truncated,
durationMs: Date.now() - t0,
},
});
}
/**
* apps.containers.ps — `docker compose ps -a` for diagnostics.
*
* Returns a one-line-per-container summary including names, image,
* state, and exit codes. Use to check whether containers are stuck
* in `Created` (Coolify queued-start failure) vs `Exited` (app crash)
* vs `Restarting` (boot loop).
*/
async function toolAppsContainersPs(principal: Principal, params: Record<string, any>) {
const uuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.containers.ps requires SSH to the Coolify host' }, { status: 501 });
}
const resolved = await resolveAppOrService(principal, uuid);
if (resolved instanceof NextResponse) return resolved;
const r = await composePs(resolved.kind, resolved.uuid);
return NextResponse.json({
result: {
ok: r.code === 0,
stdout: r.stdout.slice(-4000),
stderr: r.stderr.slice(-2000),
},
});
}
// ──────────────────────────────────────────────────
// apps.templates.* — Coolify one-click catalog browse
// ──────────────────────────────────────────────────
//
// Coolify ships ~320 vetted service templates (CRMs, AI, CMS, etc).
// These tools let agents discover what's available so they can pass
// the right slug to apps.create({ template: "..." }).
/**
* apps.templates.list — paginate the full catalog.
*
* Params:
* limit number, default 50, max 500
* offset number, default 0
* tag string, optional — restrict to templates whose tags include this substring
*
* Result: { total, items: CoolifyServiceTemplate[] }
*
* The catalog is large (~320 entries), so use apps.templates.search
* when you know what you're looking for.
*/
async function toolAppsTemplatesList(params: Record<string, any>) {
const all = await listServiceTemplates();
const tagFilter = (params.tag ? String(params.tag).trim().toLowerCase() : '');
const limit = Math.max(1, Math.min(Number(params.limit ?? 50) || 50, 500));
const offset = Math.max(0, Number(params.offset ?? 0) || 0);
let entries = Object.values(all);
if (tagFilter) {
entries = entries.filter(t => (t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter)));
}
entries.sort((a, b) => a.slug.localeCompare(b.slug));
return NextResponse.json({
result: {
total: entries.length,
offset,
limit,
items: entries.slice(offset, offset + limit),
},
});
}
/**
* apps.templates.search — find templates by name, tag, or slogan.
*
* Params:
* query string (required) — case-insensitive substring; matches
* slug > tag > slogan in priority order
* tag string, optional — additional tag filter
* limit number, default 25, max 100
*
* Result: { items: CoolifyServiceTemplate[] }
*
* Examples:
* { query: "twenty" } → [{ slug: "twenty", ... }]
* { query: "wordpress" } → 4 wordpress variants
* { query: "", tag: "crm" } → all CRM templates
* { query: "ai", tag: "vector" } → vector DBs
*/
async function toolAppsTemplatesSearch(params: Record<string, any>) {
const query = String(params.query ?? '').trim();
const tag = params.tag ? String(params.tag).trim() : undefined;
if (!query && !tag) {
return NextResponse.json({ error: 'Either `query` or `tag` is required' }, { status: 400 });
}
const limit = Math.max(1, Math.min(Number(params.limit ?? 25) || 25, 100));
const items = await searchServiceTemplates(query, { tag, limit });
return NextResponse.json({ result: { items } });
}
/**
* Ensure a Coolify Service is actually running (containers exist and
* are healthy/starting), with a fallback path for Coolify's flaky
* queued-start worker.
*
* Strategy:
* 1. Call POST /services/{uuid}/start so Coolify's records show
* "starting" and any internal hooks fire.
* 2. Wait briefly, then probe the host for any container belonging
* to this service via `docker ps --filter name={uuid}`.
* 3. If no containers materialised, run `docker compose up -d`
* directly via SSH against the rendered compose dir. This is
* the same command Coolify's worker would run; we just bypass
* the unreliable queue.
*
* Returns:
* started true if at least one container is running for this service
* startMethod which path got us there
* diag human-readable note for failures (truncated stderr)
*/
async function ensureServiceUp(uuid: string): Promise<{
started: boolean;
startMethod: 'coolify-queue' | 'compose-up' | 'failed';
diag: string;
}> {
// 1. Ask Coolify nicely
try {
await startService(uuid);
} catch (e) {
console.warn('[ensureServiceUp] startService failed (will fall back)', e);
}
// 2. Probe — has the queue actually started anything?
if (!isCoolifySshConfigured()) {
return { started: true, startMethod: 'coolify-queue', diag: '' };
}
// Allow up to ~12s for the worker to wake up; checking every 3s.
for (let i = 0; i < 4; i++) {
await new Promise(r => setTimeout(r, 3_000));
try {
const probe = await runOnCoolifyHost(
`docker ps --filter name=${uuid} --format '{{.Names}}'`,
{ timeoutMs: 8_000 },
);
if (probe.stdout.trim().length > 0) {
return { started: true, startMethod: 'coolify-queue', diag: '' };
}
} catch (e) {
console.warn('[ensureServiceUp] probe failed', e);
}
}
// 3. Fallback — run docker compose up -d ourselves
try {
const r = await composeUp('service', uuid, { timeoutMs: 600_000 });
if (r.code === 0) {
return { started: true, startMethod: 'compose-up', diag: '' };
}
// Non-zero exit but compose ran — capture the tail for diagnosis
const tail = (r.stderr || r.stdout).trim().slice(-400);
return { started: false, startMethod: 'failed', diag: tail };
} catch (e) {
return { started: false, startMethod: 'failed', diag: e instanceof Error ? e.message : String(e) };
}
}
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse {
const fqdn = String(domainParam ?? '').trim()
? String(domainParam).replace(/^https?:\/\//, '')
: workspaceAppFqdn(slug, appName);
if (!isDomainUnderWorkspace(fqdn, slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${slug}.vibnai.com` },
{ status: 403 }
);
}
return fqdn;
}
/** Upsert envs then optionally trigger deploy. Returns deploymentUuid or null. */
async function applyEnvsAndDeploy(
appUuid: string,
params: Record<string, any>,
): Promise<string | null> {
if (params.envs && typeof params.envs === 'object') {
for (const [k, v] of Object.entries(params.envs as Record<string, unknown>)) {
if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
try {
await upsertApplicationEnv(appUuid, { key: k, value: String(v) });
} catch (e) {
console.warn('[mcp apps.create] upsert env failed', k, e);
}
}
}
if (params.instantDeploy === false) return null;
try {
const dep = await deployApplication(appUuid);
return dep.deployment_uuid ?? null;
} catch (e) {
console.warn('[mcp apps.create] first deploy failed', e);
return null;
}
}
async function toolAppsUpdate(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
const allowed = new Set([
'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes',
'install_command', 'build_command', 'start_command',
'base_directory', 'dockerfile_location', 'docker_compose_location',
'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image',
]);
// ── Control params (never forwarded to Coolify) ─────────────────────
// `uuid`/`appUuid` identify the target; we've consumed them already.
const control = new Set(['uuid', 'appUuid', 'patch']);
// ── Fields we deliberately DO NOT forward from apps.update ─────────
// Each maps to a different tool; silently dropping them the way we
// used to caused real live-test bugs (PATCH returns ok, nothing
// persists, agent thinks it worked).
const redirected: Record<string, string> = {
fqdn: 'apps.domains.set',
domains: 'apps.domains.set',
docker_compose_domains: 'apps.domains.set',
git_repository: 'apps.rewire_git',
};
// Support both the flat `{ uuid, name, description, ... }` shape and
// the explicit `{ uuid, patch: { name, description, ... } }` shape.
const source: Record<string, unknown> =
params.patch && typeof params.patch === 'object' && !Array.isArray(params.patch)
? (params.patch as Record<string, unknown>)
: params;
const patch: Record<string, unknown> = {};
const ignored: string[] = [];
const rerouted: Array<{ field: string; use: string }> = [];
for (const [k, v] of Object.entries(source)) {
if (v === undefined) continue;
if (control.has(k) && source === params) continue;
if (redirected[k]) { rerouted.push({ field: k, use: redirected[k] }); continue; }
if (allowed.has(k)) { patch[k] = v; continue; }
ignored.push(k);
}
if (Object.keys(patch).length === 0) {
return NextResponse.json(
{
error:
rerouted.length
? 'No updatable fields in params. Some fields must be set via other tools — see `rerouted`.'
: 'No updatable fields in params. See `ignored` and `allowed`.',
rerouted,
ignored,
allowed: [...allowed],
},
{ status: 400 },
);
}
await updateApplication(appUuid, patch);
return NextResponse.json({
result: {
ok: true,
uuid: appUuid,
applied: Object.keys(patch),
// Non-empty `ignored`/`rerouted` are NOT errors but callers need to
// see them; silently dropping unrecognised keys was the original
// "fqdn returns ok but doesn't persist" false-positive.
...(ignored.length ? { ignored } : {}),
...(rerouted.length ? { rerouted } : {}),
},
});
}
/**
* Re-point an app's git_repository at the workspace's canonical
* HTTPS+PAT clone URL. Useful to recover older apps that were created
* with SSH URLs (which don't work on this Gitea topology), or to
* rotate the bot PAT embedded in the URL after a credential cycle.
* The repo name is inferred from the current URL unless `repo` is
* passed explicitly (in `owner/name` form).
*/
async function toolAppsRewireGit(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ws = principal.workspace;
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
{ error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' },
{ status: 503 }
);
}
const app = await getApplicationInProject(appUuid, projectUuid);
let repoOrg: string;
let repoName: string;
if (params.repo) {
const parts = String(params.repo).replace(/\.git$/, '').split('/');
if (parts.length !== 2) {
return NextResponse.json({ error: 'Param "repo" must be "owner/name"' }, { status: 400 });
}
[repoOrg, repoName] = parts;
} else {
const m = (app.git_repository ?? '').match(
/(?:git@[^:]+:|https?:\/\/(?:[^/]+@)?[^/]+\/)([^/]+)\/([^/.]+)(?:\.git)?$/
);
if (!m) {
return NextResponse.json(
{ error: 'Could not infer repo from current git_repository; pass repo="owner/name"' },
{ status: 400 }
);
}
[, repoOrg, repoName] = m;
}
if (repoOrg !== ws.gitea_org) {
return NextResponse.json(
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
{ status: 403 }
);
}
const newUrl = giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token);
await updateApplication(appUuid, { git_repository: newUrl });
return NextResponse.json({
result: {
ok: true,
uuid: appUuid,
repo: `${repoOrg}/${repoName}`,
gitUrlScheme: 'https+pat',
},
});
}
async function toolAppsDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const app = await getApplicationInProject(appUuid, projectUuid);
const confirm = String(params.confirm ?? '');
if (confirm !== app.name) {
return NextResponse.json(
{ error: 'Confirmation required', hint: `Pass confirm=${app.name} to delete` },
{ status: 409 }
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteApplication(appUuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
return NextResponse.json({
result: { ok: true, deleted: { uuid: appUuid, name: app.name, volumesKept: !deleteVolumes } },
});
}
async function toolAppsDomainsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const app = await getApplicationInProject(appUuid, projectUuid);
const raw = (app.domains ?? app.fqdn ?? '') as string;
const list = raw
.split(/[,\s]+/)
.map(s => s.trim())
.filter(Boolean)
.map(s => s.replace(/^https?:\/\//, '').replace(/\/+$/, ''));
return NextResponse.json({ result: { uuid: appUuid, domains: list } });
}
async function toolAppsDomainsSet(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const domainsIn = Array.isArray(params.domains) ? params.domains : [];
if (!appUuid || domainsIn.length === 0) {
return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 });
}
const app = await getApplicationInProject(appUuid, projectUuid);
const normalized: string[] = [];
for (const d of domainsIn) {
if (typeof d !== 'string' || !d.trim()) continue;
const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
if (!isDomainUnderWorkspace(clean, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${clean} must end with .${ws.slug}.vibnai.com` },
{ status: 403 }
);
}
normalized.push(clean);
}
const buildPack = (app.build_pack ?? 'nixpacks') as string;
const composeService =
typeof params.service === 'string' && params.service.trim()
? params.service.trim()
: typeof params.composeService === 'string' && params.composeService.trim()
? params.composeService.trim()
: undefined;
await setApplicationDomains(appUuid, normalized, {
forceOverride: true,
buildPack,
composeService,
});
return NextResponse.json({
result: {
uuid: appUuid,
domains: normalized,
buildPack,
routedTo:
buildPack === 'dockercompose'
? { field: 'docker_compose_domains', service: composeService ?? 'server' }
: { field: 'domains' },
},
});
}
// ──────────────────────────────────────────────────
// Phase 4: databases
// ──────────────────────────────────────────────────
const DB_TYPES: readonly CoolifyDatabaseType[] = [
'postgresql', 'mysql', 'mariadb', 'mongodb',
'redis', 'keydb', 'dragonfly', 'clickhouse',
];
async function toolDatabasesList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const dbs = await listDatabasesInProject(projectUuid);
return NextResponse.json({
result: dbs.map(d => ({
uuid: d.uuid,
name: d.name,
type: d.type ?? null,
status: d.status,
isPublic: d.is_public ?? false,
publicPort: d.public_port ?? null,
})),
});
}
async function toolDatabasesCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType;
if (!DB_TYPES.includes(type)) {
return NextResponse.json(
{ error: `Param "type" must be one of: ${DB_TYPES.join(', ')}` },
{ status: 400 }
);
}
const name = slugify(String(params.name ?? `${type}-${Date.now().toString(36)}`));
const { uuid } = await createDatabase({
type,
name,
description: params.description ? String(params.description) : undefined,
projectUuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isPublic: params.isPublic === true,
publicPort: typeof params.publicPort === 'number' ? params.publicPort : undefined,
image: params.image ? String(params.image) : undefined,
credentials: params.credentials && typeof params.credentials === 'object' ? params.credentials : {},
limits: params.limits && typeof params.limits === 'object' ? params.limits : undefined,
instantDeploy: params.instantDeploy !== false,
});
const db = await getDatabaseInProject(uuid, projectUuid);
return NextResponse.json({
result: {
uuid: db.uuid,
name: db.name,
type: db.type ?? type,
status: db.status,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
},
});
}
async function toolDatabasesGet(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const db = await getDatabaseInProject(uuid, projectUuid);
return NextResponse.json({
result: {
uuid: db.uuid,
name: db.name,
type: db.type ?? null,
status: db.status,
isPublic: db.is_public ?? false,
publicPort: db.public_port ?? null,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
},
});
}
async function toolDatabasesUpdate(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getDatabaseInProject(uuid, projectUuid);
const allowed = new Set(['name', 'description', 'is_public', 'public_port', 'image', 'limits_memory', 'limits_cpus']);
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
if (allowed.has(k) && v !== undefined) patch[k] = v;
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 });
}
await updateDatabase(uuid, patch);
return NextResponse.json({ result: { ok: true, uuid } });
}
async function toolDatabasesDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const db = await getDatabaseInProject(uuid, projectUuid);
const confirm = String(params.confirm ?? '');
if (confirm !== db.name) {
return NextResponse.json(
{ error: 'Confirmation required', hint: `Pass confirm=${db.name} to delete` },
{ status: 409 }
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteDatabase(uuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
return NextResponse.json({
result: { ok: true, deleted: { uuid, name: db.name, volumesKept: !deleteVolumes } },
});
}
// ──────────────────────────────────────────────────
// Phase 4: auth providers (Coolify services, curated allowlist)
// ──────────────────────────────────────────────────
const AUTH_PROVIDERS_MCP: Record<string, string> = {
pocketbase: 'pocketbase',
authentik: 'authentik',
keycloak: 'keycloak',
'keycloak-with-postgres': 'keycloak-with-postgres',
'pocket-id': 'pocket-id',
'pocket-id-with-postgresql': 'pocket-id-with-postgresql',
logto: 'logto',
'supertokens-with-postgresql': 'supertokens-with-postgresql',
};
async function toolAuthList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const all = await listServicesInProject(projectUuid);
const slugs = new Set(Object.values(AUTH_PROVIDERS_MCP));
return NextResponse.json({
result: {
providers: all
.filter(s => {
for (const slug of slugs) {
if (s.name === slug || s.name.startsWith(`${slug}-`)) return true;
}
return false;
})
.map(s => ({ uuid: s.uuid, name: s.name, status: s.status ?? null })),
allowedProviders: Object.keys(AUTH_PROVIDERS_MCP),
},
});
}
async function toolAuthCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const key = String(params.provider ?? '').toLowerCase().trim();
const coolifyType = AUTH_PROVIDERS_MCP[key];
if (!coolifyType) {
return NextResponse.json(
{
error: `Unsupported provider "${key}"`,
allowed: Object.keys(AUTH_PROVIDERS_MCP),
},
{ status: 400 }
);
}
const name = slugify(String(params.name ?? key));
const { uuid } = await createService({
projectUuid,
type: coolifyType,
name,
description: params.description ? String(params.description) : undefined,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
instantDeploy: params.instantDeploy !== false,
});
const svc = await getServiceInProject(uuid, projectUuid);
return NextResponse.json({
result: { uuid: svc.uuid, name: svc.name, provider: key, status: svc.status ?? null },
});
}
async function toolAuthDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const svc = await getServiceInProject(uuid, projectUuid);
const confirm = String(params.confirm ?? '');
if (confirm !== svc.name) {
return NextResponse.json(
{ error: 'Confirmation required', hint: `Pass confirm=${svc.name} to delete` },
{ status: 409 }
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteService(uuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
return NextResponse.json({
result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } },
});
}
// ──────────────────────────────────────────────────
// Phase 5.1: domains (OpenSRS)
// ──────────────────────────────────────────────────
async function toolDomainsSearch(principal: Principal, params: Record<string, any>) {
const namesIn = Array.isArray(params.names) ? params.names : [params.name];
const names = namesIn
.filter((x: unknown): x is string => typeof x === 'string' && x.trim().length > 0)
.map((s: string) => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''));
if (names.length === 0) {
return NextResponse.json({ error: 'Params { names: string[] } or { name: string } required' }, { status: 400 });
}
const period = typeof params.period === 'number' && params.period > 0 ? params.period : 1;
const { checkDomain } = await import('@/lib/opensrs');
const results = await Promise.all(names.map(async (name: string) => {
try {
const r = await checkDomain(name, period);
return {
domain: name,
available: r.available,
price: r.price ?? null,
currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'),
period: r.period ?? period,
};
} catch (err) {
return { domain: name, available: false, error: err instanceof Error ? err.message : String(err) };
}
}));
return NextResponse.json({ result: { mode: process.env.OPENSRS_MODE ?? 'test', results } });
}
async function toolDomainsList(principal: Principal) {
const { listDomainsForWorkspace } = await import('@/lib/domains');
const rows = await listDomainsForWorkspace(principal.workspace.id);
return NextResponse.json({
result: rows.map(r => ({
id: r.id,
domain: r.domain,
tld: r.tld,
status: r.status,
registeredAt: r.registered_at,
expiresAt: r.expires_at,
periodYears: r.period_years,
dnsProvider: r.dns_provider,
})),
});
}
async function toolDomainsGet(principal: Principal, params: Record<string, any>) {
const name = String(params.domain ?? params.name ?? '').trim().toLowerCase();
if (!name) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 });
const { getDomainForWorkspace } = await import('@/lib/domains');
const row = await getDomainForWorkspace(principal.workspace.id, name);
if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
return NextResponse.json({
result: {
id: row.id,
domain: row.domain,
tld: row.tld,
status: row.status,
registrarOrderId: row.registrar_order_id,
periodYears: row.period_years,
registeredAt: row.registered_at,
expiresAt: row.expires_at,
dnsProvider: row.dns_provider,
dnsZoneId: row.dns_zone_id,
dnsNameservers: row.dns_nameservers,
},
});
}
async function toolDomainsRegister(principal: Principal, params: Record<string, any>) {
const raw = String(params.domain ?? '').toLowerCase().trim()
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
}
if (!params.contact || typeof params.contact !== 'object') {
return NextResponse.json({ error: '`contact` object is required (see /api/workspaces/[slug]/domains POST schema)' }, { status: 400 });
}
const { domainTld: tldOf, minPeriodFor, registerDomain, OpenSrsError } = await import('@/lib/opensrs');
const tld = tldOf(raw);
if (tld === 'ca' && !params.ca) {
return NextResponse.json({ error: '.ca requires `ca.cprCategory` and `ca.legalType`' }, { status: 400 });
}
const period = minPeriodFor(tld, typeof params.period === 'number' ? params.period : 1);
const {
createDomainIntent, getDomainForWorkspace, markDomainFailed,
markDomainRegistered, recordDomainEvent,
} = await import('@/lib/domains');
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
if (intent && intent.status === 'active') {
return NextResponse.json({ error: `Domain ${raw} is already registered`, domainId: intent.id }, { status: 409 });
}
if (!intent) {
intent = await createDomainIntent({
workspaceId: principal.workspace.id,
domain: raw,
createdBy: principal.userId,
periodYears: period,
whoisPrivacy: params.whoisPrivacy ?? true,
});
}
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: 'register.attempt',
payload: { period, via: 'mcp', mode: process.env.OPENSRS_MODE ?? 'test' },
});
try {
const result = await registerDomain({
domain: raw,
period,
contact: params.contact,
nameservers: params.nameservers,
whoisPrivacy: params.whoisPrivacy ?? true,
ca: params.ca,
});
const updated = await markDomainRegistered({
domainId: intent.id,
registrarOrderId: result.orderId,
registrarUsername: result.regUsername,
registrarPassword: result.regPassword,
periodYears: period,
pricePaidCents: null,
priceCurrency: process.env.OPENSRS_CURRENCY ?? 'CAD',
registeredAt: new Date(),
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
});
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: 'register.success',
payload: { orderId: result.orderId, period, via: 'mcp' },
});
return NextResponse.json({
result: {
ok: true,
mode: process.env.OPENSRS_MODE ?? 'test',
domain: {
id: updated.id,
domain: updated.domain,
status: updated.status,
registrarOrderId: updated.registrar_order_id,
expiresAt: updated.expires_at,
},
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await markDomainFailed(intent.id, message);
if (err instanceof OpenSrsError) {
return NextResponse.json({ error: 'Registration failed', registrarCode: err.code, details: err.message }, { status: 502 });
}
return NextResponse.json({ error: 'Registration failed', details: message }, { status: 500 });
}
}
async function toolDomainsAttach(principal: Principal, params: Record<string, any>) {
const apex = String(params.domain ?? params.name ?? '').trim().toLowerCase();
if (!apex) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 });
const { getDomainForWorkspace } = await import('@/lib/domains');
const row = await getDomainForWorkspace(principal.workspace.id, apex);
if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
const { attachDomain, AttachError } = await import('@/lib/domain-attach');
try {
const result = await attachDomain(principal.workspace, row, {
appUuid: typeof params.appUuid === 'string' ? params.appUuid : undefined,
ip: typeof params.ip === 'string' ? params.ip : undefined,
cname: typeof params.cname === 'string' ? params.cname : undefined,
subdomains: Array.isArray(params.subdomains) ? params.subdomains : undefined,
updateRegistrarNs: params.updateRegistrarNs !== false,
});
return NextResponse.json({
result: {
ok: true,
domain: {
id: result.domain.id,
domain: result.domain.domain,
dnsProvider: result.domain.dns_provider,
dnsZoneId: result.domain.dns_zone_id,
dnsNameservers: result.domain.dns_nameservers,
},
zone: result.zone,
records: result.records,
registrarNsUpdate: result.registrarNsUpdate,
coolifyUpdate: result.coolifyUpdate,
},
});
} catch (err) {
if (err instanceof AttachError) {
return NextResponse.json(
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
{ status: err.status },
);
}
console.error('[mcp domains.attach] unexpected', err);
return NextResponse.json(
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
// ──────────────────────────────────────────────────
// Phase 5.3: Object storage (GCS via S3-compatible HMAC)
// ──────────────────────────────────────────────────
/**
* Shape of the S3-compatible credentials we expose to agents.
*
* The HMAC *secret* is never returned here — only the access id and
* the bucket/region/endpoint. Use `storage.inject_env` to push the
* full `{accessId, secret}` pair into a Coolify app's env vars
* server-side, where the secret never leaves our network.
*/
function describeWorkspaceStorage(ws: {
slug: string;
gcs_default_bucket_name: string | null;
gcs_hmac_access_id: string | null;
gcp_service_account_email: string | null;
gcp_provision_status: string | null;
}) {
return {
status: ws.gcp_provision_status ?? 'pending',
bucket: ws.gcs_default_bucket_name,
region: VIBN_GCS_LOCATION,
endpoint: 'https://storage.googleapis.com',
accessKeyId: ws.gcs_hmac_access_id,
serviceAccountEmail: ws.gcp_service_account_email,
note:
'S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. ' +
'The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.',
};
}
async function toolStorageDescribe(principal: Principal) {
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
}
return NextResponse.json({ result: describeWorkspaceStorage(ws) });
}
async function toolStorageProvision(principal: Principal) {
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
return NextResponse.json({ result });
}
/**
* Inject the workspace's storage credentials into a Coolify app as
* env vars, so the app can reach the bucket with any S3 SDK. The
* HMAC secret is read server-side and written directly to Coolify —
* it never transits through the agent or our API response.
*
* Envs written (all tagged is_shown_once so Coolify hides the secret
* in the UI after first render):
* STORAGE_ENDPOINT = https://storage.googleapis.com
* STORAGE_REGION = northamerica-northeast1
* STORAGE_BUCKET = vibn-ws-{slug}-{rand}
* STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id)
* STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once)
* STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS)
*
* Agents can override the env var prefix via `params.prefix`
* (e.g. "S3_" for apps that expect AWS-style names).
*/
async function toolStorageInjectEnv(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
const prefix = String(params.prefix ?? 'STORAGE_');
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {
return NextResponse.json(
{ error: 'Param "prefix" must be uppercase ASCII (letters, digits, underscores)' },
{ status: 400 },
);
}
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
if (ws.gcp_provision_status !== 'ready' || !ws.gcs_default_bucket_name) {
return NextResponse.json(
{
error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`,
},
{ status: 409 },
);
}
const creds = getWorkspaceGcsHmacCredentials(ws);
if (!creds) {
return NextResponse.json(
{ error: 'Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.' },
{ status: 409 },
);
}
const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [
{ key: `${prefix}ENDPOINT`, value: 'https://storage.googleapis.com' },
{ key: `${prefix}REGION`, value: VIBN_GCS_LOCATION },
{ key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name },
{ key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId },
{ key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true },
{ key: `${prefix}FORCE_PATH_STYLE`, value: 'true' },
];
const written: string[] = [];
const failed: Array<{ key: string; error: string }> = [];
for (const e of entries) {
try {
await upsertApplicationEnv(appUuid, {
key: e.key,
value: e.value,
is_shown_once: e.shownOnce ?? false,
});
written.push(e.key);
} catch (err) {
failed.push({ key: e.key, error: err instanceof Error ? err.message : String(err) });
}
}
return NextResponse.json({
result: {
uuid: appUuid,
prefix,
written,
failed: failed.length ? failed : undefined,
bucket: ws.gcs_default_bucket_name,
},
});
}