Files
vibn-frontend/app/api/mcp/route.ts
Mark Henderson e766315ecd fix(apps): compose-aware domains; loud apps.update ignore list
Two live-test bugs surfaced while deploying Twenty CRM:

1. apps.domains.set silently 422'd on compose apps
   Coolify hard-rejects top-level `domains` for dockercompose build
   packs — they must use `docker_compose_domains` (per-service JSON).
   setApplicationDomains now detects build_pack (fetched via GET if
   not passed) and dispatches correctly. Default service is `server`
   (matches Twenty, Plane, Cal.com); override with `service` param.

2. apps.update silently dropped unrecognised fields
   Caller got `{ok:true}` even when zero fields persisted. This
   created false-positive "bug reports" (e.g. the user-reported
   "fqdn returns ok but doesn't persist" — fqdn was never forwarded
   at all). apps.update now returns:
     - applied:  fields that were forwarded to Coolify
     - ignored:  unknown fields (agent typos, stale field names)
     - rerouted: fields that belong to a different tool
                 (fqdn/domains → apps.domains.set,
                  git_repository → apps.rewire_git)
   400 when nothing applied, 200 with diagnostics otherwise.

Made-with: Cursor
2026-04-23 13:25:16 -07:00

1391 lines
52 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 {
deployApplication,
getApplicationInProject,
listApplicationDeployments,
listApplicationEnvs,
listApplicationsInProject,
projectUuidOf,
TenantError,
upsertApplicationEnv,
deleteApplicationEnv,
// Phase 4 ── create/update/delete + domains + databases + services
createPublicApp,
updateApplication,
deleteApplication,
setApplicationDomains,
listDatabasesInProject,
createDatabase,
getDatabaseInProject,
updateDatabase,
deleteDatabase,
listServicesInProject,
createService,
getServiceInProject,
deleteService,
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.2.0',
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.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 '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;
const apps = await listApplicationsInProject(projectUuid);
return NextResponse.json({
result: apps.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,
projectUuid: projectUuidOf(a),
})),
});
}
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 });
}
await getApplicationInProject(appUuid, projectUuid);
const { deployment_uuid } = await deployApplication(appUuid);
return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid } });
}
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 });
}
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
// ──────────────────────────────────────────────────
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
if (!ws.coolify_project_uuid || !ws.gitea_org) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' },
{ status: 503 }
);
}
// We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's
// builtin SSH is on an internal-only port and port 22 hits the host's
// OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and
// the PAT is scoped to the org via team membership.
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: 'Param "repo" 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 = String(params.domain ?? '').trim()
? String(params.domain).replace(/^https?:\/\//, '')
: workspaceAppFqdn(ws.slug, appName);
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
{ status: 403 }
);
}
const created = await createPublicApp({
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
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,
isForceHttpsEnabled: true,
instantDeploy: false,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
dockerfileLocation: params.dockerfileLocation
? String(params.dockerfileLocation)
: undefined,
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
});
// Attach envs
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(created.uuid, { key: k, value: String(v) });
} catch (e) {
console.warn('[mcp apps.create] upsert env failed', k, e);
}
}
}
let deploymentUuid: string | null = null;
if (params.instantDeploy !== false) {
try {
const dep = await deployApplication(created.uuid);
deploymentUuid = dep.deployment_uuid ?? null;
} catch (e) {
console.warn('[mcp apps.create] first deploy failed', e);
}
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
deploymentUuid,
},
});
}
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,
},
});
}