import { HttpError } from "@batch-cooking/error-tools"; import { type AdminUserView, ErrorCode } from "@batch-cooking/shared"; import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { verifyAdminToken } from "../lib/admin-jwt.js"; import { toSafeAdmin } from "../lib/safe-admin.js"; /** * Shape of `res.locals` once {@link requireAdmin} has run successfully. * Type a handler's response as `Response` to read * `res.locals.adminUser` fully typed, no cast — same `res.locals` (not * global `Request` augmentation) approach as {@link AuthLocals} * (`require-auth.ts`). */ export interface AdminLocals { /** The authenticated admin operator, resolved from the admin session cookie's JWT. */ adminUser: AdminUserView; } /** * Express middleware guarding every `/admin/*` route — the operations app * (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` * against the database so a stateless JWT can still be invalidated * server-side. * * A completely separate mechanism from {@link requireAuth}, not layered on * it: an end-user session token and an admin session token are never * interchangeable in either direction. * * Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance * that doesn't run the admin app) makes {@link verifyAdminToken} throw, so * every request is rejected rather than the surface left open — same * posture as `requireInternalWorker`. * * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing * cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or * no secret configured. Never distinguishes the reason. */ export async function requireAdmin( req: Request, res: Response, next: NextFunction, ) { try { const token = req.cookies?.[env.ADMIN_COOKIE_NAME]; if (typeof token !== "string") { throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } const payload = verifyAdminToken(token); const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } }); if (!admin || admin.tokenVersion !== payload.tokenVersion) { throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } res.locals.adminUser = toSafeAdmin(admin); next(); } catch (err) { if (err instanceof HttpError) { next(err); } else { // Covers jwt.verify failures and the unset-secret throw from verifyAdminToken. next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); } } }