Skip to content

Commit 1d3c85b

Browse files
Digidaiclaude
andcommitted
fix: /api/usage accepts session cookies and derives quota from D1 tier
Two bugs in the Portal dashboard: 1. /api/usage required a Bearer API key, silently returning 401 for session users → frontend fell back to quota:0. Dashboard showed "0 / 0 credits". 2. Quota was computed from AuthContext.quotaLimit which, for session users, was 0. Now reads account.tier from D1 and maps via TIER_QUOTAS[tier]. handleUsageForAccount() is the session-safe entry point; handleUsage() stays as the Bearer-auth entry point and delegates. Route tries session first, then falls back to Bearer for SDK/CLI callers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 96ca4a4 commit 1d3c85b

2 files changed

Lines changed: 52 additions & 19 deletions

File tree

src/handlers/usage.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* Best-effort: Worker crash loses unflushed counts.
66
*/
77

8-
import type { AuthContext, Env } from "../types";
8+
import type { AuthContext, Env, Tier } from "../types";
9+
import { TIER_QUOTAS } from "../types";
910
import { CORS_HEADERS } from "../config";
1011

1112
// ─── In-memory usage buffer ─────────────────────────────────
@@ -104,17 +105,16 @@ export function shouldFlush(): boolean {
104105

105106
// ─── GET /api/usage endpoint ────────────────────────────────
106107

107-
export async function handleUsage(
108-
auth: AuthContext,
108+
/**
109+
* Return usage data for an account.
110+
*
111+
* Accepts EITHER a Bearer API key (for SDK/CLI usage) OR a portal session
112+
* cookie (for Dashboard). Callers must resolve the accountId before calling.
113+
*/
114+
export async function handleUsageForAccount(
109115
env: Env,
116+
accountId: string,
110117
): Promise<Response> {
111-
if (!auth.keyId || !auth.accountId) {
112-
return Response.json(
113-
{ error: "Unauthorized", message: "Valid API key required for /api/usage" },
114-
{ status: 401, headers: CORS_HEADERS },
115-
);
116-
}
117-
118118
if (!env.AUTH_DB) {
119119
return Response.json(
120120
{ error: "Service Unavailable", message: "Usage tracking is not configured" },
@@ -128,34 +128,46 @@ export async function handleUsage(
128128

129129
const account = await env.AUTH_DB.prepare(
130130
`SELECT tier, monthly_credits_used, monthly_credits_reset_at FROM accounts WHERE id = ?`
131-
).bind(auth.accountId).first<{
131+
).bind(accountId).first<{
132132
tier: string;
133133
monthly_credits_used: number;
134134
monthly_credits_reset_at: string;
135135
}>();
136136

137+
if (!account) {
138+
return Response.json(
139+
{ error: "Not Found", message: "Account not found" },
140+
{ status: 404, headers: CORS_HEADERS },
141+
);
142+
}
143+
137144
const dailyRows = await env.AUTH_DB.prepare(`
138145
SELECT date, requests, credits, browser_calls, cache_hits
139146
FROM usage_daily
140147
WHERE key_id IN (SELECT id FROM api_keys WHERE account_id = ?)
141148
AND date >= ?
142149
ORDER BY date ASC
143-
`).bind(auth.accountId, monthStart).all<{
150+
`).bind(accountId, monthStart).all<{
144151
date: string;
145152
requests: number;
146153
credits: number;
147154
browser_calls: number;
148155
cache_hits: number;
149156
}>();
150157

158+
// Derive quota from the authoritative tier in D1, not from stale AuthContext
159+
const tier = (account.tier === "pro" ? "pro" : account.tier === "enterprise" ? "pro" : "free") as Tier;
160+
const quota = TIER_QUOTAS[tier];
161+
const used = account.monthly_credits_used ?? 0;
162+
151163
return Response.json({
152-
tier: account?.tier || auth.tier,
153-
quota: auth.quotaLimit,
154-
used: account?.monthly_credits_used ?? auth.quotaUsed,
155-
remaining: Math.max(0, auth.quotaLimit - (account?.monthly_credits_used ?? auth.quotaUsed)),
164+
tier: account.tier,
165+
quota,
166+
used,
167+
remaining: Math.max(0, quota - used),
156168
period: {
157169
start: monthStart,
158-
reset_at: account?.monthly_credits_reset_at,
170+
reset_at: account.monthly_credits_reset_at,
159171
},
160172
daily: dailyRows.results || [],
161173
}, { headers: CORS_HEADERS });
@@ -167,3 +179,17 @@ export async function handleUsage(
167179
);
168180
}
169181
}
182+
183+
/** Bearer-auth entry point (API key users) */
184+
export async function handleUsage(
185+
auth: AuthContext,
186+
env: Env,
187+
): Promise<Response> {
188+
if (!auth.accountId) {
189+
return Response.json(
190+
{ error: "Unauthorized", message: "Valid API key required for /api/usage" },
191+
{ status: 401, headers: CORS_HEADERS },
192+
);
193+
}
194+
return handleUsageForAccount(env, auth.accountId);
195+
}

src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { isAuthorizedByToken } from "./middleware/auth";
3636
import { resolveAuth } from "./middleware/auth-d1";
3737
import { buildPolicy, checkPolicy, policyHeaders } from "./middleware/tier-gate";
3838
import { consumeRateLimit, rateLimitedResponse } from "./middleware/rate-limit";
39-
import { recordUsage, flushUsage, shouldFlush, handleUsage } from "./handlers/usage";
39+
import { recordUsage, flushUsage, shouldFlush, handleUsage, handleUsageForAccount } from "./handlers/usage";
4040
import { resolveSession } from "./middleware/session";
4141
import {
4242
handleCreateKey,
@@ -346,8 +346,15 @@ export default {
346346
return handleOgImage(url, host);
347347
}
348348

349-
// GET /api/usage — per-key usage data
349+
// GET /api/usage — per-account usage data
350+
// Accepts EITHER Bearer API key (SDK/CLI) OR portal session cookie (dashboard)
350351
if (path === "/api/usage" && request.method === "GET") {
352+
// Try session cookie first (Portal dashboard case)
353+
const session = await resolveSession(request, env);
354+
if (session) {
355+
return handleUsageForAccount(env, session.accountId);
356+
}
357+
// Fall back to Bearer API key (SDK/CLI case)
351358
const auth = await resolveAuth(request, env);
352359
return handleUsage(auth, env);
353360
}

0 commit comments

Comments
 (0)