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 ? (
-
- ) : (
-
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(),
+ });
+}