diff --git a/packages/core/src/billing/limits.test.ts b/packages/core/src/billing/limits.test.ts new file mode 100644 index 00000000..8d981804 --- /dev/null +++ b/packages/core/src/billing/limits.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { Env } from '../env.js'; +import { Limits } from './limits.js'; +import { Window } from './window.js'; + +const HOUR = 60 * 60; + +/** Allowances in hours, which is how anybody actually reasons about them. */ +function hours(fiveHour: number, sevenDay: number, thirtyDay: number) { + return { + fiveHour: fiveHour * HOUR, + sevenDay: sevenDay * HOUR, + thirtyDay: thirtyDay * HOUR + }; +} + +afterEach(() => { + Env.init({}); + Limits.reset(); +}); + +describe('The floor: one uninterrupted session must never hit a wall', () => { + test('an allowance at or below its own window is refused', () => { + // A rolling window means a single continuous session asymptotes at + // exactly the window length. So an allowance of five hours over a + // five-hour window is a wall that someone playing alone will meet, and + // meeting it is the one outcome this model may not produce. + expect(() => Limits.check(hours(5, 300, 1000), 'free')).toThrow(/must exceed the window/); + expect(() => Limits.check(hours(4, 300, 1000), 'free')).toThrow(/5-hour/); + expect(() => Limits.check(hours(10, 168, 1000), 'free')).toThrow(/7-day/); + expect(() => Limits.check(hours(10, 300, 720), 'free')).toThrow(/30-day/); + }); + + test('just above the window is accepted, because the rule is the floor', () => { + // Deliberately close to every bound at once: each allowance barely + // clears its own window, and each still sits under what the shorter + // window permits (170h of weekly is under 33.6 x 5.5h = 184.8h; 725h of + // monthly is under 4.29 x 170h = 728.6h). A set this tight is legal and + // miserable, which is the point — the rules bound the space, they do not + // choose within it. + expect(() => Limits.check(hours(5.5, 170, 725), 'free')).not.toThrow(); + }); + + test('the floors are the window lengths, stated in seconds', () => { + // Spelled out so the relationship is visible rather than implied: the + // floor is not a chosen number, it is the window. + expect(Window.FIVE_HOURS).toBe(5 * HOUR); + expect(Window.SEVEN_DAYS).toBe(168 * HOUR); + expect(Window.THIRTY_DAYS).toBe(720 * HOUR); + }); +}); + +describe('The nesting rule: every window has to bind', () => { + test('a longer allowance the shorter window already caps is refused', () => { + // The 5-hour window permits 10h per 5h sustained, which is 336h over a + // week. A 7-day allowance of 400h could never be reached, so it would + // read like a limit and never once fire. + expect(() => Limits.check(hours(10, 400, 1000), 'free')).toThrow(/never be reached/); + expect(() => Limits.check(hours(10, 300, 1400), 'free')).toThrow(/30-day/); + }); + + test('the ceiling is exclusive, because equality never binds either', () => { + // 33.6 x 10h is exactly 336h; at exactly the ceiling the window fires + // only in the limit, which is the same as not firing. + expect(() => Limits.check(hours(10, 336, 1000), 'free')).toThrow(/never be reached/); + expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow(); + }); + + test('the placeholder set satisfies both rules', () => { + // It is not a pricing decision, but it has to be a coherent one, or + // nothing downstream can be tested against it. + expect(() => Limits.validate(Limits.PLACEHOLDER)).not.toThrow(); + }); +}); + +describe('Configuration', () => { + test('unset takes the placeholder set', () => { + Env.init({}); + Limits.reset(); + expect(Limits.get()).toEqual(Limits.PLACEHOLDER); + }); + + test('the environment overrides it, and is validated on the way in', () => { + Env.init({ + BURN_LIMITS: JSON.stringify({ free: hours(12, 350, 1200), paid: hours(40, 1200, 4000) }) + }); + Limits.reset(); + expect(Limits.get().free.fiveHour).toBe(12 * HOUR); + }); + + test('a configured set that would not bind is refused rather than used', () => { + // The whole reason the check is code: these get retuned by whoever is + // closest to the burn data, and a set that quietly stops binding is not + // visible from the numbers. + Env.init({ + BURN_LIMITS: JSON.stringify({ free: hours(10, 400, 1000), paid: hours(30, 900, 3000) }) + }); + Limits.reset(); + expect(() => Limits.get()).toThrow(/never be reached/); + }); + + test('malformed JSON is refused, not ignored', () => { + Env.init({ BURN_LIMITS: '{not json' }); + Limits.reset(); + expect(() => Limits.get()).toThrow(/not valid JSON/); + }); + + test('anything that is not the paid plan gets the free allowance', () => { + Env.init({}); + Limits.reset(); + for (const plan of ['free', null, undefined, 'something-we-retired']) { + expect(Limits.forPlan(plan)).toEqual(Limits.PLACEHOLDER.free); + } + expect(Limits.forPlan('paid')).toEqual(Limits.PLACEHOLDER.paid); + }); +}); + +describe('What the placeholder set actually means', () => { + // These are the sentences the numbers are supposed to say. If a retune + // breaks one, the retune changed the product and should say so. + const free = Limits.PLACEHOLDER.free; + + test('one session running continuously never exhausts any window', () => { + for (const window of Window.ALL) { + // One session burns one unit per second, so over any window it has + // spent exactly the window length. + const state = Window.analyze({ + allowance: free[window.key], + windowSeconds: window.seconds, + usage: window.seconds, + timeUpdated: new Date() + }); + expect(state.exhausted).toBe(false); + } + }); + + test('two at once bites, and the five-hour window bites first', () => { + const now = new Date(); + const twoForFiveHours = 2 * Window.FIVE_HOURS; + expect( + Window.analyze({ + allowance: free.fiveHour, + windowSeconds: Window.FIVE_HOURS, + usage: twoForFiveHours, + timeUpdated: now + }).exhausted + ).toBe(true); + + // The same burn is nowhere near the weekly allowance, which is what + // makes the three windows do different jobs rather than one job thrice. + expect( + Window.analyze({ + allowance: free.sevenDay, + windowSeconds: Window.SEVEN_DAYS, + usage: twoForFiveHours, + timeUpdated: now + }).exhausted + ).toBe(false); + }); +}); diff --git a/packages/core/src/billing/limits.ts b/packages/core/src/billing/limits.ts new file mode 100644 index 00000000..beb7c8cf --- /dev/null +++ b/packages/core/src/billing/limits.ts @@ -0,0 +1,150 @@ +import z from 'zod'; + +import { Env } from '../env.js'; +import { ErrorCodes, VisibleError } from '../error.js'; +import { memo } from '../utils/memo.js'; +import { Window } from './window.js'; + +/** + * How much burn a plan is allowed, per window. + * + * **The unit is one second of a reference session** — the baseline size, on the + * baseline card, running alone, on hardware we own. Every factor is a multiple + * of that, so an allowance is measured in *time* and a bar can print + * `6h 20m of 12h` from the stored number instead of converting into it. + * Integers throughout; burn is never a float. + * + * On a caller's own hardware the size and hardware factors are 1, because what + * they price — a share of a card we paid for — is not being spent. Burn there + * is duration multiplied by how much is running at once, and nothing about + * their GPU enters into it. Nothing is ever measured on somebody's machine. + * + * **Configuration, not constants.** These numbers are not settled and will be + * retuned against real burn far more often than this code changes. A rate that + * needs a deploy is a rate that stays wrong for a week, so they are read from + * the environment and validated at read time. + */ +export namespace Limits { + const Allowances = z.object({ + fiveHour: z.number().int().positive(), + sevenDay: z.number().int().positive(), + thirtyDay: z.number().int().positive() + }); + + export type Allowances = z.infer; + + export const Config = z.object({ + free: Allowances, + paid: Allowances + }); + + export type Config = z.infer; + + /** + * Placeholder numbers, and deliberately labelled as such. + * + * They satisfy every rule {@link check} enforces, so the mechanism runs and + * can be tested end to end, and they are not a pricing decision. The free + * set says "one box around the clock, with room to double up now and then"; + * the paid set is the same shape, larger. Both want replacing with numbers + * chosen against measured burn. + */ + export const PLACEHOLDER: Config = { + free: { + fiveHour: 10 * 60 * 60, + sevenDay: 300 * 60 * 60, + thirtyDay: 1000 * 60 * 60 + }, + paid: { + fiveHour: 30 * 60 * 60, + sevenDay: 900 * 60 * 60, + thirtyDay: 3000 * 60 * 60 + } + }; + + /** + * The two rules that make a set of allowances mean anything. + * + * **A window's allowance must exceed the window itself.** Because the + * windows roll, one continuously-running session does not creep — it + * asymptotes at exactly the window length and stays there. So an allowance + * at or below its own window is one where a single uninterrupted session + * hits a wall, which is the one outcome the model may not produce: someone + * playing alone on hardware they own must never be stopped. + * + * **Each longer window must be smaller than what the shorter one already + * permits.** Sustained burn allowed by a window is `allowance ÷ window` per + * second, so a longer allowance above `allowance × (longer ÷ shorter)` can + * never be reached and is decoration — a number that looks like a limit, + * reads like a promise, and never fires. + * + * Both are cheap, and checking them here rather than in somebody's head is + * the point: these get retuned by whoever is closest to the burn data, and + * a set that quietly stops binding is not visible from the numbers. + */ + export function check(allowances: Allowances, plan: string): void { + for (const window of Window.ALL) { + const allowance = allowances[window.key]; + if (allowance <= window.seconds) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + `${plan}: the ${window.label} allowance (${allowance}s) must exceed the window itself (${window.seconds}s), or one uninterrupted session hits a wall` + ); + } + } + + for (let i = 1; i < Window.ALL.length; i++) { + const shorter = Window.ALL[i - 1]!; + const longer = Window.ALL[i]!; + const ceiling = (allowances[shorter.key] * longer.seconds) / shorter.seconds; + if (allowances[longer.key] >= ceiling) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + `${plan}: the ${longer.label} allowance (${allowances[longer.key]}s) can never be reached, because the ${shorter.label} window already caps it at ${Math.floor(ceiling)}s — it would never bind` + ); + } + } + } + + export function validate(config: unknown): Config { + const parsed = Config.parse(config); + check(parsed.free, 'free'); + check(parsed.paid, 'paid'); + return parsed; + } + + const _get = memo((): Config => { + const raw = Env.get().BURN_LIMITS; + if (!raw) { + return validate(PLACEHOLDER); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + 'BURN_LIMITS is not valid JSON' + ); + } + return validate(parsed); + }); + + export function get(): Config { + return _get(); + } + + /** Reset the memo. Tests change the environment between cases. */ + export function reset(): void { + _get.reset(); + } + + /** The allowances a plan gets. Anything not `paid` is free. */ + export function forPlan(plan: string | null | undefined): Allowances { + const config = get(); + return plan === 'paid' ? config.paid : config.free; + } +} diff --git a/packages/core/src/billing/window.test.ts b/packages/core/src/billing/window.test.ts new file mode 100644 index 00000000..7ad7dbbd --- /dev/null +++ b/packages/core/src/billing/window.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test'; + +import { Window } from './window.js'; + +const HOUR = 60 * 60; +const NOW = new Date('2026-09-18T12:00:00.000Z'); + +function ago(seconds: number) { + return new Date(NOW.getTime() - seconds * 1000); +} + +function fiveHour(usage: number, timeUpdated: Date | null, allowance = 10 * HOUR) { + return Window.analyze({ + allowance, + windowSeconds: Window.FIVE_HOURS, + usage, + timeUpdated, + now: NOW + }); +} + +describe('The staleness rule', () => { + test('a counter older than the window reads as zero', () => { + // This is what replaces a reset job. Nothing has to run for the window + // to roll clear, so there is no cron to misfire and no race between a + // reset and a write landing at the same moment. + const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1)); + expect(state.used).toBe(0); + expect(state.percent).toBe(0); + expect(state.exhausted).toBe(false); + expect(state.remaining).toBe(10 * HOUR); + }); + + test('a counter inside the window is counted', () => { + const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS - 60)); + expect(state.used).toBe(9 * HOUR); + expect(state.exhausted).toBe(false); + }); + + test('nothing ever recorded reads as zero rather than throwing', () => { + expect(fiveHour(0, null).used).toBe(0); + }); + + test('the boundary belongs to the window, not outside it', () => { + // Exactly one window old is the oldest moment still in view. Getting + // this backwards would silently forgive a window's worth of burn. + expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS)).used).toBe(9 * HOUR); + }); +}); + +describe('Exhaustion', () => { + test('at the allowance is exhausted, not just above it', () => { + expect(fiveHour(10 * HOUR, NOW).exhausted).toBe(true); + expect(fiveHour(10 * HOUR - 1, NOW).exhausted).toBe(false); + }); + + test('overrun is reported, but remaining never goes negative', () => { + // A run is never stopped mid-session, so burn past the allowance is a + // real and expected state — it just has nothing left to offer. + const state = fiveHour(14 * HOUR, NOW); + expect(state.used).toBe(14 * HOUR); + expect(state.remaining).toBe(0); + expect(state.percent).toBe(100); + }); + + test('a zero allowance is exhausted rather than dividing by zero', () => { + const state = fiveHour(0, NOW, 0); + expect(state.exhausted).toBe(true); + expect(state.percent).toBe(100); + }); +}); + +describe('What the bar shows is what the gate reads', () => { + test('the percent the bar draws is the same number the check uses', () => { + // The complaint about usage limits is almost never the limit, it is + // being surprised by it. One arithmetic means a full bar and a refusal + // cannot disagree. + const state = fiveHour(5 * HOUR, NOW); + expect(state.percent).toBe(50); + expect(state.exhausted).toBe(false); + expect(state.remaining).toBe(5 * HOUR); + expect(state.used + state.remaining).toBe(state.allowance); + }); + + test('percent floors rather than rounds, so it reads 99 until it is done', () => { + expect(fiveHour(10 * HOUR - 1, NOW).percent).toBe(99); + }); + + test('reset counts from when the burn rolls out of view', () => { + const state = fiveHour(3 * HOUR, ago(HOUR)); + expect(state.resetInSec).toBe(Window.FIVE_HOURS - HOUR); + }); + + test('a cleared window has nothing to wait for', () => { + expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1)).resetInSec).toBe(0); + }); +}); + +describe('The three windows are the same arithmetic', () => { + test('one function, parameterised by length', () => { + // Their monthly window is calendar-anchored; ours rolls. Reaching for a + // month-bounds helper here would be a subtle and expensive mistake. + for (const window of Window.ALL) { + const state = Window.analyze({ + allowance: 2 * window.seconds, + windowSeconds: window.seconds, + usage: window.seconds, + timeUpdated: NOW, + now: NOW + }); + expect(state.percent).toBe(50); + expect(state.exhausted).toBe(false); + } + }); +}); diff --git a/packages/core/src/billing/window.ts b/packages/core/src/billing/window.ts new file mode 100644 index 00000000..f026a4b3 --- /dev/null +++ b/packages/core/src/billing/window.ts @@ -0,0 +1,116 @@ +import z from 'zod'; + +import { fn } from '../fn.js'; + +/** + * Reading a rolling window, without a job that resets it. + * + * A counter is stored next to the time it was last written, and a counter + * whose timestamp falls outside the current window simply **reads as zero**. + * Nothing resets anything on a schedule: the reset is implied by the clock, so + * there is no cron to misfire and no race between a reset and a concurrent + * write. The same rule applied on the write side — increment if the stamp is + * inside the window, otherwise start again from this amount — makes the whole + * thing one statement. + * + * Everything here is pure. It takes numbers and gives an answer, which is what + * lets the meter a person sees and the check that stops them be the same + * arithmetic rather than two implementations that agree for now. + */ +export namespace Window { + /** Seconds. Named so a caller cannot pass minutes by accident. */ + export const FIVE_HOURS = 5 * 60 * 60; + export const SEVEN_DAYS = 7 * 24 * 60 * 60; + export const THIRTY_DAYS = 30 * 24 * 60 * 60; + + /** + * The three, shortest first. + * + * Order is load-bearing: the nesting rule that keeps each allowance + * meaningful is stated between neighbours, and the bars are read top-down. + */ + export const ALL = [ + { key: 'fiveHour' as const, seconds: FIVE_HOURS, label: '5-hour' }, + { key: 'sevenDay' as const, seconds: SEVEN_DAYS, label: '7-day' }, + { key: 'thirtyDay' as const, seconds: THIRTY_DAYS, label: '30-day' } + ]; + + export type Key = (typeof ALL)[number]['key']; + + export const State = z.object({ + /** Whether a new run may start. A live one is never stopped by this. */ + exhausted: z.boolean(), + /** Burn already spent in this window, after the staleness rule. */ + used: z.number().int(), + /** The allowance it is spent against. Never reported without `used`. */ + allowance: z.number().int(), + /** What is left, floored at zero — overrun is real but never negative. */ + remaining: z.number().int(), + /** + * Whole percent used, 0–100. + * + * For the bar, and deliberately the same number the gate reads, so a + * full bar and a refusal cannot disagree. + */ + percent: z.number().int(), + /** Seconds until this window has rolled clear of the current usage. */ + resetInSec: z.number().int() + }); + + export type State = z.infer; + + /** + * Where one window stands. + * + * `usage` and `timeUpdated` are the stored pair. A `timeUpdated` older than + * the window means everything recorded in it has rolled out of view, so the + * answer is a clean zero rather than a stale total — this is the staleness + * rule, and it is why nothing has to be reset. + */ + export const analyze = fn( + z.object({ + allowance: z.number().int().nonnegative(), + windowSeconds: z.number().int().positive(), + usage: z.number().int().nonnegative(), + /** Null when nothing has ever been recorded, which reads as zero. */ + timeUpdated: z.date().nullable(), + /** Injected so the arithmetic is testable without waiting. */ + now: z.date().optional() + }), + (input): State => { + const now = input.now ?? new Date(); + const windowMs = input.windowSeconds * 1000; + const windowStart = now.getTime() - windowMs; + + // Rolled clear: the stored total describes a window that has passed. + if (!input.timeUpdated || input.timeUpdated.getTime() < windowStart) { + return { + exhausted: false, + used: 0, + allowance: input.allowance, + remaining: input.allowance, + percent: 0, + resetInSec: 0 + }; + } + + const used = input.usage; + const remaining = Math.max(0, input.allowance - used); + const percent = + input.allowance === 0 ? 100 : Math.min(100, Math.floor((used / input.allowance) * 100)); + + // When the last write rolls out of the window, this usage is gone. + const clearsAt = input.timeUpdated.getTime() + windowMs; + const resetInSec = Math.max(0, Math.ceil((clearsAt - now.getTime()) / 1000)); + + return { + exhausted: used >= input.allowance, + used, + allowance: input.allowance, + remaining, + percent, + resetInSec + }; + } + ); +} diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 57e4495a..877e1726 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -27,6 +27,15 @@ export namespace Env { */ AUTH_INTERNAL_URL: z.string().optional(), + /** + * Burn allowances per plan, as JSON. Unset takes the placeholder set. + * + * Configuration rather than constants because these are retuned against + * real burn far more often than the code that reads them changes, and a + * rate that needs a deploy is a rate that stays wrong until the next one. + */ + BURN_LIMITS: z.string().optional(), + DATABASE_URL: z.string().optional() });