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:
2026-04-21 10:49:17 -07:00
parent 6ccfdee65f
commit b9511601bc
29 changed files with 1716 additions and 330 deletions

View File

@@ -42,10 +42,18 @@ interface WorkspaceSummary {
coolifyProjectUuid: string | null;
coolifyTeamId: number | null;
giteaOrg: string | null;
giteaBotUsername?: string | null;
giteaBotReady?: boolean;
provisionStatus: "pending" | "partial" | "ready" | "error";
provisionError: string | null;
}
interface GiteaBotCreds {
bot: { username: string; token: string };
gitea: { apiBase: string; host: string; cloneUrlTemplate: string; sshRemoteTemplate: string; webUrlTemplate: string };
workspace: { slug: string; giteaOrg: string };
}
interface ApiKey {
id: string;
name: string;
@@ -238,7 +246,7 @@ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?
onRefresh={refresh}
/>
<CursorIntegrationCard workspace={workspace} />
<AiAccessBundleCard workspace={workspace} />
{/* ── Create key modal ─────────────────────────────────────── */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
@@ -524,53 +532,229 @@ function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) {
}
// ──────────────────────────────────────────────────
// Cursor / VS Code integration block
// AI access bundle — one card that replaces all the scattered snippets
// ──────────────────────────────────────────────────
function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) {
const cursorRule = buildCursorRule(workspace);
const mcpJson = buildMcpJson(workspace, "<paste-your-vibn_sk_-token>");
const envSnippet = buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>");
function AiAccessBundleCard({ workspace }: { workspace: WorkspaceSummary }) {
const [botCreds, setBotCreds] = useState<GiteaBotCreds | null>(null);
const [revealingBot, setRevealingBot] = useState(false);
const [hideToken, setHideToken] = useState(true);
const revealBotCreds = useCallback(async () => {
setRevealingBot(true);
try {
const res = await fetch(`/api/workspaces/${workspace.slug}/gitea-credentials`, {
credentials: "include",
});
if (!res.ok) {
const body = await res.text();
throw new Error(body || `HTTP ${res.status}`);
}
const j = (await res.json()) as GiteaBotCreds;
setBotCreds(j);
setHideToken(true);
} catch (err) {
toast.error(
`Couldn't fetch Gitea bot: ${err instanceof Error ? err.message : String(err)}`
);
} finally {
setRevealingBot(false);
}
}, [workspace.slug]);
const promptBlock = useMemo(() => buildPromptBlock(workspace), [workspace]);
const cursorRule = useMemo(() => buildCursorRule(workspace), [workspace]);
const mcpJson = useMemo(
() => buildMcpJson(workspace, "<paste-your-vibn_sk_-token>"),
[workspace]
);
const envSnippet = useMemo(
() => buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>"),
[workspace]
);
const bootstrapCmd = useMemo(
() =>
`curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`,
[workspace.slug]
);
return (
<section style={cardStyle}>
<header style={cardHeaderStyle}>
<div>
<h2 style={cardTitleStyle}>Connect Cursor</h2>
<h2 style={cardTitleStyle}>AI access bundle</h2>
<p style={cardSubtitleStyle}>
Drop these into your repo (or <code>~/.cursor/</code>) so any
agent inside Cursor knows how to talk to this workspace.
Everything an AI agent needs to act on this workspace: a
system prompt, a one-line installer, and the low-level
snippets if you prefer to wire it up by hand.
</p>
</div>
</header>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
filename="vibn-workspace.mdc"
contents={cursorRule}
title="1. Paste into your AI's system prompt"
description="Tells the agent how this workspace works and which endpoints to call. Works in ChatGPT, Claude, Cursor agent, etc."
filename={`vibn-${workspace.slug}-prompt.md`}
contents={promptBlock}
language="markdown"
/>
<FileBlock
title="~/.cursor/mcp.json"
description="Registers Vibn as an MCP server so the agent can call workspace endpoints natively. Paste your minted key in place of the placeholder."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local (for shell / scripts)"
description="If the agent shells out (curl, gh, scripts), expose the key as an env var."
filename="vibn.env"
contents={envSnippet}
title="2. One-line setup inside any repo"
description="Mint a key above, export it as VIBN_API_KEY, then run this. It writes .cursor/rules, .cursor/mcp.json, and .env.local."
filename="vibn-bootstrap.sh"
contents={bootstrapCmd}
language="bash"
/>
<GiteaBotReveal
workspace={workspace}
creds={botCreds}
revealing={revealingBot}
hideToken={hideToken}
onReveal={revealBotCreds}
onToggleHide={() => setHideToken(h => !h)}
/>
<details style={{ marginTop: 18 }}>
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
Manual setup individual files
</summary>
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 12 }}>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
filename="vibn-workspace.mdc"
contents={cursorRule}
language="markdown"
/>
<FileBlock
title=".cursor/mcp.json"
description="Registers Vibn as an MCP server. Paste your minted key in place of the placeholder."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local"
description="Expose the key as an env var for shell scripts."
filename="vibn.env"
contents={envSnippet}
language="bash"
/>
</div>
</details>
</section>
);
}
function GiteaBotReveal({
workspace,
creds,
revealing,
hideToken,
onReveal,
onToggleHide,
}: {
workspace: WorkspaceSummary;
creds: GiteaBotCreds | null;
revealing: boolean;
hideToken: boolean;
onReveal: () => void;
onToggleHide: () => void;
}) {
const cloneExample = creds
? creds.gitea.cloneUrlTemplate.replace("{{repo}}", "example-repo")
: "";
const displayedToken = creds
? hideToken
? `${creds.bot.token.slice(0, 6)}${"•".repeat(24)}${creds.bot.token.slice(-4)}`
: creds.bot.token
: "";
return (
<div
style={{
marginTop: 18,
border: "1px solid var(--border, #e5e7eb)",
borderRadius: 8,
background: "#fff",
padding: 14,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
3. Gitea bot credentials
</div>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 2, lineHeight: 1.5 }}>
A dedicated Gitea user scoped to the <code>{workspace.giteaOrg ?? "(unprovisioned)"}</code> org.
Use this to <code>git clone</code> / push without exposing any root token.
</div>
</div>
{creds ? (
<Button variant="ghost" size="sm" onClick={onToggleHide}>
{hideToken ? "Show token" : "Hide"}
</Button>
) : (
<Button size="sm" onClick={onReveal} disabled={revealing}>
{revealing ? <Loader2 className="animate-spin" size={14} /> : <KeyRound size={14} />}
Reveal bot PAT
</Button>
)}
</div>
{creds && (
<div style={{ marginTop: 12, display: "grid", gap: 8, fontSize: 12.5 }}>
<KV k="Username" v={<code>{creds.bot.username}</code>} />
<KV
k="PAT"
v={
<code
style={{ fontFamily: "monospace", wordBreak: "break-all", userSelect: "all" }}
>
{displayedToken}
</code>
}
/>
<KV k="Org" v={<code>{creds.workspace.giteaOrg}</code>} />
<KV k="API base" v={<code>{creds.gitea.apiBase}</code>} />
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>
Clone URL (example)
</div>
<code style={{ fontFamily: "monospace", fontSize: 11.5, color: "var(--ink)", wordBreak: "break-all", userSelect: "all" }}>
{hideToken
? cloneExample.replace(creds.bot.token, `${creds.bot.token.slice(0, 6)}`)
: cloneExample}
</code>
</div>
</div>
)}
</div>
);
}
function KV({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div style={{ display: "flex", gap: 12, alignItems: "baseline", flexWrap: "wrap" }}>
<div
style={{
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--muted)",
minWidth: 80,
}}
>
{k}
</div>
<div style={{ color: "var(--ink)" }}>{v}</div>
</div>
);
}
function FileBlock({
title,
description,
@@ -648,6 +832,8 @@ function FileBlock({
}
function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) {
const bootstrapCmd = `export VIBN_API_KEY=${minted.token}
curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`;
const cursorRule = buildCursorRule(workspace);
const mcpJson = buildMcpJson(workspace, minted.token);
const envSnippet = buildEnvSnippet(workspace, minted.token);
@@ -662,30 +848,79 @@ function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; min
language="text"
/>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Drop into your repo so Cursor agents know about the Vibn API."
filename="vibn-workspace.mdc"
contents={cursorRule}
language="markdown"
/>
<FileBlock
title="~/.cursor/mcp.json (key embedded)"
description="Paste into Cursor's MCP config to register Vibn as a tool source."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local"
description="For shell / script use."
filename="vibn.env"
contents={envSnippet}
title="One-line setup (recommended)"
description="Paste into a terminal from the root of any repo. It writes .cursor/rules, .cursor/mcp.json, and .env.local for you."
filename="vibn-bootstrap.sh"
contents={bootstrapCmd}
language="bash"
/>
<details>
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
Manual setup individual files
</summary>
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 14 }}>
<FileBlock
title=".cursor/rules/vibn-workspace.mdc"
description="Drop into your repo so Cursor agents know about the Vibn API."
filename="vibn-workspace.mdc"
contents={cursorRule}
language="markdown"
/>
<FileBlock
title=".cursor/mcp.json (key embedded)"
description="Paste into Cursor's MCP config to register Vibn as a tool source."
filename="mcp.json"
contents={mcpJson}
language="json"
/>
<FileBlock
title=".env.local"
description="For shell / script use."
filename="vibn.env"
contents={envSnippet}
language="bash"
/>
</div>
</details>
</div>
);
}
function buildPromptBlock(w: WorkspaceSummary): string {
return `You are acting on the Vibn workspace "${w.slug}".
API base: ${APP_BASE}
Authorization header: Bearer $VIBN_API_KEY (set in .env.local, never print it)
Before doing any git work call:
GET ${APP_BASE}/api/workspaces/${w.slug}/gitea-credentials
That returns a workspace-scoped bot username, PAT, and a \`cloneUrlTemplate\`
with \`{{repo}}\` placeholder. Use that template for all \`git clone\` / push
remotes. Never use any other git credentials.
For deploys, logs, and env vars, use the workspace-scoped Coolify endpoints:
GET /api/workspaces/${w.slug}/apps
GET /api/workspaces/${w.slug}/apps/{uuid}
POST /api/workspaces/${w.slug}/apps/{uuid}/deploy
GET /api/workspaces/${w.slug}/apps/{uuid}/envs
PATCH /api/workspaces/${w.slug}/apps/{uuid}/envs body: {"key","value",...}
DELETE /api/workspaces/${w.slug}/apps/{uuid}/envs?key=FOO
All responses are tenant-scoped — a 403 means you're touching another
workspace's resources, which is not allowed. Stop and ask the user.
Workspace identity:
- Gitea org: ${w.giteaOrg ?? "(unprovisioned)"}
- Coolify project uuid: ${w.coolifyProjectUuid ?? "(unprovisioned)"}
- Provision status: ${w.provisionStatus}
Rules:
1. Ask before creating new projects, repos, or deployments.
2. Prefer PRs over force-pushing main.
3. Treat VIBN_API_KEY like a password — never echo or commit it.
`;
}
// ──────────────────────────────────────────────────
// File generators
// ──────────────────────────────────────────────────