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>
This commit is contained in:
2026-05-01 12:54:51 -07:00
parent 9ddbe5b7d8
commit 70d2176cb4
5 changed files with 207 additions and 0 deletions

View File

@@ -60,6 +60,7 @@ import {
getSentryIssueDetail,
resolveSentryIssue,
} from '@/lib/integrations/sentry';
import { QuotaExceededError } from '@/lib/quotas';
import {
composeUp,
composePs,
@@ -3461,6 +3462,14 @@ async function toolDevContainerEnsure(principal: Principal, params: Record<strin
});
return NextResponse.json({ result: r });
} catch (err) {
// Quota exceeded → 402 with structured payload so the AI's
// tool-error recovery middleware can spot it (see error-recovery.ts).
if (err instanceof QuotaExceededError) {
return NextResponse.json(
{ error: err.message, code: err.code, current: err.current, limit: err.limit },
{ status: 402 },
);
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },

View File

@@ -6,6 +6,7 @@ import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPO
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import { ensureProjectCoolifyProject } from '@/lib/projects';
import { ensureSentryProject } from '@/lib/integrations/sentry';
import { assertProjectQuota, QuotaExceededError } from '@/lib/quotas';
import { loadGithubIntegration } from '@/lib/integrations/github';
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
@@ -91,6 +92,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Project slug already exists' }, { status: 400 });
}
// Per-workspace project quota — soft cap at VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE
// (default 3) to bound runaway resource cost from bad-actor signups.
try {
await assertProjectQuota({ id: vibnWorkspace.id, slug: workspace });
} catch (qe) {
if (qe instanceof QuotaExceededError) {
return NextResponse.json(
{ error: qe.message, code: qe.code, current: qe.current, limit: qe.limit },
{ status: 402 }, // 402 Payment Required — semantically "you've hit a tier limit".
);
}
throw qe;
}
const projectId = randomUUID();
const now = new Date().toISOString();