feat(hq): staff auth with sessions and owner gate (HQ-1 task 3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 89e468eeda
commit ac7aa891db

@ -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
}

@ -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()
}

@ -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)
}

@ -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/)
})
})
Loading…
Cancel
Save