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

@@ -187,6 +187,17 @@ export async function POST(req: NextRequest) {
// this new UUID FK is the canonical link.
`ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`,
`CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`,
// ── Per-workspace Gitea bot user (for direct AI access) ──────────
// Each workspace gets its own Gitea user with a PAT scoped to the
// workspace's org, so AI agents can `git clone` / push directly
// without ever touching the root admin token.
//
// Token is encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
// Layout: iv(12) || ciphertext || authTag(16), base64-encoded.
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`,
];
for (const stmt of statements) {

View File

@@ -1,294 +1,70 @@
/**
* Vibn MCP HTTP API
*
* Exposes MCP capabilities over HTTP for web-based AI assistants
* 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 { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
import {
deployApplication,
getApplicationInProject,
listApplicationDeployments,
listApplicationEnvs,
listApplicationsInProject,
projectUuidOf,
TenantError,
upsertApplicationEnv,
deleteApplicationEnv,
} from '@/lib/coolify';
import { query } from '@/lib/db-postgres';
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const token = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
// Try MCP API key first (for ChatGPT integration)
if (token.startsWith('vibn_mcp_')) {
const mcpKeysSnapshot = await adminDb
.collection('mcpKeys')
.where('key', '==', token)
.limit(1)
.get();
// ──────────────────────────────────────────────────
// Capability descriptor
// ──────────────────────────────────────────────────
if (mcpKeysSnapshot.empty) {
return NextResponse.json({ error: 'Invalid MCP API key' }, { status: 401 });
}
const keyDoc = mcpKeysSnapshot.docs[0];
userId = keyDoc.data().userId;
// Update last used timestamp
await keyDoc.ref.update({
lastUsed: new Date().toISOString(),
});
} else {
// Try Firebase ID token (for direct user access)
try {
const decodedToken = await adminAuth.verifyIdToken(token);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
const body = await request.json();
const { action, params } = body;
// Handle different MCP actions
switch (action) {
case 'list_resources': {
return NextResponse.json({
resources: [
{
uri: `vibn://projects/${userId}`,
name: 'My Projects',
description: 'All your Vibn projects',
mimeType: 'application/json',
},
{
uri: `vibn://sessions/${userId}`,
name: 'My Sessions',
description: 'All your coding sessions',
mimeType: 'application/json',
},
],
});
}
case 'read_resource': {
const { uri } = params;
if (uri === `vibn://projects/${userId}`) {
const projectsSnapshot = await adminDb
.collection('projects')
.where('userId', '==', userId)
.orderBy('createdAt', 'desc')
.limit(50)
.get();
const projects = projectsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(projects, null, 2),
},
],
});
}
if (uri.startsWith('vibn://projects/') && uri.split('/').length === 4) {
const projectId = uri.split('/')[3];
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists || projectDoc.data()?.userId !== userId) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
return NextResponse.json({
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify({ id: projectDoc.id, ...projectDoc.data() }, null, 2),
},
],
});
}
if (uri === `vibn://sessions/${userId}`) {
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.orderBy('createdAt', 'desc')
.limit(50)
.get();
const sessions = sessionsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(sessions, null, 2),
},
],
});
}
return NextResponse.json({ error: 'Unknown resource' }, { status: 404 });
}
case 'call_tool': {
const { name, arguments: args } = params;
if (name === 'get_project_summary') {
const { projectId } = args;
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists || projectDoc.data()?.userId !== userId) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const project = { id: projectDoc.id, ...projectDoc.data() };
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.where('userId', '==', userId)
.get();
const sessions = sessionsSnapshot.docs.map(doc => doc.data());
const totalCost = sessions.reduce((sum, s: any) => sum + (s.cost || 0), 0);
const totalTokens = sessions.reduce((sum, s: any) => sum + (s.tokensUsed || 0), 0);
const totalDuration = sessions.reduce((sum, s: any) => sum + (s.duration || 0), 0);
const summary = {
project,
stats: {
totalSessions: sessions.length,
totalCost,
totalTokens,
totalDuration,
},
recentSessions: sessions.slice(0, 5),
};
return NextResponse.json({
content: [
{
type: 'text',
text: JSON.stringify(summary, null, 2),
},
],
});
}
if (name === 'search_sessions') {
const { projectId, workspacePath } = args;
let query = adminDb.collection('sessions').where('userId', '==', userId);
if (projectId) {
query = query.where('projectId', '==', projectId) as any;
}
if (workspacePath) {
query = query.where('workspacePath', '==', workspacePath) as any;
}
const snapshot = await (query as any).orderBy('createdAt', 'desc').limit(50).get();
const sessions = snapshot.docs.map((doc: any) => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
content: [
{
type: 'text',
text: JSON.stringify(sessions, null, 2),
},
],
});
}
if (name === 'get_conversation_context') {
const { projectId, limit = 50 } = args;
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists || projectDoc.data()?.userId !== userId) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('aiConversations')
.orderBy('createdAt', 'asc')
.limit(limit)
.get();
const conversations = conversationsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
content: [
{
type: 'text',
text: JSON.stringify(conversations, null, 2),
},
],
});
}
return NextResponse.json({ error: 'Unknown tool' }, { status: 404 });
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
} catch (error) {
console.error('MCP API error:', error);
return NextResponse.json(
{
error: 'Failed to process MCP request',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
// GET endpoint to list capabilities
export async function GET(request: Request) {
export async function GET() {
return NextResponse.json({
name: 'vibn-mcp-server',
version: '1.0.0',
name: 'vibn-mcp',
version: '2.0.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: {
resources: {
supported: true,
endpoints: [
'vibn://projects/{userId}',
'vibn://projects/{userId}/{projectId}',
'vibn://sessions/{userId}',
],
},
tools: {
supported: true,
available: [
'get_project_summary',
'search_sessions',
'get_conversation_context',
'workspace.describe',
'gitea.credentials',
'projects.list',
'projects.get',
'apps.list',
'apps.get',
'apps.deploy',
'apps.deployments',
'apps.envs.list',
'apps.envs.upsert',
'apps.envs.delete',
],
},
},
@@ -296,3 +72,282 @@ export async function GET(request: Request) {
});
}
// ──────────────────────────────────────────────────
// 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);
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 });
}
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);
const result = await upsertApplicationEnv(appUuid, {
key,
value,
is_preview: !!params.is_preview,
is_build_time: !!params.is_build_time,
is_literal: !!params.is_literal,
is_multiline: !!params.is_multiline,
});
return NextResponse.json({ result });
}
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 } });
}

