From ac7aa891db2f7061f66ac05607141125c1aa7871 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 00:50:36 +0530 Subject: [PATCH] feat(hq): staff auth with sessions and owner gate (HQ-1 task 3) Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 9 +++++- apps/hq/src/auth.ts | 59 +++++++++++++++++++++++++++++++++++++++ apps/hq/src/server.ts | 5 +++- apps/hq/test/auth.test.ts | 22 +++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 apps/hq/src/auth.ts create mode 100644 apps/hq/test/auth.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 212c151..73d0083 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,10 +1,17 @@ import { Router } from 'express' +import { login } from './auth' import type { DB } from './db' -export function apiRouter(_db: DB | null): Router { +export function apiRouter(db: DB): Router { const r = Router() r.get('/health', (_req, res) => { res.json({ ok: true, service: 'sims-hq', version: '0.1.0' }) }) + r.post('/auth/login', (req, res) => { + const { email, password } = req.body as { email?: string; password?: string } + const out = typeof email === 'string' && typeof password === 'string' ? login(db, email, password) : null + if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return } + res.json({ ok: true, ...out }) + }) return r } diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts new file mode 100644 index 0000000..6d75b34 --- /dev/null +++ b/apps/hq/src/auth.ts @@ -0,0 +1,59 @@ +// 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 function createStaff(db: DB, input: { + email: string; displayName: string; role: 'owner' | 'staff'; password: string +}): { 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 + db.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash) + 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 function login(db: DB, email: string, password: string): + { token: string; staff: { id: string; displayName: string; role: string } } | null { + const row = db.prepare(`SELECT * FROM staff_user WHERE email=? AND active=1`).get(email.toLowerCase()) as StaffRow | undefined + 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() + db.prepare(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`).run(token, row.id, expires) + writeAudit(db, row.id, 'login', 'staff_user', row.id) + return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } } +} + +export function verifySession(db: DB, token: string): { id: string; role: string } | null { + const row = db.prepare( + `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 > ?`, + ).get(token, new Date().toISOString()) as { id: string; role: string } | undefined + return row ?? null +} + +export const requireAuth: RequestHandler = (req, res, next) => { + const db = req.app.locals['db'] as DB + const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') + const staff = token ? 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() +} diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index c01f291..901b5e3 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -1,11 +1,14 @@ import express from 'express' import type http from 'node:http' import { apiRouter } from './api' +import { openDb } from './db' export function startServer(port: number): http.Server { const app = express() + const db = openDb(process.env['HQ_DATA_DIR']) + app.locals['db'] = db app.use(express.json({ limit: '2mb' })) - app.use('/api', apiRouter(null)) + app.use('/api', apiRouter(db)) return app.listen(port) } diff --git a/apps/hq/test/auth.test.ts b/apps/hq/test/auth.test.ts new file mode 100644 index 0000000..af20869 --- /dev/null +++ b/apps/hq/test/auth.test.ts @@ -0,0 +1,22 @@ +// apps/hq/test/auth.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createStaff, login, verifySession } from '../src/auth' + +describe('hq auth', () => { + it('logs in with correct password, rejects wrong one', () => { + const db = openDb(':memory:') + createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' }) + expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull() + const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9') + expect(ok).not.toBeNull() + const staff = verifySession(db, ok!.token) + expect(staff).toMatchObject({ role: 'owner' }) + }) + it('rejects passwords under 8 chars at creation', () => { + const db = openDb(':memory:') + expect(() => + createStaff(db, { email: 'a@b.c', displayName: 'X', role: 'staff', password: 'short' }), + ).toThrow(/8/) + }) +})