Skip to content

Commit c06378c

Browse files
authored
Merge pull request #261 from Programcode/fix/per-account-weekly-cap-bypass
Let an admin lift or change one account's weekly AI cap
2 parents b30c53b + 682d83b commit c06378c

7 files changed

Lines changed: 170 additions & 14 deletions

File tree

‎.claude/skills/verify-debatelab/features/usage-limits.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ Crossfire and Sparring are charged per round, not per call: the round's first ca
2929

3030
- Demo users are Pro with Claude Sonnet 5.5 as the default model, so every Generate in other recipes uses 2 units of their 999. The weighted step picks Opus itself.
3131
- The weekly sum counts `cap_cost_usd` of every `ai_requests` row since Monday 00:00 UTC, including voice and Jev routing rows; NULL (unknown or logged before the column existed) counts as nothing, and `cost_usd` alone never counts. Cost is logged after the provider answers, so the cap can be passed by about one request.
32+
- The weekly step relies on the demo user having no weekly override. `user_settings.weekly_budget_unlimited = true` or a `weekly_budget_override_usd` above the inserted spend lets the request through instead of opening the dialog.

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the
160160

161161
`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. Auto routes only to Claude, which runs on DebateLab's Anthropic key (BYOK in OpenRouter); other families bill OpenRouter credits, so they run only when a student picks one by hand. Auto Fast is available to Basic accounts and resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. With one candidate per pool the Edge Functions skip the TypeSafe Jev routing call; a pool with more than one candidate (all `anthropic/`, which `src/test/ai/anthropic-only-guard.test.ts` enforces) is resolved through Jev on OpenRouter before calling the concrete writing model. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.
162162

163-
The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).
163+
The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). An admin can change one account's budget in `user_settings`, which the user cannot write: `weekly_budget_override_usd` replaces the $2 and `weekly_budget_unlimited = true` skips the weekly check; the daily quota still applies. The `quotaCheck` peek uses the same per-account budget. Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).
164164

165165
Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts.
166166

‎supabase/functions/_shared/entitlements.test.ts‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ for (const [key, value] of Object.entries({
66
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
77
})) Deno.env.set(key, value);
88

9-
const { getSubscriptionInfo, checkRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts");
9+
const { getSubscriptionInfo, checkRateLimit, peekRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts");
1010
const { MODEL_QUOTA_UNITS, WEEKLY_AI_BUDGET_USD } = await import("./usage-limits.ts");
1111

1212
function json(body: unknown, status = 200): Response {
@@ -18,11 +18,12 @@ const future = () => new Date(Date.now() + DAY).toISOString();
1818
const past = () => new Date(Date.now() - DAY).toISOString();
1919

2020
interface Routes {
21-
user_settings?: unknown;
21+
user_settings?: unknown | ((url: URL) => Response);
2222
user_roles?: unknown;
2323
subscriptions?: unknown | ((url: URL) => Response);
2424
rate_limits?: unknown | ((url: URL) => Response);
2525
quota?: unknown;
26+
peek?: unknown;
2627
onQuota?: (body: Record<string, unknown>) => void;
2728
requests?: string[];
2829
}
@@ -31,7 +32,9 @@ async function withSupabase<T>(routes: Routes, run: () => Promise<T>): Promise<T
3132
const originalFetch = globalThis.fetch;
3233
globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => {
3334
const url = new URL(input instanceof Request ? input.url : String(input));
34-
if (url.pathname.includes("/rest/v1/user_settings")) return json(routes.user_settings ?? null);
35+
if (url.pathname.includes("/rest/v1/user_settings")) {
36+
return typeof routes.user_settings === "function" ? routes.user_settings(url) : json(routes.user_settings ?? null);
37+
}
3538
if (url.pathname.includes("/rest/v1/user_roles")) return json(routes.user_roles ?? { role: "user" });
3639
if (url.pathname.includes("/rest/v1/subscriptions")) {
3740
return typeof routes.subscriptions === "function" ? routes.subscriptions(url) : json(routes.subscriptions ?? []);
@@ -49,6 +52,7 @@ async function withSupabase<T>(routes: Routes, run: () => Promise<T>): Promise<T
4952
routes.onQuota?.(JSON.parse(String(init?.body ?? "{}")));
5053
return json(routes.quota ?? [{ allowed: true, current_count: 1 }]);
5154
}
55+
if (url.pathname.includes("/rpc/peek_ai_quota")) return json(routes.peek ?? [{ current_count: 0, week_spend_usd: 0 }]);
5256
throw new Error(`Unexpected request: ${url}`);
5357
}) as typeof fetch;
5458
try {
@@ -284,6 +288,55 @@ Deno.test("the weekly budget refusal has its own code", async () => {
284288
});
285289
});
286290

291+
async function sentWeeklyCap(settings: Routes["user_settings"]): Promise<unknown> {
292+
let sent: Record<string, unknown> = {};
293+
await withSupabase({ user_settings: settings, onQuota: (body) => { sent = body; } }, async () => {
294+
assertEquals((await checkRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "allowed");
295+
});
296+
return sent.p_weekly_cap_usd;
297+
}
298+
299+
Deno.test("an account without a weekly override gets the global cap", async () => {
300+
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: null, weekly_budget_unlimited: false }), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd");
301+
});
302+
303+
Deno.test("a weekly override replaces the global cap", async () => {
304+
// PostgREST sends numeric as a number or a string.
305+
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 25, weekly_budget_unlimited: false }), 25, "number");
306+
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: "7.5", weekly_budget_unlimited: false }), 7.5, "string");
307+
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 0, weekly_budget_unlimited: false }), 0, "zero");
308+
});
309+
310+
Deno.test("an unlimited account skips the weekly cap, even with an override set", async () => {
311+
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 1, weekly_budget_unlimited: true }), null, "p_weekly_cap_usd");
312+
});
313+
314+
Deno.test("a failed override read keeps the global cap instead of lifting it", async () => {
315+
const originalWarn = console.warn;
316+
console.warn = () => {};
317+
try {
318+
const settings = (url: URL) => (url.searchParams.get("select") ?? "").includes("weekly_budget")
319+
? json({ message: "column user_settings.weekly_budget_unlimited does not exist", code: "42703" }, 400)
320+
: json(proUser);
321+
assertEquals(await sentWeeklyCap(settings), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd");
322+
} finally {
323+
console.warn = originalWarn;
324+
}
325+
});
326+
327+
Deno.test("the pre-round quota check uses the account's weekly cap", async () => {
328+
const spent = [{ current_count: 1, week_spend_usd: (WEEKLY_AI_BUDGET_USD ?? 0) + 3 }];
329+
await withSupabase({ user_settings: proUser, peek: spent }, async () => {
330+
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).code, "weekly_budget_reached", "global cap");
331+
});
332+
await withSupabase({ user_settings: { ...proUser, weekly_budget_unlimited: true }, peek: spent }, async () => {
333+
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "unlimited");
334+
});
335+
await withSupabase({ user_settings: { ...proUser, weekly_budget_override_usd: 100 }, peek: spent }, async () => {
336+
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "raised cap");
337+
});
338+
});
339+
287340
Deno.test("configured daily limits prefer the account row, then the global row", async () => {
288341
const requests: string[] = [];
289342
const limits = (url: URL) => {

‎supabase/functions/_shared/entitlements.ts‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,31 @@ export async function getConfiguredDailyLimit(userId: string, fallbackLimit: num
157157
return fallbackLimit;
158158
}
159159

160+
/**
161+
* The weekly AI spend cap for this account in USD, or null for no cap.
162+
* user_settings.weekly_budget_unlimited lifts it, a non-NULL
163+
* weekly_budget_override_usd replaces it (20261028120000_weekly_budget_override.sql),
164+
* and otherwise WEEKLY_AI_BUDGET_USD applies. Read apart from
165+
* getSubscriptionInfo so a database without the columns only loses the
166+
* override instead of failing every request. A failed read keeps the global cap.
167+
*/
168+
export async function getWeeklyBudgetUsd(userId: string): Promise<number | null> {
169+
const { data, error } = await supabase
170+
.from("user_settings")
171+
.select("weekly_budget_override_usd, weekly_budget_unlimited")
172+
.eq("user_id", userId)
173+
.maybeSingle();
174+
if (error) {
175+
console.warn("[RATE LIMIT] weekly budget override lookup failed:", error.message);
176+
return WEEKLY_AI_BUDGET_USD;
177+
}
178+
if (data?.weekly_budget_unlimited === true) return null;
179+
// numeric arrives from PostgREST as a number or a string.
180+
const override = data?.weekly_budget_override_usd;
181+
const value = override === null || override === undefined ? NaN : Number(override);
182+
return Number.isFinite(value) && value >= 0 ? value : WEEKLY_AI_BUDGET_USD;
183+
}
184+
160185
// Logged and returned for API callers; the browser shows its own translation of the code.
161186
const ACCESS_REFUSAL_REASONS: Record<AccessRefusalCode, string> = {
162187
trial_ended: "Your free trial has ended. Upgrade to Pro to continue using DebateOwl.",
@@ -166,7 +191,7 @@ const ACCESS_REFUSAL_REASONS: Record<AccessRefusalCode, string> = {
166191

167192
type AccessCheck =
168193
| { denied: RateLimitResult }
169-
| { denied?: undefined; tier: string; dailyLimit: number; canAccessProModels: boolean };
194+
| { denied?: undefined; tier: string; dailyLimit: number; weeklyBudgetUsd: number | null; canAccessProModels: boolean };
170195

171196
// Ban, plan, and model access: everything that refuses a request before quota is read.
172197
async function checkAccess(userId: string, model?: ModelSelection): Promise<AccessCheck> {
@@ -191,7 +216,7 @@ async function checkAccess(userId: string, model?: ModelSelection): Promise<Acce
191216
return { denied: { allowed: false, reason: "Your daily request limit is currently set to zero. Contact support if this seems wrong.", code: "limit_reached", limit: 0, current: 0, statusCode: 402 } };
192217
}
193218

194-
return { tier: sub.tier, dailyLimit, canAccessProModels: sub.canAccessProModels };
219+
return { tier: sub.tier, dailyLimit, weeklyBudgetUsd: await getWeeklyBudgetUsd(userId), canAccessProModels: sub.canAccessProModels };
195220
}
196221

197222
function quotaDenial(deniedBy: unknown, tier: string, dailyLimit: number, current: number, units: number): RateLimitResult {
@@ -220,7 +245,7 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
220245
try {
221246
const access = await checkAccess(userId, model);
222247
if (access.denied) return access.denied;
223-
const { tier, dailyLimit, canAccessProModels } = access;
248+
const { tier, dailyLimit, weeklyBudgetUsd, canAccessProModels } = access;
224249

225250
// Atomically reserve today's quota units server-side so concurrent requests
226251
// can't race past the limit. Expensive models reserve several units, and
@@ -235,15 +260,15 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
235260
p_session_key: perSession.key,
236261
p_limit: dailyLimit,
237262
p_units: units,
238-
p_weekly_cap_usd: WEEKLY_AI_BUDGET_USD,
263+
p_weekly_cap_usd: weeklyBudgetUsd,
239264
p_max_calls: SESSION_QUOTA[perSession.feature].maxCalls,
240265
p_ttl_seconds: SESSION_QUOTA[perSession.feature].ttlSeconds,
241266
})
242267
: supabase.rpc("consume_ai_quota", {
243268
p_user_id: userId,
244269
p_limit: dailyLimit,
245270
p_units: units,
246-
p_weekly_cap_usd: WEEKLY_AI_BUDGET_USD,
271+
p_weekly_cap_usd: weeklyBudgetUsd,
247272
});
248273
let { data: quota, error: quotaError } = await reserve(session);
249274
// A database without 20261022220000_ai_quota_sessions.sql (functions
@@ -281,14 +306,14 @@ export async function peekRateLimit(userId: string, model?: ModelSelection): Pro
281306
try {
282307
const access = await checkAccess(userId, model);
283308
if (access.denied) return { ...access.denied, units: quotaUnitsFor(model) };
284-
const { tier, dailyLimit } = access;
309+
const { tier, dailyLimit, weeklyBudgetUsd } = access;
285310
const units = quotaUnitsFor(model);
286311
const { data, error } = await supabase.rpc("peek_ai_quota", { p_user_id: userId });
287312
if (error) throw error;
288313
const row = Array.isArray(data) ? data[0] : data;
289314
const current = typeof row?.current_count === "number" ? row.current_count : 0;
290315
const weekSpend = Number(row?.week_spend_usd ?? 0);
291-
if (WEEKLY_AI_BUDGET_USD !== null && weekSpend >= WEEKLY_AI_BUDGET_USD) {
316+
if (weeklyBudgetUsd !== null && weekSpend >= weeklyBudgetUsd) {
292317
return quotaDenial("weekly", tier, dailyLimit, current, units);
293318
}
294319
if (current + units > dailyLimit) return quotaDenial("daily", tier, dailyLimit, current, units);

‎supabase/functions/_shared/usage-limits.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export const MODEL_QUOTA_UNITS: Readonly<Record<string, number>> = {
3838
* Weekly cap on a user's AI spend, in USD, per calendar week starting Monday
3939
* 00:00 UTC. Only ai_requests.cap_cost_usd counts: the full logged cost of
4040
* each request, including Claude calls billed to our own BYOK Anthropic key,
41-
* plus Exa and ElevenLabs. Set to null to turn the cap off.
41+
* plus Exa and ElevenLabs. Set to null to turn the cap off for everyone. One
42+
* account's cap is changed or lifted in user_settings (weekly_budget_override_usd,
43+
* weekly_budget_unlimited); see getWeeklyBudgetUsd in entitlements.ts.
4244
*/
4345
export const WEEKLY_AI_BUDGET_USD: number | null = 2;
4446

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- Per-account override of the weekly AI spend cap (WEEKLY_AI_BUDGET_USD in
2+
-- supabase/functions/_shared/usage-limits.ts), set the same way as
3+
-- daily_limit_override: by an admin or service-role SQL, never by the user.
4+
--
5+
-- Additive only. Both columns default to "no override", so every existing
6+
-- account keeps the global cap. checkRateLimit and peekRateLimit in
7+
-- _shared/entitlements.ts read them and pass the effective cap to
8+
-- consume_ai_quota and claim_ai_session_quota, which are unchanged.
9+
--
10+
-- Precedence: weekly_budget_unlimited = true skips the weekly check; otherwise
11+
-- a non-NULL weekly_budget_override_usd replaces the global cap (0 refuses
12+
-- every request that week); otherwise the global cap applies.
13+
--
14+
-- No grant change: 20260819000000_security_hardening.sql revoked table-level
15+
-- INSERT and UPDATE on user_settings from anon and authenticated and granted
16+
-- UPDATE only on named preference columns, so new columns are server-only.
17+
ALTER TABLE public.user_settings
18+
ADD COLUMN IF NOT EXISTS weekly_budget_override_usd numeric,
19+
ADD COLUMN IF NOT EXISTS weekly_budget_unlimited boolean NOT NULL DEFAULT false;
20+
21+
-- consume_ai_quota raises on a negative cap, which would fail every request
22+
-- for the account instead of refusing it.
23+
DO $$
24+
BEGIN
25+
IF NOT EXISTS (
26+
SELECT 1 FROM pg_constraint
27+
WHERE conrelid = 'public.user_settings'::regclass
28+
AND conname = 'user_settings_weekly_budget_override_usd_check'
29+
) THEN
30+
ALTER TABLE public.user_settings
31+
ADD CONSTRAINT user_settings_weekly_budget_override_usd_check
32+
CHECK (weekly_budget_override_usd IS NULL OR weekly_budget_override_usd >= 0);
33+
END IF;
34+
END;
35+
$$;
36+
37+
COMMENT ON COLUMN public.user_settings.weekly_budget_override_usd IS
38+
'Weekly AI spend cap in USD for this account, replacing WEEKLY_AI_BUDGET_USD. NULL = global cap; 0 = refused all week. Admin or service role only.';
39+
COMMENT ON COLUMN public.user_settings.weekly_budget_unlimited IS
40+
'When true, this account skips the weekly AI spend cap; the daily limit still applies. Admin or service role only.';

0 commit comments

Comments
 (0)