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:
@@ -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
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user