| import { Ratelimit } from '@upstash/ratelimit'; |
| import { Redis } from '@upstash/redis'; |
| |
| import { resolveBearerToContext } from '../_oauth-token.js'; |
| |
| import { timingSafeIncludes } from '../_crypto.js'; |
| |
| import { getClientIp } from '../_client-ip.js'; |
| |
| import { captureSilentError } from '../_sentry-edge.js'; |
| import { redisPipeline as rawRedisPipeline } from '../_upstash-json.js'; |
| import { resolvePlanDrivenMcpAllowance } from './quota'; |
| import { |
| getBillingVerificationDenial, |
| getEntitlements, |
| isEntitlementBackendConfigured, |
| } from '../../server/_shared/entitlement-check'; |
| import { checkProMcpAccess } from '../../server/_shared/pro-mcp-gate'; |
| import type { BillingVerificationCode } from './billing-denial'; |
| import { |
| buildInternalMcpHeaders, |
| signInternalMcpRequest, |
| } from '../../server/_shared/mcp-internal-hmac'; |
| import { validateProMcpTokenOrNull } from '../../server/_shared/pro-mcp-token'; |
| import { validateUserApiKey } from '../../server/_shared/user-api-key'; |
| import { checkFailClosedScopedIpRateLimit } from '../../server/_shared/rate-limit'; |
| import { rpcError, withMcpNoStore } from './rpc'; |
| import type { |
| AuthResolution, |
| AuthResolutionRejected, |
| McpAuthContext, |
| McpHandlerDeps, |
| McpPreCheckResult, |
| } from './types'; |
| import { emitMcpRateLimitHit } from './telemetry'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let mcpRatelimit: Ratelimit | null = null; |
| let mcpProMinRatelimit: Ratelimit | null = null; |
| |
| |
| |
| |
| let mcpAnonRatelimit: Ratelimit | null = null; |
|
|
| function getMcpRatelimit(): Ratelimit | null { |
| if (mcpRatelimit) return mcpRatelimit; |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
| mcpRatelimit = new Ratelimit({ |
| redis: new Redis({ url, token, retry: false }), |
| limiter: Ratelimit.slidingWindow(60, '60 s'), |
| prefix: 'rl:mcp', |
| analytics: false, |
| }); |
| return mcpRatelimit; |
| } |
|
|
| function getMcpProMinRatelimit(): Ratelimit | null { |
| if (mcpProMinRatelimit) return mcpProMinRatelimit; |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
| mcpProMinRatelimit = new Ratelimit({ |
| redis: new Redis({ url, token, retry: false }), |
| limiter: Ratelimit.slidingWindow(60, '60 s'), |
| prefix: 'rl:mcp:pro-min', |
| analytics: false, |
| }); |
| return mcpProMinRatelimit; |
| } |
|
|
| function getMcpAnonRatelimit(): Ratelimit | null { |
| if (mcpAnonRatelimit) return mcpAnonRatelimit; |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
| mcpAnonRatelimit = new Ratelimit({ |
| redis: new Redis({ url, token, retry: false }), |
| limiter: Ratelimit.slidingWindow(60, '60 s'), |
| prefix: 'rl:mcp:anon', |
| analytics: false, |
| }); |
| return mcpAnonRatelimit; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function buildAuthHeaders( |
| context: McpAuthContext, |
| method: string, |
| url: string, |
| body: BodyInit | null | undefined, |
| ): Promise<Record<string, string>> { |
| if (context.kind === 'env_key' || context.kind === 'user_key') { |
| |
| |
| |
| |
| return { 'X-WorldMonitor-Key': context.apiKey }; |
| } |
| |
| const secret = process.env.MCP_INTERNAL_HMAC_SECRET ?? ''; |
| if (!secret) { |
| |
| |
| |
| throw new Error('MCP_INTERNAL_HMAC_SECRET not configured'); |
| } |
| const signed = await signInternalMcpRequest({ |
| method, |
| url, |
| body, |
| userId: context.userId, |
| secret, |
| }); |
| return buildInternalMcpHeaders(signed); |
| } |
|
|
| export const PRODUCTION_DEPS: McpHandlerDeps = { |
| resolveBearerToContext, |
| |
| |
| |
| |
| |
| validateProMcpToken: validateProMcpTokenOrNull, |
| getEntitlements, |
| validateUserApiKey, |
| guardUserApiKeyValidation: (request, corsHeaders) => checkFailClosedScopedIpRateLimit( |
| request, |
| 'mcp:user-api-key:pre-auth-validation', |
| 60, |
| '60 s', |
| corsHeaders, |
| ), |
| redisPipeline: rawRedisPipeline, |
| }; |
|
|
| |
| |
| |
| |
|
|
| export function wwwAuthHeader(resourceMetadataUrl: string, errorParam = ''): string { |
| const errSegment = errorParam ? `, error="${errorParam}"` : ''; |
| return `Bearer realm="worldmonitor"${errSegment}, resource_metadata="${resourceMetadataUrl}"`; |
| } |
|
|
| function userKeyValidationBackpressureResponse(response: Response, corsHeaders: Record<string, string>): Response { |
| const limited = response.status === 429; |
| return new Response( |
| JSON.stringify({ |
| jsonrpc: '2.0', |
| id: null, |
| error: { |
| code: limited ? -32029 : -32603, |
| message: limited ? 'Too many requests' : 'Auth service temporarily unavailable. Try again.', |
| }, |
| }), |
| { |
| status: response.status, |
| headers: withMcpNoStore({ |
| ...Object.fromEntries(response.headers.entries()), |
| ...corsHeaders, |
| 'Content-Type': 'application/json', |
| }), |
| }, |
| ); |
| } |
|
|
| export function getMcpBillingVerificationDenial( |
| entitlements: { |
| billingStatus?: BillingVerificationCode; |
| retryAfterSeconds?: number; |
| // Transient entitlement-lookup failure marker from getEntitlements() |
| // (server/_shared/entitlement-check.ts) — mapped to the same retryable |
| // envelope as a gateway-synthesized entitlement_verification_unavailable. |
| verificationUnavailable?: boolean; |
| } | null | undefined, |
| corsHeaders: Record<string, string>, |
| id: unknown = null, |
| ): Response | null { |
| const billingStatus = entitlements?.verificationUnavailable |
| ? 'entitlement_verification_unavailable' |
| : entitlements?.billingStatus; |
| if (billingStatus === 'entitlement_verification_unavailable') { |
| |
| |
| |
| const raw = entitlements?.retryAfterSeconds; |
| const retryAfter = Number.isFinite(raw) |
| ? Math.max(1, Math.min(60, Math.ceil(raw as number))) |
| : 5; |
| return new Response( |
| JSON.stringify({ |
| jsonrpc: '2.0', |
| id: id ?? null, |
| error: { |
| code: -32603, |
| message: 'Unable to verify API access. Retry shortly.', |
| data: { code: billingStatus }, |
| }, |
| }), |
| { |
| status: 503, |
| headers: new Headers({ |
| ...corsHeaders, |
| 'Content-Type': 'application/json', |
| 'Cache-Control': 'no-store', |
| 'Retry-After': String(retryAfter), |
| 'X-Billing-Verification': billingStatus, |
| }), |
| }, |
| ); |
| } |
|
|
| |
| |
| |
| |
| const denial = getBillingVerificationDenial( |
| billingStatus ? { billingStatus, retryAfterSeconds: entitlements?.retryAfterSeconds } : null, |
| corsHeaders, |
| ); |
| if (!denial || !billingStatus) return null; |
|
|
| const retryable = denial.status === 503; |
| const message = { |
| subscription_lapsed: 'Subscription lapsed. Re-authenticating will not help — resubscribe to restore access.', |
| renewal_verification_pending: 'Renewal verification pending. Retry shortly.', |
| renewal_verification_failed: 'Renewal verification failed. Retry shortly.', |
| }[billingStatus]; |
| const headers = new Headers(denial.headers); |
| headers.set('Cache-Control', 'no-store'); |
| headers.set('Content-Type', 'application/json'); |
|
|
| return new Response( |
| JSON.stringify({ |
| jsonrpc: '2.0', |
| id: id ?? null, |
| error: { |
| |
| |
| |
| |
| code: retryable ? -32603 : -32002, |
| message, |
| data: { code: billingStatus }, |
| }, |
| }), |
| { status: denial.status, headers }, |
| ); |
| } |
|
|
| export async function resolveAuthContext( |
| req: Request, |
| deps: McpHandlerDeps, |
| resourceMetadataUrl: string, |
| corsHeaders: Record<string, string>, |
| ): Promise<AuthResolution | AuthResolutionRejected> { |
| const authHeader = req.headers.get('Authorization') ?? ''; |
| if (authHeader.startsWith('Bearer ')) { |
| const token = authHeader.slice(7).trim(); |
| let context: McpAuthContext | null; |
| try { |
| context = await deps.resolveBearerToContext(token); |
| } catch { |
| return { |
| ok: false, |
| response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }), |
| { status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) }, |
| ), |
| }; |
| } |
| if (!context) { |
| return { |
| ok: false, |
| response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired OAuth token. Re-authenticate via /oauth/token.' } }), |
| { status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) }, |
| ), |
| }; |
| } |
| return { ok: true, context }; |
| } |
|
|
| const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? ''; |
| if (!candidateKey) { |
| return { |
| ok: false, |
| response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Authentication required. Use OAuth (/oauth/token) or pass your API key via X-WorldMonitor-Key header.' } }), |
| { status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl), ...corsHeaders }) }, |
| ), |
| }; |
| } |
| const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean); |
| if (await timingSafeIncludes(candidateKey, validKeys)) { |
| return { ok: true, context: { kind: 'env_key', apiKey: candidateKey } }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| if (candidateKey.startsWith('wm_')) { |
| let userKey: { userId: string } | null = null; |
| try { |
| |
| |
| |
| |
| const validationGuardResponse = await deps.guardUserApiKeyValidation(req, corsHeaders); |
| if (validationGuardResponse) { |
| return { |
| ok: false, |
| response: userKeyValidationBackpressureResponse(validationGuardResponse, corsHeaders), |
| }; |
| } |
| userKey = await deps.validateUserApiKey(candidateKey); |
| } catch { |
| |
| |
| return { |
| ok: false, |
| response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }), |
| { status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) }, |
| ), |
| }; |
| } |
| if (userKey) { |
| return { ok: true, context: { kind: 'user_key', apiKey: candidateKey, userId: userKey.userId } }; |
| } |
| } |
|
|
| return { |
| ok: false, |
| response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid API key' } }), |
| { status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) }, |
| ), |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function runProPreChecks( |
| context: Extract<McpAuthContext, { kind: 'pro' }>, |
| deps: McpHandlerDeps, |
| resourceMetadataUrl: string, |
| corsHeaders: Record<string, string>, |
| ctx?: { waitUntil: (p: Promise<unknown>) => void }, |
| ): Promise<McpPreCheckResult> { |
| |
| |
| |
| |
| |
| if (!process.env.MCP_INTERNAL_HMAC_SECRET) { |
| captureSilentError(new Error('MCP_INTERNAL_HMAC_SECRET unset'), { |
| tags: { route: 'api/mcp', step: 'pro-secret-preflight' }, |
| ctx, |
| }); |
| return { ok: false, response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Service temporarily unavailable, retry in a moment.' } }), |
| { status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) }, |
| ) }; |
| } |
|
|
| |
| |
| |
| |
| let validation: Awaited<ReturnType<typeof deps.validateProMcpToken>> = null; |
| try { |
| validation = await deps.validateProMcpToken(context.mcpTokenId); |
| } catch (err) { |
| captureSilentError(err, { tags: { route: 'api/mcp', step: 'pro-token-validate' }, ctx }); |
| return { ok: false, response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Service temporarily unavailable, retry in a moment.' } }), |
| { status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) }, |
| ) }; |
| } |
| if (!validation || validation.userId !== context.userId) { |
| return { ok: false, response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'MCP authorization revoked. Re-authorize at https://worldmonitor.app/mcp-grant.' } }), |
| { status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) }, |
| ) }; |
| } |
|
|
| return checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'pro-entitlement-recheck', ctx); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function checkMcpEntitlementGate( |
| userId: string, |
| deps: McpHandlerDeps, |
| resourceMetadataUrl: string, |
| corsHeaders: Record<string, string>, |
| sentryStep: string, |
| ctx?: { waitUntil: (p: Promise<unknown>) => void }, |
| ): Promise<McpPreCheckResult> { |
| const rejected = (): McpPreCheckResult => ({ ok: false, response: new Response( |
| JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Subscription not active.' } }), |
| { status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) }, |
| ) }); |
|
|
| let ent: Awaited<ReturnType<typeof deps.getEntitlements>> = null; |
| try { |
| ent = await deps.getEntitlements(userId); |
| } catch (err) { |
| captureSilentError(err, { tags: { route: 'api/mcp', step: sentryStep }, ctx }); |
| return rejected(); |
| } |
| const passed = (): McpPreCheckResult => ({ |
| ok: true, |
| mcpDailyLimit: resolvePlanDrivenMcpAllowance(ent?.planKey, ent?.features?.planLimits?.mcpCallsPerDay), |
| }); |
| |
| |
| const gate = checkProMcpAccess(ent, Date.now(), { |
| backendConfigured: isEntitlementBackendConfigured(), |
| }); |
| if (!gate) { |
| return passed(); |
| } |
| const billingDenial = getMcpBillingVerificationDenial(ent, corsHeaders); |
| if (billingDenial) return { ok: false, response: billingDenial }; |
| return rejected(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function runUserKeyPreChecks( |
| context: Extract<McpAuthContext, { kind: 'user_key' }>, |
| deps: McpHandlerDeps, |
| resourceMetadataUrl: string, |
| corsHeaders: Record<string, string>, |
| ctx?: { waitUntil: (p: Promise<unknown>) => void }, |
| ): Promise<McpPreCheckResult> { |
| const gate = await checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'user-key-entitlement', ctx); |
| |
| |
| |
| |
| |
| return gate.ok ? { ok: true } : gate; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function runContextPreChecks( |
| context: McpAuthContext, |
| deps: McpHandlerDeps, |
| resourceMetadataUrl: string, |
| corsHeaders: Record<string, string>, |
| ctx?: { waitUntil: (p: Promise<unknown>) => void }, |
| ): Promise<McpPreCheckResult> { |
| if (context.kind === 'pro') { |
| return runProPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx); |
| } |
| if (context.kind === 'user_key') { |
| return runUserKeyPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx); |
| } |
| |
| return { ok: true }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function applyPerMinuteLimit(context: McpAuthContext, headers: Record<string, string> = {}): Promise<Response | null> { |
| if (context.kind === 'env_key') { |
| const rl = getMcpRatelimit(); |
| if (!rl) return null; |
| try { |
| const { success } = await rl.limit(`key:${context.apiKey}`); |
| if (!success) { |
| emitMcpRateLimitHit(context, { |
| dimension: 'mcp_minute_burst', |
| limit: 60, |
| windowSeconds: 60, |
| }); |
| return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.', headers); |
| } |
| } catch { } |
| return null; |
| } |
| const rl = getMcpProMinRatelimit(); |
| if (!rl) return null; |
| try { |
| const { success } = await rl.limit(`pro-user:${context.userId}`); |
| if (!success) { |
| emitMcpRateLimitHit(context, { |
| dimension: 'mcp_minute_burst', |
| limit: 60, |
| windowSeconds: 60, |
| }); |
| return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per user.', headers); |
| } |
| } catch { } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function applyAnonDiscoveryLimit(req: Request, headers: Record<string, string> = {}): Promise<Response | null> { |
| const rl = getMcpAnonRatelimit(); |
| if (!rl) return null; |
| try { |
| const { success } = await rl.limit(`ip:${getClientIp(req)}`); |
| if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 unauthenticated discovery requests per minute per IP.', headers); |
| } catch { } |
| return null; |
| } |
|
|