import type { FastifyInstance } from "fastify"; import { ulid } from "ulid"; import { z } from "zod"; import { requireAdmin, hashPassword } from "./auth.js"; import { auditLog } from "./db.js"; import { toUser, toAudit } from "./mapping.js"; import type { UserRow, AuditRow } from "./mapping.js"; /** * Admin API (CHUNK #10). * * All routes live under a plugin scope whose `onRequest` hook enforces admin * membership via `requireAdmin`. The global auth hook (registered earlier) has * already populated `req.user`, so handlers read the acting user from `req.user!`. * * Validation uses Zod `.parse()` inside handlers (Fastify's `schema` option * expects a JSON schema, not a Zod object). Errors are thrown as Fastify HTTP * errors so the framework serializes the right status code. * * Sharing/CRUD for projects lives in `routes.ts` (project-level, * `requireProjectAccess`); this module is purely instance administration: * user management, the audit log, and instance stats. */ export function registerAdminRoutes(app: FastifyInstance): void { // Scope the admin routes (and their onRequest hook) under a plugin so the // admin-enforcement hook applies only to these routes, not globally. app.register(async function adminScope(instance: FastifyInstance) { const db = instance.db; // Enforce admin membership for every route in this scope. instance.addHook("onRequest", async (req, reply) => { try { requireAdmin(req); } catch (e) { reply.code(401).send(e); } }); // ---- users ---- instance.post("/api/admin/users", async (req) => { const body = userCreate.parse(req.body); const existing = db .prepare("SELECT id FROM users WHERE email = ?") .get(body.email) as { id: string } | undefined; if (existing) throw instance.httpErrors.create(409, { error: "email_taken" }); const id = ulid(); const role = body.role ?? "author"; const passHash = await hashPassword(body.password); db.prepare( "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)", ).run(id, body.email, passHash, body.displayName, role, new Date().toISOString()); auditLog(db, "user_create", req.user!.id, { email: body.email, role }); const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; return toUser(row); }); instance.patch("/api/admin/users/:id", async (req) => { const body = userPatch.parse(req.body); const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined; if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" }); if (row.disabled && !body.disabled) { throw instance.httpErrors.create(400, { error: "reset_password_to_reenable" }); } const fields: string[] = []; const values: unknown[] = []; if (body.displayName !== undefined) { fields.push("display_name = ?"); values.push(body.displayName); } if (body.role !== undefined) { fields.push("role = ?"); values.push(body.role); } if (body.disabled !== undefined) { fields.push("disabled = ?"); values.push(body.disabled ? 1 : 0); } if (fields.length) db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values, id); auditLog(db, "user_update", req.user!.id, { id, changes: Object.keys(body) }); const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; return toUser(updated); }); instance.delete("/api/admin/users/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined; if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" }); db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); db.prepare("DELETE FROM project_shares WHERE user_id = ?").run(id); db.prepare("DELETE FROM users WHERE id = ?").run(id); auditLog(db, "user_delete", req.user!.id, { id, email: row.email }); return { ok: true }; }); // ---- audit log ---- instance.get("/api/admin/audit", async (req) => { const q = req.query as { limit?: string; offset?: string; userId?: string; action?: string }; const limit = Math.min(Math.max(parseInt(q.limit ?? "50", 10) || 50, 1), 200); const offset = Math.max(parseInt(q.offset ?? "0", 10) || 0, 0); const clauses: string[] = []; const params: unknown[] = []; if (q.userId) { clauses.push("user_id = ?"); params.push(q.userId); } if (q.action) { clauses.push("action = ?"); params.push(q.action); } const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; const total = (db.prepare(`SELECT COUNT(*) AS n FROM audit${where}`).get(...params) as { n: number }).n; params.push(limit, offset); const items = ( db .prepare(`SELECT * FROM audit${where} ORDER BY at DESC LIMIT ? OFFSET ?`) .all(...params) as AuditRow[] ).map(toAudit); return { items, total, limit, offset }; }); instance.post("/api/admin/audit/clear", async (req) => { const body = req.body ?? {}; const keepAll = (body as { keepAll?: boolean }).keepAll === true; const cutoff = keepAll ? "1970-01-01T00:00:00.000Z" : new Date(Date.now() - 30 * 86400_000).toISOString(); const info = db.prepare("DELETE FROM audit WHERE at < ?").run(cutoff); auditLog(db, "audit_clear", req.user!.id, { deleted: info.changes, keepAll }); return { ok: true, deleted: info.changes }; }); // ---- stats ---- instance.get("/api/admin/stats", async () => { const users = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n; const disabled = (db.prepare("SELECT COUNT(*) AS n FROM users WHERE disabled = 1").get() as { n: number }).n; const projects = (db.prepare("SELECT COUNT(*) AS n FROM projects").get() as { n: number }).n; const shares = (db.prepare("SELECT COUNT(*) AS n FROM project_shares").get() as { n: number }).n; const audit = (db.prepare("SELECT COUNT(*) AS n FROM audit").get() as { n: number }).n; return { users, disabled, projects, shares, audit }; }); }); } // ---- validation schemas ---- const userCreate = z.object({ email: z.string().email(), password: z.string().min(8), displayName: z.string().min(1).max(80), role: z.enum(["admin", "author"]), }); const userPatch = z.object({ role: z.enum(["admin", "author"]), disabled: z.boolean(), displayName: z.string().min(1).max(80), });