import { jwtVerify } from 'jose' import bcrypt from 'bcryptjs' import { isOnsite } from './ip-check.js' import { pool } from './db.js' const APP_SLUG = process.env.APP_SLUG || 'tablet-checkin' const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') // Deliberately its own cookie name — NOT 'hnf_session' (central auth's real // staff-session cookie). Sharing that name caused a real collision bug in // hvac's kiosk (see project_hvac_kiosk memory, hit 2026-08-26): a stale // same-named cookie from testing the kiosk login could shadow a subsequent // real staff login on the same origin. Mirroring that fix here rather than // rediscovering it — this app's own requireAuth checks hnf_session first, // falls back to this one, and the two are fully independent. const KIOSK_COOKIE = 'tablet_checkin_kiosk_session' // ── Tier 1: staff JWT (central auth) + tablet kiosk session ──────────────── // Gates the human-facing staff routes: admin/audit (checkins.js), device // registry management, terminal key management, marketing config, settings. export async function requireAuth(request, reply) { const token = request.cookies?.hnf_session || request.cookies?.[KIOSK_COOKIE] if (!token) return reply.status(401).send({ error: 'Not authenticated' }) let payload try { const { payload: p } = await jwtVerify(token, secret) payload = p } catch { return reply.status(401).send({ error: 'Invalid session' }) } if (!payload.apps?.includes(APP_SLUG)) { return reply.status(403).send({ error: 'No permission for this app' }) } if (!payload.offsite_allowed) { const clientIP = request.headers['x-real-ip'] || request.ip if (!(await isOnsite(clientIP))) { return reply.status(403).send({ error: 'Access restricted to site network' }) } } const prefix = `${APP_SLUG}:` let caps if (Array.isArray(payload.caps)) { caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length)) } else { caps = [] // legacy token — nothing granted until re-login } request.user = { email: payload.sub, name: payload.name, is_admin: payload.is_admin ?? false, caps, } } export function hasCap(request, cap) { return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true } export function requireCap(cap) { return async (request, reply) => { if (!hasCap(request, cap)) { return reply.status(403).send({ error: `Missing capability: ${cap}` }) } } } export { KIOSK_COOKIE } // ── Tier 2: terminal API key ──────────────────────────────────────────────── // Authorizes the standalone Chrome extension's push call (routes/staff.js) // only — never used for anything human-facing. Distinct from both the staff // JWT and the tablet's own kiosk session: hotel-owned physical/desktop // hardware gets a device-level bearer credential instead of a per-user login. // Header: `Authorization: Bearer `. The raw key is shown once at // creation (routes/terminals.js) and only its bcrypt hash is ever stored, so // verifying means iterating the small set of non-revoked terminals and // comparing — fine at this scale (a handful of reception PCs, not users). export async function requireTerminalAuth(request, reply) { const header = request.headers['authorization'] || '' const key = header.startsWith('Bearer ') ? header.slice(7).trim() : null if (!key) return reply.status(401).send({ error: 'Terminal API key required' }) const { rows } = await pool.query( `SELECT terminal_id, api_key_hash FROM terminals WHERE revoked_at IS NULL` ) for (const row of rows) { if (await bcrypt.compare(key, row.api_key_hash)) { await pool.query(`UPDATE terminals SET last_used_at = now() WHERE terminal_id = $1`, [row.terminal_id]) request.terminal = { terminal_id: row.terminal_id } return } } return reply.status(401).send({ error: 'Invalid or revoked terminal key' }) }