diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/overview/page.tsx index 4fc825b..28da1fc 100644 --- a/app/[workspace]/project/[projectId]/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/overview/page.tsx @@ -200,19 +200,16 @@ export default function ProjectOverviewPage() { Refresh - {project.theiaWorkspaceUrl ? ( - - ) : ( - - )} + Open IDE + + diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index b13e12a..dcbe6ba 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; +import { provisionTheiaWorkspace } from '@/lib/coolify-workspace'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; @@ -90,7 +91,24 @@ export async function POST(request: Request) { } // ────────────────────────────────────────────── - // 3. Save project record + // 3. Provision dedicated Theia workspace + // ────────────────────────────────────────────── + let theiaWorkspaceUrl: string | null = null; + let theiaAppUuid: string | null = null; + let theiaError: string | null = null; + + try { + const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo); + theiaWorkspaceUrl = workspace.workspaceUrl; + theiaAppUuid = workspace.appUuid; + console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`); + } catch (err) { + theiaError = err instanceof Error ? err.message : String(err); + console.error('[API] Theia workspace provisioning failed (non-fatal):', theiaError); + } + + // ────────────────────────────────────────────── + // 4. Save project record // ────────────────────────────────────────────── const projectData = { id: projectId, @@ -127,6 +145,10 @@ export async function POST(request: Request) { giteaSshUrl, giteaWebhookId, giteaError, + // Theia workspace + theiaWorkspaceUrl, + theiaAppUuid, + theiaError, // Context snapshot (kept fresh by webhooks) contextSnapshot: null, createdAt: now, @@ -163,6 +185,8 @@ export async function POST(request: Request) { ? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl } : null, giteaError: giteaError ?? undefined, + theiaWorkspaceUrl, + theiaError: theiaError ?? undefined, }); } catch (error) { console.error('[POST /api/projects/create] Error:', error); diff --git a/app/api/theia-auth/route.ts b/app/api/theia-auth/route.ts index ccb1de7..94bb115 100644 --- a/app/api/theia-auth/route.ts +++ b/app/api/theia-auth/route.ts @@ -1,17 +1,21 @@ /** * GET /api/theia-auth * - * Traefik ForwardAuth endpoint for theia.vibnai.com. + * Traefik ForwardAuth endpoint for Theia IDE domains. * - * Traefik calls this URL for every request to the Theia IDE, forwarding - * the user's Cookie header via authRequestHeaders. We validate the - * NextAuth database session (strategy: "database") by looking up the - * session token directly in Postgres — avoiding Prisma / authOptions - * imports that cause build-time issues under --network host. + * Handles two cases: + * 1. theia.vibnai.com — shared IDE: any authenticated user may access + * 2. {slug}.ide.vibnai.com — per-project IDE: only the project owner may access + * + * Traefik calls this URL for every request to those Theia domains, forwarding + * the user's Cookie header via authRequestHeaders. We validate the NextAuth + * database session directly in Postgres (avoids Prisma / authOptions build-time + * issues under --network host). * * Returns: - * 200 — valid session, Traefik lets the request through + * 200 — valid session (and owner check passed), Traefik lets the request through * 302 — no/expired session, redirect browser to Vibn login + * 403 — authenticated but not the project owner */ import { NextRequest, NextResponse } from 'next/server'; @@ -19,34 +23,33 @@ import { query } from '@/lib/db-postgres'; export const dynamic = 'force-dynamic'; -const APP_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com'; +const APP_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com'; const THEIA_URL = 'https://theia.vibnai.com'; +const IDE_SUFFIX = '.ide.vibnai.com'; -// NextAuth v4 uses these cookie names for database sessions const SESSION_COOKIE_NAMES = [ - '__Secure-next-auth.session-token', // HTTPS (production) - 'next-auth.session-token', // HTTP fallback + '__Secure-next-auth.session-token', + 'next-auth.session-token', ]; export async function GET(request: NextRequest) { - // Extract session token from cookies + // ── 1. Extract session token ────────────────────────────────────────────── let sessionToken: string | null = null; for (const name of SESSION_COOKIE_NAMES) { const val = request.cookies.get(name)?.value; if (val) { sessionToken = val; break; } } - if (!sessionToken) { - return redirectToLogin(request); - } + if (!sessionToken) return redirectToLogin(request); - // Look up session in Postgres (NextAuth stores sessions in the "sessions" table) + // ── 2. Validate session in Postgres ────────────────────────────────────── let userEmail: string | null = null; - let userName: string | null = null; + let userName: string | null = null; + let userId: string | null = null; try { - const rows = await query<{ email: string; name: string }>( - `SELECT u.email, u.name + const rows = await query<{ email: string; name: string; user_id: string }>( + `SELECT u.email, u.name, s.user_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token = $1 @@ -56,31 +59,57 @@ export async function GET(request: NextRequest) { ); if (rows.length > 0) { userEmail = rows[0].email; - userName = rows[0].name; + userName = rows[0].name; + userId = rows[0].user_id; } } catch (err) { console.error('[theia-auth] DB error:', err); return redirectToLogin(request); } - if (!userEmail) { - return redirectToLogin(request); + if (!userEmail || !userId) return redirectToLogin(request); + + // ── 3. Per-project ownership check for *.ide.vibnai.com ────────────────── + const forwardedHost = request.headers.get('x-forwarded-host') ?? ''; + + if (forwardedHost.endsWith(IDE_SUFFIX)) { + const slug = forwardedHost.slice(0, -IDE_SUFFIX.length); + + try { + const rows = await query<{ user_id: string }>( + `SELECT user_id FROM fs_projects WHERE slug = $1 LIMIT 1`, + [slug], + ); + + if (rows.length === 0) { + // Unknown project slug — deny + return new NextResponse('Workspace not found', { status: 403 }); + } + + const ownerUserId = rows[0].user_id; + if (ownerUserId !== userId) { + // Authenticated but not the owner + return new NextResponse('Access denied — this workspace belongs to another user', { status: 403 }); + } + } catch (err) { + console.error('[theia-auth] project ownership check error:', err); + return redirectToLogin(request); + } } - // Session valid — pass user identity to Theia via response headers + // ── 4. Allow — pass user identity headers to Theia ─────────────────────── return new NextResponse(null, { status: 200, headers: { 'X-Auth-Email': userEmail, - 'X-Auth-Name': userName ?? '', + 'X-Auth-Name': userName ?? '', }, }); } function redirectToLogin(request: NextRequest): NextResponse { - // Traefik ForwardAuth sets X-Forwarded-Host to the auth service's host (vibnai.com), - // not the original request host (theia.vibnai.com). Use THEIA_URL directly as the - // destination so the user returns to Theia after logging in. + // Use THEIA_URL as the callbackUrl so the user lands back on Theia after login. + // (X-Forwarded-Host points to vibnai.com via Traefik, not the original Theia domain.) const loginUrl = `${APP_URL}/auth?callbackUrl=${encodeURIComponent(THEIA_URL)}`; return NextResponse.redirect(loginUrl, { status: 302 }); } diff --git a/components/project-creation-modal.tsx b/components/project-creation-modal.tsx index 647dd8f..a564579 100644 --- a/components/project-creation-modal.tsx +++ b/components/project-creation-modal.tsx @@ -44,6 +44,7 @@ export function ProjectCreationModal({ const [selectedRepo, setSelectedRepo] = useState(null); const [createdProjectId, setCreatedProjectId] = useState(null); const [createdGiteaRepo, setCreatedGiteaRepo] = useState<{ repo: string; repoUrl: string } | null>(null); + const [createdTheiaUrl, setCreatedTheiaUrl] = useState(null); const [loading, setLoading] = useState(false); const [githubConnected, setGithubConnected] = useState(false); @@ -90,6 +91,7 @@ export function ProjectCreationModal({ setSelectedRepo(null); setCreatedProjectId(null); setCreatedGiteaRepo(null); + setCreatedTheiaUrl(null); setLoading(false); }; @@ -123,6 +125,7 @@ export function ProjectCreationModal({ const data = await response.json(); setCreatedProjectId(data.projectId); if (data.gitea) setCreatedGiteaRepo(data.gitea); + if (data.theiaWorkspaceUrl) setCreatedTheiaUrl(data.theiaWorkspaceUrl); toast.success('Project created!'); setStep(3); } else { @@ -322,12 +325,20 @@ export function ProjectCreationModal({ )} Webhook registered (push, PR, issues → Vibn) +
  • + {createdTheiaUrl ? ( + + ) : ( + + )} + Dedicated IDE workspace{createdTheiaUrl ? ` at ${createdTheiaUrl.replace('https://', '')}` : ' — provisioning failed'} +
  • { + const workspaceUrl = `https://${slug}${IDE_DOMAIN_SUFFIX}`; + const appName = `theia-${slug}`; + + // ── Step 1: Create the app ──────────────────────────────────────────────── + const createRes = await fetch(`${COOLIFY_URL}/api/v1/applications/dockerimage`, { + method: 'POST', + headers: coolifyHeaders(), + body: JSON.stringify({ + project_uuid: COOLIFY_PROJECT_UUID, + environment_name: COOLIFY_ENVIRONMENT, + server_uuid: COOLIFY_SERVER_UUID, + docker_registry_image_name: THEIA_IMAGE_NAME, + docker_registry_image_tag: THEIA_IMAGE_TAG, + name: appName, + description: `Theia IDE for Vibn project ${slug}`, + ports_exposes: THEIA_PORT, + domains: workspaceUrl, + instant_deploy: false, // we deploy after patching labels + }), + }); + + if (!createRes.ok) { + const body = await createRes.text(); + throw new Error(`Coolify create app failed (${createRes.status}): ${body}`); + } + + const { uuid: appUuid } = await createRes.json() as { uuid: string }; + + // ── Step 2: Patch with vibn-auth Traefik labels ─────────────────────────── + const patchRes = await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, { + method: 'PATCH', + headers: coolifyHeaders(), + body: JSON.stringify({ + custom_labels: buildCustomLabels(appUuid), + }), + }); + + if (!patchRes.ok) { + console.warn(`[workspace] PATCH labels failed (${patchRes.status}) — continuing`); + } + + // ── Step 3: Set environment variables ──────────────────────────────────── + const envVars = [ + { key: 'VIBN_PROJECT_ID', value: projectId, is_preview: false }, + { key: 'VIBN_PROJECT_SLUG', value: slug, is_preview: false }, + { key: 'GITEA_REPO', value: giteaRepo ?? '', is_preview: false }, + { key: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com', is_preview: false }, + ]; + + await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/envs/bulk`, { + method: 'POST', + headers: coolifyHeaders(), + body: JSON.stringify({ data: envVars }), + }); + + // ── Step 4: Deploy ──────────────────────────────────────────────────────── + await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/start`, { + method: 'POST', + headers: coolifyHeaders(), + }); + + console.log(`[workspace] Provisioned ${appName} → ${workspaceUrl} (uuid: ${appUuid})`); + + return { appUuid, workspaceUrl }; +} + +/** + * Deletes a provisioned Theia workspace from Coolify. + */ +export async function deleteTheiaWorkspace(appUuid: string): Promise { + await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, { + method: 'DELETE', + headers: coolifyHeaders(), + }); +}