| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export const config = { runtime: 'edge' }; |
|
|
| import { verifyGrant, GrantConfigError } from '../_mcp-grant-hmac'; |
| import { |
| getEntitlements, |
| isEntitlementBackendConfigured, |
| type BillingVerificationDenial, |
| } from '../../server/_shared/entitlement-check'; |
| import { checkProMcpAccess, type ProMcpEntitlement } from '../../server/_shared/pro-mcp-gate'; |
| import { |
| issueProMcpTokenForUser, |
| revokeProMcpToken, |
| ProMcpIssueFailed, |
| } from '../../server/_shared/pro-mcp-token'; |
| |
| import { captureSilentError } from '../_sentry-edge.js'; |
|
|
| |
| const CODE_TTL_SECONDS = 600; |
|
|
| const PAGE_HEADERS: Record<string, string> = { |
| 'Content-Type': 'text/html; charset=utf-8', |
| 'X-Frame-Options': 'DENY', |
| 'Cache-Control': 'no-store', |
| Pragma: 'no-cache', |
| }; |
|
|
| const GLOBE_SVG = |
| '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>'; |
|
|
| function escapeHtml(str: string): string { |
| return String(str) |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function htmlError( |
| title: string, |
| detail: string, |
| status: number = 400, |
| extraHeaders: Record<string, string> = {}, |
| ): Response { |
| return new Response( |
| `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error — WorldMonitor MCP</title> |
| <style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:ui-monospace,'SF Mono','Cascadia Code',monospace;background:#0a0a0a;color:#e8e8e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem}.wm-logo{display:flex;align-items:center;gap:.5rem;margin-bottom:2rem;text-decoration:none}.wm-logo svg{color:#2d8a6e}.wm-logo-text{font-size:.75rem;color:#555;letter-spacing:.1em;text-transform:uppercase}.card{width:100%;max-width:420px;background:#111;border:1px solid #1e1e1e;padding:2rem}h1{font-size:.95rem;font-weight:600;color:#ef4444;margin-bottom:.75rem;letter-spacing:.02em}p{font-size:.85rem;color:#666;line-height:1.6}.back{display:inline-block;margin-top:1.5rem;font-size:.75rem;color:#444;text-decoration:none;letter-spacing:.03em}.back:hover{color:#888}.footer{margin-top:1.5rem;font-size:.7rem;color:#2a2a2a;text-align:center}.footer a{color:#333;text-decoration:none}.footer a:hover{color:#555}</style></head> |
| <body><a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a> |
| <div class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p><a href="javascript:history.back()" class="back">← go back</a></div> |
| <p class="footer"><a href="https://www.worldmonitor.app" target="_blank" rel="noopener">worldmonitor.app</a></p> |
| </body></html>`, |
| { status, headers: { ...PAGE_HEADERS, ...extraHeaders } }, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function billingVerificationPage( |
| denial: BillingVerificationDenial, |
| ): Response { |
| const headers: Record<string, string> = { 'X-Billing-Verification': denial.code }; |
| if (!denial.retryable) { |
| return htmlError( |
| 'Subscription Lapsed', |
| 'Your WorldMonitor Pro subscription is no longer active, so this connection ' |
| + 'cannot be authorized. Renew your subscription at worldmonitor.app, then ' |
| + 'start the connection again from your MCP client.', |
| 403, |
| headers, |
| ); |
| } |
| headers['Retry-After'] = String(denial.retryAfterSeconds); |
| return htmlError( |
| 'Verifying Your Subscription', |
| `We could not confirm your WorldMonitor Pro subscription just now — this is ` |
| + `temporary and does not mean your subscription has a problem. Wait about ` |
| + `${denial.retryAfterSeconds} seconds, then start the connection again from your ` |
| + `MCP client. (This authorization session is single-use, so it has to be ` |
| + `restarted rather than reloaded.)`, |
| 503, |
| headers, |
| ); |
| } |
|
|
| |
| |
| |
| |
|
|
| async function rawRedisGetDel(key: string): Promise<unknown | null> { |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) throw new Error('Redis not configured'); |
| const resp = await fetch(`${url}/getdel/${encodeURIComponent(key)}`, { |
| headers: { Authorization: `Bearer ${token}` }, |
| signal: AbortSignal.timeout(3_000), |
| }); |
| if (!resp.ok) throw new Error(`Redis HTTP ${resp.status}`); |
| const data = (await resp.json()) as { result?: string | null }; |
| if (!data?.result) return null; |
| try { |
| return JSON.parse(data.result); |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function rawRedisGet(key: string): Promise<unknown | null> { |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) throw new Error('Redis not configured'); |
| const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, { |
| headers: { Authorization: `Bearer ${token}` }, |
| signal: AbortSignal.timeout(3_000), |
| }); |
| if (!resp.ok) throw new Error(`Redis HTTP ${resp.status}`); |
| const data = (await resp.json()) as { result?: string | null }; |
| if (!data?.result) return null; |
| try { |
| return JSON.parse(data.result); |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function rawRedisSetEx(key: string, value: unknown, ttlSeconds: number): Promise<boolean> { |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return false; |
| try { |
| const resp = await fetch(`${url}/pipeline`, { |
| method: 'POST', |
| headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, |
| body: JSON.stringify([['SET', key, JSON.stringify(value), 'EX', ttlSeconds]]), |
| signal: AbortSignal.timeout(3_000), |
| }); |
| if (!resp.ok) return false; |
| const results = (await resp.json().catch(() => null)) as Array<{ result?: string }> | null; |
| return Array.isArray(results) && results[0]?.result === 'OK'; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
|
|
| export interface NonceData { |
| client_id: string; |
| redirect_uri: string; |
| code_challenge: string; |
| state?: string; |
| created_at?: number; |
| } |
|
|
| export interface ClientData { |
| client_name?: string; |
| redirect_uris?: unknown; |
| } |
|
|
| export interface GrantRedisData { |
| userId: string; |
| exp: number; |
| } |
|
|
| export interface AuthorizeProDeps { |
| |
| redisGetDel: (key: string) => Promise<unknown | null>; |
| |
| redisGet: (key: string) => Promise<unknown | null>; |
| |
| redisSetEx: (key: string, value: unknown, ttlSeconds: number) => Promise<boolean>; |
| |
| verifyGrant: typeof verifyGrant; |
| |
| |
| |
| |
| |
| |
| |
| |
| getEntitlements: (userId: string) => Promise<ProMcpEntitlement | null>; |
| |
| issueProMcpTokenForUser: typeof issueProMcpTokenForUser; |
| |
| revokeProMcpToken: typeof revokeProMcpToken; |
| |
| randomCode: () => string; |
| |
| now: () => number; |
| } |
|
|
| export async function authorizeProHandler(req: Request, deps: AuthorizeProDeps): Promise<Response> { |
| if (req.method !== 'GET') { |
| return new Response(null, { |
| status: 405, |
| headers: { Allow: 'GET', 'Cache-Control': 'no-store' }, |
| }); |
| } |
|
|
| const url = new URL(req.url); |
| const nonce = url.searchParams.get('nonce') ?? ''; |
| const grantToken = url.searchParams.get('grant') ?? ''; |
|
|
| if (!nonce || !grantToken) { |
| |
| return htmlError( |
| 'Invalid Authorization Request', |
| 'The authorization link is missing required parameters. Please start over from your dashboard.', |
| ); |
| } |
|
|
| |
| let verifyResult: Awaited<ReturnType<typeof verifyGrant>>; |
| try { |
| verifyResult = await deps.verifyGrant(grantToken, undefined, deps.now()); |
| } catch (err) { |
| if (err instanceof GrantConfigError) { |
| console.warn('[authorize-pro] missing MCP_PRO_GRANT_HMAC_SECRET'); |
| return htmlError( |
| 'Service Unavailable', |
| 'Pro MCP authorization is temporarily unavailable. Please try again shortly.', |
| 500, |
| ); |
| } |
| throw err; |
| } |
| if (!verifyResult.ok) { |
| |
| return htmlError( |
| 'Authorization Expired', |
| 'This authorization link is no longer valid. Please start over from your dashboard.', |
| ); |
| } |
| const grantPayload = verifyResult.payload; |
|
|
| |
| |
| |
| if (grantPayload.nonce !== nonce) { |
| return htmlError( |
| 'Authorization Mismatch', |
| 'This authorization link is no longer valid. Please start over from your dashboard.', |
| ); |
| } |
|
|
| |
| let grantRedis: GrantRedisData | null; |
| try { |
| grantRedis = (await deps.redisGetDel(`mcp-grant:${nonce}`)) as GrantRedisData | null; |
| } catch { |
| return htmlError( |
| 'Service Unavailable', |
| 'Authorization service is temporarily unavailable. Please try again shortly.', |
| 503, |
| ); |
| } |
| if (!grantRedis || typeof grantRedis.userId !== 'string' || typeof grantRedis.exp !== 'number') { |
| |
| return htmlError( |
| 'Authorization Expired', |
| 'This authorization link is no longer valid. Please start over from your dashboard.', |
| ); |
| } |
| |
| |
| if (grantRedis.userId !== grantPayload.userId || grantRedis.exp !== grantPayload.exp) { |
| return htmlError( |
| 'Authorization Mismatch', |
| 'This authorization link is no longer valid. Please start over from your dashboard.', |
| ); |
| } |
| const userId = grantPayload.userId; |
|
|
| |
| let nonceData: NonceData | null; |
| try { |
| nonceData = (await deps.redisGetDel(`oauth:nonce:${nonce}`)) as NonceData | null; |
| } catch { |
| return htmlError( |
| 'Service Unavailable', |
| 'Authorization service is temporarily unavailable. Please try again shortly.', |
| 503, |
| ); |
| } |
| if ( |
| !nonceData || |
| typeof nonceData.client_id !== 'string' || |
| typeof nonceData.redirect_uri !== 'string' || |
| typeof nonceData.code_challenge !== 'string' |
| ) { |
| return htmlError( |
| 'Session Expired', |
| 'Your authorization session has expired. Please start over from your dashboard.', |
| ); |
| } |
| const { client_id, redirect_uri, code_challenge } = nonceData; |
| const state = typeof nonceData.state === 'string' ? nonceData.state : ''; |
|
|
| |
| let clientData: ClientData | null; |
| try { |
| clientData = (await deps.redisGet(`oauth:client:${client_id}`)) as ClientData | null; |
| } catch { |
| return htmlError( |
| 'Service Unavailable', |
| 'Authorization service is temporarily unavailable. Please try again shortly.', |
| 503, |
| ); |
| } |
| if (!clientData) { |
| return htmlError( |
| 'Unknown Client', |
| 'The OAuth client registration has expired. Please re-register the client.', |
| ); |
| } |
|
|
| |
| const uris = Array.isArray(clientData.redirect_uris) ? clientData.redirect_uris : []; |
| if (!uris.includes(redirect_uri)) { |
| return htmlError( |
| 'Redirect URI Mismatch', |
| 'The redirect_uri does not match any registered redirect URI for this client.', |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const ent = await deps.getEntitlements(userId); |
| const gate = checkProMcpAccess(ent, deps.now(), { |
| backendConfigured: isEntitlementBackendConfigured(), |
| }); |
| if (gate) { |
| if (gate.kind === 'billing_verification') return billingVerificationPage(gate.denial); |
| return htmlError( |
| 'Pro Subscription Required', |
| 'A WorldMonitor Pro subscription is required for this connection. Please subscribe and try again.', |
| 403, |
| ); |
| } |
|
|
| |
| const clientName = (typeof clientData.client_name === 'string' && clientData.client_name) || 'Unknown Client'; |
| let issueResult: { tokenId: string }; |
| try { |
| issueResult = await deps.issueProMcpTokenForUser(userId, client_id, `Connected via ${clientName}`); |
| } catch (err) { |
| if (err instanceof ProMcpIssueFailed) { |
| if (err.kind === 'pro-required') { |
| return htmlError( |
| 'Pro Subscription Required', |
| 'A WorldMonitor Pro subscription is required for this connection. Please subscribe and try again.', |
| 403, |
| ); |
| } |
| if (err.kind === 'invalid-user-id') { |
| return htmlError( |
| 'Authorization Failed', |
| 'Could not complete authorization. Please sign out, sign in again, and try again.', |
| 400, |
| ); |
| } |
| if (err.kind === 'config') { |
| console.warn('[authorize-pro] Convex config missing for issue helper'); |
| return htmlError( |
| 'Service Unavailable', |
| 'Pro MCP authorization is temporarily unavailable. Please try again shortly.', |
| 500, |
| ); |
| } |
| |
| return htmlError( |
| 'Service Unavailable', |
| 'Pro MCP authorization is temporarily unavailable. Please try again shortly.', |
| 503, |
| ); |
| } |
| throw err; |
| } |
|
|
| |
| const code = deps.randomCode(); |
| const codeData = { |
| kind: 'pro' as const, |
| userId, |
| mcpTokenId: issueResult.tokenId, |
| client_id, |
| redirect_uri, |
| code_challenge, |
| scope: 'mcp_pro' as const, |
| }; |
|
|
| let codeStored = false; |
| try { |
| codeStored = await deps.redisSetEx(`oauth:code:${code}`, codeData, CODE_TTL_SECONDS); |
| } catch { |
| codeStored = false; |
| } |
|
|
| if (!codeStored) { |
| |
| |
| |
| |
| |
| try { |
| const rollback = await deps.revokeProMcpToken(userId, issueResult.tokenId); |
| if (!rollback.ok) { |
| console.warn( |
| `[authorize-pro] orphaned mcpProTokens row ${issueResult.tokenId} for user ${userId}: revoke failed (${rollback.reason})`, |
| ); |
| } |
| } catch (err) { |
| |
| |
| console.warn( |
| `[authorize-pro] revoke rollback unexpectedly threw for token ${issueResult.tokenId}:`, |
| err instanceof Error ? err.message : String(err), |
| ); |
| captureSilentError(err, { |
| tags: { route: 'api/oauth/authorize-pro', step: 'rollback-revoke' }, |
| }); |
| } |
| return htmlError( |
| 'Server Error', |
| 'Failed to store authorization code. Please try again.', |
| 500, |
| ); |
| } |
|
|
| |
| const redirectUrl = new URL(redirect_uri); |
| redirectUrl.searchParams.set('code', code); |
| if (state) redirectUrl.searchParams.set('state', state); |
|
|
| return new Response(null, { |
| status: 302, |
| headers: { |
| Location: redirectUrl.toString(), |
| 'Cache-Control': 'no-store', |
| Pragma: 'no-cache', |
| }, |
| }); |
| } |
|
|
| |
| |
| |
|
|
| export default async function handler(req: Request): Promise<Response> { |
| return authorizeProHandler(req, { |
| redisGetDel: rawRedisGetDel, |
| redisGet: rawRedisGet, |
| redisSetEx: rawRedisSetEx, |
| verifyGrant, |
| getEntitlements, |
| issueProMcpTokenForUser, |
| revokeProMcpToken, |
| randomCode: () => crypto.randomUUID(), |
| now: () => Date.now(), |
| }); |
| } |
|
|