feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s

The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.

Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
  + pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors

Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
  upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
  Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
  Zhipu (CN) and which tier uses which

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-23 23:50:00 +02:00
parent 66128c73d8
commit bc174c1302
14 changed files with 537 additions and 58 deletions

View File

@@ -8,6 +8,7 @@ const Env = z.object({
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3001'),
OAUTH_KEY_DIR: z.string().default('./keys'),
ANTHROPIC_API_KEY: z.string().optional(),
GLM_API_KEY: z.string().optional(),
SECRETS_ENCRYPTION_KEY: z
.string()
.min(64, '32 bytes hex required')
@@ -33,6 +34,7 @@ export const config = Env.parse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
GLM_API_KEY: process.env.GLM_API_KEY,
SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY,
CONTROL_PLANE_PUBLIC_URL: process.env.CONTROL_PLANE_PUBLIC_URL,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,

23
apps/api/src/lib/plan.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createDb, eq, organizations } from '@bmm/db';
import type { Plan } from '@bmm/llm';
const db = createDb();
/** Look up an org's current plan. Defaults to 'hobby' if the org row is gone
* for any reason — fail-closed to the least expensive tier. */
export async function getOrgPlan(orgId: string): Promise<Plan> {
const [row] = await db
.select({ plan: organizations.plan })
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return (row?.plan ?? 'hobby') as Plan;
}
/** Max MCP servers per org by plan. Enforced at POST /v1/servers. */
export const SERVER_LIMITS: Record<Plan, number> = {
hobby: 1,
pro: 5,
team: 25,
enterprise: Number.MAX_SAFE_INTEGER,
};

View File

@@ -1,8 +1,11 @@
import crypto from 'node:crypto';
import { getRedis } from './redis.js';
import type { GeneratorSpec } from '@bmm/types';
import { getRedis } from './redis.js';
const TTL_SECONDS = 5 * 60;
// 24h: previews are LLM-priced; a long TTL eliminates the cache-miss path on
// the build worker (each miss = another LLM call). Specs are tiny JSON (~5KB),
// Redis-memory impact is negligible.
const TTL_SECONDS = 24 * 60 * 60;
function key(previewId: string): string {
return `preview:${previewId}`;

View File

@@ -0,0 +1,51 @@
import type { Plan } from '@bmm/llm';
import { getRedis } from './redis.js';
const DAY_SEC = 24 * 60 * 60;
function todayKey(): string {
return new Date().toISOString().slice(0, 10);
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
resetIn: number;
}
/**
* Daily counter via Redis INCR. Atomic — no race window between read & write.
* First INCR (count === 1) sets the TTL so the key auto-rolls at midnight UTC.
*/
export async function checkDailyLimit(
scope: string,
userId: string,
max: number,
): Promise<RateLimitResult> {
const key = `ratelimit:${scope}:${userId}:${todayKey()}`;
const redis = getRedis();
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, DAY_SEC);
const ttl = await redis.ttl(key);
return {
ok: count <= max,
remaining: Math.max(0, max - count),
resetIn: ttl > 0 ? ttl : DAY_SEC,
};
}
// Per-tier daily limits on the two LLM-priced actions.
// Preview = ~€0.002-0.015/call · Build = ~€0.005-0.22/call.
export const PREVIEW_DAILY_LIMIT: Record<Plan, number> = {
hobby: 5,
pro: 40,
team: 150,
enterprise: 1000,
};
export const BUILD_DAILY_LIMIT: Record<Plan, number> = {
hobby: 3,
pro: 20,
team: 100,
enterprise: 500,
};

View File

@@ -13,6 +13,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { getOrgPlan } from '../lib/plan.js';
import { sendSms, smsConfigured } from '../lib/sms.js';
const SESSION_COOKIE = 'bmm_session';
@@ -128,7 +129,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const token = req.cookies[SESSION_COOKIE];
const session = await getSession(token);
if (!session) return reply.code(401).send({ error: 'unauthorized' });
return reply.send({ user: session });
// Plan is on the org, not the session — look it up fresh so a Stripe
// upgrade is reflected without forcing a re-login.
const plan = await getOrgPlan(session.orgId);
return reply.send({ user: { ...session, plan } });
});
app.post('/v1/auth/admin/login', async (req, reply) => {

View File

@@ -11,7 +11,13 @@ import {
sql,
templates,
} from '@bmm/db';
import { BannedPatternError, SpecTimeoutError, SpecValidationError, generateSpec } from '@bmm/llm';
import {
BannedPatternError,
SpecTimeoutError,
SpecValidationError,
generateSpec,
pickPreviewModel,
} from '@bmm/llm';
import {
BuildEvent,
CreateServerInput,
@@ -26,8 +32,10 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgPlan } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { requireAuth } from '../plugins/session.js';
import { getForkRefTemplate } from './templates.js';
@@ -46,26 +54,47 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
});
app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = PreviewInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const plan = await getOrgPlan(user.orgId);
// Daily preview rate-limit per user. Free is tight (5/day) because every
// preview is a paid LLM call; paid tiers have headroom for real iteration.
const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: PREVIEW_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
const choice = pickPreviewModel(plan);
try {
const { spec, source } = await generateSpec(parsed.data.prompt, {
provider: choice.provider,
apiKey: config.ANTHROPIC_API_KEY,
// Preview generates the spec synchronously inside an HTTP request that
// sits behind Cloudflare's edge timeout. Haiku 4.5 (~200 tok/s — a full
// 8k-token spec in ~40s) is the only model fast enough; Sonnet and Opus
// overran the proxy cap, which reached the browser as a CORS error. The
// hard 60s timeout guarantees a clean 504 before the proxy gives up.
model: 'claude-haiku-4-5-20251001',
timeoutMs: 60_000,
glmApiKey: config.GLM_API_KEY,
model: choice.model,
maxTokens: choice.maxTokens,
timeoutMs: choice.timeoutMs,
maxRetries: 0,
});
const previewId = await cacheSpec(spec);
return reply.send({
previewId,
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
@@ -112,6 +141,37 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
templateId,
} = parsed.data;
// ---- Plan enforcement (must happen before any DB write) ----
const plan = await getOrgPlan(user.orgId);
// Daily build rate-limit.
const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${plan}" (${BUILD_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: BUILD_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
// Server-count quota. Counted via SQL (not cached) so race risk is tiny.
const [serverCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const existingCount = serverCountRow?.count ?? 0;
if (existingCount >= SERVER_LIMITS[plan]) {
return reply.code(402).send({
error: 'plan_limit_reached',
detail: `Plan "${plan}" allows ${SERVER_LIMITS[plan]} server(s); you have ${existingCount}. Upgrade to add more.`,
plan,
limit: SERVER_LIMITS[plan],
current: existingCount,
});
}
// ---- Template-fork validation ----
// templateId is user-controlled. To prevent fork_count manipulation + garbage
// template_id rows, the user MUST have hit POST /v1/templates/:slug/fork,