tiers.ts232 lines · main
1import { brivenError } from '@briven/shared';
2import { and, eq, isNull, sql } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { projects, type ProjectTier } from '../db/schema.js';
6
7/**
8 * Tier limits. Single source of truth for every hard cap enforced at
9 * project-create or deploy time. Rate-limit middleware handles the
10 * per-request floor separately (Phase 3 free tier: 60 invokes / 10s).
11 */
12export interface TierLimits {
13 readonly projectsPerOrg: number;
14 readonly functionsPerProject: number;
15 /** Soft cap — surfaced in dashboard; no hard enforcement per month yet. */
16 readonly invokesPerMonth: number;
17 /**
18 * Total bytes on disk for the project's schema (user tables + indexes
19 * + toast, excluding `_briven_*` bookkeeping). Soft cap — surfaced in
20 * the dashboard usage widget; deploy-time enforcement is a Phase 3
21 * follow-up that lands once the Polar metering push goes live.
22 */
23 readonly storageBytes: number;
24 /**
25 * Sum of realtime WebSocket connection-seconds across the calendar
26 * month. Sampled hourly from the realtime /metrics gauge and pushed
27 * to Polar's connection_seconds meter for overage billing. Soft cap
28 * — surfaced on the dashboard usage widget; per-request rate limits
29 * (RATE_LIMITS_BY_TIER) already throttle the connect path.
30 */
31 readonly connectionSecondsPerMonth: number;
32 /**
33 * Maximum concurrent subscriptions a single project can have open
34 * across all of its WebSocket connections. Hard cap, enforced at
35 * subscribe time by the realtime service. Stops one bad project
36 * from exhausting the year-one 10,000 concurrent-subs target.
37 */
38 readonly concurrentSubscriptions: number;
39 /**
40 * Monthly active users for briven auth — distinct end-user accounts
41 * with at least one session created in the trailing 30 days. Soft cap
42 * for v1: surfaced in the auth → usage panel; overage feeds the Polar
43 * `briven_auth_mau` meter once Polar is live (BUILD_PLAN.md §9). Free
44 * + pro placeholders per "Decisions locked" Q5; team is the locked
45 * 250k figure.
46 */
47 readonly authMauPerMonth: number;
48 /**
49 * File-recovery window (days) — how long deleted/overwritten objects in a
50 * project's storage bucket are kept (bucket versioning + lifecycle expiry)
51 * so they can be restored on request. Kept copies COUNT toward storageBytes.
52 * Storage sprint decision Q13: free 7 / pro 30 / team 90.
53 */
54 readonly storageRecoveryDays: number;
55}
56
57export const TIERS: Record<ProjectTier, TierLimits> = {
58 free: {
59 projectsPerOrg: 3,
60 functionsPerProject: 20,
61 invokesPerMonth: 100_000,
62 storageBytes: 1_073_741_824, // 1 GiB
63 connectionSecondsPerMonth: 1_000_000, // ~12 days of one continuous connection
64 concurrentSubscriptions: 100,
65 authMauPerMonth: 1_000, // placeholder per BUILD_PLAN.md "Decisions locked" Q5
66 storageRecoveryDays: 7,
67 },
68 pro: {
69 projectsPerOrg: 20,
70 functionsPerProject: 200,
71 invokesPerMonth: 1_000_000,
72 storageBytes: 10_737_418_240, // 10 GiB
73 connectionSecondsPerMonth: 10_000_000, // ~115 days = roughly 4 always-on subs
74 concurrentSubscriptions: 1_000,
75 authMauPerMonth: 25_000, // placeholder per BUILD_PLAN.md "Decisions locked" Q5
76 storageRecoveryDays: 30,
77 },
78 team: {
79 projectsPerOrg: 100,
80 functionsPerProject: 2_000,
81 invokesPerMonth: 10_000_000,
82 storageBytes: 107_374_182_400, // 100 GiB
83 connectionSecondsPerMonth: 100_000_000, // ~1158 days = roughly 38 always-on
84 concurrentSubscriptions: 10_000,
85 authMauPerMonth: 250_000, // locked: BUILD_PLAN.md "Decisions locked" Q5
86 storageRecoveryDays: 90,
87 },
88};
89
90/**
91 * Per-request rate-limit ceilings, by scope and tier. The values are the
92 * burst allowance over a 60-second sliding window — the rate-limit
93 * middleware (`middleware/rate-limit.ts`) enforces these on every
94 * request. The monthly `invokesPerMonth` softs above are separate;
95 * those are reported in the dashboard but not hard-enforced today.
96 *
97 * Free-tier numbers are sized so a developer doing local dogfood work
98 * never hits them, but a leaked key can't be turned into a load
99 * generator. Pro is 10× free, Team is 100× free — same pattern as the
100 * structural caps above.
101 */
102export type RateLimitScope = 'invoke' | 'deploy' | 'mutate' | 'read';
103
104/**
105 * `mutate` is the catch-all scope for state-changing project routes that
106 * aren't already covered by `invoke` or `deploy` — env writes, member
107 * mutations, invitation create/revoke. Numbers are tighter than `invoke`
108 * because these are admin-tier human ops, not hot-path RPCs.
109 *
110 * `read` covers read-only dashboard queries (studio table/row/column
111 * listings, schema views). These are GETs that change nothing, so they get
112 * a much higher cap than `mutate` — a human browsing studio, or the table
113 * view's auto-refresh poll, fires several reads per view and must not be
114 * throttled into the write budget. (Pre-fix these were on `mutate`, so the
115 * free tier's 30/min exhausted in seconds and Studio 500'd.)
116 */
117export const RATE_LIMITS_BY_TIER: Record<RateLimitScope, Record<ProjectTier, number>> = {
118 invoke: { free: 60, pro: 600, team: 6_000 },
119 deploy: { free: 5, pro: 30, team: 100 },
120 mutate: { free: 30, pro: 300, team: 3_000 },
121 read: { free: 300, pro: 3_000, team: 30_000 },
122};
123
124export const RATE_LIMIT_WINDOW_MS = 60_000;
125
126/**
127 * Resolve a project's tier from the (denormalised) `projects.tier` column.
128 * Falls back to 'free' if the project doesn't exist — callers (e.g.
129 * rate-limit middleware) pass `null` through so an unknown project ID
130 * doesn't amplify into a DoS vector via a tight DB-lookup loop.
131 *
132 * In-process cache with a 60-second TTL. Every invoke / deploy / mutate
133 * route hits this on the rate-limit path, so a tight per-request DB
134 * lookup is a meaningful cost at moderate scale; tier changes via
135 * Polar webhook are rare (~hourly at peak) and a 60s staleness window
136 * is well inside the wider rate-limit window. Cache is cleared by
137 * `invalidateTierCache(projectId)` from the Polar webhook handler so a
138 * paid upgrade takes effect within seconds, not after the TTL.
139 */
140interface CacheEntry {
141 tier: ProjectTier | null;
142 expiresAt: number;
143}
144const TIER_CACHE_TTL_MS = 60_000;
145const tierCache = new Map<string, CacheEntry>();
146
147export async function getProjectTier(projectId: string): Promise<ProjectTier | null> {
148 const now = Date.now();
149 const hit = tierCache.get(projectId);
150 if (hit && hit.expiresAt > now) {
151 return hit.tier;
152 }
153 const db = getDb();
154 const [row] = await db
155 .select({ tier: projects.tier, deletedAt: projects.deletedAt })
156 .from(projects)
157 .where(eq(projects.id, projectId))
158 .limit(1);
159 const tier = !row || row.deletedAt ? null : row.tier;
160 tierCache.set(projectId, { tier, expiresAt: now + TIER_CACHE_TTL_MS });
161 return tier;
162}
163
164/**
165 * Drop the cached tier for a project. Called from the Polar webhook
166 * handler after a successful subscription upsert so the new tier
167 * takes effect on the very next request, not after the TTL.
168 */
169export function invalidateTierCache(projectId: string): void {
170 tierCache.delete(projectId);
171}
172
173/**
174 * Per-request rate-limit cap for a scope on a given project. Returns
175 * `null` if the project can't be resolved — the middleware treats null
176 * as "skip rate limiting" so we don't leak existence vs. non-existence
177 * via response timing.
178 */
179export async function resolveProjectRateLimit(
180 projectId: string,
181 scope: RateLimitScope,
182): Promise<number | null> {
183 const tier = await getProjectTier(projectId).catch(() => null);
184 if (!tier) return null;
185 return RATE_LIMITS_BY_TIER[scope][tier];
186}
187
188export class TierLimitExceeded extends brivenError {
189 constructor(reason: string, context: Record<string, unknown>) {
190 super('tier_limit_exceeded', reason, { status: 402, context });
191 this.name = 'TierLimitExceeded';
192 }
193}
194
195/**
196 * Count a user's non-deleted projects. Called by services/projects.ts
197 * before inserting a new row.
198 */
199export async function assertProjectCreateAllowed(
200 orgId: string,
201 orgTier: ProjectTier = 'free',
202): Promise<void> {
203 const db = getDb();
204 const [row] = await db
205 .select({ count: sql<number>`count(*)::int` })
206 .from(projects)
207 .where(and(eq(projects.orgId, orgId), isNull(projects.deletedAt)));
208 const count = row?.count ?? 0;
209 const limit = TIERS[orgTier].projectsPerOrg;
210 if (count >= limit) {
211 throw new TierLimitExceeded(`project limit reached for tier '${orgTier}' (${count}/${limit})`, {
212 orgId,
213 tier: orgTier,
214 count,
215 limit,
216 });
217 }
218}
219
220/**
221 * Cap the number of functions a deployment can ship. Called by the deploy
222 * route before handing off to schema-apply.
223 */
224export function assertFunctionCountAllowed(functionCount: number, tier: ProjectTier): void {
225 const limit = TIERS[tier].functionsPerProject;
226 if (functionCount > limit) {
227 throw new TierLimitExceeded(
228 `deployment has ${functionCount} functions, tier '${tier}' caps at ${limit}`,
229 { functionCount, tier, limit },
230 );
231 }
232}