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
195 lines
6.6 KiB
TypeScript
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.
|
|
`;
|
|
}
|