tier-enforcement.ts73 lines · main
1import { TIERS, getProjectTier } from './tiers.js';
2import { currentMonthBounds, getInvocationUsage } from './usage.js';
3
4/**
5 * Monthly invocation cap enforcement.
6 *
7 * Called from the invoke route on every request, so the underlying DB
8 * query for current-month invocations is cached in-process for 60s. A
9 * over-cap project then leaks ≤60s of additional invocations before the
10 * rejection fires — acceptable for the alpha, and the operator can drop
11 * the TTL once the dashboard quota meter goes live.
12 *
13 * Why an explicit service (not just a `tiers.ts` helper): the cache lives
14 * here, isolated from the tier resolution path which already has its own
15 * cache with different semantics + invalidation.
16 */
17
18const TTL_MS = 60_000;
19
20interface CacheEntry {
21 current: number;
22 expiresAt: number;
23}
24
25const cache = new Map<string, CacheEntry>();
26
27export interface QuotaState {
28 tier: 'free' | 'pro' | 'team';
29 current: number;
30 limit: number;
31 // True when current >= limit. Callers should reject with 429 before
32 // forwarding the invoke to the runtime.
33 exceeded: boolean;
34}
35
36/**
37 * Returns the project's quota state for the current UTC calendar month.
38 * Defaults to `free` tier behaviour if the project is unknown — that
39 * branch only kicks in for ids that don't exist, which the upstream auth
40 * middleware will 401 before this gets called.
41 */
42export async function getQuotaState(projectId: string): Promise<QuotaState> {
43 const tier = (await getProjectTier(projectId)) ?? 'free';
44 const limit = TIERS[tier].invokesPerMonth;
45 const current = await getCachedCurrentMonthInvocations(projectId);
46 return {
47 tier,
48 current,
49 limit,
50 exceeded: current >= limit,
51 };
52}
53
54async function getCachedCurrentMonthInvocations(projectId: string): Promise<number> {
55 const now = Date.now();
56 const hit = cache.get(projectId);
57 if (hit && hit.expiresAt > now) return hit.current;
58
59 const { periodStart, periodEnd } = currentMonthBounds();
60 const usage = await getInvocationUsage(projectId, periodStart, periodEnd);
61 cache.set(projectId, { current: usage.count, expiresAt: now + TTL_MS });
62 return usage.count;
63}
64
65/** Drop the cached count for one project — used by tests. */
66export function invalidateQuotaCache(projectId: string): void {
67 cache.delete(projectId);
68}
69
70/** Drop everything cached — used by tests + the polar webhook on plan changes. */
71export function clearQuotaCache(): void {
72 cache.clear();
73}