diff --git a/README.md b/README.md index 128bab4..3b25745 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ The project follows a modular, feature-based architecture. All source code is lo # Set to 'true' to enable multiplexing SSE transport (handles multiple clients with a single transport) # Set to 'false' to use individual SSE transport for each client (legacy behavior) USE_MULTIPLEXING_SSE=false + + # Rate Limiting Configuration + RATE_LIMIT_WINDOW_MS=900000 # Time window for rate limiting in milliseconds (e.g., 900000 for 15 minutes) + RATE_LIMIT_MAX_REQUESTS=100 # Maximum number of requests allowed per window per IP + RATE_LIMIT_SSE_MAX=5 # Maximum number of SSE connections allowed per minute per IP + RATE_LIMIT_MESSAGES_MAX=30 # Maximum number of messages allowed per minute per IP + DEFAULT_USER_RATE_LIMIT=1000 # Default number of requests allowed per hour for a user ``` 4. Build the project: @@ -251,6 +258,18 @@ The server provides the following GitHub API tools: - `get_me` - Get details of the authenticated user +### Rate Limiting + +This server implements a robust rate limiting strategy to ensure fair usage and protect against abuse. The rate limiting is configured in `src/server.ts` and includes several layers of protection: + +- **General Limiter**: A global rate limit is applied to all incoming requests to prevent excessive traffic from a single IP address. +- **SSE Limiter**: A specific rate limit for Server-Sent Events (SSE) connections to manage real-time communication resources. +- **Message Limiter**: A rate limit on the number of messages that can be sent to the server to prevent spam and overload. +- **User-Specific Limiter**: A dynamic rate limit that can be customized for individual users, providing more flexible and granular control. +- **Critical Operations Limiter**: A stricter rate limit for critical operations such as creating repositories or merging pull requests to prevent accidental or malicious use of sensitive features. + +The rate limiting is implemented using the `express-rate-limit` library, which provides a flexible and easy-to-configure solution for Express-based applications. The configuration is managed through environment variables, allowing for easy adjustments without modifying the code. + ## Troubleshooting ### Connection Issues @@ -300,4 +319,4 @@ If you're experiencing issues with the multiplexing SSE transport: ## License -MIT \ No newline at end of file +MIT diff --git a/package-lock.json b/package-lock.json index 1f086ab..cd2a5b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,12 +24,15 @@ "@types/cors": "^2.8.19", "@types/dompurify": "^3.0.5", "@types/express": "^5.0.1", + "@types/express-rate-limit": "^5.1.3", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.14.1", "eventsource": "^4.0.0", + "express-rate-limit": "^8.1.0", "jest": "^30.0.5", "node-fetch": "^3.3.2", + "supertest": "^7.1.4", "ts-jest": "^29.4.1", "typescript": "^5.9.2" } @@ -1168,6 +1171,21 @@ "node": ">=18.0.0" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1181,6 +1199,29 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1341,6 +1382,16 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-rate-limit": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz", + "integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express-serve-static-core": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", @@ -1886,6 +1937,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2371,6 +2429,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2424,6 +2492,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2575,6 +2650,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dompurify": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", @@ -2899,10 +2985,14 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", + "dev": true, "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2934,6 +3024,13 @@ "node": ">=10.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3099,6 +3196,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3506,6 +3621,16 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4518,6 +4643,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4532,6 +4667,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5671,6 +5819,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index f91713b..d53862a 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,15 @@ "@types/cors": "^2.8.19", "@types/dompurify": "^3.0.5", "@types/express": "^5.0.1", + "@types/express-rate-limit": "^5.1.3", "@types/jest": "^30.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.14.1", "eventsource": "^4.0.0", + "express-rate-limit": "^8.1.0", "jest": "^30.0.5", "node-fetch": "^3.3.2", + "supertest": "^7.1.4", "ts-jest": "^29.4.1", "typescript": "^5.9.2" } diff --git a/src/config/index.ts b/src/config/index.ts index cb30210..8a50a35 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -23,4 +23,10 @@ export const config = { sseTimeout: process.env.SSE_TIMEOUT ? parseInt(process.env.SSE_TIMEOUT, 10) : 1800000, corsAllowOrigin: process.env.CORS_ALLOW_ORIGIN ?? '', useMultiplexing: process.env.USE_MULTIPLEXING_SSE === 'true', + // Rate Limiting Configuration + rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000, // 15 minutes + rateLimitMaxRequests: process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) : 100, // 100 requests + rateLimitSseMax: process.env.RATE_LIMIT_SSE_MAX ? parseInt(process.env.RATE_LIMIT_SSE_MAX, 10) : 5, // 5 SSE connections per minute + rateLimitMessagesMax: process.env.RATE_LIMIT_MESSAGES_MAX ? parseInt(process.env.RATE_LIMIT_MESSAGES_MAX, 10) : 30, // 30 messages per minute + defaultUserRateLimit: process.env.DEFAULT_USER_RATE_LIMIT ? parseInt(process.env.DEFAULT_USER_RATE_LIMIT, 10) : 1000, // 1000 requests per hour per user }; diff --git a/src/server.ts b/src/server.ts index 428e04d..64777ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,3 @@ -import express from "express"; import http from 'http'; import cors from 'cors'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -14,6 +13,93 @@ import { z } from 'zod'; // Importar configuración y logger centralizados import { config } from '#config/index'; import { logger } from '#core/logger'; +import rateLimit from 'express-rate-limit'; +import express, { Request, Response, NextFunction } from 'express'; + +const generalLimiter = rateLimit({ + windowMs: config.rateLimitWindowMs, + max: config.rateLimitMaxRequests, + message: { + error: 'Too many requests from this IP', + retryAfter: `${config.rateLimitWindowMs / 60000} minutes` + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn(`Rate limit exceeded for IP: ${req.ip} on ${req.method} ${req.url}`); + res.status(429).json({ + error: 'Rate limit exceeded', + retryAfter: Math.ceil((req.rateLimit?.resetTime?.getTime() ?? Date.now()) / 1000) + }); + } +}); + +const sseLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minuto + max: config.rateLimitSseMax, + message: 'Too many SSE connections from this IP' +}); + +const messageLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minuto + max: config.rateLimitMessagesMax, + message: 'Too many messages from this IP', // This will be overridden by handler + handler: (req, res) => { + logger.warn(`Message Rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + error: 'Rate limit exceeded', + message: 'Too many messages from this IP' + }); + } +}); + +const createUserLimiter = () => rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hora + max: (req: Request) => { + // @ts-ignore + return req.user?.rateLimits?.requestsPerHour ?? config.defaultUserRateLimit; + }, + message: 'User rate limit exceeded' +}); + +const CRITICAL_TOOLS = [ + 'create_repository', + 'merge_pull_request', + 'push_files', + 'create_fork' +]; + +const isCriticalOperation = (toolName: string): boolean => { + return CRITICAL_TOOLS.includes(toolName); +}; + +const criticalOperationsLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hora + max: 10, // Solo 10 operaciones críticas por hora + message: 'Critical operation rate limit exceeded', // This will be overridden by handler + handler: (req, res) => { + logger.warn(`Critical operation rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + error: 'Rate limit exceeded', + message: 'Critical operation rate limit exceeded' + }); + } +}); + +const rateLimitMonitor = (req: Request, res: Response, next: NextFunction) => { + const remaining = req.rateLimit?.remaining || 0; + const total = req.rateLimit?.limit || 0; + + if (remaining > 0 && remaining < total * 0.1) { + logger.warn(`Rate limit warning for ${req.ip} on ${req.method} ${req.url}: ${remaining}/${total} remaining`); + } + + if (remaining === 0) { + logger.error(`Rate limit exceeded for ${req.ip} on ${req.method} ${req.url}`); + } + + next(); +}; const sseTransports: Record = {}; let multiplexingTransport: MultiplexingSSEServerTransport | null = null; @@ -61,6 +147,10 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { } } })); + + // Aplicar rate limiting general a todas las rutas + app.use(generalLimiter); + app.use(rateLimitMonitor); // Aplicar el monitor después del limiter app.get('/health', (req: express.Request, res: express.Response) => { res.status(200).json({ @@ -94,38 +184,69 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { }); } - app.all('/mcp', (req: express.Request, res: express.Response) => { + app.all('/mcp', createUserLimiter(), (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug(`New Streamable HTTP request: ${req.method} ${req.url}`); if (req.method === 'OPTIONS') { res.status(200).end(); return; } - - const requestTimeout = setTimeout(() => { - if (!res.writableEnded) { - logger.error(`Request timeout for ${req.url}`); - res.status(408).json({ - error: 'Request timeout', - message: 'The request took too long to process' - }); - } - }, config.mcpTimeout); - - mcpStreamableTransport.handleRequest(req, res, req.body) - .then(() => clearTimeout(requestTimeout)) - .catch((error) => { - clearTimeout(requestTimeout); - logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + + // Check for critical operations + const toolName = req.body?.toolName; // Assuming toolName is in the request body + if (toolName && isCriticalOperation(toolName)) { + criticalOperationsLimiter(req, res, () => { + // Continue with the original /mcp logic after criticalOperationsLimiter + const requestTimeout = setTimeout(() => { + if (!res.writableEnded) { + logger.error(`Request timeout for ${req.url}`); + res.status(408).json({ + error: 'Request timeout', + message: 'The request took too long to process' + }); + } + }, config.mcpTimeout); + + mcpStreamableTransport.handleRequest(req, res, req.body) + .then(() => clearTimeout(requestTimeout)) + .catch((error) => { + clearTimeout(requestTimeout); + logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + if (!res.writableEnded) { + res.status(500).json({ + error: 'Error processing Streamable HTTP request', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + }); + } else { + // Original /mcp logic if not a critical operation + const requestTimeout = setTimeout(() => { if (!res.writableEnded) { - res.status(500).json({ - error: 'Error processing Streamable HTTP request', - message: error instanceof Error ? error.message : 'Unknown error' + logger.error(`Request timeout for ${req.url}`); + res.status(408).json({ + error: 'Request timeout', + message: 'The request took too long to process' }); } - }); + }, config.mcpTimeout); + + mcpStreamableTransport.handleRequest(req, res, req.body) + .then(() => clearTimeout(requestTimeout)) + .catch((error) => { + clearTimeout(requestTimeout); + logger.error(`Error handling Streamable HTTP request for ${req.url}:`, error); + if (!res.writableEnded) { + res.status(500).json({ + error: 'Error processing Streamable HTTP request', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + } }); - app.get('/sse', (req: express.Request, res: express.Response) => { + app.get('/sse', sseLimiter, (req: express.Request, res: express.Response) => { logger.info('New SSE connection request'); if (config.useMultiplexing && multiplexingTransport) { const clientSessionId = randomUUID(); @@ -192,7 +313,7 @@ export function createServer(mcpServer: McpServer, port: number): http.Server { } }); - app.post('/messages', (req: express.Request, res: express.Response) => { + app.post('/messages', messageLimiter, (req: express.Request, res: express.Response) => { try { const sessionIdSchema = z.string().uuid(); const sessionId = sessionIdSchema.parse(req.query.sessionId);