File size: 7,379 Bytes
3464008 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | /**
* GET /api/user/mcp-quota
*
* Clerk-authenticated read-only endpoint that returns the caller's current
* Pro MCP daily quota usage. Reads the SAME Redis key shape that U7 writes
* via INCR-first reservation in `api/mcp.ts` (`mcp:pro-usage:<userId>:<YYYY-MM-DD>`).
* Single source of truth β `dailyCounterKey` is imported from
* `server/_shared/pro-mcp-token.ts` so a writer/reader drift cannot occur.
*
* Response shape:
* 200 { used: number, limit: number | null, resetsAt: <ISO at next UTC midnight> }
*
* `limit` is the caller's PLAN allowance (plan 2026-07-25-001 U3b), resolved
* from `features.planLimits.mcpCallsPerDay` through the SAME `resolveDailyLimit`
* that `api/mcp/quota.ts` enforces with β `null` means unlimited. Before U3b
* this reported a hardcoded 50, so a Pro Business user at 120 of 250 read
* "50 / 50" in Settings while enforcement served them fine.
*
* Edge cases:
* - First call of the UTC day: Redis key missing β `used: 0`.
* - Malformed Redis value (non-numeric): treat as 0 (the counter is
* INCR-only; non-numeric values would be a serious upstream regression
* better surfaced as "0 today" than as a 500).
* - Redis transient: log + return `used: 0`. The settings UI is best-effort
* informational; we never want a broken Redis to block the settings tab.
* - Entitlement lookup unavailable (null, or throwing): fall back to the
* pre-U3b behaviour (50). Same cost-protection direction as enforcement,
* and a lookup blip must never 500 a previously-working endpoint.
*
* Status codes:
* - 200 OK on success
* - 401 if no/invalid Clerk session
* - 405 on non-GET methods
*
* Cache-Control: no-store β quota state changes per-call, never cache.
*/
export const config = { runtime: 'edge' };
// @ts-expect-error β JS module, no declaration file
import { getCorsHeaders } from '../_cors.js';
// @ts-expect-error β JS module, no declaration file
import { captureSilentError } from '../_sentry-edge.js';
import { resolveClerkSession } from '../../server/_shared/auth-session';
import { getEntitlements } from '../../server/_shared/entitlement-check';
import { resolveDailyLimit, resolvePlanDrivenMcpAllowance } from '../mcp/quota';
import {
dailyCounterKey,
secondsUntilUtcMidnight,
} from '../../server/_shared/pro-mcp-token';
/** Inner handler β exported for unit tests with injected deps. */
export interface QuotaDeps {
/** Resolves the Clerk userId from the request's Bearer header. Null = unauth. */
resolveUserId: (req: Request) => Promise<string | null>;
/**
* Reads the daily counter key from Redis. Returns the stringified count
* (Upstash returns INCR results as strings) or null if the key does not
* exist. Throws on transport failure β the caller fail-softs to "0 used".
*/
redisGet: (key: string) => Promise<string | null>;
/**
* Cached entitlement read for the plan allowance. Only `planKey` and
* `features.planLimits.mcpCallsPerDay` are consumed; null/throw fall back
* to the plan default via `resolveDailyLimit`.
*/
getEntitlements: (userId: string) => Promise<{
planKey?: string;
features?: {
planLimits?: { mcpCallsPerDay?: number | null };
};
} | null>;
/** Injectable for deterministic tests. */
now: () => Date;
}
const REDIS_OP_TIMEOUT_MS = 1_500;
async function rawRedisGetString(key: string): Promise<string | null> {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(REDIS_OP_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`Redis HTTP ${resp.status}`);
const data = (await resp.json()) as { result?: string | null };
return typeof data?.result === 'string' ? data.result : null;
}
export async function quotaHandler(req: Request, deps: QuotaDeps): Promise<Response> {
const cors = getCorsHeaders(req);
const jsonHeaders = {
...cors,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
};
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
status: 405,
headers: { ...jsonHeaders, Allow: 'GET, OPTIONS' },
});
}
const userId = await deps.resolveUserId(req);
if (!userId) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401,
headers: jsonHeaders,
});
}
const now = deps.now();
const key = dailyCounterKey(userId, now);
// Plan allowance first β `used` is clamped to THIS number, not to the
// historical 50. An unreadable entitlement leaves `planDailyLimit`
// undefined, which resolveDailyLimit turns into the plan default. The
// plan-family gate mirrors enforcement (`checkMcpEntitlementGate`): an
// API-tier plan's catalog allowance is NOT what the meter applies, so it
// must not be what this endpoint displays.
let planDailyLimit: number | null | undefined;
try {
const ent = await deps.getEntitlements(userId);
planDailyLimit = resolvePlanDrivenMcpAllowance(ent?.planKey, ent?.features?.planLimits?.mcpCallsPerDay);
} catch (err) {
console.warn(
'[mcp-quota] entitlement lookup failed:',
err instanceof Error ? err.message : String(err),
);
captureSilentError(err, {
tags: { route: 'api/user/mcp-quota', step: 'entitlements' },
});
}
const limit = resolveDailyLimit(planDailyLimit);
let raw: string | null = null;
try {
raw = await deps.redisGet(key);
} catch (err) {
// Best-effort: Redis blip β report 0 used. The hard cap is enforced
// server-side at INCR time; this endpoint is informational.
console.warn(
'[mcp-quota] Redis read failed:',
err instanceof Error ? err.message : String(err),
);
captureSilentError(err, {
tags: { route: 'api/user/mcp-quota', step: 'redis-get' },
});
}
let used = 0;
if (raw !== null) {
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) {
// Cap displayed value at the resolved limit so a stale-rollover or test
// injection cannot show "73 / 50". Unlimited plans have nothing to clamp
// to β the raw counter IS the display value there.
const floored = Math.floor(n);
used = limit === null ? floored : Math.min(floored, limit);
}
}
// Compute resetsAt deterministically from now + secondsUntilUtcMidnight.
// Equivalent to floor-to-day + 1 day in UTC, but reuses the helper U7
// already uses for Retry-After to guarantee the displayed countdown
// matches the enforcement window exactly.
const resetsAtMs = now.getTime() + secondsUntilUtcMidnight(now) * 1000;
const resetsAt = new Date(resetsAtMs).toISOString();
return new Response(
JSON.stringify({ used, limit, resetsAt }),
{ status: 200, headers: jsonHeaders },
);
}
export default async function handler(req: Request): Promise<Response> {
return quotaHandler(req, {
resolveUserId: async (r) => (await resolveClerkSession(r))?.userId ?? null,
redisGet: rawRedisGetString,
getEntitlements,
now: () => new Date(),
});
}
|