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:
@@ -187,6 +187,17 @@ export async function POST(req: NextRequest) {
|
|||||||
// this new UUID FK is the canonical link.
|
// 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`,
|
`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)`,
|
`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) {
|
for (const stmt of statements) {
|
||||||
|
|||||||
@@ -1,294 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* Vibn MCP HTTP API
|
* Vibn MCP HTTP bridge.
|
||||||
*
|
*
|
||||||
* Exposes MCP capabilities over HTTP for web-based AI assistants
|
* 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 { 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) {
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
try {
|
|
||||||
// Authenticate user
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split('Bearer ')[1];
|
// ──────────────────────────────────────────────────
|
||||||
const adminAuth = getAdminAuth();
|
// Capability descriptor
|
||||||
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();
|
|
||||||
|
|
||||||
if (mcpKeysSnapshot.empty) {
|
export async function GET() {
|
||||||
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) {
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: 'vibn-mcp-server',
|
name: 'vibn-mcp',
|
||||||
version: '1.0.0',
|
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: {
|
capabilities: {
|
||||||
resources: {
|
|
||||||
supported: true,
|
|
||||||
endpoints: [
|
|
||||||
'vibn://projects/{userId}',
|
|
||||||
'vibn://projects/{userId}/{projectId}',
|
|
||||||
'vibn://sessions/{userId}',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tools: {
|
tools: {
|
||||||
supported: true,
|
supported: true,
|
||||||
available: [
|
available: [
|
||||||
'get_project_summary',
|
'workspace.describe',
|
||||||
'search_sessions',
|
'gitea.credentials',
|
||||||
'get_conversation_context',
|
'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 } });
|
||||||
|
}
|
||||||
|
|||||||
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
45
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
45
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/workspaces/[slug]/apps/route.ts
Normal file
50
app/api/workspaces/[slug]/apps/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal 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.
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal file
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export async function GET(
|
|||||||
coolifyProjectUuid: w.coolify_project_uuid,
|
coolifyProjectUuid: w.coolify_project_uuid,
|
||||||
coolifyTeamId: w.coolify_team_id,
|
coolifyTeamId: w.coolify_team_id,
|
||||||
giteaOrg: w.gitea_org,
|
giteaOrg: w.gitea_org,
|
||||||
|
giteaBotUsername: w.gitea_bot_username,
|
||||||
|
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
|
||||||
provisionStatus: w.provision_status,
|
provisionStatus: w.provision_status,
|
||||||
provisionError: w.provision_error,
|
provisionError: w.provision_error,
|
||||||
createdAt: w.created_at,
|
createdAt: w.created_at,
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ function serializeWorkspace(w: import('@/lib/workspaces').VibnWorkspace) {
|
|||||||
name: w.name,
|
name: w.name,
|
||||||
coolifyProjectUuid: w.coolify_project_uuid,
|
coolifyProjectUuid: w.coolify_project_uuid,
|
||||||
giteaOrg: w.gitea_org,
|
giteaOrg: w.gitea_org,
|
||||||
|
giteaBotUsername: w.gitea_bot_username,
|
||||||
|
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
|
||||||
provisionStatus: w.provision_status,
|
provisionStatus: w.provision_status,
|
||||||
provisionError: w.provision_error,
|
provisionError: w.provision_error,
|
||||||
createdAt: w.created_at,
|
createdAt: w.created_at,
|
||||||
|
|||||||
@@ -42,10 +42,18 @@ interface WorkspaceSummary {
|
|||||||
coolifyProjectUuid: string | null;
|
coolifyProjectUuid: string | null;
|
||||||
coolifyTeamId: number | null;
|
coolifyTeamId: number | null;
|
||||||
giteaOrg: string | null;
|
giteaOrg: string | null;
|
||||||
|
giteaBotUsername?: string | null;
|
||||||
|
giteaBotReady?: boolean;
|
||||||
provisionStatus: "pending" | "partial" | "ready" | "error";
|
provisionStatus: "pending" | "partial" | "ready" | "error";
|
||||||
provisionError: string | null;
|
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 {
|
interface ApiKey {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -238,7 +246,7 @@ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?
|
|||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CursorIntegrationCard workspace={workspace} />
|
<AiAccessBundleCard workspace={workspace} />
|
||||||
|
|
||||||
{/* ── Create key modal ─────────────────────────────────────── */}
|
{/* ── Create key modal ─────────────────────────────────────── */}
|
||||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
@@ -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 }) {
|
function AiAccessBundleCard({ workspace }: { workspace: WorkspaceSummary }) {
|
||||||
const cursorRule = buildCursorRule(workspace);
|
const [botCreds, setBotCreds] = useState<GiteaBotCreds | null>(null);
|
||||||
const mcpJson = buildMcpJson(workspace, "<paste-your-vibn_sk_-token>");
|
const [revealingBot, setRevealingBot] = useState(false);
|
||||||
const envSnippet = buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>");
|
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, "<paste-your-vibn_sk_-token>"),
|
||||||
|
[workspace]
|
||||||
|
);
|
||||||
|
const envSnippet = useMemo(
|
||||||
|
() => buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>"),
|
||||||
|
[workspace]
|
||||||
|
);
|
||||||
|
const bootstrapCmd = useMemo(
|
||||||
|
() =>
|
||||||
|
`curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`,
|
||||||
|
[workspace.slug]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={cardStyle}>
|
<section style={cardStyle}>
|
||||||
<header style={cardHeaderStyle}>
|
<header style={cardHeaderStyle}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={cardTitleStyle}>Connect Cursor</h2>
|
<h2 style={cardTitleStyle}>AI access bundle</h2>
|
||||||
<p style={cardSubtitleStyle}>
|
<p style={cardSubtitleStyle}>
|
||||||
Drop these into your repo (or <code>~/.cursor/</code>) so any
|
Everything an AI agent needs to act on this workspace: a
|
||||||
agent inside Cursor knows how to talk to this workspace.
|
system prompt, a one-line installer, and the low-level
|
||||||
|
snippets if you prefer to wire it up by hand.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<FileBlock
|
<FileBlock
|
||||||
title=".cursor/rules/vibn-workspace.mdc"
|
title="1. Paste into your AI's system prompt"
|
||||||
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
|
description="Tells the agent how this workspace works and which endpoints to call. Works in ChatGPT, Claude, Cursor agent, etc."
|
||||||
filename="vibn-workspace.mdc"
|
filename={`vibn-${workspace.slug}-prompt.md`}
|
||||||
contents={cursorRule}
|
contents={promptBlock}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FileBlock
|
<FileBlock
|
||||||
title="~/.cursor/mcp.json"
|
title="2. One-line setup inside any repo"
|
||||||
description="Registers Vibn as an MCP server so the agent can call workspace endpoints natively. Paste your minted key in place of the placeholder."
|
description="Mint a key above, export it as VIBN_API_KEY, then run this. It writes .cursor/rules, .cursor/mcp.json, and .env.local."
|
||||||
filename="mcp.json"
|
filename="vibn-bootstrap.sh"
|
||||||
contents={mcpJson}
|
contents={bootstrapCmd}
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FileBlock
|
|
||||||
title=".env.local (for shell / scripts)"
|
|
||||||
description="If the agent shells out (curl, gh, scripts), expose the key as an env var."
|
|
||||||
filename="vibn.env"
|
|
||||||
contents={envSnippet}
|
|
||||||
language="bash"
|
language="bash"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GiteaBotReveal
|
||||||
|
workspace={workspace}
|
||||||
|
creds={botCreds}
|
||||||
|
revealing={revealingBot}
|
||||||
|
hideToken={hideToken}
|
||||||
|
onReveal={revealBotCreds}
|
||||||
|
onToggleHide={() => setHideToken(h => !h)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<details style={{ marginTop: 18 }}>
|
||||||
|
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
|
||||||
|
Manual setup — individual files
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/rules/vibn-workspace.mdc"
|
||||||
|
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
|
||||||
|
filename="vibn-workspace.mdc"
|
||||||
|
contents={cursorRule}
|
||||||
|
language="markdown"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/mcp.json"
|
||||||
|
description="Registers Vibn as an MCP server. Paste your minted key in place of the placeholder."
|
||||||
|
filename="mcp.json"
|
||||||
|
contents={mcpJson}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".env.local"
|
||||||
|
description="Expose the key as an env var for shell scripts."
|
||||||
|
filename="vibn.env"
|
||||||
|
contents={envSnippet}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 18,
|
||||||
|
border: "1px solid var(--border, #e5e7eb)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
|
||||||
|
3. Gitea bot credentials
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 2, lineHeight: 1.5 }}>
|
||||||
|
A dedicated Gitea user scoped to the <code>{workspace.giteaOrg ?? "(unprovisioned)"}</code> org.
|
||||||
|
Use this to <code>git clone</code> / push without exposing any root token.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{creds ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onToggleHide}>
|
||||||
|
{hideToken ? "Show token" : "Hide"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={onReveal} disabled={revealing}>
|
||||||
|
{revealing ? <Loader2 className="animate-spin" size={14} /> : <KeyRound size={14} />}
|
||||||
|
Reveal bot PAT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creds && (
|
||||||
|
<div style={{ marginTop: 12, display: "grid", gap: 8, fontSize: 12.5 }}>
|
||||||
|
<KV k="Username" v={<code>{creds.bot.username}</code>} />
|
||||||
|
<KV
|
||||||
|
k="PAT"
|
||||||
|
v={
|
||||||
|
<code
|
||||||
|
style={{ fontFamily: "monospace", wordBreak: "break-all", userSelect: "all" }}
|
||||||
|
>
|
||||||
|
{displayedToken}
|
||||||
|
</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KV k="Org" v={<code>{creds.workspace.giteaOrg}</code>} />
|
||||||
|
<KV k="API base" v={<code>{creds.gitea.apiBase}</code>} />
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>
|
||||||
|
Clone URL (example)
|
||||||
|
</div>
|
||||||
|
<code style={{ fontFamily: "monospace", fontSize: 11.5, color: "var(--ink)", wordBreak: "break-all", userSelect: "all" }}>
|
||||||
|
{hideToken
|
||||||
|
? cloneExample.replace(creds.bot.token, `${creds.bot.token.slice(0, 6)}…`)
|
||||||
|
: cloneExample}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KV({ k, v }: { k: string; v: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "baseline", flexWrap: "wrap" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
minWidth: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ink)" }}>{v}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FileBlock({
|
function FileBlock({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -648,6 +832,8 @@ function FileBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) {
|
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 cursorRule = buildCursorRule(workspace);
|
||||||
const mcpJson = buildMcpJson(workspace, minted.token);
|
const mcpJson = buildMcpJson(workspace, minted.token);
|
||||||
const envSnippet = buildEnvSnippet(workspace, minted.token);
|
const envSnippet = buildEnvSnippet(workspace, minted.token);
|
||||||
@@ -662,30 +848,79 @@ function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; min
|
|||||||
language="text"
|
language="text"
|
||||||
/>
|
/>
|
||||||
<FileBlock
|
<FileBlock
|
||||||
title=".cursor/rules/vibn-workspace.mdc"
|
title="One-line setup (recommended)"
|
||||||
description="Drop into your repo so Cursor agents know about the Vibn API."
|
description="Paste into a terminal from the root of any repo. It writes .cursor/rules, .cursor/mcp.json, and .env.local for you."
|
||||||
filename="vibn-workspace.mdc"
|
filename="vibn-bootstrap.sh"
|
||||||
contents={cursorRule}
|
contents={bootstrapCmd}
|
||||||
language="markdown"
|
|
||||||
/>
|
|
||||||
<FileBlock
|
|
||||||
title="~/.cursor/mcp.json (key embedded)"
|
|
||||||
description="Paste into Cursor's MCP config to register Vibn as a tool source."
|
|
||||||
filename="mcp.json"
|
|
||||||
contents={mcpJson}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
<FileBlock
|
|
||||||
title=".env.local"
|
|
||||||
description="For shell / script use."
|
|
||||||
filename="vibn.env"
|
|
||||||
contents={envSnippet}
|
|
||||||
language="bash"
|
language="bash"
|
||||||
/>
|
/>
|
||||||
|
<details>
|
||||||
|
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
|
||||||
|
Manual setup — individual files
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/rules/vibn-workspace.mdc"
|
||||||
|
description="Drop into your repo so Cursor agents know about the Vibn API."
|
||||||
|
filename="vibn-workspace.mdc"
|
||||||
|
contents={cursorRule}
|
||||||
|
language="markdown"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/mcp.json (key embedded)"
|
||||||
|
description="Paste into Cursor's MCP config to register Vibn as a tool source."
|
||||||
|
filename="mcp.json"
|
||||||
|
contents={mcpJson}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".env.local"
|
||||||
|
description="For shell / script use."
|
||||||
|
filename="vibn.env"
|
||||||
|
contents={envSnippet}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// File generators
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
46
lib/auth/secret-box.ts
Normal file
46
lib/auth/secret-box.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
114
lib/coolify.ts
114
lib/coolify.ts
@@ -22,6 +22,10 @@ export interface CoolifyDatabase {
|
|||||||
status: string;
|
status: string;
|
||||||
internal_db_url?: string;
|
internal_db_url?: string;
|
||||||
external_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 {
|
export interface CoolifyApplication {
|
||||||
@@ -31,6 +35,21 @@ export interface CoolifyApplication {
|
|||||||
fqdn?: string;
|
fqdn?: string;
|
||||||
git_repository?: string;
|
git_repository?: string;
|
||||||
git_branch?: 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 = {}) {
|
async function coolifyFetch(path: string, options: RequestInit = {}) {
|
||||||
@@ -203,3 +222,98 @@ export async function getApplication(uuid: string): Promise<CoolifyApplication>
|
|||||||
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
|
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
|
||||||
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
|
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listApplicationDeployments(uuid: string): Promise<Array<{
|
||||||
|
uuid: string;
|
||||||
|
status: string;
|
||||||
|
created_at?: string;
|
||||||
|
finished_at?: string;
|
||||||
|
commit?: string;
|
||||||
|
}>> {
|
||||||
|
return coolifyFetch(`/applications/${uuid}/deployments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Environment variables
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listApplicationEnvs(uuid: string): Promise<CoolifyEnvVar[]> {
|
||||||
|
return coolifyFetch(`/applications/${uuid}/envs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertApplicationEnv(
|
||||||
|
uuid: string,
|
||||||
|
env: CoolifyEnvVar & { is_preview?: boolean }
|
||||||
|
): Promise<CoolifyEnvVar> {
|
||||||
|
// Coolify accepts PATCH for updates and POST for creates. We try
|
||||||
|
// PATCH first (idempotent upsert on key), fall back to POST.
|
||||||
|
try {
|
||||||
|
return await coolifyFetch(`/applications/${uuid}/envs`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(env),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('404') || msg.includes('405')) {
|
||||||
|
return coolifyFetch(`/applications/${uuid}/envs`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(env),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApplicationEnv(uuid: string, key: string): Promise<void> {
|
||||||
|
await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Tenant helpers
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Coolify project UUID an application belongs to, working
|
||||||
|
* around Coolify v4 sometimes nesting it under `environment`.
|
||||||
|
*/
|
||||||
|
export function projectUuidOf(app: CoolifyApplication): string | null {
|
||||||
|
return (
|
||||||
|
app.project_uuid ??
|
||||||
|
app.environment?.project_uuid ??
|
||||||
|
app.environment?.project?.uuid ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an application AND verify it lives in the expected Coolify
|
||||||
|
* project. Throws a `TenantError` when the app is cross-tenant so
|
||||||
|
* callers can translate to HTTP 403.
|
||||||
|
*/
|
||||||
|
export class TenantError extends Error {
|
||||||
|
status = 403 as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApplicationInProject(
|
||||||
|
appUuid: string,
|
||||||
|
expectedProjectUuid: string
|
||||||
|
): Promise<CoolifyApplication> {
|
||||||
|
const app = await getApplication(appUuid);
|
||||||
|
const actualProject = projectUuidOf(app);
|
||||||
|
if (!actualProject || actualProject !== expectedProjectUuid) {
|
||||||
|
throw new TenantError(
|
||||||
|
`Application ${appUuid} does not belong to project ${expectedProjectUuid}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List applications that belong to the given Coolify project. */
|
||||||
|
export async function listApplicationsInProject(
|
||||||
|
projectUuid: string
|
||||||
|
): Promise<CoolifyApplication[]> {
|
||||||
|
const all = await listApplications();
|
||||||
|
return all.filter(a => projectUuidOf(a) === projectUuid);
|
||||||
|
}
|
||||||
|
|||||||
139
lib/gitea.ts
139
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<GiteaUser> {
|
||||||
|
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<GiteaTeam[]> {
|
||||||
|
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<GiteaTeam> {
|
||||||
|
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<void> {
|
||||||
|
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<GiteaTeam> {
|
||||||
|
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.
|
* Get an existing repo.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,9 +17,19 @@
|
|||||||
* Coolify exposes team creation.
|
* Coolify exposes team creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { query, queryOne } from '@/lib/db-postgres';
|
import { query, queryOne } from '@/lib/db-postgres';
|
||||||
import { createProject as createCoolifyProject } from '@/lib/coolify';
|
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 {
|
export interface VibnWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +39,9 @@ export interface VibnWorkspace {
|
|||||||
coolify_project_uuid: string | null;
|
coolify_project_uuid: string | null;
|
||||||
coolify_team_id: number | null;
|
coolify_team_id: number | null;
|
||||||
gitea_org: string | 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_status: 'pending' | 'partial' | 'ready' | 'error';
|
||||||
provision_error: string | null;
|
provision_error: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -64,6 +77,16 @@ export function giteaOrgNameFor(slug: string): string {
|
|||||||
return `vibn-${slug}`;
|
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
|
// 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'] =
|
const status: VibnWorkspace['provision_status'] =
|
||||||
coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending';
|
allReady ? 'ready' : errors.length > 0 ? 'partial' : 'pending';
|
||||||
|
|
||||||
const updated = await query<VibnWorkspace>(
|
const updated = await query<VibnWorkspace>(
|
||||||
`UPDATE vibn_workspaces
|
`UPDATE vibn_workspaces
|
||||||
SET coolify_project_uuid = COALESCE($2, coolify_project_uuid),
|
SET coolify_project_uuid = COALESCE($2, coolify_project_uuid),
|
||||||
gitea_org = COALESCE($3, gitea_org),
|
gitea_org = COALESCE($3, gitea_org),
|
||||||
provision_status = $4,
|
gitea_bot_username = COALESCE($4, gitea_bot_username),
|
||||||
provision_error = $5,
|
gitea_bot_user_id = COALESCE($5, gitea_bot_user_id),
|
||||||
updated_at = now()
|
gitea_bot_token_encrypted= COALESCE($6, gitea_bot_token_encrypted),
|
||||||
|
provision_status = $7,
|
||||||
|
provision_error = $8,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *`,
|
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];
|
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
|
* Convenience: get-or-create + provision in one call. Used by the
|
||||||
* project-create flow so the first project in a fresh account always
|
* project-create flow so the first project in a fresh account always
|
||||||
|
|||||||
Reference in New Issue
Block a user