import session from "express-session"; import type { Db } from "./db.js"; /** Postgres-backed sessions so every pod sees the same login. */ export class PgSessionStore extends session.Store { private readonly timer: NodeJS.Timeout; constructor( private readonly db: Db, private readonly ttlMs: number, ) { super(); // Expired rows are ignored on read; sweep them occasionally. this.timer = setInterval(() => { void this.db .query("DELETE FROM sessions WHERE expire < now()") .catch(() => {}); }, 10 * 60_000); this.timer.unref(); } private expiry(sess: session.SessionData): Date { const exp = sess.cookie?.expires; return exp ? new Date(exp) : new Date(Date.now() + this.ttlMs); } get( sid: string, cb: (err?: unknown, sess?: session.SessionData | null) => void, ) { this.db .query<{ sess: session.SessionData }>( "SELECT sess FROM sessions WHERE sid = $1 AND expire > now()", [sid], ) .then((r) => cb(null, r.rows[0]?.sess ?? null), cb); } set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) { this.db .query( `INSERT INTO sessions (sid, sess, expire) VALUES ($1, $2, $3) ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire`, [sid, JSON.stringify(sess), this.expiry(sess)], ) .then( () => cb?.(), (e) => cb?.(e), ); } touch(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) { this.db .query("UPDATE sessions SET expire = $2 WHERE sid = $1", [ sid, this.expiry(sess), ]) .then( () => cb?.(), (e) => cb?.(e), ); } /** Sign a user out everywhere except `exceptSid` (e.g. after a password change). */ async destroyForUser(userId: string, exceptSid: string): Promise { await this.db.query( "DELETE FROM sessions WHERE sess->'passport'->>'user' = $1 AND sid <> $2", [userId, exceptSid], ); } destroy(sid: string, cb?: (err?: unknown) => void) { this.db.query("DELETE FROM sessions WHERE sid = $1", [sid]).then( () => cb?.(), (e) => cb?.(e), ); } } /** In-memory sessions (tests / no database), with the same revoke hook. */ export class MemorySessionStore extends session.MemoryStore { async destroyForUser(userId: string, exceptSid: string): Promise { const all = await new Promise>( (resolve, reject) => this.all((err, sessions) => err ? reject(err) : resolve(sessions ?? {}), ), ); for (const [sid, sess] of Object.entries(all)) { const passport = (sess as { passport?: { user?: string } }).passport; if (sid !== exceptSid && passport?.user === userId) { await new Promise((r) => this.destroy(sid, () => r())); } } } } export type RevocableStore = (PgSessionStore | MemorySessionStore) & session.Store;