166 lines
5.4 KiB
TypeScript
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(),
|
|
},
|
|
};
|
|
}
|