import type { MiddlewareHandler } from "hono"; import { and, eq, getDb, sql, staff, account, user } 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 // OOBE setup routes also handle their own auth — staff record is created during setup if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) { 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) { c.set("staff", fallbackRow); await next(); return; } // Auto-link by email: staff record exists with matching email but no userId if (jwt.email) { const [byEmail] = await db .select() .from(staff) .where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`)); if (byEmail) { await db .update(staff) .set({ userId: jwt.sub, updatedAt: new Date() }) .where(eq(staff.id, byEmail.id)); c.set("staff", { ...byEmail, userId: jwt.sub }); await next(); return; } } // Auto-provision for Better-Auth users (GRO-2052): the user signed in via // Better-Auth (email/password, magic link, etc.), so a row exists in `user` // for jwt.sub but no `account` provider row is required. Create a minimal // groomer staff record on first login. This is the primary auto-provision // path; the OIDC branch below remains as a fallback for legacy accounts // that exist in `account` but not in `user`. const [userRow] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) .where(eq(user.id, jwt.sub)) .limit(1); if (userRow) { const emailPrefix = userRow.email ? userRow.email.split("@")[0] : "Unknown"; const name = userRow.name?.trim() || jwt.name?.trim() || emailPrefix; const [newStaff] = await db .insert(staff) .values({ userId: jwt.sub, email: userRow.email ?? jwt.email ?? "", name, role: "groomer", isSuperUser: false, active: true, } as Parameters[0] extends { values: infer V } ? V : never) .returning()!; if (!newStaff) { return c.json({ error: "Forbidden: auto-provision failed" }, 500); } console.log( `[rbac] auto-provisioned staff record for Better-Auth user: ${jwt.sub} -> staff:${newStaff.id} (${name})` ); c.set("staff", newStaff); await next(); return; } // Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account // (e.g. authentik). If so, create a groomer staff record on the fly. This // is kept for backward compatibility with legacy OIDC sessions whose user // row may not yet exist in the Better-Auth `user` table. if (jwt.email) { const [oidcAccount] = await db .select({ id: account.id }) .from(account) .where( and( eq(account.userId, jwt.sub), sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')` ) ) .limit(1); if (oidcAccount) { // Derive name: prefer jwt.name, fall back to email prefix, then "Unknown" const emailPrefix = jwt.email ? jwt.email.split("@")[0] : "Unknown"; const name = jwt.name?.trim() || emailPrefix; const [newStaff] = await db .insert(staff) .values({ userId: jwt.sub, email: (jwt.email ?? "") as string, name, role: "groomer", isSuperUser: false, active: true, } as Parameters[0] extends { values: infer V } ? V : never) .returning()!; if (!newStaff) { return c.json({ error: "Forbidden: auto-provision failed" }, 500); } console.log( `[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})` ); c.set("staff", newStaff); await next(); return; } } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 ); }; /** * 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: hasAllowedRole ? "Forbidden: super user privileges required" : `Forbidden: role \'${staffRow.role}\' is not permitted`, }, 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(); }; }