Files
vibn-frontend/app/api/workspaces/[slug]/bootstrap.sh/route.ts
Mark Henderson b9511601bc 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
2026-04-21 10:49:17 -07:00

195 lines
6.6 KiB
TypeScript

/**
* GET /api/workspaces/[slug]/bootstrap.sh
*
* One-shot installer. Intended usage inside a repo:
*
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
*
* Writes three files into the cwd:
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
* - .env.local (appends VIBN_* envs; never overwrites)
*
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
* same token in the generated mcp.json so Cursor agents can re-use it.
* Session auth works too but then nothing is embedded (the user gets
* placeholder strings to fill in themselves).
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
const tokenFromHeader = extractBearer(request);
// For API-key callers we can safely echo the token they sent us
// back into the generated files. For session callers we emit a
// placeholder — we don't want to re-issue long-lived tokens from
// a cookie-authenticated browser request.
const embedToken =
principal.source === 'api_key' && tokenFromHeader
? tokenFromHeader
: '<paste your vibn_sk_ token here>';
const script = buildScript({
slug: ws.slug,
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
appBase: APP_BASE,
token: embedToken,
});
return new NextResponse(script, {
status: 200,
headers: {
'Content-Type': 'text/x-shellscript; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function extractBearer(request: Request): string | null {
const a = request.headers.get('authorization');
if (!a) return null;
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
return m?.[1] ?? null;
}
function buildScript(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
token: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
// Build the file bodies in TS so we can shell-escape them cleanly
// using base64. The script itself does no string interpolation on
// these payloads — it just decodes and writes.
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
const mcp = JSON.stringify(
{
mcpServers: {
[`vibn-${slug}`]: {
url: `${appBase}/api/mcp`,
headers: { Authorization: `Bearer ${token}` },
},
},
},
null,
2
);
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
const b64Env = Buffer.from(env, 'utf8').toString('base64');
return `#!/usr/bin/env sh
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
# Workspace: ${slug}
#
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
# and appends VIBN_* env vars to .env.local (never overwrites).
set -eu
mkdir -p .cursor/rules
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
echo " wrote .cursor/rules/vibn-workspace.mdc"
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
echo " wrote .cursor/mcp.json"
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
echo " .env.local already has VIBN_* — skipping env append"
else
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
echo "${b64Env}" | base64 -d >> .env.local
echo " appended VIBN_* to .env.local"
fi
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
echo '.env.local' >> .gitignore
echo " added .env.local to .gitignore"
fi
echo ""
echo "Vibn workspace '${slug}' is wired up."
echo "Restart Cursor to pick up the new MCP server."
`;
}
function buildCursorRule(opts: {
slug: string;
giteaOrg: string;
coolifyProjectUuid: string;
appBase: string;
}): string {
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
return `---
description: Vibn workspace "${slug}" — one-shot setup for AI agents
alwaysApply: true
---
# Vibn workspace: ${slug}
You are acting on behalf of the Vibn workspace **${slug}**. All AI
integration with Gitea and Coolify happens through the Vibn REST API,
which enforces tenancy for you.
## How to act
1. Before any git or deploy work, call:
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
workspace-scoped bot username, PAT, and clone URL template.
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
as the git remote. Never pass the root admin token to git.
3. For deploys, logs, env vars, call the workspace-scoped Coolify
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
Any cross-tenant attempt is rejected with HTTP 403.
## Identity
- Gitea org: \`${giteaOrg}\`
- Coolify project uuid: \`${coolifyProjectUuid}\`
- API base: \`${appBase}\`
## Useful endpoints
| Method | Path |
|-------:|----------------------------------------------------------------|
| GET | /api/workspaces/${slug} |
| GET | /api/workspaces/${slug}/gitea-credentials |
| GET | /api/workspaces/${slug}/apps |
| GET | /api/workspaces/${slug}/apps/{uuid} |
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
## Rules
- Never print or commit \`$VIBN_API_KEY\`.
- Prefer PRs over force-pushing \`main\`.
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
outside this workspace — stop and ask the user.
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
`;
}