import { Router, Request } from 'express'; import { and, asc, eq, ne } from 'drizzle-orm'; import { db } from '../db/client'; import { users, authSessions } from '../db/schema'; import { AuthedRequest, findUserByUsername, hashPassword, readSessionToken, validateNewCredentials, MIN_PASSWORD_LENGTH, } from '../auth'; import { toSnakeCase } from '../utils/case'; // User management (memory-bank/FEATURE_User_Accounts.md). Mounted behind // requireAuth; everyone-equal trust model — any signed-in user can add users, // reset passwords, and remove accounts other than their own. Password hashes // never leave the server. const router = Router(); function callerId(req: Request): number { return (req as AuthedRequest).user!.id; } router.get('/', (req, res) => { const rows = db .select({ id: users.id, username: users.username, createdAt: users.createdAt }) .from(users) .orderBy(asc(users.username)) .all(); res.json(rows.map(toSnakeCase)); }); router.post('/', (req, res) => { const { username, password } = req.body ?? {}; const invalid = validateNewCredentials(username, password); if (invalid) { res.status(400).json({ error: invalid }); return; } // Case-insensitive duplicate guard (the UNIQUE index is case-sensitive). if (findUserByUsername((username as string).trim())) { res.status(409).json({ error: 'That username is already taken' }); return; } const created = db .insert(users) .values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) }) .returning({ id: users.id, username: users.username, createdAt: users.createdAt }) .get(); res.status(201).json(toSnakeCase(created)); }); // Password reset — also the "someone forgot theirs" path, so no current-password // check (everyone equal). Signs the target out everywhere except the caller's // own current session (relevant when resetting your own password). router.patch('/:id', (req, res) => { const id = Number(req.params.id); const target = db.select().from(users).where(eq(users.id, id)).get(); if (!target) { res.status(404).json({ error: 'User not found' }); return; } const { password } = req.body ?? {}; if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); return; } db.update(users).set({ passwordHash: hashPassword(password) }).where(eq(users.id, id)).run(); const currentToken = readSessionToken(req); db.delete(authSessions) .where( currentToken ? and(eq(authSessions.userId, id), ne(authSessions.token, currentToken)) : eq(authSessions.userId, id) ) .run(); res.json({ id, username: target.username }); }); // You can't delete yourself — which also means the last account can never be // deleted (you'd have to be signed in as someone else), so no lockout state. router.delete('/:id', (req, res) => { const id = Number(req.params.id); if (id === callerId(req)) { res.status(400).json({ error: 'You cannot delete your own account' }); return; } const target = db.select({ id: users.id }).from(users).where(eq(users.id, id)).get(); if (!target) { res.status(404).json({ error: 'User not found' }); return; } // Explicit session cleanup: better-sqlite3 runs with foreign_keys OFF, so the // schema's ON DELETE CASCADE is not enforced at runtime. db.transaction((tx) => { tx.delete(authSessions).where(eq(authSessions.userId, id)).run(); tx.delete(users).where(eq(users.id, id)).run(); }); res.status(204).end(); }); export default router;