/** * 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 { return queryOne(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]); } export async function getWorkspaceBySlug(slug: string): Promise { return queryOne(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]); } export async function getWorkspaceByOwner(userId: string): Promise { return queryOne( `SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`, [userId] ); } export async function listWorkspacesForUser(userId: string): Promise { return query( `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 { 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 { 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( `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 { 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( `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 { const ws = await ensureWorkspaceForUser(opts); return ensureWorkspaceProvisioned(ws); } // ────────────────────────────────────────────────── // Slug uniqueness // ────────────────────────────────────────────────── async function pickAvailableSlug(base: string): Promise { // 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)}`; }