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:
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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user