Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress

Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
This commit is contained in:
2026-04-22 18:05:01 -07:00
parent d6c87a052e
commit 651ddf1e11
105 changed files with 7509 additions and 2319 deletions

View File

@@ -6,7 +6,7 @@
* 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`)
*
* Either way we resolve a `WorkspacePrincipal` that is scoped to one
* workspace. Routes that touch Coolify/Gitea/Theia must call
* workspace. Routes that touch Coolify/Gitea must call
* `requireWorkspacePrincipal()` and use `principal.workspace` to fetch
* the right Coolify Project / Gitea org.
*/

49
lib/chat-context-refs.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* References attached from the overview sidebar so the user can point Vibn at a
* PRD section or discovery phase. Sent with chat; not shown in stored user text.
*/
export type ChatContextRef =
| { kind: "section"; label: string; phaseId: string | null }
| { kind: "phase"; label: string; phaseId: string }
| { kind: "app"; label: string; path: string };
export function contextRefKey(r: ChatContextRef): string {
if (r.kind === "section") return `s:${r.label}`;
if (r.kind === "phase") return `p:${r.phaseId}`;
return `a:${r.path}`;
}
export function augmentAtlasMessage(
message: string,
refs: ChatContextRef[] | null | undefined
): string {
const list = refs?.filter(Boolean) ?? [];
if (!list.length) return message;
const lines = list.map(r =>
r.kind === "section"
? `- PRD section: "${r.label}"${r.phaseId ? ` (discovery phase: ${r.phaseId})` : ""}`
: r.kind === "phase"
? `- Discovery phase: "${r.label}" (id: ${r.phaseId})`
: `- Monorepo workspace / package: "${r.label}" (path: ${r.path})`
);
return `[The user attached these project references for this message. Prioritize them in your reply:\n${lines.join("\n")}\n]\n\n${message}`;
}
export function parseContextRefs(raw: unknown): ChatContextRef[] {
if (!Array.isArray(raw)) return [];
const out: ChatContextRef[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const o = item as Record<string, unknown>;
if (o.kind === "section" && typeof o.label === "string") {
const phaseId: string | null =
o.phaseId === null ? null : typeof o.phaseId === "string" ? o.phaseId : null;
out.push({ kind: "section", label: o.label, phaseId });
} else if (o.kind === "phase" && typeof o.label === "string" && typeof o.phaseId === "string") {
out.push({ kind: "phase", label: o.label, phaseId: o.phaseId });
} else if (o.kind === "app" && typeof o.label === "string" && typeof o.path === "string") {
out.push({ kind: "app", label: o.label, path: o.path });
}
}
return out;
}

View File

@@ -1,203 +0,0 @@
/**
* Cloud Run Workspace Provisioning
*
* Provisions a dedicated Theia IDE instance per Vibn project using
* Google Cloud Run. Each workspace:
* - Gets its own Cloud Run service: theia-{slug}
* - Scales to zero when idle (zero cost when not in use)
* - Starts in ~5-15s from cached image on demand
* - Is accessible at the Cloud Run URL stored on the project record
* - Auth is enforced by our Vibn session before the URL is revealed
*/
import { GoogleAuth, JWT } from 'google-auth-library';
const PROJECT_ID = 'master-ai-484822';
const REGION = 'northamerica-northeast1';
const IMAGE = `${REGION}-docker.pkg.dev/${PROJECT_ID}/vibn-ide/theia:latest`;
const VIBN_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com';
const CLOUD_RUN_API = `https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/services`;
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform'];
async function getAccessToken(): Promise<string> {
// Prefer an explicit service account key (avoids GCE metadata scope limitations).
// Stored as base64 to survive Docker ARG/ENV special-character handling.
const keyB64 = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64;
if (keyB64) {
const keyJson = Buffer.from(keyB64, 'base64').toString('utf-8');
const key = JSON.parse(keyJson) as {
client_email: string;
private_key: string;
};
const jwt = new JWT({
email: key.client_email,
key: key.private_key,
scopes: SCOPES,
});
const token = await jwt.getAccessToken();
if (!token.token) throw new Error('Failed to get GCP access token from service account key');
return token.token as string;
}
// Fall back to ADC (works locally or on GCE with cloud-platform scope)
const auth = new GoogleAuth({ scopes: SCOPES });
const client = await auth.getClient();
const token = await client.getAccessToken();
if (!token.token) throw new Error('Failed to get GCP access token');
return token.token;
}
export interface ProvisionResult {
serviceUrl: string; // https://theia-{slug}-xxx.run.app
serviceName: string; // theia-{slug}
}
/**
* Creates a new Cloud Run service for a Vibn project workspace.
* The service scales to zero when idle and starts on first request.
*/
export async function provisionTheiaWorkspace(
slug: string,
projectId: string,
giteaRepo: string | null,
): Promise<ProvisionResult> {
const token = await getAccessToken();
const serviceName = `theia-${slug}`.slice(0, 49); // Cloud Run max 49 chars
// Cloud Run v2: name must be empty in the body — it's passed via ?serviceId= in the URL
const serviceBody = {
template: {
scaling: {
minInstanceCount: 0, // scale to zero when idle
maxInstanceCount: 1, // one instance per workspace
},
containers: [{
image: IMAGE,
ports: [{ containerPort: 3000 }],
resources: {
limits: { cpu: '1', memory: '2Gi' },
cpuIdle: true, // only allocate CPU during requests
},
env: [
{ name: 'VIBN_PROJECT_ID', value: projectId },
{ name: 'VIBN_PROJECT_SLUG', value: slug },
{ name: 'VIBN_API_URL', value: VIBN_URL },
{ name: 'GITEA_REPO', value: giteaRepo ?? '' },
{ name: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com' },
// Token lets the startup script clone and push to the project's repo
{ name: 'GITEA_TOKEN', value: process.env.GITEA_API_TOKEN ?? '' },
// Gemini API key — needed by startup.sh to configure AI features in Theia
{ name: 'GOOGLE_API_KEY', value: process.env.GOOGLE_API_KEY ?? '' },
],
// 5 minute startup timeout — Theia needs time to initialise
startupProbe: {
httpGet: { path: '/', port: 3000 },
failureThreshold: 30,
periodSeconds: 10,
},
}],
// Keep container alive for 15 minutes of idle before scaling to zero
timeout: '900s',
},
ingress: 'INGRESS_TRAFFIC_ALL',
};
const createRes = await fetch(`${CLOUD_RUN_API}?serviceId=${serviceName}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(serviceBody),
});
if (!createRes.ok) {
const body = await createRes.text();
// 409 = service already exists — fetch its URL instead of failing
if (createRes.status === 409) {
console.log(`[workspace] Cloud Run service already exists: ${serviceName} — fetching existing URL`);
const serviceUrl = await waitForServiceUrl(serviceName, token);
await allowUnauthenticated(serviceName, token);
console.log(`[workspace] Linked to existing service: ${serviceName}${serviceUrl}`);
return { serviceUrl, serviceName };
}
throw new Error(`Cloud Run create service failed (${createRes.status}): ${body}`);
}
// Make service publicly accessible (auth handled by Vibn before URL is revealed)
await allowUnauthenticated(serviceName, token);
// Poll until the service URL is available (usually 10-30s)
const serviceUrl = await waitForServiceUrl(serviceName, token);
console.log(`[workspace] Cloud Run service ready: ${serviceName}${serviceUrl}`);
return { serviceUrl, serviceName };
}
/**
* Grants allUsers invoker access so the service URL works without GCP auth.
* Vibn controls access by only sharing the URL with the project owner.
*/
async function allowUnauthenticated(serviceName: string, token: string): Promise<void> {
await fetch(`${CLOUD_RUN_API}/${serviceName}:setIamPolicy`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
policy: {
bindings: [{ role: 'roles/run.invoker', members: ['allUsers'] }],
},
}),
});
}
/**
* Polls until Cloud Run reports the service URL (service is ready).
*/
async function waitForServiceUrl(serviceName: string, token: string, maxWaitMs = 60_000): Promise<string> {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 3000));
const res = await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const svc = await res.json() as { urls?: string[] };
if (svc.urls?.[0]) return svc.urls[0];
}
}
// Return expected URL pattern even if polling timed out
return `https://${serviceName}-${PROJECT_ID.slice(-6)}.${REGION}.run.app`;
}
/**
* Triggers a warm-up request to a workspace so the container is ready
* before the user clicks "Open IDE". Call this on user login.
*/
export async function prewarmWorkspace(serviceUrl: string): Promise<void> {
try {
await fetch(`${serviceUrl}/`, { signal: AbortSignal.timeout(5000) });
} catch {
// Ignore — fire and forget
}
}
/**
* Deletes a Cloud Run workspace service.
*/
export async function deleteTheiaWorkspace(serviceName: string): Promise<void> {
const token = await getAccessToken();
await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}

