Files
vibn-frontend/lib/workspaces.ts
Mark Henderson d9d3514647 fix(gitea-bot): mint PAT via Basic auth, not Sudo header
Gitea's POST /users/{name}/tokens is explicitly Basic-auth only;
neither the admin token nor Sudo header is accepted. Keep the random
password we generate at createUser time and pass it straight into
createAccessTokenFor as Basic auth.

For bots that already exist from a half-failed previous provision
run, reset their password via PATCH /admin/users/{name} so we can
Basic-auth as them and mint a fresh token.

Made-with: Cursor
2026-04-21 10:58:25 -07:00

398 lines
15 KiB
TypeScript

/**
* Vibn workspaces — logical multi-tenancy on top of Coolify + Gitea.
*
* Each Vibn user gets one workspace. The workspace owns:
* - a Coolify Project UUID (the team/namespace boundary inside Coolify)
* - a Gitea org (which contains all repos for the workspace)
*
* All Vibn projects, apps, deployments, and AI access keys are
* scoped to a single workspace. Code that touches Coolify or Gitea
* MUST resolve a workspace first and use its IDs (never the legacy
* hardcoded admin user / project UUID).
*
* Coolify cannot create real Teams via its public API today — see
* Coolify changelog notes about scoping queries to current team and
* the lack of POST /teams. We treat one Coolify *Project* as our
* tenant boundary instead, and stamp `coolify_team_id` later if/when
* 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,
createUser,
createAccessTokenFor,
ensureOrgTeamMembership,
adminEditUser,
} from '@/lib/gitea';
import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box';
export interface VibnWorkspace {
id: string;
slug: string;
name: string;
owner_user_id: string;
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;
updated_at: Date;
}
export interface VibnWorkspaceMember {
id: string;
workspace_id: string;
user_id: string;
role: 'owner' | 'admin' | 'member';
created_at: Date;
}
// ──────────────────────────────────────────────────
// Slug helpers
// ──────────────────────────────────────────────────
const SAFE_SLUG = /[^a-z0-9]+/g;
export function workspaceSlugFromEmail(email: string): string {
const local = email.split('@')[0]?.toLowerCase() ?? 'user';
return local.replace(SAFE_SLUG, '-').replace(/^-+|-+$/g, '') || 'user';
}
/** Coolify Project name we use for a workspace. Prefixed to avoid collisions. */
export function coolifyProjectNameFor(slug: string): string {
return `vibn-ws-${slug}`;
}
/** Gitea org name we use for a workspace. Same prefix for consistency. */
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
// ──────────────────────────────────────────────────
export async function getWorkspaceById(id: string): Promise<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]);
}
export async function getWorkspaceBySlug(slug: string): Promise<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]);
}
export async function getWorkspaceByOwner(userId: string): Promise<VibnWorkspace | null> {
return queryOne<VibnWorkspace>(
`SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`,
[userId]
);
}
export async function listWorkspacesForUser(userId: string): Promise<VibnWorkspace[]> {
return query<VibnWorkspace>(
`SELECT w.* FROM vibn_workspaces w
LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id
WHERE w.owner_user_id = $1 OR m.user_id = $1
GROUP BY w.id
ORDER BY w.created_at ASC`,
[userId]
);
}
export async function userHasWorkspaceAccess(userId: string, workspaceId: string): Promise<boolean> {
const row = await queryOne<{ id: string }>(
`SELECT w.id FROM vibn_workspaces w
LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id AND m.user_id = $1
WHERE w.id = $2 AND (w.owner_user_id = $1 OR m.user_id = $1)
LIMIT 1`,
[userId, workspaceId]
);
return !!row;
}
// ──────────────────────────────────────────────────
// Get-or-create + provision
// ──────────────────────────────────────────────────
/**
* Idempotently ensures a workspace row exists for the user. Does NOT
* provision Coolify/Gitea — call ensureWorkspaceProvisioned() for that.
*
* Suitable to call from the NextAuth signIn callback (cheap, single insert).
*/
export async function ensureWorkspaceForUser(opts: {
userId: string;
email: string;
displayName?: string | null;
}): Promise<VibnWorkspace> {
const existing = await getWorkspaceByOwner(opts.userId);
if (existing) return existing;
const slug = await pickAvailableSlug(workspaceSlugFromEmail(opts.email));
const name = opts.displayName?.trim() || opts.email.split('@')[0];
const inserted = await query<VibnWorkspace>(
`INSERT INTO vibn_workspaces (slug, name, owner_user_id)
VALUES ($1, $2, $3)
RETURNING *`,
[slug, name, opts.userId]
);
const workspace = inserted[0];
await query(
`INSERT INTO vibn_workspace_members (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING`,
[workspace.id, opts.userId]
);
return workspace;
}
/**
* Provisions Coolify Project + Gitea org for a workspace if not yet done.
* Idempotent. Failures are recorded on the row but do not throw — callers
* can retry by calling again. Returns the up-to-date workspace row.
*/
export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Promise<VibnWorkspace> {
if (workspace.provision_status === 'ready') return workspace;
let coolifyUuid = workspace.coolify_project_uuid;
let giteaOrg = workspace.gitea_org;
const errors: string[] = [];
// ── Coolify Project ────────────────────────────────────────────────
if (!coolifyUuid) {
try {
const project = await createCoolifyProject(
coolifyProjectNameFor(workspace.slug),
`Vibn workspace ${workspace.slug}`
);
coolifyUuid = project.uuid;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Coolify returns 400/409 if the name collides — fall through; the
// workspace can still be patched manually with the right UUID.
errors.push(`coolify: ${msg}`);
console.error('[workspaces] Coolify provisioning failed', workspace.slug, msg);
}
}
// ── Gitea Org ──────────────────────────────────────────────────────
if (!giteaOrg) {
const wantOrg = giteaOrgNameFor(workspace.slug);
try {
const existingOrg = await getOrg(wantOrg);
if (existingOrg) {
giteaOrg = existingOrg.username;
} else {
const created = await createOrg({
name: wantOrg,
fullName: workspace.name,
description: `Vibn workspace for ${workspace.slug}`,
visibility: 'private',
});
giteaOrg = created.username;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`gitea: ${msg}`);
console.error('[workspaces] Gitea org provisioning failed', workspace.slug, msg);
}
}
// ── Add the workspace owner to the Gitea org if they have a Gitea account.
// Best-effort: most Vibn users won't have a Gitea login, so a 404 is fine.
if (giteaOrg) {
try {
const ownerEmail = await queryOne<{ email: string }>(
`SELECT data->>'email' AS email FROM fs_users WHERE id = $1`,
[workspace.owner_user_id]
);
const candidateLogin = ownerEmail?.email
? workspaceSlugFromEmail(ownerEmail.email)
: null;
if (candidateLogin) {
const giteaUser = await getUser(candidateLogin);
if (giteaUser) {
await addOrgOwner(giteaOrg, giteaUser.login);
}
}
} catch (err) {
// Membership add is best-effort
console.warn('[workspaces] Skipping Gitea owner add', err);
}
}
// ── 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. We *always* generate a fresh
// random password — if the user already exists, we can't
// retrieve its password to auth with, so we'll fail at the
// token-mint step. The `partial` provision status + error
// message tells the operator to delete the bot user in Gitea
// (or reset its password) and re-run. Since this only hurts
// re-provisioning after a half-failed run, we also try to
// reset the password via the admin API in that case.
const password = `bot-${randomBytes(24).toString('base64url')}`;
let existingBot = await getUser(wantBot);
if (!existingBot) {
const created = await createUser({
username: wantBot,
email: giteaBotEmailFor(workspace.slug),
password,
fullName: `Vibn bot (${workspace.slug})`,
});
existingBot = { id: created.id, login: created.login };
} else {
// Existing bot from a half-failed previous run — reset its
// password so we can basic-auth as it below.
await adminEditUser({ username: wantBot, password });
}
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,
password,
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'] =
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),
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,
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
* has somewhere to land.
*/
export async function getOrCreateProvisionedWorkspace(opts: {
userId: string;
email: string;
displayName?: string | null;
}): Promise<VibnWorkspace> {
const ws = await ensureWorkspaceForUser(opts);
return ensureWorkspaceProvisioned(ws);
}
// ──────────────────────────────────────────────────
// Slug uniqueness
// ──────────────────────────────────────────────────
async function pickAvailableSlug(base: string): Promise<string> {
// Try base, then base-2, base-3, … up to base-99.
for (let i = 0; i < 100; i++) {
const candidate = i === 0 ? base : `${base}-${i + 1}`;
const existing = await queryOne<{ id: string }>(
`SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1`,
[candidate]
);
if (!existing) return candidate;
}
// Extremely unlikely fallback
return `${base}-${Date.now().toString(36)}`;
}