This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/lib/quotas.ts

166 lines
5.4 KiB
TypeScript

/**
* 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<void> {
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<void> {
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(),
},
};
}