View File

@@ -1,145 +0,0 @@
/**
* Coolify Workspace Provisioning
*
* Provisions a dedicated Theia IDE instance per Vibn project using the
* Coolify Docker image application API. Each workspace is:
* - Hosted at {slug}.ide.vibnai.com
* - Protected by the vibn-auth ForwardAuth (project-owner only)
* - Running ghcr.io/eclipse-theia/theia-blueprint/theia-ide:latest
*/
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000';
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
// Coolify resource IDs (stable — tied to the Vibn server/project setup)
const COOLIFY_PROJECT_UUID = 'f4owwggokksgw0ogo0844os0'; // "Vibn" project
const COOLIFY_ENVIRONMENT = 'production';
const COOLIFY_SERVER_UUID = 'jws4g4cgssss4cw48s488woc'; // localhost (Coolify host)
const THEIA_IMAGE_NAME = 'ghcr.io/eclipse-theia/theia-blueprint/theia-ide';
const THEIA_IMAGE_TAG = 'latest';
const THEIA_PORT = '3000';
const IDE_DOMAIN_SUFFIX = '.ide.vibnai.com';
function coolifyHeaders() {
return {
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
'Content-Type': 'application/json',
};
}
/**
* Builds the newline-separated Traefik label string that Coolify stores
* as custom_labels. We add vibn-auth@file to the HTTPS router middleware
* chain after Coolify's generated labels.
*
* Router naming convention observed in Coolify:
* https-0-{uuid} → the TLS router for the app
*/
function buildCustomLabels(appUuid: string): string {
const routerName = `https-0-${appUuid}`;
return [
'traefik.enable=true',
`traefik.http.routers.${routerName}.middlewares=vibn-auth@file,gzip`,
].join('\n');
}
export interface ProvisionResult {
appUuid: string;
workspaceUrl: string;
}
/**
* Creates a new Coolify Docker-image application for a Vibn project Theia workspace.
* Sets the vibn-auth ForwardAuth middleware so only the project owner can access it.
*/
export async function provisionTheiaWorkspace(
slug: string,
projectId: string,
giteaRepo: string | null,
): Promise<ProvisionResult> {
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 giteaBaseUrl = process.env.GITEA_URL ?? 'https://git.vibnai.com';
const giteaToken = process.env.GITEA_TOKEN ?? '';
// Authenticated clone URL so Theia can git clone on startup
const giteaCloneUrl = giteaRepo
? `https://${giteaToken ? `oauth2:${giteaToken}@` : ''}${giteaBaseUrl.replace(/^https?:\/\//, '')}/${giteaRepo}.git`
: '';
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_CLONE_URL', value: giteaCloneUrl, is_preview: false },
{ key: 'GITEA_API_URL', value: giteaBaseUrl, is_preview: false },
// Theia opens this path as its workspace root
{ key: 'THEIA_WORKSPACE_ROOT', value: `/home/theia/${slug}`, 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<void> {
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, {
method: 'DELETE',
headers: coolifyHeaders(),
});
}

View File

@@ -408,6 +408,16 @@ export async function setApplicationDomains(
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
});
// Coolify API: send `domains` (NOT `fqdn`). The controller maps it to
// the DB's `fqdn` column internally, but only when the destination
// server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false`
// — i.e. when Server::isProxyShouldRun() returns true. If either is
// misconfigured, the controller silently drops the field (PATCH returns
// 200, fqdn unchanged). We hit this on the missinglettr-test app on
// 2026-04-22; the underlying server had proxy.type=null and
// is_build_server=true. Fix is in Coolify server-config (UI/DB), not
// the client. Sending `fqdn` directly is rejected with 422 ("This
// field is not allowed").
return updateApplication(uuid, {
domains: normalized.join(','),
force_domain_override: opts.forceOverride ?? true,

10
lib/dev-bypass.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Local dev: treat the app as signed-in for data fetching when bypass is on.
* Server: lib/auth/session-server.ts must use the same env flag.
*/
export function isClientDevProjectBypass(): boolean {
return (
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH === "true"
);
}

View File

@@ -420,10 +420,11 @@ export async function registerDomain(
const regUsername = input.regUsername ?? generateHandle(input.domain);
const regPassword = input.regPassword ?? generateRandomPassword();
const nameservers =
const nameservers = (
input.nameservers && input.nameservers.length >= 2
? input.nameservers
: defaultNameservers(cfg.mode);
: defaultNameservers(cfg.mode)
).map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
const contactSet = {
owner: input.contact,
@@ -492,6 +493,10 @@ export async function updateDomainNameservers(
if (!nameservers || nameservers.length < 2) {
throw new Error('At least two nameservers are required');
}
// OpenSRS rejects FQDN-style nameservers (with trailing dot). Cloud DNS
// returns NS records as `ns-cloud-eX.googledomains.com.` so we must strip
// the trailing dot before handing them to the registrar.
const normalized = nameservers.map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
const cfg = configFromEnv(overrides);
const parsed = await rawCall(
'advanced_update_nameservers',
@@ -499,7 +504,7 @@ export async function updateDomainNameservers(
{
domain,
op_type: 'assign',
assign_ns: nameservers,
assign_ns: normalized,
},
cfg,
);

33
lib/prd-sections.ts Normal file
View File

@@ -0,0 +1,33 @@
/** Shared PRD plan sections (discovery phases → document headings). */
export const DISCOVERY_PHASE_ORDER = [
"big_picture",
"users_personas",
"features_scope",
"business_model",
"screens_data",
"risks_questions",
] as const;
export type DiscoveryPhaseId = (typeof DISCOVERY_PHASE_ORDER)[number];
export const PRD_PLAN_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" as const },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" as const },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" as const },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" as const },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" as const },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" as const },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" as const },
{ id: "business_model", label: "Business Model", phaseId: "business_model" as const },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" as const },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" as const },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" as const },
] as const;
/** Matches legacy PrdContent: sections without a phase are never marked done in the checklist. */
export function isSectionFilled(phaseId: string | null, savedPhaseIds: Set<string>): boolean {
if (phaseId === null) return false;
return savedPhaseIds.has(phaseId);
}