@@ -16,6 +16,7 @@ import {
1616 signInternalMcpRequest ,
1717} from '../../server/_shared/mcp-internal-hmac' ;
1818import { validateProMcpTokenOrNull } from '../../server/_shared/pro-mcp-token' ;
19+ import { validateUserApiKey } from '../../server/_shared/user-api-key' ;
1920import { rpcError , withMcpNoStore } from './rpc' ;
2021import type {
2122 AuthResolution ,
@@ -101,7 +102,11 @@ export async function buildAuthHeaders(
101102 url : string ,
102103 body : BodyInit | null | undefined ,
103104) : Promise < Record < string , string > > {
104- if ( context . kind === 'env_key' ) {
105+ if ( context . kind === 'env_key' || context . kind === 'user_key' ) {
106+ // user_key (#4859): the downstream REST gateway validates the raw key
107+ // itself (Convex hash lookup + the #4611 apiAccess gate + per-account
108+ // limits), so usage attributes to the key owner exactly like a direct
109+ // REST call — no internal-HMAC identity smuggling needed.
105110 return { 'X-WorldMonitor-Key' : context . apiKey } ;
106111 }
107112 // context.kind === 'pro'
@@ -131,6 +136,7 @@ export const PRODUCTION_DEPS: McpHandlerDeps = {
131136 // to distinguish revoked from transient (F3 of the U7+U8 review pass).
132137 validateProMcpToken : validateProMcpTokenOrNull ,
133138 getEntitlements,
139+ validateUserApiKey,
134140 redisPipeline : rawRedisPipeline ,
135141} ;
136142
@@ -188,16 +194,44 @@ export async function resolveAuthContext(
188194 } ;
189195 }
190196 const validKeys = ( process . env . WORLDMONITOR_VALID_KEYS || '' ) . split ( ',' ) . filter ( Boolean ) ;
191- if ( ! await timingSafeIncludes ( candidateKey , validKeys ) ) {
192- return {
193- ok : false ,
194- response : new Response (
195- JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'Invalid API key' } } ) ,
196- { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
197- ) ,
198- } ;
197+ if ( await timingSafeIncludes ( candidateKey , validKeys ) ) {
198+ return { ok : true , context : { kind : 'env_key' , apiKey : candidateKey } } ;
199199 }
200- return { ok : true , context : { kind : 'env_key' , apiKey : candidateKey } } ;
200+
201+ // #4859: customer-issued dashboard keys (Convex userApiKeys). The env
202+ // allowlist above holds only legacy operator keys; every key a user mints
203+ // in the dashboard lives in Convex — before this fallback, ALL of them got
204+ // "Invalid API key" here while the same keys worked on the REST gateway.
205+ // Identity resolution only: the owner's mcpAccess entitlement is enforced
206+ // at the gated-method pre-check (runUserKeyPreChecks), symmetric with the
207+ // pro path, so a lapsed owner can still list tools but never call them.
208+ if ( candidateKey . startsWith ( 'wm_' ) ) {
209+ let userKey : { userId : string } | null = null ;
210+ try {
211+ userKey = await deps . validateUserApiKey ( candidateKey ) ;
212+ } catch {
213+ // Production validateUserApiKey fail-softs to null; a throw means the
214+ // auth backend itself is unreachable — 503 mirrors the bearer path.
215+ return {
216+ ok : false ,
217+ response : new Response (
218+ JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32603 , message : 'Auth service temporarily unavailable. Try again.' } } ) ,
219+ { status : 503 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'Retry-After' : '5' , ...corsHeaders } ) } ,
220+ ) ,
221+ } ;
222+ }
223+ if ( userKey ) {
224+ return { ok : true , context : { kind : 'user_key' , apiKey : candidateKey , userId : userKey . userId } } ;
225+ }
226+ }
227+
228+ return {
229+ ok : false ,
230+ response : new Response (
231+ JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'Invalid API key' } } ) ,
232+ { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
233+ ) ,
234+ } ;
201235}
202236
203237/**
@@ -227,40 +261,111 @@ export async function runProPreChecks(
227261 ) ;
228262 }
229263
230- const validation = await deps . validateProMcpToken ( context . mcpTokenId ) ;
264+ // #4860: this await was the only unguarded step on the gated path — the
265+ // wired helper never rejects today, but a rejection here previously escaped
266+ // mcpHandler (no top-level catch) as a raw 500 with zero Sentry. Fail
267+ // closed with the same retryable 503 shape as the bearer-resolve catch.
268+ let validation : Awaited < ReturnType < typeof deps . validateProMcpToken > > = null ;
269+ try {
270+ validation = await deps . validateProMcpToken ( context . mcpTokenId ) ;
271+ } catch ( err ) {
272+ captureSilentError ( err , { tags : { route : 'api/mcp' , step : 'pro-token-validate' } , ctx } ) ;
273+ return new Response (
274+ JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32603 , message : 'Service temporarily unavailable, retry in a moment.' } } ) ,
275+ { status : 503 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'Retry-After' : '5' , ...corsHeaders } ) } ,
276+ ) ;
277+ }
231278 if ( ! validation || validation . userId !== context . userId ) {
232279 return new Response (
233280 JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'MCP authorization revoked. Re-authorize at https://worldmonitor.app/mcp-grant.' } } ) ,
234281 { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
235282 ) ;
236283 }
237284
285+ return checkMcpEntitlementGate ( context . userId , deps , resourceMetadataUrl , corsHeaders , 'pro-entitlement-recheck' , ctx ) ;
286+ }
287+
288+ /**
289+ * Shared mcpAccess entitlement gate for identity-resolved contexts (pro AND
290+ * user_key). Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
291+ * Returns null when the owner has an active tier>=1 + mcpAccess entitlement;
292+ * a 401 Response otherwise.
293+ */
294+ async function checkMcpEntitlementGate (
295+ userId : string ,
296+ deps : McpHandlerDeps ,
297+ resourceMetadataUrl : string ,
298+ corsHeaders : Record < string , string > ,
299+ sentryStep : string ,
300+ ctx ?: { waitUntil : ( p : Promise < unknown > ) => void } ,
301+ ) : Promise < Response | null > {
302+ const rejected = ( ) => new Response (
303+ JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'Subscription not active.' } } ) ,
304+ { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
305+ ) ;
306+
238307 let ent : Awaited < ReturnType < typeof deps . getEntitlements > > = null ;
239308 try {
240- ent = await deps . getEntitlements ( context . userId ) ;
309+ ent = await deps . getEntitlements ( userId ) ;
241310 } catch ( err ) {
242- // Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
243- captureSilentError ( err , { tags : { route : 'api/mcp' , step : 'pro-entitlement-recheck' } , ctx } ) ;
244- return new Response (
245- JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'Subscription not active.' } } ) ,
246- { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
247- ) ;
311+ captureSilentError ( err , { tags : { route : 'api/mcp' , step : sentryStep } , ctx } ) ;
312+ return rejected ( ) ;
248313 }
249314 const tier = ent ?. features ?. tier ?? 0 ;
250315 const mcpAccess = ent ?. features ?. mcpAccess === true ;
251316 const validUntil = ent ?. validUntil ?? 0 ;
252317 if ( ! ent || tier < 1 || ! mcpAccess || validUntil < Date . now ( ) ) {
253- return new Response (
254- JSON . stringify ( { jsonrpc : '2.0' , id : null , error : { code : - 32001 , message : 'Subscription not active.' } } ) ,
255- { status : 401 , headers : withMcpNoStore ( { 'Content-Type' : 'application/json' , 'WWW-Authenticate' : wwwAuthHeader ( resourceMetadataUrl , 'invalid_token' ) , ...corsHeaders } ) } ,
256- ) ;
318+ return rejected ( ) ;
319+ }
320+ return null ;
321+ }
322+
323+ /**
324+ * user_key (#4859) pre-check: the key row proved identity at auth-resolution
325+ * time; data methods must additionally verify the OWNER still has an active
326+ * mcpAccess entitlement. Without this, a user_key context would be the one
327+ * credential class that skips the entitlement gate (env_key is operator-owned
328+ * and intentionally ungated; pro re-checks on every gated call).
329+ */
330+ export async function runUserKeyPreChecks (
331+ context : Extract < McpAuthContext , { kind : 'user_key' } > ,
332+ deps : McpHandlerDeps ,
333+ resourceMetadataUrl : string ,
334+ corsHeaders : Record < string , string > ,
335+ ctx ?: { waitUntil : ( p : Promise < unknown > ) => void } ,
336+ ) : Promise < Response | null > {
337+ return checkMcpEntitlementGate ( context . userId , deps , resourceMetadataUrl , corsHeaders , 'user-key-entitlement' , ctx ) ;
338+ }
339+
340+ /**
341+ * Kind-dispatched pre-checks for gated (data/quota) methods. env_key needs
342+ * none; pro and user_key each run their own. Single entry point so a future
343+ * context kind can't silently ship without deciding its gate (the tracer
344+ * finding on #4859: mapping user keys onto env_key would have bypassed
345+ * entitlements entirely).
346+ */
347+ export async function runContextPreChecks (
348+ context : McpAuthContext ,
349+ deps : McpHandlerDeps ,
350+ resourceMetadataUrl : string ,
351+ corsHeaders : Record < string , string > ,
352+ ctx ?: { waitUntil : ( p : Promise < unknown > ) => void } ,
353+ ) : Promise < Response | null > {
354+ if ( context . kind === 'pro' ) {
355+ return runProPreChecks ( context , deps , resourceMetadataUrl , corsHeaders , ctx ) ;
356+ }
357+ if ( context . kind === 'user_key' ) {
358+ return runUserKeyPreChecks ( context , deps , resourceMetadataUrl , corsHeaders , ctx ) ;
257359 }
258360 return null ;
259361}
260362
261363/** Per-minute rate limit. Both paths fail-OPEN on Upstash error (graceful);
262364 * the daily quota is the hard-cap fail-CLOSED gate. Returns null on success
263- * or pass-through, a Response on a real 60/min limit hit. */
365+ * or pass-through, a Response on a real 60/min limit hit.
366+ * user_key (#4859) shares the per-USER limiter with pro — the principal is
367+ * the key OWNER, so a user with an OAuth connection and a dashboard key gets
368+ * one combined 60/min budget instead of two stackable ones. */
264369export async function applyPerMinuteLimit ( context : McpAuthContext , headers : Record < string , string > = { } ) : Promise < Response | null > {
265370 if ( context . kind === 'env_key' ) {
266371 const rl = getMcpRatelimit ( ) ;
@@ -275,7 +380,7 @@ export async function applyPerMinuteLimit(context: McpAuthContext, headers: Reco
275380 if ( ! rl ) return null ;
276381 try {
277382 const { success } = await rl . limit ( `pro-user:${ context . userId } ` ) ;
278- if ( ! success ) return rpcError ( null , - 32029 , 'Rate limit exceeded. Max 60 requests per minute per Pro user.' , headers ) ;
383+ if ( ! success ) return rpcError ( null , - 32029 , 'Rate limit exceeded. Max 60 requests per minute per user.' , headers ) ;
279384 } catch { /* graceful degradation */ }
280385 return null ;
281386}
0 commit comments