adventure/src/lib/session-store.ts
nholtware e69a02c6df
All checks were successful
/ checks (push) Successful in 40s
/ deploy (push) Successful in 8s
feat: change password (revokes other sessions) and logout/login regression test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 13:47:37 -06:00

99 lines
3 KiB
TypeScript

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<void> {
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<void> {
const all = await new Promise<Record<string, session.SessionData>>(
(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<void>((r) => this.destroy(sid, () => r()));
}
}
}
}
export type RevocableStore = (PgSessionStore | MemorySessionStore) &
session.Store;