View File

@@ -0,0 +1,47 @@
/**
* POST /api/workspaces/[slug]/apps/[uuid]/deploy
*
* Trigger a deploy on a Coolify app. Guard: app must belong to this
* workspace's Coolify project before we forward the call.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
deployApplication,
getApplicationInProject,
TenantError,
} from '@/lib/coolify';
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
// Tenant check before any mutation.
await getApplicationInProject(uuid, ws.coolify_project_uuid);
const result = await deployApplication(uuid);
return NextResponse.json({
ok: true,
deploymentUuid: result.deployment_uuid,
appUuid: uuid,
});
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,41 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/deployments
*
* Recent deployments for an app. Tenant-checked.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
listApplicationDeployments,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
const deployments = await listApplicationDeployments(uuid);
return NextResponse.json({ deployments });
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,154 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
* body: { key, value, is_preview?, is_build_time?, is_literal?, is_multiline? }
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
*
* Tenant boundary: the app must belong to the workspace's Coolify project.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
deleteApplicationEnv,
getApplicationInProject,
listApplicationEnvs,
TenantError,
upsertApplicationEnv,
} from '@/lib/coolify';
async function verify(request: Request, slug: string, uuid: string) {
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return { error: principal };
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return {
error: NextResponse.json(
{ error: 'Workspace has no Coolify project yet' },
{ status: 503 }
),
};
}
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
}
return {
error: NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
),
};
}
return { principal };
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
try {
const envs = await listApplicationEnvs(uuid);
// Redact values by default for API-key callers — they can re-fetch
// with ?reveal=true when they need the actual values (e.g. to copy
// a DATABASE_URL). Session callers always get full values.
const url = new URL(request.url);
const reveal =
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
return NextResponse.json({
envs: envs.map(e => ({
key: e.key,
value: reveal ? e.value : maskValue(e.value),
isPreview: e.is_preview ?? false,
isBuildTime: e.is_build_time ?? false,
isLiteral: e.is_literal ?? false,
isMultiline: e.is_multiline ?? false,
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
let body: {
key?: string;
value?: string;
is_preview?: boolean;
is_build_time?: boolean;
is_literal?: boolean;
is_multiline?: boolean;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.key || typeof body.value !== 'string') {
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
}
try {
const env = await upsertApplicationEnv(uuid, {
key: body.key,
value: body.value,
is_preview: body.is_preview ?? false,
is_build_time: body.is_build_time ?? false,
is_literal: body.is_literal ?? false,
is_multiline: body.is_multiline ?? false,
});
return NextResponse.json({ ok: true, key: env.key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
const key = new URL(request.url).searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
}
try {
await deleteApplicationEnv(uuid, key);
return NextResponse.json({ ok: true, key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
function maskValue(v: string): string {
if (!v) return '';
if (v.length <= 4) return '•'.repeat(v.length);
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
}

View File

@@ -0,0 +1,45 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]
*
* Single Coolify app details. Verifies the app's project uuid matches
* the workspace's before returning anything.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getApplicationInProject, projectUuidOf, TenantError } from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
try {
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
return NextResponse.json({
uuid: app.uuid,
name: app.name,
status: app.status,
fqdn: app.fqdn ?? null,
gitRepository: app.git_repository ?? null,
gitBranch: app.git_branch ?? null,
projectUuid: projectUuidOf(app),
});
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace
*
* Auth: session OR `Bearer vibn_sk_...`. The workspace's
* `coolify_project_uuid` acts as the tenant boundary — any app whose
* Coolify project uuid doesn't match is filtered out even if the
* token issuer accidentally had wider reach.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { listApplicationsInProject, projectUuidOf } from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace has no Coolify project yet', apps: [] },
{ status: 503 }
);
}
try {
const apps = await listApplicationsInProject(ws.coolify_project_uuid);
return NextResponse.json({
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
apps: 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),
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,194 @@
/**
* GET /api/workspaces/[slug]/bootstrap.sh
*
* One-shot installer. Intended usage inside a repo:
*
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
*
* Writes three files into the cwd:
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
* - .env.local (appends VIBN_* envs; never overwrites)
*
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
* same token in the generated mcp.json so Cursor agents can re-use it.
* Session auth works too but then nothing is embedded (the user gets
* placeholder strings to fill in themselves).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
const tokenFromHeader = extractBearer(request);
// For API-key callers we can safely echo the token they sent us
// back into the generated files. For session callers we emit a
// placeholder — we don't want to re-issue long-lived tokens from
// a cookie-authenticated browser request.
const embedToken =
principal.source === 'api_key' && tokenFromHeader
? tokenFromHeader
: '<paste your vibn_sk_ token here>';
const script = buildScript({
slug: ws.slug,
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
appBase: APP_BASE,
token: embedToken,
});
return new NextResponse(script, {
status: 200,
headers: {
'Content-Type': 'text/x-shellscript; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function extractBearer(request: Request): string | null {
const a = request.headers.get('authorization');
if (!a) return null;
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
return m?.[1] ?? null;
}
function buildScript(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
token: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
// Build the file bodies in TS so we can shell-escape them cleanly
// using base64. The script itself does no string interpolation on
// these payloads — it just decodes and writes.
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
const mcp = JSON.stringify(
{
mcpServers: {
[`vibn-${slug}`]: {
url: `${appBase}/api/mcp`,
headers: { Authorization: `Bearer ${token}` },
},
},
},
null,
2
);
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
const b64Env = Buffer.from(env, 'utf8').toString('base64');
return `#!/usr/bin/env sh
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
# Workspace: ${slug}
#
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
# and appends VIBN_* env vars to .env.local (never overwrites).
set -eu
mkdir -p .cursor/rules
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
echo " wrote .cursor/rules/vibn-workspace.mdc"
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
echo " wrote .cursor/mcp.json"
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
echo " .env.local already has VIBN_* — skipping env append"
else
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
echo "${b64Env}" | base64 -d >> .env.local
echo " appended VIBN_* to .env.local"
fi
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
echo '.env.local' >> .gitignore
echo " added .env.local to .gitignore"
fi
echo ""
echo "Vibn workspace '${slug}' is wired up."
echo "Restart Cursor to pick up the new MCP server."
`;
}
function buildCursorRule(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
return `---
description: Vibn workspace "${slug}" — one-shot setup for AI agents
alwaysApply: true
---
# Vibn workspace: ${slug}
You are acting on behalf of the Vibn workspace **${slug}**. All AI
integration with Gitea and Coolify happens through the Vibn REST API,
which enforces tenancy for you.
## How to act
1. Before any git or deploy work, call:
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
workspace-scoped bot username, PAT, and clone URL template.
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
as the git remote. Never pass the root admin token to git.
3. For deploys, logs, env vars, call the workspace-scoped Coolify
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
Any cross-tenant attempt is rejected with HTTP 403.
## Identity
- Gitea org: \`${giteaOrg}\`
- Coolify project uuid: \`${coolifyProjectUuid}\`
- API base: \`${appBase}\`
## Useful endpoints
| Method | Path |
|-------:|----------------------------------------------------------------|
| GET | /api/workspaces/${slug} |
| GET | /api/workspaces/${slug}/gitea-credentials |
| GET | /api/workspaces/${slug}/apps |
| GET | /api/workspaces/${slug}/apps/{uuid} |
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
## Rules
- Never print or commit \`$VIBN_API_KEY\`.
- Prefer PRs over force-pushing \`main\`.
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
outside this workspace — stop and ask the user.
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
`;
}

View File

@@ -0,0 +1,53 @@
/**
* GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs
*
* Raw deployment logs. We can't tell from a deployment UUID alone
* which project it belongs to, so we require `?appUuid=...` and
* verify that app belongs to the workspace first. This keeps the
* tenant boundary intact even though Coolify's log endpoint is
* global.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
getApplicationInProject,
getDeploymentLogs,
TenantError,
} from '@/lib/coolify';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
) {
const { slug, deploymentUuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
const appUuid = new URL(request.url).searchParams.get('appUuid');
if (!appUuid) {
return NextResponse.json(
{ error: 'Query param "appUuid" is required for tenant enforcement' },
{ status: 400 }
);
}
try {
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
const logs = await getDeploymentLogs(deploymentUuid);
return NextResponse.json(logs);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,85 @@
/**
* GET /api/workspaces/[slug]/gitea-credentials
*
* Returns a ready-to-use git clone URL for the workspace's Gitea org,
* plus the bot username/token. This is the one endpoint an AI agent
* calls before doing any git work — it hides all the admin/org/bot
* bookkeeping behind a single bearer-auth request.
*
* Auth: NextAuth session (owner) OR `Bearer vibn_sk_...` scoped to
* this workspace. Never returns credentials for a different workspace.
*
* The plaintext PAT is decrypted on the server on every call — we
* never persist it in logs or client state.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
// If the bot has never been provisioned, do it now. Idempotent.
let workspace = principal.workspace;
if (!workspace.gitea_bot_token_encrypted || !workspace.gitea_org) {
try {
workspace = await ensureWorkspaceProvisioned(workspace);
} catch (err) {
return NextResponse.json(
{
error: 'Provisioning failed',
details: err instanceof Error ? err.message : String(err),
},
{ status: 502 }
);
}
}
const creds = getWorkspaceBotCredentials(workspace);
if (!creds) {
return NextResponse.json(
{
error: 'Workspace has no Gitea bot yet',
provisionStatus: workspace.provision_status,
provisionError: workspace.provision_error,
hint:
'POST /api/workspaces/' +
slug +
'/provision to retry bot provisioning.',
},
{ status: 503 }
);
}
const apiBase = GITEA_API_URL.replace(/\/$/, '');
const host = new URL(apiBase).host;
return NextResponse.json({
workspace: { slug: workspace.slug, giteaOrg: creds.org },
bot: {
username: creds.username,
// Full plaintext PAT — treat like a password.
token: creds.token,
},
gitea: {
apiBase,
host,
// Templates for the agent. Substitute {{repo}} with the repo name.
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
sshRemoteTemplate: `git@${host}:${creds.org}/{{repo}}.git`,
webUrlTemplate: `${apiBase}/${creds.org}/{{repo}}`,
},
principal: {
source: principal.source,
apiKeyId: principal.apiKeyId ?? null,
},
});
}

View File

@@ -21,6 +21,8 @@ export async function GET(
coolifyProjectUuid: w.coolify_project_uuid,
coolifyTeamId: w.coolify_team_id,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
provisionStatus: w.provision_status,
provisionError: w.provision_error,
createdAt: w.created_at,

View File

@@ -59,6 +59,8 @@ function serializeWorkspace(w: import('@/lib/workspaces').VibnWorkspace) {
name: w.name,
coolifyProjectUuid: w.coolify_project_uuid,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
provisionStatus: w.provision_status,
provisionError: w.provision_error,
createdAt: w.created_at,