import 'server-only'; import { createHash, timingSafeEqual } from 'node:crypto'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; /** * Admin session: the operator posts the admin token once (`/api/admin/login`); we compare it server-side with * `SI_ADMIN_TOKEN` and set an httpOnly cookie holding sha256(token). Every admin request re-derives the expected hash * and compares in constant time. The raw token never reaches the browser (all FastAPI calls happen in `admin-api.ts`). */ export const ADMIN_COOKIE = 'si_admin'; export const ADMIN_SESSION_SECONDS = 12 * 3600; export function adminToken(): string { return process.env.SI_ADMIN_TOKEN ?? ''; } export function hashToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a, 'utf8'); const bb = Buffer.from(b, 'utf8'); return ba.length === bb.length && timingSafeEqual(ba, bb); } /** True when `candidate` is the configured admin token (a blank server token disables the admin entirely). */ export function verifyToken(candidate: string | null | undefined): boolean { const expected = adminToken(); if (!expected || !candidate) return false; return safeEqual(hashToken(candidate), hashToken(expected)); } export function verifyCookieValue(value: string | null | undefined): boolean { const expected = adminToken(); if (!expected || !value) return false; return safeEqual(value, hashToken(expected)); } export async function isAdmin(): Promise { const jar = await cookies(); return verifyCookieValue(jar.get(ADMIN_COOKIE)?.value); } /** Server components / route handlers: redirect anonymous visitors to the login form. */ export async function requireAdmin(next?: string): Promise { if (!(await isAdmin())) redirect(next ? `/admin/login?next=${encodeURIComponent(next)}` : '/admin/login'); } export function cookieOptions(): { httpOnly: true; sameSite: 'lax'; secure: boolean; path: string; maxAge: number } { return { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: ADMIN_SESSION_SECONDS }; } /** Only allow same-site relative redirects after login. */ export function safeNextPath(next: string | null | undefined): string { if (!next || !next.startsWith('/admin') || next.startsWith('//')) return '/admin'; return next; }