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:
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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.
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user