Soft caps on the two resources a bad-actor signup could pump fastest:
- 3 active projects per workspace
- 3 active (running/provisioning) dev containers per workspace
Suspended dev containers don't count (they're free), so a power
user can have many projects with most containers idle. Limits are
overridable via env vars (VIBN_QUOTA_MAX_*) for a global lift.
Hits surface as HTTP 402 with structured payload {error, code,
current, limit}. AI's error-recovery middleware matches the
QUOTA_EXCEEDED code and synthesizes guidance: tell the user which
cap was hit, offer to suspend something or contact support, do NOT
retry blindly.
Wired:
- lib/quotas.ts — assertProjectQuota,
assertDevContainerQuota,
getQuotaStatus
- app/api/projects/create/route.ts — checks before create
- lib/dev-container.ts — checks before resume +
net-new ensure
- app/api/mcp/route.ts — devcontainer.ensure
translates QuotaExceededError
to 402
- lib/ai/error-recovery.ts — workspace-quota-exceeded rule
Closes BETA_LAUNCH_PLAN.md task 4.6.
Co-authored-by: Cursor <cursoragent@cursor.com>
159 lines
5.3 KiB
TypeScript
159 lines
5.3 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 = 3;
|
|
const DEFAULT_DEV_CONTAINER_LIMIT = 3;
|
|
|
|
function projectLimit(): number {
|
|
return parsePositiveInt(
|
|
process.env.VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE,
|
|
DEFAULT_PROJECT_LIMIT,
|
|
);
|
|
}
|
|
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(),
|
|
},
|
|
};
|
|
}
|