Files
community-rule/lib/server/session.ts
T
2026-04-22 22:24:59 -06:00

145 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cookies } from "next/headers";
import type { User } from "@prisma/client";
import { logger } from "../logger";
import { prisma } from "./db";
import { getSessionPepper } from "./env";
import { hashSessionToken, newSessionToken } from "./hash";
/**
* Custom session lifecycle (CR-85).
*
* Decisions documented here so the implementation below is the canonical
* source of truth (referenced from `docs/guides/backend-roadmap.md` §45).
*
* 1. **Policy: multi-device.** A new sign-in (`createSessionForUser`) does
* NOT delete the user's other still-valid sessions. Users routinely use
* phone + laptop and there is no v1 security argument for forcing a
* single active session — pre-publish state lives in `localStorage`
* until "Save & Exit", and `/api/auth/logout` only revokes the current
* cookie by design.
* 2. **Rotation: deferred.** No token rotation on privilege-sensitive
* actions in v1. Revisit if/when product requires it (ticket calls
* this v1.1).
* 3. **Cleanup: lazy, two-tier, no cron.** Every sign-in prunes the
* signing user's own expired rows (cheap — uses `@@index([userId])`).
* A small fraction of sign-ins (`SESSION_GLOBAL_PRUNE_PROB`) also runs
* a global sweep so rows from users who never return are still bounded
* over months. Cleanup is best-effort: a prune failure never fails the
* sign-in itself.
*/
export const SESSION_COOKIE_NAME = "cr_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
const SESSION_GLOBAL_PRUNE_PROB = 0.05;
export async function getSessionUser(): Promise<User | null> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return null;
}
const tokenHash = hashSessionToken(token, pepper);
const session = await prisma.session.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
return null;
}
return session.user;
}
/**
* Delete expired `Session` rows. Scoped to a single user when `userId` is
* provided (uses the `@@index([userId])` lookup); otherwise sweeps the
* whole table. Returns the number of rows deleted.
*/
export async function pruneExpiredSessions(
opts: { userId?: string } = {},
): Promise<number> {
const where: { expiresAt: { lt: Date }; userId?: string } = {
expiresAt: { lt: new Date() },
};
if (opts.userId) {
where.userId = opts.userId;
}
const { count } = await prisma.session.deleteMany({ where });
return count;
}
export async function createSessionForUser(
userId: string,
): Promise<{ token: string; expiresAt: Date }> {
const pepper = getSessionPepper();
const token = newSessionToken();
const tokenHash = hashSessionToken(token, pepper);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
await prisma.session.create({
data: {
userId,
tokenHash,
expiresAt,
},
});
try {
await pruneExpiredSessions({ userId });
if (Math.random() < SESSION_GLOBAL_PRUNE_PROB) {
await pruneExpiredSessions();
}
} catch (err) {
logger.warn("[session] expired-row cleanup failed", err);
}
return { token, expiresAt };
}
export async function setSessionCookie(
token: string,
expiresAt: Date,
): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt,
});
}
export async function clearSessionCookie(): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
}
export async function destroySessionFromRequest(): Promise<void> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
await clearSessionCookie();
if (!token) return;
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return;
}
const tokenHash = hashSessionToken(token, pepper);
await prisma.session.deleteMany({ where: { tokenHash } });
}