import "server-only"; import { RateLimiter, SESSION_COOKIE } from "@autoscholar/auth"; import { type AuthenticatedSession, forbidden, getEnv, resolveSession, unauthorized, verifySessionCsrf, } from "@autoscholar/core"; import { cookies } from "next/headers"; import { db } from "./db"; import { clientIp } from "./ip"; export const loginLimiterIp = new RateLimiter(20, 15 * 60_000); export const loginLimiterUser = new RateLimiter(10, 15 * 60_000); export const adminMutationLimiter = new RateLimiter(240, 60_000); export const publicLimiter = new RateLimiter(600, 60_000); export const uploadLimiter = new RateLimiter(30, 10 * 60_000); export function cookieOptions() { const env = getEnv(); const secure = env.NODE_ENV === "production" || env.APP_BASE_URL.startsWith("https://"); return { httpOnly: true, secure, sameSite: "strict" as const, path: "/", maxAge: 12 * 60 * 60 }; } export async function currentSession(): Promise { const jar = await cookies(); const token = jar.get(SESSION_COOKIE)?.value; if (!token) return null; return resolveSession(db(), token); } export async function sessionToken(): Promise { const jar = await cookies(); return jar.get(SESSION_COOKIE)?.value ?? null; } /** Server-side guard for admin APIs. `allowPendingRotation` lets the change-password endpoints run before rotation. */ export async function requireAdmin( req: Request, opts: { mutation?: boolean; allowPendingRotation?: boolean } = {}, ): Promise { const token = await sessionToken(); const sess = token ? await resolveSession(db(), token) : null; if (!sess) throw unauthorized(); if (sess.mustChangePassword && !opts.allowPendingRotation) throw forbidden("password rotation required before using the admin API"); if (opts.mutation || !["GET", "HEAD"].includes(req.method)) { const presented = req.headers.get("x-csrf-token"); if (!(await verifySessionCsrf(db(), token!, presented))) throw forbidden("invalid CSRF token"); const origin = req.headers.get("origin"); const base = new URL(getEnv().APP_BASE_URL).origin; if (origin && origin !== base && !isLocalOrigin(origin)) throw forbidden("cross-origin request rejected"); const key = `${sess.userId}`; if (!adminMutationLimiter.check(key).allowed) throw new (await import("@autoscholar/core")).AppError("rate_limited", "too many requests", 429); } return { ...sess, ip: clientIp(req) }; } function isLocalOrigin(origin: string): boolean { try { const u = new URL(origin); return ( ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) || /^192\.168\.\d+\.\d+$/.test(u.hostname) ); } catch { return false; } }