Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.
Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
a Writers team (write perms only) on the workspace's org, and stores
its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
bot PAT + clone URL template; session or vibn_sk_ bearer auth.
Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
keys, exposing workspace.describe, gitea.credentials, projects.*,
apps.* (list/get/deploy/deployments, envs.list/upsert/delete).
Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
.cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
fallback. Minted-key modal also shows the bootstrap one-liner.
Ops prerequisites:
- Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
- Run /api/admin/migrate to add the three bot columns.
- GITEA_API_TOKEN must be a site-admin token (needed for admin/users
+ Sudo PAT mint); otherwise provision_status lands on 'partial'.
Made-with: Cursor
320 lines
9.9 KiB
TypeScript
320 lines
9.9 KiB
TypeScript
/**
|
|
* Coolify API client for Vibn project provisioning.
|
|
*
|
|
* Used server-side only. Credentials from env vars:
|
|
* COOLIFY_URL — e.g. http://34.19.250.135:8000
|
|
* COOLIFY_API_TOKEN — admin bearer token
|
|
*/
|
|
|
|
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000';
|
|
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
|
|
|
|
export interface CoolifyProject {
|
|
uuid: string;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface CoolifyDatabase {
|
|
uuid: string;
|
|
name: string;
|
|
type: string;
|
|
status: string;
|
|
internal_db_url?: string;
|
|
external_db_url?: string;
|
|
/** When true, Coolify publishes a host port for remote connections */
|
|
is_public?: boolean;
|
|
/** Host port mapped to 5432 inside the container */
|
|
public_port?: number;
|
|
}
|
|
|
|
export interface CoolifyApplication {
|
|
uuid: string;
|
|
name: string;
|
|
status: string;
|
|
fqdn?: string;
|
|
git_repository?: string;
|
|
git_branch?: string;
|
|
project_uuid?: string;
|
|
environment_name?: string;
|
|
/** Coolify sometimes nests these under an `environment` object */
|
|
environment?: { project_uuid?: string; project?: { uuid?: string } };
|
|
}
|
|
|
|
export interface CoolifyEnvVar {
|
|
uuid?: string;
|
|
key: string;
|
|
value: string;
|
|
is_preview?: boolean;
|
|
is_build_time?: boolean;
|
|
is_literal?: boolean;
|
|
is_multiline?: boolean;
|
|
is_shown_once?: boolean;
|
|
}
|
|
|
|
async function coolifyFetch(path: string, options: RequestInit = {}) {
|
|
const url = `${COOLIFY_URL}/api/v1${path}`;
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
|
|
...(options.headers ?? {}),
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Coolify API error ${res.status} on ${path}: ${text}`);
|
|
}
|
|
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Projects
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export async function listProjects(): Promise<CoolifyProject[]> {
|
|
return coolifyFetch('/projects');
|
|
}
|
|
|
|
export async function createProject(name: string, description?: string): Promise<CoolifyProject> {
|
|
return coolifyFetch('/projects', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
}
|
|
|
|
export async function getProject(uuid: string): Promise<CoolifyProject> {
|
|
return coolifyFetch(`/projects/${uuid}`);
|
|
}
|
|
|
|
export async function deleteProject(uuid: string): Promise<void> {
|
|
await coolifyFetch(`/projects/${uuid}`, { method: 'DELETE' });
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Databases
|
|
// ──────────────────────────────────────────────────
|
|
|
|
type DBType = 'postgresql' | 'mysql' | 'mariadb' | 'redis' | 'mongodb' | 'keydb';
|
|
|
|
export async function createDatabase(opts: {
|
|
projectUuid: string;
|
|
name: string;
|
|
type: DBType;
|
|
serverUuid?: string;
|
|
environmentName?: string;
|
|
}): Promise<CoolifyDatabase> {
|
|
const { projectUuid, name, type, serverUuid = '0', environmentName = 'production' } = opts;
|
|
|
|
return coolifyFetch(`/databases`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
project_uuid: projectUuid,
|
|
name,
|
|
type,
|
|
server_uuid: serverUuid,
|
|
environment_name: environmentName,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function getDatabase(uuid: string): Promise<CoolifyDatabase> {
|
|
return coolifyFetch(`/databases/${uuid}`);
|
|
}
|
|
|
|
export async function deleteDatabase(uuid: string): Promise<void> {
|
|
await coolifyFetch(`/databases/${uuid}`, { method: 'DELETE' });
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Applications
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export async function createApplication(opts: {
|
|
projectUuid: string;
|
|
name: string;
|
|
gitRepo: string; // e.g. "https://git.vibnai.com/mark/taskmaster.git"
|
|
gitBranch?: string;
|
|
serverUuid?: string;
|
|
environmentName?: string;
|
|
buildPack?: string; // nixpacks, static, dockerfile
|
|
ports?: string; // e.g. "3000"
|
|
}): Promise<CoolifyApplication> {
|
|
const {
|
|
projectUuid, name, gitRepo,
|
|
gitBranch = 'main',
|
|
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
|
environmentName = 'production',
|
|
buildPack = 'nixpacks',
|
|
ports = '3000',
|
|
} = opts;
|
|
|
|
return coolifyFetch(`/applications`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
project_uuid: projectUuid,
|
|
name,
|
|
git_repository: gitRepo,
|
|
git_branch: gitBranch,
|
|
server_uuid: serverUuid,
|
|
environment_name: environmentName,
|
|
build_pack: buildPack,
|
|
ports_exposes: ports,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a Coolify service for one app inside a Turborepo monorepo.
|
|
* Build command uses `turbo run build --filter` to target just that app.
|
|
*/
|
|
export async function createMonorepoAppService(opts: {
|
|
projectUuid: string;
|
|
appName: string;
|
|
gitRepo: string;
|
|
gitBranch?: string;
|
|
domain: string;
|
|
serverUuid?: string;
|
|
environmentName?: string;
|
|
}): Promise<CoolifyApplication> {
|
|
const {
|
|
projectUuid, appName, gitRepo,
|
|
gitBranch = 'main',
|
|
domain,
|
|
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
|
environmentName = 'production',
|
|
} = opts;
|
|
|
|
return coolifyFetch(`/applications`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
project_uuid: projectUuid,
|
|
name: appName,
|
|
git_repository: gitRepo,
|
|
git_branch: gitBranch,
|
|
server_uuid: serverUuid,
|
|
environment_name: environmentName,
|
|
build_pack: 'nixpacks',
|
|
build_command: `pnpm install && turbo run build --filter=${appName}`,
|
|
start_command: `turbo run start --filter=${appName}`,
|
|
ports_exposes: '3000',
|
|
fqdn: `https://${domain}`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function listApplications(): Promise<CoolifyApplication[]> {
|
|
return coolifyFetch('/applications');
|
|
}
|
|
|
|
export async function deployApplication(uuid: string): Promise<{ deployment_uuid: string }> {
|
|
return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' });
|
|
}
|
|
|
|
export async function getApplication(uuid: string): Promise<CoolifyApplication> {
|
|
return coolifyFetch(`/applications/${uuid}`);
|
|
}
|
|
|
|
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
|
|
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
|
|
}
|
|
|
|
export async function listApplicationDeployments(uuid: string): Promise<Array<{
|
|
uuid: string;
|
|
status: string;
|
|
created_at?: string;
|
|
finished_at?: string;
|
|
commit?: string;
|
|
}>> {
|
|
return coolifyFetch(`/applications/${uuid}/deployments`);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Environment variables
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export async function listApplicationEnvs(uuid: string): Promise<CoolifyEnvVar[]> {
|
|
return coolifyFetch(`/applications/${uuid}/envs`);
|
|
}
|
|
|
|
export async function upsertApplicationEnv(
|
|
uuid: string,
|
|
env: CoolifyEnvVar & { is_preview?: boolean }
|
|
): Promise<CoolifyEnvVar> {
|
|
// Coolify accepts PATCH for updates and POST for creates. We try
|
|
// PATCH first (idempotent upsert on key), fall back to POST.
|
|
try {
|
|
return await coolifyFetch(`/applications/${uuid}/envs`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(env),
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes('404') || msg.includes('405')) {
|
|
return coolifyFetch(`/applications/${uuid}/envs`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(env),
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function deleteApplicationEnv(uuid: string, key: string): Promise<void> {
|
|
await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tenant helpers
|
|
// ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return the Coolify project UUID an application belongs to, working
|
|
* around Coolify v4 sometimes nesting it under `environment`.
|
|
*/
|
|
export function projectUuidOf(app: CoolifyApplication): string | null {
|
|
return (
|
|
app.project_uuid ??
|
|
app.environment?.project_uuid ??
|
|
app.environment?.project?.uuid ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch an application AND verify it lives in the expected Coolify
|
|
* project. Throws a `TenantError` when the app is cross-tenant so
|
|
* callers can translate to HTTP 403.
|
|
*/
|
|
export class TenantError extends Error {
|
|
status = 403 as const;
|
|
}
|
|
|
|
export async function getApplicationInProject(
|
|
appUuid: string,
|
|
expectedProjectUuid: string
|
|
): Promise<CoolifyApplication> {
|
|
const app = await getApplication(appUuid);
|
|
const actualProject = projectUuidOf(app);
|
|
if (!actualProject || actualProject !== expectedProjectUuid) {
|
|
throw new TenantError(
|
|
`Application ${appUuid} does not belong to project ${expectedProjectUuid}`
|
|
);
|
|
}
|
|
return app;
|
|
}
|
|
|
|
/** List applications that belong to the given Coolify project. */
|
|
export async function listApplicationsInProject(
|
|
projectUuid: string
|
|
): Promise<CoolifyApplication[]> {
|
|
const all = await listApplications();
|
|
return all.filter(a => projectUuidOf(a) === projectUuid);
|
|
}
|