import { createHmac, timingSafeEqual } from "node:crypto"; /** * Signed-cookie session for the manager app — adapted from (not imported * from) the main NextChair app's src/lib/booking-draft/sign.ts and * src/lib/security/secrets-match.ts. Deliberately copied rather than * shared: this app must never import anything from the main app's src/ — * a previous attempt at this exact tool was built as routes inside the * main app and repeatedly broke from transitively importing Prisma/ * Auth.js/business data into a mode that has no business database at * all — see ../../README.md's "Why it's a separate app" for the full * history. Keeping this app's module graph fully separate is what makes * that failure structurally impossible to repeat here. * * HMAC-signed (tamper-evident), not encrypted — the payload is just an * email and an expiry, nothing sensitive to disclose to the browser * holding its own cookie. Session length: 7 days. */ const CONTEXT = "nextchair-manager-session-v1"; const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export interface ManagerSession { email: string; exp: number; } function getSigningKey(): Buffer { const secret = process.env.MANAGER_SESSION_SECRET; if (!secret) { throw new Error("MANAGER_SESSION_SECRET must be set."); } return createHmac("sha256", secret).update(CONTEXT).digest(); } function sign(payload: string): string { const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url"); const encodedPayload = Buffer.from(payload, "utf8").toString("base64url"); return `${encodedPayload}.${mac}`; } function verifyToken(token: string): string | null { const separatorIndex = token.indexOf("."); if (separatorIndex === -1) return null; const encodedPayload = token.slice(0, separatorIndex); const mac = token.slice(separatorIndex + 1); if (!encodedPayload || !mac) return null; let payload: string; try { payload = Buffer.from(encodedPayload, "base64url").toString("utf8"); } catch { return null; } const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url"); const macBuffer = Buffer.from(mac); const expectedBuffer = Buffer.from(expectedMac); if (macBuffer.length !== expectedBuffer.length) return null; if (!timingSafeEqual(macBuffer, expectedBuffer)) return null; return payload; } export function createSessionToken(email: string): string { const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS }; return sign(JSON.stringify(payload)); } /** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */ export function verifySessionToken(token: string | undefined): ManagerSession | null { if (!token) return null; const payload = verifyToken(token); if (!payload) return null; let session: ManagerSession; try { session = JSON.parse(payload); } catch { return null; } if (typeof session.email !== "string" || typeof session.exp !== "number") return null; if (Date.now() > session.exp) return null; return session; } /** * Same signal/reasoning as the main app's src/lib/security/cookie- * security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean * TLS is actually in front of this app, so the cookie's Secure attribute * is derived from the deployer-declared public URL instead. */ export function isSecureDeployment(): boolean { return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://"); } export const SESSION_COOKIE_NAME = "manager_session";