import { db, withSuperAdmin } from "../db"; import { sql } from "drizzle-orm"; import { logger } from "../utils/logger"; /** * Очистка цепочек ротации и истёкших сессий (таблица user_sessions). * * Каждая ротация refresh-токена оставляет replaced-строку — она нужна только * для детекта кражи (повторное предъявление старого токена → отзыв всей family), * т.е. имеет смысл лишь в небольшом grace-окне. Раз в сутки удаляем: * - replaced-строки старше REPLACED_RETENTION_HOURS часов (grace-окно прошло); * - любые истёкшие сессии (expires_at < now) — они в любом случае мёртвые. * Без этого таблица и список устройств разрастались на каждом рефреше токена * (до фильтра isReplaced в listUserSessions UI показывал «новое устройство» * на каждую ротацию). * Таймер unref'нут; первый запуск — сразу при старте. */ const log = logger("session-cleanup"); const TICK_MS = 24 * 60 * 60 * 1000; // раз в сутки const REPLACED_RETENTION_HOURS = 72; // grace-окно детекта кражи токена let isRunning = false; async function cleanup(): Promise { const result = await withSuperAdmin(() => db.execute(sql` DELETE FROM user_sessions WHERE (is_replaced = true AND created_at < now() - make_interval(hours => ${REPLACED_RETENTION_HOURS})) OR expires_at < now() `) ); const deleted = (result as { rowCount?: number }).rowCount ?? 0; if (deleted > 0) { log.info(`Удалено replaced/истёкших сессий: ${deleted}`); } else { log.debug(`Replaced/истёкших сессий для удаления нет`); } } export function startSessionCleanup(): void { if (isRunning) return; isRunning = true; const timer = setInterval(() => { cleanup().catch((err) => log.error("Cleanup error:", err)); }, TICK_MS); timer.unref?.(); // Первый прогон сразу после старта (в фоне, не блокируя listen) cleanup().catch((err) => log.error("Initial cleanup error:", err)); log.info(`started (replaced старше ${REPLACED_RETENTION_HOURS}ч + истёкшие, тик раз в сутки)`); }