// apps/hq/src/auth.ts import { randomBytes } from 'node:crypto' import type { RequestHandler } from 'express' import { hashPin, verifyPin } from '@sims/auth' import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' const SESSION_DAYS = 14 export async function createStaff(db: DB, input: { email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string }): Promise<{ id: string }> { if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const id = uuidv7() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied await db.run( `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, id, input.email.toLowerCase(), input.displayName, input.role, salt, hash, ) await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role }) return { id } } interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number } export async function login(db: DB, email: string, password: string): Promise<{ token: string; staff: { id: string; displayName: string; role: string } } | null> { const row = await db.get(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase()) if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null const token = randomBytes(32).toString('hex') const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString() await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires) await writeAudit(db, row.id, 'login', 'staff_user', row.id) return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } } } export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> { // u.active=1: a deactivated employee's unexpired token must die immediately. const row = await db.get<{ id: string; role: string }>( `SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id WHERE s.token=? AND s.expires_at > ? AND u.active = 1`, token, new Date().toISOString(), ) return row ?? null } export const requireAuth: RequestHandler = (req, res, next) => { void (async () => { const db = req.app.locals['db'] as DB const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') const staff = token ? await verifySession(db, token) : null if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return } res.locals['staff'] = staff next() })() } export const requireOwner: RequestHandler = (_req, res, next) => { if ((res.locals['staff'] as { role: string }).role !== 'owner') { res.status(403).json({ ok: false, error: 'Owner only' }); return } next() } /** True for the managerial roles (owner|manager) allowed to see/act beyond their own rows. */ export const isManagerial = (role: string): boolean => role === 'owner' || role === 'manager' /** * The shared server-side role gate for owner-scoped queries. Input comes from * res.locals.staff — never from the request body. Staff are forced to their own * rows and any widening param is ignored; owner/manager are unfiltered by default * and may narrow via `requestedOwnerId`. Returns the employee id every owner-scoped * query must filter by, or undefined for no filter. */ export function ownerScope( viewer: { id: string; role: string }, requestedOwnerId?: string, ): string | undefined { if (!isManagerial(viewer.role)) return viewer.id return requestedOwnerId !== undefined && requestedOwnerId !== '' ? requestedOwnerId : undefined }