Initial scaffold: tablet-checkin app
Staff-push guest check-in tablet. Fastify backend + React/Vite frontend, three-tier auth (staff JWT, terminal API key, kiosk cookie), NewBook integration via bookings_get/guests_update/bookings_update, pdfkit-based regcard generation, marketing opt-in with Sendy/Mailchimp adapters. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
3b2e25df3f
51 changed files with 10296 additions and 0 deletions
99
backend/src/auth.js
Normal file
99
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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 <raw key>`. 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' })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue