Files
vibn-frontend/lib/quotas.ts
Mark Henderson 70d2176cb4 feat(quotas): per-workspace soft caps + AI recovery rule
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>
2026-05-01 12:54:51 -07:00

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(),
},
};
}