| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export const config = { runtime: 'edge' }; |
|
|
| |
| import { getCorsHeaders } from '../_cors.js'; |
| |
| import { captureSilentError } from '../_sentry-edge.js'; |
| import { resolveClerkSession } from '../../server/_shared/auth-session'; |
| import { invalidateProMcpTokenCache } from '../../server/_shared/pro-mcp-token'; |
|
|
| |
| const CONVEX_TIMEOUT_MS = 3_000; |
|
|
| |
| export interface RevokeDeps { |
| |
| resolveUserId: (req: Request) => Promise<string | null>; |
| |
| |
| |
| |
| |
| |
| |
| |
| convexRevoke: ( |
| userId: string, |
| tokenId: string, |
| ) => Promise<{ ok: true } | { ok: false; reason: 'not-found' | 'already-revoked' | 'network' }>; |
| |
| |
| |
| |
| |
| |
| |
| invalidateCache: (tokenId: string) => Promise<void>; |
| } |
|
|
| async function callConvexRevoke( |
| userId: string, |
| tokenId: string, |
| ): Promise< |
| { ok: true } | { ok: false; reason: 'not-found' | 'already-revoked' | 'network' } |
| > { |
| const siteUrl = process.env.CONVEX_SITE_URL; |
| const sharedSecret = process.env.CONVEX_SERVER_SHARED_SECRET; |
| if (!siteUrl || !sharedSecret) { |
| return { ok: false, reason: 'network' }; |
| } |
|
|
| let resp: Response; |
| try { |
| resp = await fetch(`${siteUrl}/api/internal-revoke-pro-mcp-token`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'User-Agent': 'worldmonitor-gateway/1.0', |
| 'x-convex-shared-secret': sharedSecret, |
| }, |
| body: JSON.stringify({ userId, tokenId }), |
| signal: AbortSignal.timeout(CONVEX_TIMEOUT_MS), |
| }); |
| } catch (err) { |
| console.warn( |
| '[mcp-revoke] Convex fetch failed:', |
| err instanceof Error ? err.message : String(err), |
| ); |
| captureSilentError(err, { |
| tags: { route: 'api/user/mcp-revoke', step: 'convex-fetch' }, |
| }); |
| return { ok: false, reason: 'network' }; |
| } |
|
|
| if (resp.ok) return { ok: true }; |
| if (resp.status === 404) return { ok: false, reason: 'not-found' }; |
| if (resp.status === 409) return { ok: false, reason: 'already-revoked' }; |
| return { ok: false, reason: 'network' }; |
| } |
|
|
| export async function revokeHandler(req: Request, deps: RevokeDeps): 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 !== 'POST') { |
| return new Response(JSON.stringify({ error: 'method_not_allowed' }), { |
| status: 405, |
| headers: { ...jsonHeaders, Allow: 'POST, OPTIONS' }, |
| }); |
| } |
|
|
| const userId = await deps.resolveUserId(req); |
| if (!userId) { |
| return new Response(JSON.stringify({ error: 'unauthenticated' }), { |
| status: 401, |
| headers: jsonHeaders, |
| }); |
| } |
|
|
| let body: unknown; |
| try { |
| body = await req.json(); |
| } catch { |
| return new Response(JSON.stringify({ error: 'invalid_json' }), { |
| status: 400, |
| headers: jsonHeaders, |
| }); |
| } |
|
|
| const tokenId = (body as { tokenId?: unknown })?.tokenId; |
| if (typeof tokenId !== 'string' || tokenId.length === 0) { |
| return new Response(JSON.stringify({ error: 'missing_token_id' }), { |
| status: 400, |
| headers: jsonHeaders, |
| }); |
| } |
|
|
| |
| const result = await deps.convexRevoke(userId, tokenId); |
|
|
| if (result.ok) { |
| |
| |
| |
| |
| |
| |
| |
| |
| try { |
| await deps.invalidateCache(tokenId); |
| } catch (err) { |
| console.warn( |
| '[mcp-revoke] invalidateCache failed (revoke still succeeded):', |
| err instanceof Error ? err.message : String(err), |
| ); |
| captureSilentError(err, { |
| tags: { route: 'api/user/mcp-revoke', step: 'invalidate-cache' }, |
| }); |
| } |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: jsonHeaders }); |
| } |
|
|
| if (result.reason === 'not-found') { |
| return new Response(JSON.stringify({ error: 'not_found' }), { |
| status: 404, |
| headers: jsonHeaders, |
| }); |
| } |
| if (result.reason === 'already-revoked') { |
| return new Response(JSON.stringify({ error: 'already_revoked' }), { |
| status: 409, |
| headers: jsonHeaders, |
| }); |
| } |
| |
| return new Response(JSON.stringify({ error: 'service_unavailable' }), { |
| status: 503, |
| headers: { ...jsonHeaders, 'Retry-After': '5' }, |
| }); |
| } |
|
|
| export default async function handler(req: Request): Promise<Response> { |
| return revokeHandler(req, { |
| resolveUserId: async (r) => (await resolveClerkSession(r))?.userId ?? null, |
| convexRevoke: callConvexRevoke, |
| invalidateCache: invalidateProMcpTokenCache, |
| }); |
| } |
|
|