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:
2026-04-21 10:49:17 -07:00
parent 6ccfdee65f
commit b9511601bc
29 changed files with 1716 additions and 330 deletions

View File

@@ -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.
*/