feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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:
@@ -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
23
apps/api/src/lib/plan.ts
Normal 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,
|
||||
};
|
||||
@@ -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}`;
|
||||
|
||||
51
apps/api/src/lib/rate-limit.ts
Normal file
51
apps/api/src/lib/rate-limit.ts
Normal 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,
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user