/** * Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the * base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the * session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode. */ /** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */ export const SESSION_COOKIE_NAME = 'panel_session' /** * Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are * ignored; last write wins on duplicate names. */ export function parseCookieHeader(header: string | undefined): Record { const out: Record = {} if (header === undefined || header === '') return out for (const part of header.split(';')) { const eq = part.indexOf('=') if (eq <= 0) continue const name = part.slice(0, eq).trim() if (name === '') continue out[name] = part.slice(eq + 1).trim() } return out } interface SetCookieOptions { readonly value: string readonly maxAgeSec: number readonly secure: boolean } /** * Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't * ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure * cookie is never sent over http:// — forcing it would break a loopback/http operator session). */ export function buildSetCookie(opts: SetCookieOptions): string { const parts = [ `${SESSION_COOKIE_NAME}=${opts.value}`, 'Path=/', `Max-Age=${opts.maxAgeSec}`, 'HttpOnly', 'SameSite=Strict', ] if (opts.secure) parts.push('Secure') return parts.join('; ') } /** Build a `Set-Cookie` that immediately expires the session cookie (logout). */ export function buildClearCookie(opts: { secure: boolean }): string { return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure }) }