import type { MiddlewareHandler } from "hono"; import { eq, getDb, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; export interface AppEnv { Variables: { jwtPayload: { sub: string; email?: string; name?: string }; staff: StaffRow; }; } /** * Resolves the authenticated staff record from the DB and stores it in context. * Must be applied after authMiddleware on all protected routes. * * Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth * user ID), or falls back to the first manager in the DB. */ export const resolveStaffMiddleware: MiddlewareHandler = async ( c, next ) => { // Better-Auth's own routes handle their own auth — skip staff resolution if (c.req.path.startsWith("/api/auth/")) { await next(); return; } const db = getDb(); if (process.env.AUTH_DISABLED === "true") { const devUserId = c.req.header("X-Dev-User-Id"); if (!devUserId) { // No header — fall back to first manager const [manager] = await db .select() .from(staff) .where(eq(staff.role, "manager")) .limit(1); if (!manager) { return c.json({ error: "Forbidden: no staff records found" }, 403); } c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false }); await next(); return; } // Treat X-Dev-User-Id as the Better-Auth user ID first const [row] = await db .select() .from(staff) .where(eq(staff.userId, devUserId)); if (row) { c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false }); await next(); return; } // Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login // may send the primary key for staff records that predate the userId field) const [fallbackRow] = await db .select() .from(staff) .where(eq(staff.id, devUserId)); if (!fallbackRow) { return c.json( { error: "Forbidden: no staff record found for X-Dev-User-Id" }, 403 ); } c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false }); await next(); return; } const jwt = c.get("jwtPayload"); const [row] = await db .select() .from(staff) .where(eq(staff.userId, jwt.sub)); if (row) { c.set("staff", row); await next(); return; } // Fallback: staff records that predate the userId field may still have oidcSub const [fallbackRow] = await db .select() .from(staff) .where(eq(staff.oidcSub, jwt.sub)); if (!fallbackRow) { return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 ); } c.set("staff", fallbackRow); await next(); }; /** * Middleware factory that enforces one of the allowed roles. * Must be applied after resolveStaffMiddleware. * * @example * api.use("/staff/*", requireRole("manager")); * api.use("/reports/*", requireRole("manager")); */ export function requireRole( ...allowedRoles: StaffRole[] ): MiddlewareHandler { return async (c, next) => { const staffRow = c.get("staff"); if (!staffRow) { return c.json({ error: "Forbidden: staff record not resolved" }, 403); } if (!(allowedRoles as string[]).includes(staffRow.role)) { return c.json( { error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`, }, 403 ); } await next(); }; } /** * Middleware that allows access if the staff member has any of the allowed roles OR is a super user. * Use for routes where managers OR super-users should have access. * * @example * api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); */ export function requireRoleOrSuperUser( ...allowedRoles: StaffRole[] ): MiddlewareHandler { return async (c, next) => { const staffRow = c.get("staff"); if (!staffRow) { return c.json({ error: "Forbidden: staff record not resolved" }, 403); } const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role); if (hasAllowedRole || staffRow.isSuperUser) { await next(); return; } return c.json( { error: staffRow.isSuperUser ? `Forbidden: role '${staffRow.role}' is not permitted` : "Forbidden: super user privileges required", }, 403 ); }; } /** * Middleware that enforces the staff member is a super user. * Must be applied after resolveStaffMiddleware and (typically) after requireRole. * * @example * api.use("/staff/*", requireRole("manager")); * api.use("/staff/*", requireSuperUser()); */ export function requireSuperUser(): MiddlewareHandler { return async (c, next) => { const staffRow = c.get("staff"); if (!staffRow) { return c.json({ error: "Forbidden: staff record not resolved" }, 403); } if (!staffRow.isSuperUser) { return c.json( { error: "Forbidden: super user privileges required" }, 403 ); } await next(); }; }