Relay & Durable Objects

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.