File size: 25,288 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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// @ts-expect-error β€” JS module, no declaration file
import { resolveBearerToContext } from '../_oauth-token.js';
// @ts-expect-error β€” JS module, no declaration file
import { timingSafeIncludes } from '../_crypto.js';
// @ts-expect-error β€” JS module, no declaration file
import { getClientIp } from '../_client-ip.js';
// @ts-expect-error β€” JS module, no declaration file
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';

// ---------------------------------------------------------------------------
// Rate limiters
// ---------------------------------------------------------------------------
//   - Legacy per-key 60/min (Starter+ env-key bearers): prefix `rl:mcp`,
//     keyed `key:<apiKey>`. Unchanged from pre-U7.
//   - Pro per-user 60/min: prefix `rl:mcp:pro-min`, keyed `pro-user:<userId>`.
//     Independent limiter so a Pro user with two Claude installations sees
//     combined 60/min across both bearers (same userId).
// ---------------------------------------------------------------------------

let mcpRatelimit: Ratelimit | null = null;
let mcpProMinRatelimit: Ratelimit | null = null;
// Anonymous MCP discovery limiter (initialize / tools/list without credentials).
// Keyed by client IP so a public discovery surface can't be hammered by an
// unauthenticated caller. Separate prefix from the authed per-key/per-user
// limiters above so anon traffic never shares a bucket with a real principal.
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;
}

/**
 * Build the Authorization header set for a downstream `_execute` fetch.
 *
 *   - env_key β†’ `X-WorldMonitor-Key: <apiKey>` (existing, unchanged).
 *   - pro     β†’ `X-WM-MCP-Internal: <ts>.<sig>` + `X-WM-MCP-User-Id: <userId>`.
 *               Signature binds method+pathname+queryHash+bodyHash+userId.
 *
 * `body` MUST be the EXACT bytes the caller passes to `fetch()` so the
 * signed payload matches the wire bytes. For JSON, pre-stringify on the
 * caller side and pass the same string here.
 */
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') {
    // user_key (#4859): the downstream REST gateway validates the raw key
    // itself (Convex hash lookup + the #4611 apiAccess gate + per-account
    // limits), so usage attributes to the key owner exactly like a direct
    // REST call β€” no internal-HMAC identity smuggling needed.
    return { 'X-WorldMonitor-Key': context.apiKey };
  }
  // context.kind === 'pro'
  const secret = process.env.MCP_INTERNAL_HMAC_SECRET ?? '';
  if (!secret) {
    // Should never happen in production (deploy gate at U10) β€” surface as
    // an error so the tool fetch fails fast rather than silently 401-ing
    // at the gateway with a confusing "invalid_internal_mcp_signature".
    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,
  // Per-request validate path uses the legacy `userId | null` wrapper β€”
  // transient Convex blips fail-closed (401 prompts the client to retry
  // via OAuth, which is the correct safety direction here). The refresh-
  // grant path in api/oauth/token.ts uses the discriminated-union form
  // to distinguish revoked from transient (F3 of the U7+U8 review pass).
  validateProMcpToken: validateProMcpTokenOrNull,
  getEntitlements,
  validateUserApiKey,
  guardUserApiKeyValidation: (request, corsHeaders) => checkFailClosedScopedIpRateLimit(
    request,
    'mcp:user-api-key:pre-auth-validation',
    60,
    '60 s',
    corsHeaders,
  ),
  redisPipeline: rawRedisPipeline,
};

// ---------------------------------------------------------------------------
// Auth + Pro-pre-check helpers (extracted from mcpHandler so the top-level
// handler stays under the cognitive-complexity threshold).
// ---------------------------------------------------------------------------

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') {
    // Gateway-synthesized backend-unreachable 503 (server/gateway.ts wm_-key
    // branch). The shared Convex-facing helper doesn't recognize this code, so
    // build the same retryable envelope here; clamp mirrors the shared helper.
    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,
        }),
      },
    );
  }

  // The shared helper owns status, retry normalization, no-store, and billing
  // headers. Its parameter asks only for the billing fields, so both the
  // McpHandlerDeps entitlement shape and dispatch's synthesized
  // BillingDenialError shape are directly assignable.
  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: {
        // -32002 is the confirmed-lapse code (HTTP 403, no WWW-Authenticate).
        // -32001 stays reserved for authentication failures at HTTP 401 per
        // docs/mcp-error-catalog.mdx β€” reusing it here sent doc-following
        // agents into a pointless OAuth re-auth loop.
        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 } };
  }

  // #4859: customer-issued dashboard keys (Convex userApiKeys). The env
  // allowlist above holds only legacy operator keys; every key a user mints
  // in the dashboard lives in Convex β€” before this fallback, ALL of them got
  // "Invalid API key" here while the same keys worked on the REST gateway.
  // Identity resolution only: the owner's mcpAccess entitlement is enforced
  // at the gated-method pre-check (runUserKeyPreChecks), symmetric with the
  // pro path, so a lapsed owner can still list tools but never call them.
  if (candidateKey.startsWith('wm_')) {
    let userKey: { userId: string } | null = null;
    try {
      // Identity is not known until after this Convex-backed lookup, so the
      // normal per-user MCP limit cannot protect it. Bound rotating unknown
      // wm_ guesses by client IP first; otherwise each unique key evades the
      // per-hash negative cache and reaches the auth backend.
      const validationGuardResponse = await deps.guardUserApiKeyValidation(req, corsHeaders);
      if (validationGuardResponse) {
        return {
          ok: false,
          response: userKeyValidationBackpressureResponse(validationGuardResponse, corsHeaders),
        };
      }
      userKey = await deps.validateUserApiKey(candidateKey);
    } catch {
      // validateUserApiKey throws UserApiKeyUnavailableError when Convex is
      // unreachable/misconfigured β€” 503 mirrors the bearer path (not 401).
      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 }) },
    ),
  };
}

