tier-enforcement.ts73 lines · main
| 1 | import { TIERS, getProjectTier } from './tiers.js'; |
| 2 | import { 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 | |
| 18 | const TTL_MS = 60_000; |
| 19 | |
| 20 | interface CacheEntry { |
| 21 | current: number; |
| 22 | expiresAt: number; |
| 23 | } |
| 24 | |
| 25 | const cache = new Map<string, CacheEntry>(); |
| 26 | |
| 27 | export 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 | */ |
| 42 | export 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 | |
| 54 | async 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. */ |
| 66 | export function invalidateQuotaCache(projectId: string): void { |
| 67 | cache.delete(projectId); |
| 68 | } |
| 69 | |
| 70 | /** Drop everything cached — used by tests + the polar webhook on plan changes. */ |
| 71 | export function clearQuotaCache(): void { |
| 72 | cache.clear(); |
| 73 | } |