Files
vibn-frontend/app/api/workspaces/[slug]/apps/[uuid]/envs/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

155 lines
4.6 KiB
TypeScript

/**
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
* body: { key, value, is_preview?, is_build_time?, is_literal?, is_multiline? }
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
*
* Tenant boundary: the app must belong to the workspace's Coolify project.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
deleteApplicationEnv,
getApplicationInProject,
listApplicationEnvs,
TenantError,
upsertApplicationEnv,
} from '@/lib/coolify';
async function verify(request: Request, slug: string, uuid: string) {
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return { error: principal };
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return {
error: NextResponse.json(
{ error: 'Workspace has no Coolify project yet' },
{ status: 503 }
),
};
}
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (err) {
if (err instanceof TenantError) {
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
}
return {
error: NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
),
};
}
return { principal };
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
try {
const envs = await listApplicationEnvs(uuid);
// Redact values by default for API-key callers — they can re-fetch
// with ?reveal=true when they need the actual values (e.g. to copy
// a DATABASE_URL). Session callers always get full values.
const url = new URL(request.url);
const reveal =
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
return NextResponse.json({
envs: envs.map(e => ({
key: e.key,
value: reveal ? e.value : maskValue(e.value),
isPreview: e.is_preview ?? false,
isBuildTime: e.is_build_time ?? false,
isLiteral: e.is_literal ?? false,
isMultiline: e.is_multiline ?? false,
})),
});
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
let body: {
key?: string;
value?: string;
is_preview?: boolean;
is_build_time?: boolean;
is_literal?: boolean;
is_multiline?: boolean;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.key || typeof body.value !== 'string') {
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
}
try {
const env = await upsertApplicationEnv(uuid, {
key: body.key,
value: body.value,
is_preview: body.is_preview ?? false,
is_build_time: body.is_build_time ?? false,
is_literal: body.is_literal ?? false,
is_multiline: body.is_multiline ?? false,
});
return NextResponse.json({ ok: true, key: env.key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const check = await verify(request, slug, uuid);
if ('error' in check) return check.error;
const key = new URL(request.url).searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
}
try {
await deleteApplicationEnv(uuid, key);
return NextResponse.json({ ok: true, key });
} catch (err) {
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
);
}
}
function maskValue(v: string): string {
if (!v) return '';
if (v.length <= 4) return '•'.repeat(v.length);
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
}