import { randomUUID } from "node:crypto"; interface Session { id: string; userId: string; createdAt: number; expiresAt: number; } const MAX_SESSIONS_PER_USER = 20; const SWEEP_INTERVAL_MS = 10 * 60 * 1000; /** * In-memory session store. The API runs as a single container/process, so a * restart clearing every session is an acceptable trade-off for the * simplicity of not needing a persisted session table. * * Sessions slide forward on activity (`idleTtlMs`) but never outlive * `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever. */ export class SessionStore { private readonly sessions = new Map(); constructor( private readonly idleTtlMs: number, private readonly absoluteTtlMs: number, private readonly now: () => number = Date.now ) {} /** Removes expired sessions; unreferenced ones would otherwise pile up. */ startSweeping(): void { setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref(); } sweep(): void { const now = this.now(); for (const [id, session] of this.sessions) { if (this.isExpired(session, now)) this.sessions.delete(id); } } create(userId: string): Session { this.enforcePerUserLimit(userId); const now = this.now(); const session: Session = { id: randomUUID(), userId, createdAt: now, expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs), }; this.sessions.set(session.id, session); return session; } /** Returns the session and slides its expiry forward, or undefined if gone/expired. */ get(sessionId: string | undefined, slideExpiry = true): Session | undefined { if (!sessionId) return undefined; const session = this.sessions.get(sessionId); if (!session) return undefined; const now = this.now(); if (this.isExpired(session, now)) { this.sessions.delete(sessionId); return undefined; } if (slideExpiry) { session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs); } return session; } /** Remaining lifetime, used as cookie max-age so browser and server agree. */ remainingMs(session: Session): number { return Math.max(0, session.expiresAt - this.now()); } destroy(sessionId: string | undefined): void { if (!sessionId) return; this.sessions.delete(sessionId); } /** Ends every session of a user, optionally except one (e.g. the caller's own). */ destroyAllForUser(userId: string, exceptSessionId?: string): void { for (const [id, session] of this.sessions) { if (session.userId === userId && id !== exceptSessionId) { this.sessions.delete(id); } } } private isExpired(session: Session, now: number): boolean { return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now; } // Oldest sessions go first, so a legitimate user logging in on a new // device is never refused - only memory growth is bounded. private enforcePerUserLimit(userId: string): void { const own = [...this.sessions.values()] .filter((session) => session.userId === userId) .sort((a, b) => a.createdAt - b.createdAt); while (own.length >= MAX_SESSIONS_PER_USER) { const oldest = own.shift(); if (oldest) this.sessions.delete(oldest.id); } } }