// Session Manager - handles session storage and authentication import fs from 'fs'; import path from 'path'; import bcrypt from 'bcrypt'; import { config } from '../config'; import { generateSessionId } from '../utils/idGenerator'; import { logger } from '../utils/logger'; export interface Session { id: string; passwordHash: string; createdAt: string; lastConnected: string; } interface SessionStore { sessions: Record; } // Flush pending (debounced) writes at most this often. const FLUSH_INTERVAL_MS = 60 * 1000; // Prune expired sessions on this cadence. const PRUNE_INTERVAL_MS = 60 * 60 * 1000; export class SessionManager { private sessionsFile: string; private sessions: Map = new Map(); private dirty = false; private flushTimer: NodeJS.Timeout | null = null; private pruneTimer: NodeJS.Timeout | null = null; constructor() { this.sessionsFile = path.join(config.dataDir, 'sessions.json'); this.load(); this.pruneExpired(); this.startBackgroundTasks(); } private startBackgroundTasks(): void { this.flushTimer = setInterval(() => { if (this.dirty) { this.save(); this.dirty = false; } }, FLUSH_INTERVAL_MS); if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref(); this.pruneTimer = setInterval(() => { this.pruneExpired(); }, PRUNE_INTERVAL_MS); if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref(); } private load(): void { try { if (fs.existsSync(this.sessionsFile)) { const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore; for (const [id, session] of Object.entries(data.sessions || {})) { this.sessions.set(id, session); } logger.info(`Loaded ${this.sessions.size} sessions`); } } catch (error) { logger.error('Failed to load sessions:', error); } } // Atomic write: write to a temp file in the same directory, then rename. private save(): void { try { const store: SessionStore = { sessions: Object.fromEntries(this.sessions) }; const dir = path.dirname(this.sessionsFile); fs.mkdirSync(dir, { recursive: true }); const tmpFile = path.join( dir, `.sessions.${process.pid}.${Date.now()}.tmp` ); fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2)); fs.renameSync(tmpFile, this.sessionsFile); } catch (error) { logger.error('Failed to save sessions:', error); } } // Prune sessions that have been unused past the configured TTL. private pruneExpired(): void { const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000; if (ttlMs <= 0) return; const now = Date.now(); let pruned = 0; for (const [id, session] of this.sessions) { const last = Date.parse(session.lastConnected || session.createdAt); if (!Number.isNaN(last) && now - last > ttlMs) { this.sessions.delete(id); pruned++; } } if (pruned > 0) { logger.info(`Pruned ${pruned} expired session(s)`); this.save(); this.dirty = false; } } async exists(sessionId: string): Promise { return this.sessions.has(sessionId); } async createSession(password: string): Promise { // Reject empty / short passwords before doing any hashing. if (!password || password.length < config.minPasswordLength) { throw new Error( `Password must be at least ${config.minPasswordLength} characters` ); } // Cap total sessions to prevent unbounded resource exhaustion. if (this.sessions.size >= config.maxSessions) { // Try to reclaim space by pruning expired sessions first. this.pruneExpired(); if (this.sessions.size >= config.maxSessions) { throw new Error('Session limit reached'); } } // Generate unique session ID let id: string; do { id = generateSessionId(config.sessionIdLength); } while (this.sessions.has(id)); const passwordHash = await bcrypt.hash(password, config.bcryptRounds); // Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only // suspension point between the first cap check and the set() below, so // concurrent createSession calls could each have passed the earlier check // and then filled the map while we were hashing. Re-checking here — with // no await between this check and the synchronous set() — makes the // check-and-insert atomic under Node's single-threaded model, so the cap // cannot be overshot. if (this.sessions.size >= config.maxSessions) { this.pruneExpired(); if (this.sessions.size >= config.maxSessions) { throw new Error('Session limit reached'); } } const now = new Date().toISOString(); const session: Session = { id, passwordHash, createdAt: now, lastConnected: now }; this.sessions.set(id, session); this.save(); // creation is important, persist immediately logger.info(`Created new session: ${id}`); return session; } async validatePassword(sessionId: string, password: string): Promise { const session = this.sessions.get(sessionId); if (!session) { return false; } const valid = await bcrypt.compare(password, session.passwordHash); if (valid) { // Update last-connected time, but only mark dirty (debounced flush) // if it moved by more than a minute to avoid writing on every auth. const now = Date.now(); const prev = Date.parse(session.lastConnected); if (Number.isNaN(prev) || now - prev > 60 * 1000) { session.lastConnected = new Date(now).toISOString(); this.dirty = true; } } return valid; } getSession(sessionId: string): Session | undefined { return this.sessions.get(sessionId); } updateLastConnected(sessionId: string): void { const session = this.sessions.get(sessionId); if (session) { session.lastConnected = new Date().toISOString(); this.dirty = true; } } deleteSession(sessionId: string): boolean { const deleted = this.sessions.delete(sessionId); if (deleted) { this.save(); logger.info(`Deleted session: ${sessionId}`); } return deleted; } // Flush any pending writes and stop background timers. shutdown(): void { if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } if (this.pruneTimer) { clearInterval(this.pruneTimer); this.pruneTimer = null; } if (this.dirty) { this.save(); this.dirty = false; } } }