From b9511601bc790d3211cef984da1bc50125ca910c Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 21 Apr 2026 10:49:17 -0700 Subject: [PATCH] feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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 --- .../{ => (workspace)}/analytics/page.tsx | 0 .../{ => (workspace)}/assist/page.tsx | 0 .../{ => (workspace)}/build/page.tsx | 0 .../{ => (workspace)}/deployment/page.tsx | 0 .../{ => (workspace)}/design/page.tsx | 0 .../{ => (workspace)}/grow/page.tsx | 0 .../{ => (workspace)}/growth/page.tsx | 0 .../{ => (workspace)}/infrastructure/page.tsx | 0 .../{ => (workspace)}/insights/page.tsx | 0 .../{ => (workspace)}/overview/page.tsx | 0 .../{ => (workspace)}/prd/page.tsx | 0 .../{ => (workspace)}/settings/page.tsx | 0 app/api/admin/migrate/route.ts | 11 + app/api/mcp/route.ts | 613 ++++++++++-------- .../[slug]/apps/[uuid]/deploy/route.ts | 47 ++ .../[slug]/apps/[uuid]/deployments/route.ts | 41 ++ .../[slug]/apps/[uuid]/envs/route.ts | 154 +++++ .../workspaces/[slug]/apps/[uuid]/route.ts | 45 ++ app/api/workspaces/[slug]/apps/route.ts | 50 ++ .../workspaces/[slug]/bootstrap.sh/route.ts | 194 ++++++ .../[deploymentUuid]/logs/route.ts | 53 ++ .../[slug]/gitea-credentials/route.ts | 85 +++ app/api/workspaces/[slug]/route.ts | 2 + app/api/workspaces/route.ts | 2 + components/workspace/WorkspaceKeysPanel.tsx | 321 +++++++-- lib/auth/secret-box.ts | 46 ++ lib/coolify.ts | 114 ++++ lib/gitea.ts | 139 ++++ lib/workspaces.ts | 129 +++- 29 files changed, 1716 insertions(+), 330 deletions(-) rename app/[workspace]/project/[projectId]/{ => (workspace)}/analytics/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/assist/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/build/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/deployment/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/design/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/grow/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/growth/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/infrastructure/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/insights/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/overview/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/prd/page.tsx (100%) rename app/[workspace]/project/[projectId]/{ => (workspace)}/settings/page.tsx (100%) create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/route.ts create mode 100644 app/api/workspaces/[slug]/apps/route.ts create mode 100644 app/api/workspaces/[slug]/bootstrap.sh/route.ts create mode 100644 app/api/workspaces/[slug]/deployments/[deploymentUuid]/logs/route.ts create mode 100644 app/api/workspaces/[slug]/gitea-credentials/route.ts create mode 100644 lib/auth/secret-box.ts diff --git a/app/[workspace]/project/[projectId]/analytics/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/analytics/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/analytics/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/analytics/page.tsx diff --git a/app/[workspace]/project/[projectId]/assist/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/assist/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/build/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/build/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/build/page.tsx diff --git a/app/[workspace]/project/[projectId]/deployment/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/deployment/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx diff --git a/app/[workspace]/project/[projectId]/design/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/design/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/design/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/design/page.tsx diff --git a/app/[workspace]/project/[projectId]/grow/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/grow/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/grow/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/grow/page.tsx diff --git a/app/[workspace]/project/[projectId]/growth/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/growth/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx diff --git a/app/[workspace]/project/[projectId]/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/infrastructure/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx diff --git a/app/[workspace]/project/[projectId]/insights/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/insights/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/insights/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/insights/page.tsx diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/overview/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx diff --git a/app/[workspace]/project/[projectId]/prd/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/prd/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx diff --git a/app/[workspace]/project/[projectId]/settings/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/settings/page.tsx similarity index 100% rename from app/[workspace]/project/[projectId]/settings/page.tsx rename to app/[workspace]/project/[projectId]/(workspace)/settings/page.tsx diff --git a/app/api/admin/migrate/route.ts b/app/api/admin/migrate/route.ts index e8bd311..c9cc630 100644 --- a/app/api/admin/migrate/route.ts +++ b/app/api/admin/migrate/route.ts @@ -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) { diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index b2ec642..8999661 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -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 }; + 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; + + 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>, + { 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 } }); +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts new file mode 100644 index 0000000..28ea723 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts @@ -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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts new file mode 100644 index 0000000..5af973c --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts @@ -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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts new file mode 100644 index 0000000..4752705 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts @@ -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)}`; +} diff --git a/app/api/workspaces/[slug]/apps/[uuid]/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/route.ts new file mode 100644 index 0000000..f42dd86 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/apps/route.ts b/app/api/workspaces/[slug]/apps/route.ts new file mode 100644 index 0000000..84786f5 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/route.ts @@ -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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/bootstrap.sh/route.ts b/app/api/workspaces/[slug]/bootstrap.sh/route.ts new file mode 100644 index 0000000..12e6684 --- /dev/null +++ b/app/api/workspaces/[slug]/bootstrap.sh/route.ts @@ -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//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 + : ''; + + 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. +`; +} diff --git a/app/api/workspaces/[slug]/deployments/[deploymentUuid]/logs/route.ts b/app/api/workspaces/[slug]/deployments/[deploymentUuid]/logs/route.ts new file mode 100644 index 0000000..f5a76da --- /dev/null +++ b/app/api/workspaces/[slug]/deployments/[deploymentUuid]/logs/route.ts @@ -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 } + ); + } +} diff --git a/app/api/workspaces/[slug]/gitea-credentials/route.ts b/app/api/workspaces/[slug]/gitea-credentials/route.ts new file mode 100644 index 0000000..f890e49 --- /dev/null +++ b/app/api/workspaces/[slug]/gitea-credentials/route.ts @@ -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, + }, + }); +} diff --git a/app/api/workspaces/[slug]/route.ts b/app/api/workspaces/[slug]/route.ts index 759c0b7..9e2baf7 100644 --- a/app/api/workspaces/[slug]/route.ts +++ b/app/api/workspaces/[slug]/route.ts @@ -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, diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 62c37c5..5ce0937 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -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, diff --git a/components/workspace/WorkspaceKeysPanel.tsx b/components/workspace/WorkspaceKeysPanel.tsx index b8a7855..77714bc 100644 --- a/components/workspace/WorkspaceKeysPanel.tsx +++ b/components/workspace/WorkspaceKeysPanel.tsx @@ -42,10 +42,18 @@ interface WorkspaceSummary { coolifyProjectUuid: string | null; coolifyTeamId: number | null; giteaOrg: string | null; + giteaBotUsername?: string | null; + giteaBotReady?: boolean; provisionStatus: "pending" | "partial" | "ready" | "error"; provisionError: string | null; } +interface GiteaBotCreds { + bot: { username: string; token: string }; + gitea: { apiBase: string; host: string; cloneUrlTemplate: string; sshRemoteTemplate: string; webUrlTemplate: string }; + workspace: { slug: string; giteaOrg: string }; +} + interface ApiKey { id: string; name: string; @@ -238,7 +246,7 @@ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug? onRefresh={refresh} /> - + {/* ── Create key modal ─────────────────────────────────────── */} @@ -524,53 +532,229 @@ function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) { } // ────────────────────────────────────────────────── -// Cursor / VS Code integration block +// AI access bundle — one card that replaces all the scattered snippets // ────────────────────────────────────────────────── -function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) { - const cursorRule = buildCursorRule(workspace); - const mcpJson = buildMcpJson(workspace, ""); - const envSnippet = buildEnvSnippet(workspace, ""); +function AiAccessBundleCard({ workspace }: { workspace: WorkspaceSummary }) { + const [botCreds, setBotCreds] = useState(null); + const [revealingBot, setRevealingBot] = useState(false); + const [hideToken, setHideToken] = useState(true); + + const revealBotCreds = useCallback(async () => { + setRevealingBot(true); + try { + const res = await fetch(`/api/workspaces/${workspace.slug}/gitea-credentials`, { + credentials: "include", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `HTTP ${res.status}`); + } + const j = (await res.json()) as GiteaBotCreds; + setBotCreds(j); + setHideToken(true); + } catch (err) { + toast.error( + `Couldn't fetch Gitea bot: ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + setRevealingBot(false); + } + }, [workspace.slug]); + + const promptBlock = useMemo(() => buildPromptBlock(workspace), [workspace]); + const cursorRule = useMemo(() => buildCursorRule(workspace), [workspace]); + const mcpJson = useMemo( + () => buildMcpJson(workspace, ""), + [workspace] + ); + const envSnippet = useMemo( + () => buildEnvSnippet(workspace, ""), + [workspace] + ); + const bootstrapCmd = useMemo( + () => + `curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`, + [workspace.slug] + ); return (
-

