| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export const config = { runtime: 'edge' }; |
|
|
| |
| import { getCorsHeaders } from '../_cors.js'; |
| |
| 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'; |
|
|
| |
| export interface QuotaDeps { |
| |
| resolveUserId: (req: Request) => Promise<string | null>; |
| |
| |
| |
| |
| |
| redisGet: (key: string) => Promise<string | null>; |
| |
| |
| |
| |
| |
| getEntitlements: (userId: string) => Promise<{ |
| planKey?: string; |
| features?: { |
| planLimits?: { mcpCallsPerDay?: number | null }; |
| }; |
| } | null>; |
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| 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) { |
| |
| |
| |
| const floored = Math.floor(n); |
| used = limit === null ? floored : Math.min(floored, limit); |
| } |
| } |
|
|
| |
| |
| |
| |
| 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(), |
| }); |
| } |
|
|