UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

511 lines 17.2 kB
// src/lib/auth/sessionManager.ts import { withTimeout } from "../utils/async/withTimeout.js"; import { logger } from "../utils/logger.js"; import { withSpan } from "../telemetry/withSpan.js"; import { tracers } from "../telemetry/tracers.js"; /** Mask an identifier for safe logging: show first 4 chars + "***" */ function maskId(id) { if (id.length <= 4) { return "***"; } return `${id.slice(0, 4)}***`; } const REDIS_CONNECT_TIMEOUT_MS = 5000; /** * In-memory session storage * * Simple session storage using Map. Suitable for single-instance deployments * or development. Sessions are lost on restart. */ export class MemorySessionStorage { sessions = new Map(); userSessions = new Map(); async get(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return null; } // Check expiration if (session.expiresAt && new Date() > session.expiresAt) { await this.delete(sessionId); return null; } return session; } async set(session) { this.sessions.set(session.id, session); // Track user's sessions let sessionIds = this.userSessions.get(session.user.id); if (!sessionIds) { sessionIds = new Set(); this.userSessions.set(session.user.id, sessionIds); } sessionIds.add(session.id); } async delete(sessionId) { const session = this.sessions.get(sessionId); if (session) { const userSessionSet = this.userSessions.get(session.user.id); if (userSessionSet) { userSessionSet.delete(sessionId); if (userSessionSet.size === 0) { this.userSessions.delete(session.user.id); } } this.sessions.delete(sessionId); } } async getUserSessions(userId) { const sessionIds = this.userSessions.get(userId); if (!sessionIds) { return []; } const sessions = []; for (const sessionId of sessionIds) { const session = await this.get(sessionId); if (session) { sessions.push(session); } } return sessions; } async deleteUserSessions(userId) { const sessionIds = this.userSessions.get(userId); if (sessionIds) { for (const sessionId of sessionIds) { this.sessions.delete(sessionId); } this.userSessions.delete(userId); } } async clear() { this.sessions.clear(); this.userSessions.clear(); } async isHealthy() { return true; } } /** * Redis session storage * * Distributed session storage using Redis. Suitable for multi-instance * deployments. Requires the "redis" (node-redis) package. * * Note: Redis client must be provided or configured via environment. */ export class RedisSessionStorage { prefix; ttl; redisUrl; client = null; initPromise = null; constructor(config) { this.redisUrl = config.url; this.prefix = config.prefix || "neurolink:sessions:"; this.ttl = config.ttl || 3600; } async getClient() { if (this.client) { return this.client; } if (!this.initPromise) { this.initPromise = this.createClient(); } return this.initPromise; } async createClient() { try { // Use variable indirection to prevent TypeScript from resolving the module at compile time const moduleName = "redis"; const redisModule = (await import( /* @vite-ignore */ moduleName)); const client = redisModule.createClient({ url: this.redisUrl, }); client.on("error", (err) => { logger.error("Redis session client error:", err.message); }); await withTimeout(client.connect(), REDIS_CONNECT_TIMEOUT_MS, `Redis session client connect timed out after ${REDIS_CONNECT_TIMEOUT_MS}ms`); this.client = client; return client; } catch (error) { this.initPromise = null; logger.error('Redis client not available. Ensure the "redis" package is installed and Redis is reachable when using storage: "redis".'); throw error instanceof Error ? error : new Error("Redis client not available"); } } sessionKey(sessionId) { return `${this.prefix}${sessionId}`; } userSessionsKey(userId) { return `${this.prefix}user:${userId}`; } async get(sessionId) { try { const client = await this.getClient(); const data = await client.get(this.sessionKey(sessionId)); if (!data) { return null; } if (typeof data !== "string") { logger.warn("Unexpected Redis session payload type", { sessionId: maskId(sessionId), type: typeof data, }); return null; } const session = JSON.parse(data); // Parse dates session.createdAt = new Date(session.createdAt); if (session.expiresAt !== null && session.expiresAt !== undefined) { session.expiresAt = new Date(session.expiresAt); } // Check expiration if (session.expiresAt && new Date() > session.expiresAt) { await this.delete(sessionId); return null; } return session; } catch (error) { logger.error("Redis session get error:", error); return null; } } async set(session) { try { const client = await this.getClient(); // Calculate TTL from session expiration const ttlSeconds = session.expiresAt ? Math.max(1, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000)) : this.ttl; // Store session await client.setEx(this.sessionKey(session.id), ttlSeconds, JSON.stringify(session)); // Track user's sessions await client.sAdd(this.userSessionsKey(session.user.id), session.id); await client.expire(this.userSessionsKey(session.user.id), this.ttl); } catch (error) { logger.error("Redis session set error:", error); throw error; } } async delete(sessionId) { try { const client = await this.getClient(); // Read the raw session data directly instead of calling this.get(), // which checks expiration and calls this.delete() — causing infinite // recursion for expired sessions. const data = await client.get(this.sessionKey(sessionId)); if (data) { if (typeof data !== "string") { logger.warn("Unexpected Redis session payload type during delete", { sessionId: maskId(sessionId), type: typeof data, }); } else { try { const session = JSON.parse(data); await client.sRem(this.userSessionsKey(session.user.id), sessionId); } catch { // If parsing fails, we still delete the key below logger.warn(`Failed to parse session data for cleanup: ${maskId(sessionId)}`); } } } await client.del(this.sessionKey(sessionId)); } catch (error) { logger.error("Redis session delete error:", error); } } async getUserSessions(userId) { try { const client = await this.getClient(); const sessionIds = await client.sMembers(this.userSessionsKey(userId)); const sessions = []; for (const sessionId of sessionIds) { const session = await this.get(sessionId); if (session) { sessions.push(session); } } return sessions; } catch (error) { logger.error("Redis getUserSessions error:", error); return []; } } async deleteUserSessions(userId) { try { const client = await this.getClient(); const sessionIds = await client.sMembers(this.userSessionsKey(userId)); for (const sessionId of sessionIds) { await client.del(this.sessionKey(sessionId)); } await client.del(this.userSessionsKey(userId)); } catch (error) { logger.error("Redis deleteUserSessions error:", error); } } async clear() { try { const client = await this.getClient(); // Use SCAN instead of KEYS to avoid blocking Redis in production let cursor = "0"; do { const result = await client.scan(cursor, { MATCH: `${this.prefix}*`, COUNT: 100, }); cursor = result.cursor; if (result.keys.length > 0) { await client.del(result.keys); } } while (cursor !== "0"); } catch (error) { logger.error("Redis clear error:", error); } } async isHealthy() { try { const client = await this.getClient(); const pong = await client.ping(); return pong === "PONG"; } catch { return false; } } async disconnect() { if (this.client) { await this.client.quit(); this.client = null; this.initPromise = null; } } } /** * Session Manager * * High-level session management that handles session lifecycle, * automatic refresh, and storage abstraction. */ export class SessionManager { storage; config; constructor(config = {}) { this.config = { ...config, duration: config.duration || 3600, autoRefresh: config.autoRefresh ?? true, refreshThreshold: config.refreshThreshold || 300, storage: config.storage || "memory", }; // Initialize storage based on config this.storage = this.createStorage(); } createStorage() { switch (this.config.storage) { case "redis": if (!this.config.redis?.url) { logger.warn("Redis URL not provided, falling back to memory storage"); return new MemorySessionStorage(); } return new RedisSessionStorage({ url: this.config.redis.url, prefix: this.config.redis.prefix, ttl: this.config.redis.ttl || this.config.duration, }); case "memory": default: return new MemorySessionStorage(); } } /** * Create a new session */ async createSession(user, metadata) { return withSpan({ name: "neurolink.auth.session.create", tracer: tracers.auth, attributes: { "auth.user_id": maskId(user.id), "auth.storage": this.config.storage ?? "memory", }, }, async () => this._createSession(user, metadata)); } async _createSession(user, metadata) { const sessionId = crypto.randomUUID(); const now = new Date(); const duration = this.config.duration || 3600; const session = { id: sessionId, user, createdAt: now, expiresAt: new Date(now.getTime() + duration * 1000), isValid: true, ipAddress: metadata?.ipAddress, userAgent: metadata?.userAgent, deviceId: metadata?.deviceId, }; await this.storage.set(session); logger.debug(`Session created: ${maskId(sessionId)} for user: ${maskId(user.id)}`); return session; } /** * Get a session by ID * * Optionally auto-refreshes if close to expiration. */ async getSession(sessionId, autoRefresh = this.config.autoRefresh) { return withSpan({ name: "neurolink.auth.session.get", tracer: tracers.auth, attributes: { "auth.session_id": maskId(sessionId), "auth.auto_refresh": autoRefresh ?? false, }, }, async (span) => { const result = await this._getSession(sessionId, autoRefresh); span.setAttribute("auth.found", result !== null); return result; }); } async _getSession(sessionId, autoRefresh = this.config.autoRefresh) { const session = await this.storage.get(sessionId); if (!session) { return null; } // Auto-refresh if close to expiration if (autoRefresh && this.shouldRefresh(session)) { return this.refreshSession(sessionId); } return session; } /** * Check if session should be refreshed */ shouldRefresh(session) { if (!session.expiresAt) { return false; } const threshold = this.config.refreshThreshold || 300; const timeUntilExpiry = (session.expiresAt.getTime() - Date.now()) / 1000; return timeUntilExpiry < threshold; } /** * Refresh a session */ async refreshSession(sessionId) { return withSpan({ name: "neurolink.auth.session.refresh", tracer: tracers.auth, attributes: { "auth.session_id": maskId(sessionId) }, }, async (span) => { const session = await this.storage.get(sessionId); if (!session) { span.setAttribute("auth.found", false); return null; } const duration = this.config.duration || 3600; session.expiresAt = new Date(Date.now() + duration * 1000); await this.storage.set(session); span.setAttribute("auth.found", true); logger.debug(`Session refreshed: ${maskId(sessionId)}`); return session; }); } /** * Destroy a session */ async destroySession(sessionId) { await this.storage.delete(sessionId); logger.debug(`Session destroyed: ${maskId(sessionId)}`); } /** * Get all sessions for a user */ async getUserSessions(userId) { return this.storage.getUserSessions(userId); } /** * Destroy all sessions for a user (global logout) */ async destroyAllUserSessions(userId) { await this.storage.deleteUserSessions(userId); logger.debug(`All sessions destroyed for user: ${maskId(userId)}`); } /** * Validate a session is still active */ async validateSession(sessionId) { return withSpan({ name: "neurolink.auth.session.validate", tracer: tracers.auth, attributes: { "auth.session_id": maskId(sessionId) }, }, async (span) => { const session = await this.storage.get(sessionId); const valid = session !== null && session.isValid; span.setAttribute("auth.valid", valid); return valid; }); } /** * Update session metadata */ async updateSessionMetadata(sessionId, metadata) { const session = await this.storage.get(sessionId); if (!session) { return null; } session.metadata = { ...session.metadata, ...metadata, }; await this.storage.set(session); return session; } /** * Health check */ async isHealthy() { return this.storage.isHealthy(); } /** * Clear all sessions (for testing/cleanup) */ async clear() { await this.storage.clear(); } } /** * Create session storage based on configuration */ export function createSessionStorage(config) { switch (config.storage) { case "redis": if (!config.redis?.url) { logger.warn("Redis URL not provided, falling back to memory storage"); return new MemorySessionStorage(); } return new RedisSessionStorage({ url: config.redis.url, prefix: config.redis.prefix, ttl: config.redis.ttl || config.duration, }); case "memory": default: return new MemorySessionStorage(); } } //# sourceMappingURL=sessionManager.js.map