Connect Cursor

+

AI access bundle

- Drop these into your repo (or ~/.cursor/) so any - agent inside Cursor knows how to talk to this workspace. + Everything an AI agent needs to act on this workspace: a + system prompt, a one-line installer, and the low-level + snippets if you prefer to wire it up by hand.

- - + + setHideToken(h => !h)} + /> + +
+ + Manual setup — individual files + +
+ + + +
+
); } +function GiteaBotReveal({ + workspace, + creds, + revealing, + hideToken, + onReveal, + onToggleHide, +}: { + workspace: WorkspaceSummary; + creds: GiteaBotCreds | null; + revealing: boolean; + hideToken: boolean; + onReveal: () => void; + onToggleHide: () => void; +}) { + const cloneExample = creds + ? creds.gitea.cloneUrlTemplate.replace("{{repo}}", "example-repo") + : ""; + const displayedToken = creds + ? hideToken + ? `${creds.bot.token.slice(0, 6)}${"•".repeat(24)}${creds.bot.token.slice(-4)}` + : creds.bot.token + : ""; + + return ( +
+
+
+
+ 3. Gitea bot credentials +
+
+ A dedicated Gitea user scoped to the {workspace.giteaOrg ?? "(unprovisioned)"} org. + Use this to git clone / push without exposing any root token. +
+
+ {creds ? ( + + ) : ( + + )} +
+ + {creds && ( +
+ {creds.bot.username}} /> + + {displayedToken} + + } + /> + {creds.workspace.giteaOrg}} /> + {creds.gitea.apiBase}} /> +
+
+ Clone URL (example) +
+ + {hideToken + ? cloneExample.replace(creds.bot.token, `${creds.bot.token.slice(0, 6)}…`) + : cloneExample} + +
+
+ )} +
+ ); +} + +function KV({ k, v }: { k: string; v: React.ReactNode }) { + return ( +
+
+ {k} +
+
{v}
+
+ ); +} + function FileBlock({ title, description, @@ -648,6 +832,8 @@ function FileBlock({ } function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) { + const bootstrapCmd = `export VIBN_API_KEY=${minted.token} +curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`; const cursorRule = buildCursorRule(workspace); const mcpJson = buildMcpJson(workspace, minted.token); const envSnippet = buildEnvSnippet(workspace, minted.token); @@ -662,30 +848,79 @@ function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; min language="text" /> - - +
+ + Manual setup — individual files + +
+ + + +
+
); } +function buildPromptBlock(w: WorkspaceSummary): string { + return `You are acting on the Vibn workspace "${w.slug}". + +API base: ${APP_BASE} +Authorization header: Bearer $VIBN_API_KEY (set in .env.local, never print it) + +Before doing any git work call: + GET ${APP_BASE}/api/workspaces/${w.slug}/gitea-credentials +That returns a workspace-scoped bot username, PAT, and a \`cloneUrlTemplate\` +with \`{{repo}}\` placeholder. Use that template for all \`git clone\` / push +remotes. Never use any other git credentials. + +For deploys, logs, and env vars, use the workspace-scoped Coolify endpoints: + GET /api/workspaces/${w.slug}/apps + GET /api/workspaces/${w.slug}/apps/{uuid} + POST /api/workspaces/${w.slug}/apps/{uuid}/deploy + GET /api/workspaces/${w.slug}/apps/{uuid}/envs + PATCH /api/workspaces/${w.slug}/apps/{uuid}/envs body: {"key","value",...} + DELETE /api/workspaces/${w.slug}/apps/{uuid}/envs?key=FOO + +All responses are tenant-scoped — a 403 means you're touching another +workspace's resources, which is not allowed. Stop and ask the user. + +Workspace identity: +- Gitea org: ${w.giteaOrg ?? "(unprovisioned)"} +- Coolify project uuid: ${w.coolifyProjectUuid ?? "(unprovisioned)"} +- Provision status: ${w.provisionStatus} + +Rules: +1. Ask before creating new projects, repos, or deployments. +2. Prefer PRs over force-pushing main. +3. Treat VIBN_API_KEY like a password — never echo or commit it. +`; +} + // ────────────────────────────────────────────────── // File generators // ────────────────────────────────────────────────── diff --git a/lib/auth/secret-box.ts b/lib/auth/secret-box.ts new file mode 100644 index 0000000..c31c88a --- /dev/null +++ b/lib/auth/secret-box.ts @@ -0,0 +1,46 @@ +/** + * Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.) + * at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ). + * + * The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR + * any string we hash down to 32 bytes. We hash with SHA-256 so both + * forms work — rotating just means generating a new env value and + * re-provisioning workspaces. + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; + +const IV_BYTES = 12; + +function getKey(): Buffer { + const raw = process.env.VIBN_SECRETS_KEY; + if (!raw || raw.length < 16) { + throw new Error( + 'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets' + ); + } + // Normalize any input into a 32-byte key via SHA-256. + return createHash('sha256').update(raw).digest(); +} + +export function encryptSecret(plain: string): string { + const key = getKey(); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, enc, tag]).toString('base64'); +} + +export function decryptSecret(payload: string): string { + const buf = Buffer.from(payload, 'base64'); + if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short'); + const iv = buf.subarray(0, IV_BYTES); + const tag = buf.subarray(buf.length - 16); + const ciphertext = buf.subarray(IV_BYTES, buf.length - 16); + const key = getKey(); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return dec.toString('utf8'); +} diff --git a/lib/coolify.ts b/lib/coolify.ts index b26eb07..0daddec 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -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 export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> { return coolifyFetch(`/deployments/${deploymentUuid}/logs`); } + +export async function listApplicationDeployments(uuid: string): Promise> { + return coolifyFetch(`/applications/${uuid}/deployments`); +} + +// ────────────────────────────────────────────────── +// Environment variables +// ────────────────────────────────────────────────── + +export async function listApplicationEnvs(uuid: string): Promise { + return coolifyFetch(`/applications/${uuid}/envs`); +} + +export async function upsertApplicationEnv( + uuid: string, + env: CoolifyEnvVar & { is_preview?: boolean } +): Promise { + // 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 { + 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 { + 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 { + const all = await listApplications(); + return all.filter(a => projectUuidOf(a) === projectUuid); +} diff --git a/lib/gitea.ts b/lib/gitea.ts index d5bb313..e1045f8 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -145,6 +145,145 @@ export async function getUser(username: string): Promise<{ id: number; login: st } } +// ────────────────────────────────────────────────── +// Admin: create users + mint PATs + team management +// (used for per-workspace bot provisioning) +// ────────────────────────────────────────────────── + +export interface GiteaUser { + id: number; + login: string; + full_name?: string; + email?: string; +} + +/** + * Create a Gitea user via the admin API. Requires the root admin token + * to have the site-admin bit. `must_change_password: false` because the + * bot never logs in interactively — only the PAT is used. + */ +export async function createUser(opts: { + username: string; + email: string; + password: string; + fullName?: string; +}): Promise { + return giteaFetch(`/admin/users`, { + method: 'POST', + body: JSON.stringify({ + username: opts.username, + email: opts.email, + password: opts.password, + full_name: opts.fullName ?? opts.username, + must_change_password: false, + send_notify: false, + source_id: 0, + }), + }); +} + +/** + * Mint a Personal Access Token on behalf of another user. This uses + * Gitea's `Sudo` header — only works when GITEA_API_TOKEN is a site-admin + * token. Returns the plaintext token string, which must be stored + * encrypted (Gitea never shows it again). + */ +export async function createAccessTokenFor(opts: { + username: string; + name: string; + scopes?: string[]; +}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> { + const { username, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts; + return giteaFetch(`/users/${username}/tokens`, { + method: 'POST', + headers: { Sudo: username }, + body: JSON.stringify({ name, scopes }), + }); +} + +export interface GiteaTeam { + id: number; + name: string; + permission?: string; +} + +export async function listOrgTeams(orgName: string): Promise { + return giteaFetch(`/orgs/${orgName}/teams`); +} + +/** + * Create a new team inside an org with a scoped permission. + * `permission`: "read" | "write" | "admin" | "owner". + * Used to give bot users less than full owner access. + */ +export async function createOrgTeam(opts: { + org: string; + name: string; + description?: string; + permission?: 'read' | 'write' | 'admin'; + includesAllRepos?: boolean; +}): Promise { + const { + org, + name, + description = '', + permission = 'write', + includesAllRepos = true, + } = opts; + return giteaFetch(`/orgs/${org}/teams`, { + method: 'POST', + body: JSON.stringify({ + name, + description, + permission, + includes_all_repositories: includesAllRepos, + can_create_org_repo: true, + units: [ + 'repo.code', + 'repo.issues', + 'repo.pulls', + 'repo.releases', + 'repo.wiki', + 'repo.ext_wiki', + 'repo.ext_issues', + 'repo.projects', + 'repo.actions', + 'repo.packages', + ], + }), + }); +} + +/** Add a user to a team by id. */ +export async function addOrgTeamMember(teamId: number, username: string): Promise { + await giteaFetch(`/teams/${teamId}/members/${username}`, { method: 'PUT' }); +} + +/** + * Ensure a team exists on an org with the requested permission, and + * that `username` is a member of it. Idempotent. + */ +export async function ensureOrgTeamMembership(opts: { + org: string; + teamName: string; + permission?: 'read' | 'write' | 'admin'; + username: string; +}): Promise { + const { org, teamName, permission = 'write', username } = opts; + const teams = await listOrgTeams(org); + let team = teams.find(t => t.name.toLowerCase() === teamName.toLowerCase()); + if (!team) { + team = await createOrgTeam({ + org, + name: teamName, + description: `Vibn ${teamName} team`, + permission, + }); + } + await addOrgTeamMember(team.id, username); + return team; +} + /** * Get an existing repo. */ diff --git a/lib/workspaces.ts b/lib/workspaces.ts index 178a367..9c0458c 100644 --- a/lib/workspaces.ts +++ b/lib/workspaces.ts @@ -17,9 +17,19 @@ * Coolify exposes team creation. */ +import { randomBytes } from 'crypto'; import { query, queryOne } from '@/lib/db-postgres'; import { createProject as createCoolifyProject } from '@/lib/coolify'; -import { createOrg, getOrg, getUser, addOrgOwner } from '@/lib/gitea'; +import { + createOrg, + getOrg, + getUser, + addOrgOwner, + createUser, + createAccessTokenFor, + ensureOrgTeamMembership, +} from '@/lib/gitea'; +import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box'; export interface VibnWorkspace { id: string; @@ -29,6 +39,9 @@ export interface VibnWorkspace { coolify_project_uuid: string | null; coolify_team_id: number | null; gitea_org: string | null; + gitea_bot_username: string | null; + gitea_bot_user_id: number | null; + gitea_bot_token_encrypted: string | null; provision_status: 'pending' | 'partial' | 'ready' | 'error'; provision_error: string | null; created_at: Date; @@ -64,6 +77,16 @@ export function giteaOrgNameFor(slug: string): string { return `vibn-${slug}`; } +/** Gitea username we mint for each workspace's bot account. */ +export function giteaBotUsernameFor(slug: string): string { + return `vibn-bot-${slug}`; +} + +/** Placeholder-looking email so Gitea accepts the user; never delivered to. */ +export function giteaBotEmailFor(slug: string): string { + return `bot+${slug}@vibnai.invalid`; +} + // ────────────────────────────────────────────────── // CRUD // ────────────────────────────────────────────────── @@ -219,24 +242,114 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom } } + // ── Per-workspace Gitea bot user + PAT ───────────────────────────── + // Gives AI agents a credential that is scoped exclusively to this + // workspace's org. The bot has no other org memberships, so even a + // leaked PAT cannot reach other tenants' repos. + let botUsername = workspace.gitea_bot_username; + let botUserId = workspace.gitea_bot_user_id; + let botTokenEncrypted = workspace.gitea_bot_token_encrypted; + + if (giteaOrg && (!botUsername || !botTokenEncrypted)) { + try { + const wantBot = giteaBotUsernameFor(workspace.slug); + + // 1. Ensure the bot user exists. + let existingBot = await getUser(wantBot); + if (!existingBot) { + const created = await createUser({ + username: wantBot, + email: giteaBotEmailFor(workspace.slug), + // Password is never used (only the PAT is), but Gitea requires one. + password: `bot-${randomBytes(24).toString('base64url')}`, + fullName: `Vibn bot (${workspace.slug})`, + }); + existingBot = { id: created.id, login: created.login }; + } + botUsername = existingBot.login; + botUserId = existingBot.id; + + // 2. Add the bot to the org's Writers team (scoped permissions). + await ensureOrgTeamMembership({ + org: giteaOrg, + teamName: 'Writers', + permission: 'write', + username: botUsername, + }); + + // 3. Mint a PAT for the bot. Gitea shows the plaintext exactly + // once, so if we already stored an encrypted copy we skip. + if (!botTokenEncrypted) { + const pat = await createAccessTokenFor({ + username: botUsername, + name: `vibn-${workspace.slug}-${Date.now().toString(36)}`, + scopes: ['write:repository', 'write:issue', 'write:user'], + }); + botTokenEncrypted = encryptSecret(pat.sha1); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`gitea-bot: ${msg}`); + console.error('[workspaces] Gitea bot provisioning failed', workspace.slug, msg); + } + } + + const allReady = !!(coolifyUuid && giteaOrg && botUsername && botTokenEncrypted); const status: VibnWorkspace['provision_status'] = - coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; + allReady ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; const updated = await query( `UPDATE vibn_workspaces - SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), - gitea_org = COALESCE($3, gitea_org), - provision_status = $4, - provision_error = $5, - updated_at = now() + SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), + gitea_org = COALESCE($3, gitea_org), + gitea_bot_username = COALESCE($4, gitea_bot_username), + gitea_bot_user_id = COALESCE($5, gitea_bot_user_id), + gitea_bot_token_encrypted= COALESCE($6, gitea_bot_token_encrypted), + provision_status = $7, + provision_error = $8, + updated_at = now() WHERE id = $1 RETURNING *`, - [workspace.id, coolifyUuid, giteaOrg, status, errors.length ? errors.join('; ') : null] + [ + workspace.id, + coolifyUuid, + giteaOrg, + botUsername, + botUserId, + botTokenEncrypted, + status, + errors.length ? errors.join('; ') : null, + ] ); return updated[0]; } +/** + * Decrypt and return the bot credentials for a workspace. Call this + * from endpoints that need to hand the AI a usable git clone URL. + * Returns null when the workspace has not been fully provisioned. + */ +export function getWorkspaceBotCredentials(workspace: VibnWorkspace): { + username: string; + token: string; + org: string; +} | null { + if (!workspace.gitea_bot_username || !workspace.gitea_bot_token_encrypted || !workspace.gitea_org) { + return null; + } + try { + return { + username: workspace.gitea_bot_username, + token: decryptSecret(workspace.gitea_bot_token_encrypted), + org: workspace.gitea_org, + }; + } catch (err) { + console.error('[workspaces] Failed to decrypt bot token for', workspace.slug, err); + return null; + } +} + /** * Convenience: get-or-create + provision in one call. Used by the * project-create flow so the first project in a fresh account always