feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP

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
This commit is contained in:
2026-04-21 10:49:17 -07:00
parent 6ccfdee65f
commit b9511601bc
29 changed files with 1716 additions and 330 deletions

View File

@@ -22,6 +22,10 @@ export interface CoolifyDatabase {
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 {
@@ -31,6 +35,21 @@ export interface CoolifyApplication {
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 = {}) {
@@ -203,3 +222,98 @@ export async function getApplication(uuid: string): Promise<CoolifyApplication>
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);
}