Building a Credit-Based Quota System with 5-Hour Rolling Windows
TermOnMac enforces per-user usage limits using a credit model backed by Cloudflare KV, with 5-hour rolling windows and a separate welcome bonus quota. The implementation is in relay_server/src/usage-tracking.ts.
The Credit Model
Two types of usage consume credits:
// relay_server/src/usage-tracking.ts
// Credit model: 1 credit per relay message, 2 credits per minute of DO alive time
- Message tokens: 1 credit per relay message forwarded
- Duration tokens: 2 credits per minute of active Durable Object connection time
The UsageRecord struct tracks both:
export interface UsageRecord {
message_tokens: number;
duration_tokens: number;
total_tokens: number; // message_tokens + duration_tokens
period: string; // "5h-{windowNumber}"
last_updated: number;
}
Rolling 5-Hour Windows
The window size is 5 hours:
export const WINDOW_MS = 5 * 3600 * 1000; // 5 hours in ms
export function current5HourPeriod(): string {
const windowNumber = Math.floor(Date.now() / WINDOW_MS);
return `5h-${windowNumber}`;
}
windowNumber is epoch_ms / 18_000_000. Every user in the same 5-hour window shares the same period key string (e.g. 5h-96742).
KV keys follow the pattern usage:{userId}:{period}, with a 30-day TTL:
const USAGE_TTL = 30 * 86400; // 30 days in seconds
await kv.put(key, JSON.stringify(record), { expirationTtl: USAGE_TTL });
Tier Limits
// relay_server/src/tier.ts
// free / pro / premium
credits_per_window: 1_000 / 10_000 / 50_000
max_rooms: 4 / 32 / 32
Free tier: 1,000 credits per 5-hour window. Pro: 10,000. Premium: 50,000.
Per-user overrides are supported via a quota:{userId} KV key that takes precedence over the tier default.
Welcome Bonus (Extra Quota)
New users receive a 10,000-credit bonus valid for 7 days:
export const EXTRA_QUOTA_LIMIT = 10_000;
const EXTRA_QUOTA_DURATION_MS = 7 * 24 * 3600 * 1000; // 7 days
Extra quota is consumed before regular tier quota:
export async function recordMessageTokens(kv, userId, messageCount) {
const extraConsumed = await consumeExtraQuota(kv, userId, messageCount);
const remainder = messageCount - extraConsumed;
if (remainder <= 0) return; // fully covered by extra quota
// write to regular period key
}
Split KV Keys for Extra Quota
Extra quota uses two separate KV keys to avoid a read-modify-write race:
function extraQuotaKey(userId: string): string {
return `extra_quota:${userId}`; // grant record — written once, never modified
}
function extraQuotaUsedKey(userId: string): string {
return `extra_quota_used:${userId}`; // used counter — separate key
}
The code comment explains the design intent:
// Writes ONLY to the used counter key (extra_quota_used:{userId}), never to
// the grant key. This prevents Room DO flushes from overwriting admin grants
// via stale KV cache reads.
A version check in the used counter prevents stale reads from a previous grant:
// Version check: only trust used counter if it matches current grant
if (usedRecord.grant_created_at === grant.created_at) {
used = usedRecord.used;
}
// else: stale used counter from previous grant — treat as 0
See the KV read-modify-write race condition for the full story of this race condition and fix.
quota_exceeded Message
When quota is exceeded, the relay sends a structured message before closing the WebSocket:
// relay_server/src/types.ts
export interface QuotaExceededMessage {
type: "quota_exceeded";
usage: number;
limit: number;
period: string; // e.g. "5h-96742"
resets_at: string; // ISO 8601 timestamp of next window reset
extra_quota_used?: number;
extra_quota_limit?: number;
extra_quota_expires_at?: string;
}
The resets_at field is the end timestamp of the current 5-hour window:
export function nextResetTime(period: string): string {
const windowNumber = parseInt(period.split("-")[1], 10);
const endMs = (windowNumber + 1) * WINDOW_MS;
return new Date(endMs).toISOString();
}
Human-Readable Period Labels
The period key 5h-96742 is opaque. The periodLabel() function converts it for display:
export function periodLabel(period: string): string {
// Returns e.g. "Mar 7, 10:00 – 15:00 UTC"
// If window spans midnight: "Mar 7, 23:00 – Mar 8, 04:00 UTC"
}
The label handles cross-midnight windows by including both dates when the start and end fall on different UTC days.