From 682d83b220d00b1d772b9981d37e5d3d60f83b77 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:37:04 +0000 Subject: [PATCH] Let an admin lift or change one account's weekly AI cap Staging test accounts keep hitting the $2 weekly AI budget. Two new user_settings columns, writable only by the service role, override it per account: weekly_budget_override_usd replaces the cap and weekly_budget_unlimited skips it. NULL and false keep the global cap. The daily limit is unchanged. checkRateLimit passes the account's cap to consume_ai_quota and claim_ai_session_quota, and peekRateLimit compares against it, so the pre-round quota check agrees with the real reservation. The columns are read apart from getSubscriptionInfo, so functions deployed before the migration keep working, and a failed read keeps the global cap. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/usage-limits.md | 1 + docs/architecture.md | 2 +- .../functions/_shared/entitlements.test.ts | 59 ++++++++++++++++++- supabase/functions/_shared/entitlements.ts | 39 +++++++++--- supabase/functions/_shared/usage-limits.ts | 4 +- .../20261028120000_weekly_budget_override.sql | 40 +++++++++++++ .../tests/user_settings_privileges_test.sql | 39 +++++++++++- 7 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20261028120000_weekly_budget_override.sql diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md index bba4aac2c..6d6aaa030 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -29,3 +29,4 @@ Crossfire and Sparring are charged per round, not per call: the round's first ca - 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. - 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. +- 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. diff --git a/docs/architecture.md b/docs/architecture.md index ac69bb1ba..6f8025ee7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -160,7 +160,7 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the `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. -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). +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). 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. diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index 935b09fc6..589cc74c8 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -6,7 +6,7 @@ for (const [key, value] of Object.entries({ SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", })) Deno.env.set(key, value); -const { getSubscriptionInfo, checkRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts"); +const { getSubscriptionInfo, checkRateLimit, peekRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts"); const { MODEL_QUOTA_UNITS, WEEKLY_AI_BUDGET_USD } = await import("./usage-limits.ts"); function json(body: unknown, status = 200): Response { @@ -18,11 +18,12 @@ const future = () => new Date(Date.now() + DAY).toISOString(); const past = () => new Date(Date.now() - DAY).toISOString(); interface Routes { - user_settings?: unknown; + user_settings?: unknown | ((url: URL) => Response); user_roles?: unknown; subscriptions?: unknown | ((url: URL) => Response); rate_limits?: unknown | ((url: URL) => Response); quota?: unknown; + peek?: unknown; onQuota?: (body: Record) => void; requests?: string[]; } @@ -31,7 +32,9 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { const url = new URL(input instanceof Request ? input.url : String(input)); - if (url.pathname.includes("/rest/v1/user_settings")) return json(routes.user_settings ?? null); + if (url.pathname.includes("/rest/v1/user_settings")) { + return typeof routes.user_settings === "function" ? routes.user_settings(url) : json(routes.user_settings ?? null); + } if (url.pathname.includes("/rest/v1/user_roles")) return json(routes.user_roles ?? { role: "user" }); if (url.pathname.includes("/rest/v1/subscriptions")) { return typeof routes.subscriptions === "function" ? routes.subscriptions(url) : json(routes.subscriptions ?? []); @@ -49,6 +52,7 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { }); }); +async function sentWeeklyCap(settings: Routes["user_settings"]): Promise { + let sent: Record = {}; + await withSupabase({ user_settings: settings, onQuota: (body) => { sent = body; } }, async () => { + assertEquals((await checkRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "allowed"); + }); + return sent.p_weekly_cap_usd; +} + +Deno.test("an account without a weekly override gets the global cap", async () => { + assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: null, weekly_budget_unlimited: false }), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd"); +}); + +Deno.test("a weekly override replaces the global cap", async () => { + // PostgREST sends numeric as a number or a string. + assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 25, weekly_budget_unlimited: false }), 25, "number"); + assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: "7.5", weekly_budget_unlimited: false }), 7.5, "string"); + assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 0, weekly_budget_unlimited: false }), 0, "zero"); +}); + +Deno.test("an unlimited account skips the weekly cap, even with an override set", async () => { + assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 1, weekly_budget_unlimited: true }), null, "p_weekly_cap_usd"); +}); + +Deno.test("a failed override read keeps the global cap instead of lifting it", async () => { + const originalWarn = console.warn; + console.warn = () => {}; + try { + const settings = (url: URL) => (url.searchParams.get("select") ?? "").includes("weekly_budget") + ? json({ message: "column user_settings.weekly_budget_unlimited does not exist", code: "42703" }, 400) + : json(proUser); + assertEquals(await sentWeeklyCap(settings), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd"); + } finally { + console.warn = originalWarn; + } +}); + +Deno.test("the pre-round quota check uses the account's weekly cap", async () => { + const spent = [{ current_count: 1, week_spend_usd: (WEEKLY_AI_BUDGET_USD ?? 0) + 3 }]; + await withSupabase({ user_settings: proUser, peek: spent }, async () => { + assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).code, "weekly_budget_reached", "global cap"); + }); + await withSupabase({ user_settings: { ...proUser, weekly_budget_unlimited: true }, peek: spent }, async () => { + assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "unlimited"); + }); + await withSupabase({ user_settings: { ...proUser, weekly_budget_override_usd: 100 }, peek: spent }, async () => { + assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "raised cap"); + }); +}); + Deno.test("configured daily limits prefer the account row, then the global row", async () => { const requests: string[] = []; const limits = (url: URL) => { diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 7c3440c73..83c5edb28 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -157,6 +157,31 @@ export async function getConfiguredDailyLimit(userId: string, fallbackLimit: num return fallbackLimit; } +/** + * The weekly AI spend cap for this account in USD, or null for no cap. + * user_settings.weekly_budget_unlimited lifts it, a non-NULL + * weekly_budget_override_usd replaces it (20261028120000_weekly_budget_override.sql), + * and otherwise WEEKLY_AI_BUDGET_USD applies. Read apart from + * getSubscriptionInfo so a database without the columns only loses the + * override instead of failing every request. A failed read keeps the global cap. + */ +export async function getWeeklyBudgetUsd(userId: string): Promise { + const { data, error } = await supabase + .from("user_settings") + .select("weekly_budget_override_usd, weekly_budget_unlimited") + .eq("user_id", userId) + .maybeSingle(); + if (error) { + console.warn("[RATE LIMIT] weekly budget override lookup failed:", error.message); + return WEEKLY_AI_BUDGET_USD; + } + if (data?.weekly_budget_unlimited === true) return null; + // numeric arrives from PostgREST as a number or a string. + const override = data?.weekly_budget_override_usd; + const value = override === null || override === undefined ? NaN : Number(override); + return Number.isFinite(value) && value >= 0 ? value : WEEKLY_AI_BUDGET_USD; +} + // Logged and returned for API callers; the browser shows its own translation of the code. const ACCESS_REFUSAL_REASONS: Record = { trial_ended: "Your free trial has ended. Upgrade to Pro to continue using DebateOwl.", @@ -166,7 +191,7 @@ const ACCESS_REFUSAL_REASONS: Record = { type AccessCheck = | { denied: RateLimitResult } - | { denied?: undefined; tier: string; dailyLimit: number; canAccessProModels: boolean }; + | { denied?: undefined; tier: string; dailyLimit: number; weeklyBudgetUsd: number | null; canAccessProModels: boolean }; // Ban, plan, and model access: everything that refuses a request before quota is read. async function checkAccess(userId: string, model?: ModelSelection): Promise { @@ -191,7 +216,7 @@ async function checkAccess(userId: string, model?: ModelSelection): Promise= WEEKLY_AI_BUDGET_USD) { + if (weeklyBudgetUsd !== null && weekSpend >= weeklyBudgetUsd) { return quotaDenial("weekly", tier, dailyLimit, current, units); } if (current + units > dailyLimit) return quotaDenial("daily", tier, dailyLimit, current, units); diff --git a/supabase/functions/_shared/usage-limits.ts b/supabase/functions/_shared/usage-limits.ts index d946088e4..d92bbc5ec 100644 --- a/supabase/functions/_shared/usage-limits.ts +++ b/supabase/functions/_shared/usage-limits.ts @@ -38,7 +38,9 @@ export const MODEL_QUOTA_UNITS: Readonly> = { * Weekly cap on a user's AI spend, in USD, per calendar week starting Monday * 00:00 UTC. Only ai_requests.cap_cost_usd counts: the full logged cost of * each request, including Claude calls billed to our own BYOK Anthropic key, - * plus Exa and ElevenLabs. Set to null to turn the cap off. + * plus Exa and ElevenLabs. Set to null to turn the cap off for everyone. One + * account's cap is changed or lifted in user_settings (weekly_budget_override_usd, + * weekly_budget_unlimited); see getWeeklyBudgetUsd in entitlements.ts. */ export const WEEKLY_AI_BUDGET_USD: number | null = 2; diff --git a/supabase/migrations/20261028120000_weekly_budget_override.sql b/supabase/migrations/20261028120000_weekly_budget_override.sql new file mode 100644 index 000000000..5c165f72f --- /dev/null +++ b/supabase/migrations/20261028120000_weekly_budget_override.sql @@ -0,0 +1,40 @@ +-- Per-account override of the weekly AI spend cap (WEEKLY_AI_BUDGET_USD in +-- supabase/functions/_shared/usage-limits.ts), set the same way as +-- daily_limit_override: by an admin or service-role SQL, never by the user. +-- +-- Additive only. Both columns default to "no override", so every existing +-- account keeps the global cap. checkRateLimit and peekRateLimit in +-- _shared/entitlements.ts read them and pass the effective cap to +-- consume_ai_quota and claim_ai_session_quota, which are unchanged. +-- +-- Precedence: weekly_budget_unlimited = true skips the weekly check; otherwise +-- a non-NULL weekly_budget_override_usd replaces the global cap (0 refuses +-- every request that week); otherwise the global cap applies. +-- +-- No grant change: 20260819000000_security_hardening.sql revoked table-level +-- INSERT and UPDATE on user_settings from anon and authenticated and granted +-- UPDATE only on named preference columns, so new columns are server-only. +ALTER TABLE public.user_settings + ADD COLUMN IF NOT EXISTS weekly_budget_override_usd numeric, + ADD COLUMN IF NOT EXISTS weekly_budget_unlimited boolean NOT NULL DEFAULT false; + +-- consume_ai_quota raises on a negative cap, which would fail every request +-- for the account instead of refusing it. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'public.user_settings'::regclass + AND conname = 'user_settings_weekly_budget_override_usd_check' + ) THEN + ALTER TABLE public.user_settings + ADD CONSTRAINT user_settings_weekly_budget_override_usd_check + CHECK (weekly_budget_override_usd IS NULL OR weekly_budget_override_usd >= 0); + END IF; +END; +$$; + +COMMENT ON COLUMN public.user_settings.weekly_budget_override_usd IS + '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.'; +COMMENT ON COLUMN public.user_settings.weekly_budget_unlimited IS + 'When true, this account skips the weekly AI spend cap; the daily limit still applies. Admin or service role only.'; diff --git a/supabase/tests/user_settings_privileges_test.sql b/supabase/tests/user_settings_privileges_test.sql index 6265e2325..2c74266d4 100644 --- a/supabase/tests/user_settings_privileges_test.sql +++ b/supabase/tests/user_settings_privileges_test.sql @@ -1,5 +1,7 @@ BEGIN; -SELECT plan(5); +SELECT plan(9); + +INSERT INTO auth.users (id) VALUES ('c7000000-0000-4000-8000-00000000000a'); SELECT ok( has_column_privilege('authenticated', 'public.user_settings', 'output_language', 'UPDATE'), @@ -16,9 +18,24 @@ SELECT ok( NOT has_column_privilege('authenticated', 'public.user_settings', 'subscription_tier', 'UPDATE') AND NOT has_column_privilege('authenticated', 'public.user_settings', 'daily_limit_override', 'UPDATE') AND NOT has_column_privilege('authenticated', 'public.user_settings', 'grace_period_ends_at', 'UPDATE') - AND NOT has_column_privilege('authenticated', 'public.user_settings', 'trial_ends_at', 'UPDATE'), + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'trial_ends_at', 'UPDATE') + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'weekly_budget_override_usd', 'UPDATE') + AND NOT has_column_privilege('authenticated', 'public.user_settings', 'weekly_budget_unlimited', 'UPDATE'), 'users cannot change entitlement columns' ); +SELECT results_eq( + $$SELECT weekly_budget_override_usd, weekly_budget_unlimited FROM public.user_settings + WHERE user_id = 'c7000000-0000-4000-8000-00000000000a'$$, + $$VALUES (NULL::numeric, false)$$, + 'a new account has no weekly budget override' +); +SELECT throws_ok( + $$UPDATE public.user_settings SET weekly_budget_override_usd = -1 + WHERE user_id = 'c7000000-0000-4000-8000-00000000000a'$$, + '23514', + NULL, + 'a negative weekly budget override is rejected' +); SELECT ok( NOT has_table_privilege('authenticated', 'public.user_settings', 'INSERT'), 'users cannot insert settings rows' @@ -28,5 +45,23 @@ SELECT ok( 'users cannot delete settings rows' ); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT set_config('request.jwt.claim.sub', 'c7000000-0000-4000-8000-00000000000a', true); +SELECT throws_ok( + $$UPDATE public.user_settings SET weekly_budget_unlimited = true + WHERE user_id = 'c7000000-0000-4000-8000-00000000000a'$$, + '42501', + NULL, + 'a user cannot lift their own weekly cap' +); +SELECT throws_ok( + $$UPDATE public.user_settings SET weekly_budget_override_usd = 1000 + WHERE user_id = 'c7000000-0000-4000-8000-00000000000a'$$, + '42501', + NULL, + 'a user cannot raise their own weekly cap' +); + SELECT * FROM finish(); ROLLBACK;