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:
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
name?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
creationStage?: string;
|
||||
sourceData?: {
|
||||
chatText?: string;
|
||||
repoUrl?: string;
|
||||
liveUrl?: string;
|
||||
hosting?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysisResult?: Record<string, unknown>;
|
||||
migrationPlan?: string;
|
||||
}
|
||||
|
||||
export default function ProjectOverviewPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { status: authStatus } = useSession();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus !== "authenticated") {
|
||||
if (authStatus === "unauthenticated") setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [authStatus, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||
Project not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectName = project.productName || project.name || "Untitled";
|
||||
const mode = project.creationMode ?? "fresh";
|
||||
|
||||
if (mode === "chat-import") {
|
||||
return (
|
||||
<ChatImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "code-import") {
|
||||
return (
|
||||
<CodeImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "migration") {
|
||||
return (
|
||||
<MigrateMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
migrationPlan={project.migrationPlan}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||
return (
|
||||
<FreshIdeaMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user