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:
46
lib/auth/secret-box.ts
Normal file
46
lib/auth/secret-box.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.)
|
||||
* at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ).
|
||||
*
|
||||
* The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR
|
||||
* any string we hash down to 32 bytes. We hash with SHA-256 so both
|
||||
* forms work — rotating just means generating a new env value and
|
||||
* re-provisioning workspaces.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const raw = process.env.VIBN_SECRETS_KEY;
|
||||
if (!raw || raw.length < 16) {
|
||||
throw new Error(
|
||||
'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets'
|
||||
);
|
||||
}
|
||||
// Normalize any input into a 32-byte key via SHA-256.
|
||||
return createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, enc, tag]).toString('base64');
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string): string {
|
||||
const buf = Buffer.from(payload, 'base64');
|
||||
if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short');
|
||||
const iv = buf.subarray(0, IV_BYTES);
|
||||
const tag = buf.subarray(buf.length - 16);
|
||||
const ciphertext = buf.subarray(IV_BYTES, buf.length - 16);
|
||||
const key = getKey();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return dec.toString('utf8');
|
||||
}
|
||||
114
lib/coolify.ts
114
lib/coolify.ts
@@ -22,6 +22,10 @@ export interface CoolifyDatabase {
|
||||
status: string;
|
||||
internal_db_url?: string;
|
||||
external_db_url?: string;
|
||||
/** When true, Coolify publishes a host port for remote connections */
|
||||
is_public?: boolean;
|
||||
/** Host port mapped to 5432 inside the container */
|
||||
public_port?: number;
|
||||
}
|
||||
|
||||
export interface CoolifyApplication {
|
||||
@@ -31,6 +35,21 @@ export interface CoolifyApplication {
|
||||
fqdn?: string;
|
||||
git_repository?: string;
|
||||
git_branch?: string;
|
||||
project_uuid?: string;
|
||||
environment_name?: string;
|
||||
/** Coolify sometimes nests these under an `environment` object */
|
||||
environment?: { project_uuid?: string; project?: { uuid?: string } };
|
||||
}
|
||||
|
||||
export interface CoolifyEnvVar {
|
||||
uuid?: string;
|
||||
key: string;
|
||||
value: string;
|
||||
is_preview?: boolean;
|
||||
is_build_time?: boolean;
|
||||
is_literal?: boolean;
|
||||
is_multiline?: boolean;
|
||||
is_shown_once?: boolean;
|
||||
}
|
||||
|
||||
async function coolifyFetch(path: string, options: RequestInit = {}) {
|
||||
@@ -203,3 +222,98 @@ export async function getApplication(uuid: string): Promise<CoolifyApplication>
|
||||
export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> {
|
||||
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
|
||||
}
|
||||
|
||||
export async function listApplicationDeployments(uuid: string): Promise<Array<{
|
||||
uuid: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
finished_at?: string;
|
||||
commit?: string;
|
||||
}>> {
|
||||
return coolifyFetch(`/applications/${uuid}/deployments`);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Environment variables
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function listApplicationEnvs(uuid: string): Promise<CoolifyEnvVar[]> {
|
||||
return coolifyFetch(`/applications/${uuid}/envs`);
|
||||
}
|
||||
|
||||
export async function upsertApplicationEnv(
|
||||
uuid: string,
|
||||
env: CoolifyEnvVar & { is_preview?: boolean }
|
||||
): Promise<CoolifyEnvVar> {
|
||||
// Coolify accepts PATCH for updates and POST for creates. We try
|
||||
// PATCH first (idempotent upsert on key), fall back to POST.
|
||||
try {
|
||||
return await coolifyFetch(`/applications/${uuid}/envs`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(env),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('404') || msg.includes('405')) {
|
||||
return coolifyFetch(`/applications/${uuid}/envs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(env),
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApplicationEnv(uuid: string, key: string): Promise<void> {
|
||||
await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tenant helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the Coolify project UUID an application belongs to, working
|
||||
* around Coolify v4 sometimes nesting it under `environment`.
|
||||
*/
|
||||
export function projectUuidOf(app: CoolifyApplication): string | null {
|
||||
return (
|
||||
app.project_uuid ??
|
||||
app.environment?.project_uuid ??
|
||||
app.environment?.project?.uuid ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an application AND verify it lives in the expected Coolify
|
||||
* project. Throws a `TenantError` when the app is cross-tenant so
|
||||
* callers can translate to HTTP 403.
|
||||
*/
|
||||
export class TenantError extends Error {
|
||||
status = 403 as const;
|
||||
}
|
||||
|
||||
export async function getApplicationInProject(
|
||||
appUuid: string,
|
||||
expectedProjectUuid: string
|
||||
): Promise<CoolifyApplication> {
|
||||
const app = await getApplication(appUuid);
|
||||
const actualProject = projectUuidOf(app);
|
||||
if (!actualProject || actualProject !== expectedProjectUuid) {
|
||||
throw new TenantError(
|
||||
`Application ${appUuid} does not belong to project ${expectedProjectUuid}`
|
||||
);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
/** List applications that belong to the given Coolify project. */
|
||||
export async function listApplicationsInProject(
|
||||
projectUuid: string
|
||||
): Promise<CoolifyApplication[]> {
|
||||
const all = await listApplications();
|
||||
return all.filter(a => projectUuidOf(a) === projectUuid);
|
||||
}
|
||||
|
||||
139
lib/gitea.ts
139
lib/gitea.ts
@@ -145,6 +145,145 @@ export async function getUser(username: string): Promise<{ id: number; login: st
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Admin: create users + mint PATs + team management
|
||||
// (used for per-workspace bot provisioning)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export interface GiteaUser {
|
||||
id: number;
|
||||
login: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Gitea user via the admin API. Requires the root admin token
|
||||
* to have the site-admin bit. `must_change_password: false` because the
|
||||
* bot never logs in interactively — only the PAT is used.
|
||||
*/
|
||||
export async function createUser(opts: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
fullName?: string;
|
||||
}): Promise<GiteaUser> {
|
||||
return giteaFetch(`/admin/users`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: opts.username,
|
||||
email: opts.email,
|
||||
password: opts.password,
|
||||
full_name: opts.fullName ?? opts.username,
|
||||
must_change_password: false,
|
||||
send_notify: false,
|
||||
source_id: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a Personal Access Token on behalf of another user. This uses
|
||||
* Gitea's `Sudo` header — only works when GITEA_API_TOKEN is a site-admin
|
||||
* token. Returns the plaintext token string, which must be stored
|
||||
* encrypted (Gitea never shows it again).
|
||||
*/
|
||||
export async function createAccessTokenFor(opts: {
|
||||
username: string;
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> {
|
||||
const { username, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts;
|
||||
return giteaFetch(`/users/${username}/tokens`, {
|
||||
method: 'POST',
|
||||
headers: { Sudo: username },
|
||||
body: JSON.stringify({ name, scopes }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface GiteaTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export async function listOrgTeams(orgName: string): Promise<GiteaTeam[]> {
|
||||
return giteaFetch(`/orgs/${orgName}/teams`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team inside an org with a scoped permission.
|
||||
* `permission`: "read" | "write" | "admin" | "owner".
|
||||
* Used to give bot users less than full owner access.
|
||||
*/
|
||||
export async function createOrgTeam(opts: {
|
||||
org: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permission?: 'read' | 'write' | 'admin';
|
||||
includesAllRepos?: boolean;
|
||||
}): Promise<GiteaTeam> {
|
||||
const {
|
||||
org,
|
||||
name,
|
||||
description = '',
|
||||
permission = 'write',
|
||||
includesAllRepos = true,
|
||||
} = opts;
|
||||
return giteaFetch(`/orgs/${org}/teams`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
permission,
|
||||
includes_all_repositories: includesAllRepos,
|
||||
can_create_org_repo: true,
|
||||
units: [
|
||||
'repo.code',
|
||||
'repo.issues',
|
||||
'repo.pulls',
|
||||
'repo.releases',
|
||||
'repo.wiki',
|
||||
'repo.ext_wiki',
|
||||
'repo.ext_issues',
|
||||
'repo.projects',
|
||||
'repo.actions',
|
||||
'repo.packages',
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Add a user to a team by id. */
|
||||
export async function addOrgTeamMember(teamId: number, username: string): Promise<void> {
|
||||
await giteaFetch(`/teams/${teamId}/members/${username}`, { method: 'PUT' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a team exists on an org with the requested permission, and
|
||||
* that `username` is a member of it. Idempotent.
|
||||
*/
|
||||
export async function ensureOrgTeamMembership(opts: {
|
||||
org: string;
|
||||
teamName: string;
|
||||
permission?: 'read' | 'write' | 'admin';
|
||||
username: string;
|
||||
}): Promise<GiteaTeam> {
|
||||
const { org, teamName, permission = 'write', username } = opts;
|
||||
const teams = await listOrgTeams(org);
|
||||
let team = teams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
|
||||
if (!team) {
|
||||
team = await createOrgTeam({
|
||||
org,
|
||||
name: teamName,
|
||||
description: `Vibn ${teamName} team`,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
await addOrgTeamMember(team.id, username);
|
||||
return team;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing repo.
|
||||
*/
|
||||
|
||||
@@ -17,9 +17,19 @@
|
||||
* Coolify exposes team creation.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { query, queryOne } from '@/lib/db-postgres';
|
||||
import { createProject as createCoolifyProject } from '@/lib/coolify';
|
||||
import { createOrg, getOrg, getUser, addOrgOwner } from '@/lib/gitea';
|
||||
import {
|
||||
createOrg,
|
||||
getOrg,
|
||||
getUser,
|
||||
addOrgOwner,
|
||||
createUser,
|
||||
createAccessTokenFor,
|
||||
ensureOrgTeamMembership,
|
||||
} from '@/lib/gitea';
|
||||
import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box';
|
||||
|
||||
export interface VibnWorkspace {
|
||||
id: string;
|
||||
@@ -29,6 +39,9 @@ export interface VibnWorkspace {
|
||||
coolify_project_uuid: string | null;
|
||||
coolify_team_id: number | null;
|
||||
gitea_org: string | null;
|
||||
gitea_bot_username: string | null;
|
||||
gitea_bot_user_id: number | null;
|
||||
gitea_bot_token_encrypted: string | null;
|
||||
provision_status: 'pending' | 'partial' | 'ready' | 'error';
|
||||
provision_error: string | null;
|
||||
created_at: Date;
|
||||
@@ -64,6 +77,16 @@ export function giteaOrgNameFor(slug: string): string {
|
||||
return `vibn-${slug}`;
|
||||
}
|
||||
|
||||
/** Gitea username we mint for each workspace's bot account. */
|
||||
export function giteaBotUsernameFor(slug: string): string {
|
||||
return `vibn-bot-${slug}`;
|
||||
}
|
||||
|
||||
/** Placeholder-looking email so Gitea accepts the user; never delivered to. */
|
||||
export function giteaBotEmailFor(slug: string): string {
|
||||
return `bot+${slug}@vibnai.invalid`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -219,24 +242,114 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-workspace Gitea bot user + PAT ─────────────────────────────
|
||||
// Gives AI agents a credential that is scoped exclusively to this
|
||||
// workspace's org. The bot has no other org memberships, so even a
|
||||
// leaked PAT cannot reach other tenants' repos.
|
||||
let botUsername = workspace.gitea_bot_username;
|
||||
let botUserId = workspace.gitea_bot_user_id;
|
||||
let botTokenEncrypted = workspace.gitea_bot_token_encrypted;
|
||||
|
||||
if (giteaOrg && (!botUsername || !botTokenEncrypted)) {
|
||||
try {
|
||||
const wantBot = giteaBotUsernameFor(workspace.slug);
|
||||
|
||||
// 1. Ensure the bot user exists.
|
||||
let existingBot = await getUser(wantBot);
|
||||
if (!existingBot) {
|
||||
const created = await createUser({
|
||||
username: wantBot,
|
||||
email: giteaBotEmailFor(workspace.slug),
|
||||
// Password is never used (only the PAT is), but Gitea requires one.
|
||||
password: `bot-${randomBytes(24).toString('base64url')}`,
|
||||
fullName: `Vibn bot (${workspace.slug})`,
|
||||
});
|
||||
existingBot = { id: created.id, login: created.login };
|
||||
}
|
||||
botUsername = existingBot.login;
|
||||
botUserId = existingBot.id;
|
||||
|
||||
// 2. Add the bot to the org's Writers team (scoped permissions).
|
||||
await ensureOrgTeamMembership({
|
||||
org: giteaOrg,
|
||||
teamName: 'Writers',
|
||||
permission: 'write',
|
||||
username: botUsername,
|
||||
});
|
||||
|
||||
// 3. Mint a PAT for the bot. Gitea shows the plaintext exactly
|
||||
// once, so if we already stored an encrypted copy we skip.
|
||||
if (!botTokenEncrypted) {
|
||||
const pat = await createAccessTokenFor({
|
||||
username: botUsername,
|
||||
name: `vibn-${workspace.slug}-${Date.now().toString(36)}`,
|
||||
scopes: ['write:repository', 'write:issue', 'write:user'],
|
||||
});
|
||||
botTokenEncrypted = encryptSecret(pat.sha1);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`gitea-bot: ${msg}`);
|
||||
console.error('[workspaces] Gitea bot provisioning failed', workspace.slug, msg);
|
||||
}
|
||||
}
|
||||
|
||||
const allReady = !!(coolifyUuid && giteaOrg && botUsername && botTokenEncrypted);
|
||||
const status: VibnWorkspace['provision_status'] =
|
||||
coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending';
|
||||
allReady ? 'ready' : errors.length > 0 ? 'partial' : 'pending';
|
||||
|
||||
const updated = await query<VibnWorkspace>(
|
||||
`UPDATE vibn_workspaces
|
||||
SET coolify_project_uuid = COALESCE($2, coolify_project_uuid),
|
||||
gitea_org = COALESCE($3, gitea_org),
|
||||
provision_status = $4,
|
||||
provision_error = $5,
|
||||
updated_at = now()
|
||||
SET coolify_project_uuid = COALESCE($2, coolify_project_uuid),
|
||||
gitea_org = COALESCE($3, gitea_org),
|
||||
gitea_bot_username = COALESCE($4, gitea_bot_username),
|
||||
gitea_bot_user_id = COALESCE($5, gitea_bot_user_id),
|
||||
gitea_bot_token_encrypted= COALESCE($6, gitea_bot_token_encrypted),
|
||||
provision_status = $7,
|
||||
provision_error = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[workspace.id, coolifyUuid, giteaOrg, status, errors.length ? errors.join('; ') : null]
|
||||
[
|
||||
workspace.id,
|
||||
coolifyUuid,
|
||||
giteaOrg,
|
||||
botUsername,
|
||||
botUserId,
|
||||
botTokenEncrypted,
|
||||
status,
|
||||
errors.length ? errors.join('; ') : null,
|
||||
]
|
||||
);
|
||||
|
||||
return updated[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and return the bot credentials for a workspace. Call this
|
||||
* from endpoints that need to hand the AI a usable git clone URL.
|
||||
* Returns null when the workspace has not been fully provisioned.
|
||||
*/
|
||||
export function getWorkspaceBotCredentials(workspace: VibnWorkspace): {
|
||||
username: string;
|
||||
token: string;
|
||||
org: string;
|
||||
} | null {
|
||||
if (!workspace.gitea_bot_username || !workspace.gitea_bot_token_encrypted || !workspace.gitea_org) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
username: workspace.gitea_bot_username,
|
||||
token: decryptSecret(workspace.gitea_bot_token_encrypted),
|
||||
org: workspace.gitea_org,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[workspaces] Failed to decrypt bot token for', workspace.slug, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: get-or-create + provision in one call. Used by the
|
||||
* project-create flow so the first project in a fresh account always
|
||||
|
||||
Reference in New Issue
Block a user