/** * Per-workspace compute quotas. * * Why these exist: the platform spawns Coolify projects, Docker * containers, and dev VMs in response to AI tool calls. Without a * cap, a single bad-actor signup could spin up unbounded resources * before we notice — and the GCE bill is the one place where bad * actors hit *us* for real money. * * Quotas are SOFT caps — they return friendly errors with a clear * upgrade path. They are NOT a security boundary; auth + tenant * isolation are. They are a "don't melt the fleet" control. * * Numbers are intentionally small for beta. Lift via env vars * once we trust the load profile of real users: * * VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE (default 3) * VIBN_QUOTA_MAX_DEV_CONTAINERS_PER_WORKSPACE (default 3) * * To bump for a single power-user, set their workspace's * `data.quotaOverrides` jsonb on fs_users — TODO when we have * an admin UI; for now, raise the global env var. */ import { query } from "@/lib/db-postgres"; export class QuotaExceededError extends Error { readonly code = "QUOTA_EXCEEDED"; readonly resource: string; readonly current: number; readonly limit: number; constructor( resource: string, current: number, limit: number, message: string, ) { super(message); this.name = "QuotaExceededError"; this.resource = resource; this.current = current; this.limit = limit; } } const DEFAULT_PROJECT_LIMIT = 9999; // Removed limit for users const DEFAULT_DEV_CONTAINER_LIMIT = 3; function projectLimit(): number { return DEFAULT_PROJECT_LIMIT; // Removed process.env.VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE } function devContainerLimit(): number { return parsePositiveInt( process.env.VIBN_QUOTA_MAX_DEV_CONTAINERS_PER_WORKSPACE, DEFAULT_DEV_CONTAINER_LIMIT, ); } function parsePositiveInt(raw: string | undefined, fallback: number): number { const n = Number(raw); if (Number.isFinite(n) && n > 0) return Math.floor(n); return fallback; } /** * Count active projects for a Vibn workspace and throw * QuotaExceededError if creating one more would breach the cap. * * "Active" = fs_projects.data.status === 'active' (the default for * a freshly-created project). Soft-deleted / archived projects are * not counted; they don't consume runtime resources. * * Pass either the workspace UUID (preferred — survives slug changes) * or the slug, or both. We'll OR them together so back-compat works * for older projects that pre-date the vibn_workspace_id column. */ export async function assertProjectQuota(workspace: { id?: string | null; slug?: string | null; }): Promise { const limit = projectLimit(); const rows = await query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM fs_projects WHERE (vibn_workspace_id = $1 OR workspace = $2) AND COALESCE(data->>'status', 'active') = 'active'`, [workspace.id ?? null, workspace.slug ?? null], ); const current = Number(rows[0]?.count ?? 0); if (current >= limit) { throw new QuotaExceededError( "projects", current, limit, `Workspace already has ${current}/${limit} active projects. ` + `Delete one or contact support@vibnai.com to lift the cap.`, ); } } /** * Count running/provisioning dev containers for a workspace and * throw if creating one more would breach the cap. Each Vibn * project has at most one dev container (project_id is unique * key on fs_project_dev_containers), so this functionally caps * the number of *projects with active containers*. * * "Running/provisioning" excludes suspended containers — those * cost nothing while idle and resume on demand. */ export async function assertDevContainerQuota( workspaceSlug: string, ): Promise { const limit = devContainerLimit(); const rows = await query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM fs_project_dev_containers WHERE workspace = $1 AND state IN ('running', 'provisioning')`, [workspaceSlug], ); const current = Number(rows[0]?.count ?? 0); if (current >= limit) { throw new QuotaExceededError( "devContainers", current, limit, `Workspace already has ${current}/${limit} active dev containers running. ` + `Suspend an idle one (it'll resume next time you chat with that project) ` + `or contact support@vibnai.com to lift the cap.`, ); } } /** * Read-only quota status for the workspace — used by an admin UI * (eventually) and by the AI when explaining limits to users. */ export async function getQuotaStatus(workspace: { id?: string | null; slug?: string | null; }) { const [pRows, cRows] = await Promise.all([ query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM fs_projects WHERE (vibn_workspace_id = $1 OR workspace = $2) AND COALESCE(data->>'status', 'active') = 'active'`, [workspace.id ?? null, workspace.slug ?? null], ), workspace.slug ? query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM fs_project_dev_containers WHERE workspace = $1 AND state IN ('running', 'provisioning')`, [workspace.slug], ) : Promise.resolve([{ count: "0" }]), ]); return { projects: { current: Number(pRows[0]?.count ?? 0), limit: projectLimit() }, devContainers: { current: Number(cRows[0]?.count ?? 0), limit: devContainerLimit(), }, }; }