import 'server-only'; import { cache } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { and, eq, gt, isNull } from '@/lib/db'; import { newId } from '@rareindex/shared'; import { db, sessions, users } from '@/lib/db'; import { hmacToken, randomToken } from './crypto'; import { clientInfo } from './request'; export const SESSION_COOKIE = 'ri_session'; export const DEVICE_COOKIE = 'ri_device'; export const CURRENCY_COOKIE = 'ri_currency'; const SESSION_DAYS = 30; export type SessionUser = typeof users.$inferSelect; const isProd = () => process.env.NODE_ENV === 'production'; export async function createSession(userId: string): Promise { const raw = randomToken(32); const { ip, userAgent } = await clientInfo(); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000); await db().insert(sessions).values({ id: hmacToken(raw, 'session'), userId, expiresAt, userAgent, ip, lastSeenAt: new Date() }); const jar = await cookies(); jar.set(SESSION_COOKIE, raw, { httpOnly: true, secure: isProd(), sameSite: 'lax', path: '/', expires: expiresAt }); } export async function destroySession(): Promise { const jar = await cookies(); const raw = jar.get(SESSION_COOKIE)?.value; if (raw) await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, hmacToken(raw, 'session'))); jar.delete(SESSION_COOKIE); } export async function revokeAllSessions(userId: string, keepCurrent = true): Promise { const jar = await cookies(); const raw = jar.get(SESSION_COOKIE)?.value; const keepId = keepCurrent && raw ? hmacToken(raw, 'session') : null; const rows = await db().select({ id: sessions.id }).from(sessions).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt))); let n = 0; for (const r of rows) { if (r.id === keepId) continue; await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, r.id)); n++; } return n; } export async function currentSessionId(): Promise { const jar = await cookies(); const raw = jar.get(SESSION_COOKIE)?.value; return raw ? hmacToken(raw, 'session') : null; } /** Resolve the signed-in user for this request (memoised per request). Null when anonymous. */ export const getCurrentUser = cache(async (): Promise => { const jar = await cookies(); const raw = jar.get(SESSION_COOKIE)?.value; if (!raw) return null; const id = hmacToken(raw, 'session'); const rows = await db() .select({ user: users, sessionId: sessions.id, lastSeenAt: sessions.lastSeenAt }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(and(eq(sessions.id, id), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date()))) .limit(1); const row = rows[0]; if (!row) return null; if (row.user.deletedAt) return null; // touch at most every 10 minutes (cheap activity tracking) if (!row.lastSeenAt || Date.now() - row.lastSeenAt.getTime() > 600_000) { void db().update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, id)).catch(() => {}); } return row.user; }); export async function requireUser(next?: string): Promise { const u = await getCurrentUser(); if (!u) redirect(`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`); return u; } export function newUserId(): string { return newId('user'); } export const DISPLAY_CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY'] as const; export type DisplayCurrency = (typeof DISPLAY_CURRENCIES)[number]; /** Display currency for this request: user preference → cookie → USD. */ export async function getDisplayCurrency(): Promise { const u = await getCurrentUser(); const pref = u?.displayCurrency; if (pref && (DISPLAY_CURRENCIES as readonly string[]).includes(pref)) return pref as DisplayCurrency; const c = (await cookies()).get(CURRENCY_COOKIE)?.value; return c && (DISPLAY_CURRENCIES as readonly string[]).includes(c) ? (c as DisplayCurrency) : 'USD'; }