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,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
status?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaRepo?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
coolifyDeployUrl?: string;
|
||||
customDomain?: string;
|
||||
prd?: string;
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05", marginBottom: 12, ...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeploymentPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [customDomainInput, setCustomDomainInput] = useState("");
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleConnectDomain = async () => {
|
||||
if (!customDomainInput.trim()) return;
|
||||
setConnecting(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
toast.info("Domain connection coming soon — we'll hook this to Coolify.");
|
||||
setConnecting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||
const hasPRD = Boolean(project?.prd);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h3 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||
}}>
|
||||
Deployment
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||
Links, environments, and hosting for {project?.productName ?? "this project"}
|
||||
</p>
|
||||
|
||||
{/* Project URLs */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Project URLs</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<>
|
||||
{project?.coolifyDeployUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>▲</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Staging</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
||||
</div>
|
||||
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.customDomain && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#2e7d3210", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#2e7d32" }}>●</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Production</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#2e7d32", fontWeight: 500 }}>{project.customDomain}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
||||
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.giteaRepoUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>⚙</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Build repo</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||
</div>
|
||||
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
View ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||
{!hasPRD
|
||||
? "Complete your PRD with Vibn first, then build and deploy."
|
||||
: !hasRepo
|
||||
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
||||
: "No deployment yet — kick off a build to get a live URL."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Custom domain */}
|
||||
{hasDeploy && !project?.customDomain && (
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Custom Domain</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 14 }}>
|
||||
Point your own domain to this project. SSL certificates are handled automatically.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
placeholder="app.yourdomain.com"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
style={{ flex: 1, padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a" }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleConnectDomain}
|
||||
disabled={connecting}
|
||||
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{/* Environment variables */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Environment Variables</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
Manage environment variables in Coolify for your deployed services.
|
||||
{project?.coolifyDeployUrl && (
|
||||
<> <a href="http://34.19.250.135:8000" target="_blank" rel="noopener noreferrer" style={{ color: "#3d5afe", textDecoration: "none" }}>Open Coolify ↗</a></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>Available after first build completes.</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Deploy history */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Deploy History</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
{project?.status === "live"
|
||||
? "Deploy history will appear here."
|
||||
: "No deploys yet."}
|
||||
</p>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user