/**
 * Pro-only pre-checks: validate Convex row + cross-user-binding + entitlement
 * re-check. On success the result also carries the plan's daily MCP allowance
 * (plan 2026-07-25-001 U3) β€” this is the one place on the gated path that has
 * the entitlement object in hand, so resolving it here spares the dispatcher a
 * second Convex round-trip.
 */
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> {
  // F12: Pro path is unusable without MCP_INTERNAL_HMAC_SECRET β€” every
  // tool fetch will throw inside buildAuthHeaders. Surface the misconfig
  // at auth-resolution time so operators see a single clear 503 rather
  // than a confusing mid-tool-fetch -32603. Belt-and-suspenders with the
  // U10 deploy gate; matches the runtime check in `buildAuthHeaders`.
  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 }) },
    ) };
  }

  // #4860: this await was the only unguarded step on the gated path β€” the
  // wired helper never rejects today, but a rejection here previously escaped
  // mcpHandler (no top-level catch) as a raw 500 with zero Sentry. Fail
  // closed with the same retryable 503 shape as the bearer-resolve catch.
  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);
}

/**
 * Shared mcpAccess entitlement gate for identity-resolved contexts (pro AND
 * user_key). Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
 * Passes when the owner has an active tier>=1 + mcpAccess entitlement; rejects
 * with a 401 Response otherwise.
 *
 * A passing result also reports `mcpDailyLimit`, read straight off the
 * entitlement this call already fetched β€” but only for plan-driven plan
 * families (`resolvePlanDrivenMcpAllowance`): API-tier subscribers reach this
 * gate through the same OAuth door, and their catalog allowance must not
 * out-rank the 50/day their `user_key` is capped at. A row with no
 * `planLimits` (legacy shape) or a non-plan-driven plan reports `undefined`,
 * which the quota layer resolves to the plan default β€” the entitlement is
 * NOT re-fetched to fill the gap.
 */
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),
  });
  // Single-source Pro MCP decision. A current fallback entitlement still wins
  // over billing uncertainty; this caller keeps the JSON-RPC denial rendering.
  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();
}

/**
 * user_key (#4859) pre-check: the key row proved identity at auth-resolution
 * time; data methods must additionally verify the OWNER still has an active
 * mcpAccess entitlement. Without this, a user_key context would be the one
 * credential class that skips the entitlement gate (env_key is operator-owned
 * and intentionally ungated; pro re-checks on every gated call).
 */
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);
  // KTD6: the entitlement verdict applies, the plan's MCP allowance does NOT.
  // user_key callers stay on the hardcoded daily cap whatever their API plan
  // advertises β€” raising API-tier MCP allowances is a deliberate follow-up, and
  // dropping the limit HERE (rather than guarding at the metering site) keeps
  // one place to change when that follow-up lands.
  return gate.ok ? { ok: true } : gate;
}

/**
 * Kind-dispatched pre-checks for gated (data/quota) methods. env_key needs
 * none; pro and user_key each run their own. Single entry point so a future
 * context kind can't silently ship without deciding its gate (the tracer
 * finding on #4859: mapping user keys onto env_key would have bypassed
 * entitlements entirely).
 */
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);
  }
  // env_key: operator-owned, ungated, and never metered by the daily counter.
  return { ok: true };
}

/** Per-minute rate limit. Both paths fail-OPEN on Upstash error (graceful);
 *  the daily quota is the hard-cap fail-CLOSED gate. Returns null on success
 *  or pass-through, a Response on a real 60/min limit hit.
 *  user_key (#4859) shares the per-USER limiter with pro β€” the principal is
 *  the key OWNER, so a user with an OAuth connection and a dashboard key gets
 *  one combined 60/min budget instead of two stackable ones. */
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 { /* graceful degradation */ }
    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 { /* graceful degradation */ }
  return null;
}

/** Per-IP rate limit for the UNAUTHENTICATED discovery path (initialize /
 *  tools/list without credentials β€” the metadata surface agent scanners probe).
 *  Keyed on the trusted client IP (cf-connecting-ip / x-real-ip; falls back to a
 *  shared bucket so x-forwarded-for spoofing can't rotate identities). Fail-OPEN
 *  on Upstash error, matching `applyPerMinuteLimit` β€” the discovery response is a
 *  cheap in-memory payload, so availability beats strict enforcement here.
 *  Returns null on success/skip, a Response on a real 60/min limit hit. */
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 { /* graceful degradation */ }
  return null;
}