feat: RBAC middleware and role-based route guards (Phase 1) #89

Merged
groombook-engineer[bot] merged 2 commits from feat/rbac-middleware-gro-103 into main 2026-03-21 19:31:18 +00:00
5 changed files with 435 additions and 91 deletions
+45 -51
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { JwtPayload } from "../middleware/auth.js";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock data ───────────────────────────────────────────────────────────────
@@ -160,13 +161,29 @@ vi.mock("@groombook/db", () => {
// ─── App setup ───────────────────────────────────────────────────────────────
const { impersonationRouter } = await import("../routes/impersonation.js");
const { requireRole } = await import("../middleware/rbac.js");
function createApp(sub: string) {
const app = new Hono<{ Variables: { jwtPayload: JwtPayload } }>();
/**
* Build a test app. If staffRow is null the middleware simulates
* resolveStaffMiddleware returning 403 (staff not found). An optional
* roleGuard applies requireRole(...roles) before the router.
*/
function createApp(
staffRow: (typeof MANAGER_STAFF) | null,
roleGuard?: string[]
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub } as JwtPayload);
if (!staffRow) {
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
}
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
c.set("staff", staffRow as unknown as StaffRow);
await next();
});
if (roleGuard && roleGuard.length > 0) {
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
}
app.route("/impersonation", impersonationRouter);
return app;
}
@@ -187,9 +204,8 @@ beforeEach(() => resetMock());
describe("POST /impersonation/sessions", () => {
it("creates a session for a manager", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[CLIENT], // client lookup
[], // expireTimedOutSessions active query
[] // existing active check
@@ -205,9 +221,8 @@ describe("POST /impersonation/sessions", () => {
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
});
it("rejects non-managers", async () => {
const app = createApp("oidc-groomer-sub");
selectQueue.push([GROOMER_STAFF]);
it("rejects non-managers via requireRole guard", async () => {
const app = createApp(GROOMER_STAFF, ["manager"]);
const res = await app.request(
"/impersonation/sessions",
@@ -216,12 +231,11 @@ describe("POST /impersonation/sessions", () => {
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/only managers/i);
expect(body.error).toMatch(/forbidden/i);
});
it("returns 403 when staff record not found", async () => {
const app = createApp("unknown-sub");
selectQueue.push([]);
const app = createApp(null);
const res = await app.request(
"/impersonation/sessions",
@@ -232,9 +246,8 @@ describe("POST /impersonation/sessions", () => {
});
it("returns 404 when client not found", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // client not found
);
@@ -247,10 +260,9 @@ describe("POST /impersonation/sessions", () => {
});
it("returns 409 when active session already exists", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF, ["manager"]);
const existing = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[CLIENT], // client lookup
[], // expireTimedOutSessions
[existing] // existing active session
@@ -271,10 +283,9 @@ describe("POST /impersonation/sessions", () => {
describe("GET /impersonation/sessions/:id", () => {
it("returns session for the owning staff member", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup
);
@@ -283,10 +294,9 @@ describe("GET /impersonation/sessions/:id", () => {
});
it("returns 403 for a different staff member", async () => {
const app = createApp("oidc-groomer-sub");
const app = createApp(GROOMER_STAFF);
const session = makeSession(); // owned by manager
selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[session] // session lookup
);
@@ -297,9 +307,8 @@ describe("GET /impersonation/sessions/:id", () => {
});
it("returns 404 for nonexistent session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // no session
);
@@ -308,10 +317,9 @@ describe("GET /impersonation/sessions/:id", () => {
});
it("auto-expires a timed-out session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup
);
@@ -329,10 +337,9 @@ describe("GET /impersonation/sessions/:id", () => {
describe("POST /impersonation/sessions/:id/extend", () => {
it("extends an active non-expired session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup
);
@@ -350,10 +357,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
});
it("returns 400 when extending a time-expired session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup
);
@@ -367,10 +373,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
});
it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub");
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[session] // owned by manager
);
@@ -382,10 +387,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
});
it("returns 400 for an ended session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" });
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -403,10 +407,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
describe("POST /impersonation/sessions/:id/end", () => {
it("ends an active non-expired session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -420,10 +423,9 @@ describe("POST /impersonation/sessions/:id/end", () => {
});
it("returns 400 when ending a time-expired session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -437,10 +439,9 @@ describe("POST /impersonation/sessions/:id/end", () => {
});
it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub");
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[session]
);
@@ -458,10 +459,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
const logBody = { action: "page_visit", pageVisited: "/dashboard" };
it("logs an audit entry for the session owner", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -474,10 +474,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
});
it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub");
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[session]
);
@@ -491,10 +490,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
});
it("returns 400 when session has expired by time", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -508,10 +506,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
});
it("returns 400 for an ended session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" });
selectQueue.push(
[MANAGER_STAFF],
[session]
);
@@ -529,11 +526,10 @@ describe("POST /impersonation/sessions/:id/log", () => {
describe("GET /impersonation/sessions/:id/audit-log", () => {
it("returns audit logs for the session owner", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
const session = makeSession();
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session], // session lookup
logs // audit logs query (where + orderBy chain)
);
@@ -547,10 +543,9 @@ describe("GET /impersonation/sessions/:id/audit-log", () => {
});
it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub");
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[GROOMER_STAFF],
[session]
);
@@ -563,9 +558,8 @@ describe("GET /impersonation/sessions/:id/audit-log", () => {
});
it("returns 404 for nonexistent session", async () => {
const app = createApp("oidc-manager-sub");
const app = createApp(MANAGER_STAFF);
selectQueue.push(
[MANAGER_STAFF],
[]
);
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock staff data ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
role: "manager",
name: "Manager McManager",
email: "manager@example.com",
active: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
}),
}),
}),
staff,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
const h = handler ?? ((c: Context<AppEnv>) => c.json({ ok: true }));
app.get("/test", h);
app.post("/test", h);
return app;
}
/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */
function buildWithStaff(
staffRow: StaffRow,
guard: MiddlewareHandler<AppEnv>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
c.set("staff", staffRow);
await next();
});
app.use("*", guard);
app.get("/test", (c) => c.json({ ok: true }));
app.post("/test", (c) => c.json({ ok: true }));
return app;
}
// ─── Import middleware ────────────────────────────────────────────────────────
const { resolveStaffMiddleware, requireRole } = await import(
"../middleware/rbac.js"
);
beforeEach(() => resetMocks());
afterEach(() => {
delete process.env.AUTH_DISABLED;
});
// ─── resolveStaffMiddleware tests ─────────────────────────────────────────────
describe("resolveStaffMiddleware", () => {
it("resolves staff from DB and sets it on context", async () => {
staffLookupResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.id).toBe(MANAGER.id);
});
it("returns 403 when no staff record found for the OIDC sub", async () => {
staffLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
it("dev mode: resolves staff by X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
staffLookupResult = GROOMER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test", {
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
});
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("groomer");
});
it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("manager");
});
it("dev mode: returns 403 when no manager exists and no header provided", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
it("allows access when staff role matches the only allowed role", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows access when staff role is one of multiple allowed roles", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 for an unauthorized role", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/forbidden/i);
expect(body.error).toContain("groomer");
});
it("includes the role name in the 403 error message", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain("receptionist");
});
it("groomer is blocked from manager+receptionist-only routes", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist"));
const res = await app.request("/test", { method: "POST" });
expect(res.status).toBe(403);
});
it("manager passes all-role checks", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 with JSON body (not plain text)", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("application/json");
});
});
+29
View File
@@ -16,6 +16,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -57,6 +58,34 @@ app.get("/api/branding", async (c) => {
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
api.use("*", resolveStaffMiddleware);
// ── Role guards ────────────────────────────────────────────────────────────────
// Manager-only: staff, admin settings, reports, invoices, impersonation
api.use("/staff/*", requireRole("manager"));
api.use("/admin/*", requireRole("manager"));
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
// Clients, pets, appointments: all roles may read; only manager + receptionist may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
["/clients/*", "/pets/*", "/appointments/*"],
requireRole("manager", "receptionist")
);
// Services: all roles may read; only managers may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
"/services/*",
requireRole("manager")
);
// ──────────────────────────────────────────────────────────────────────────────
api.route("/clients", clientsRouter);
api.route("/pets", petsRouter);
+101
View File
@@ -0,0 +1,101 @@
import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db";
import type { JwtPayload } from "./auth.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
export interface AppEnv {
Variables: {
jwtPayload: JwtPayload;
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 (treated
* as oidcSub), or falls back to the first manager in the DB.
*/
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
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);
await next();
return;
}
// Treat X-Dev-User-Id as the oidcSub
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, devUserId));
if (!row) {
return c.json(
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
403
);
}
c.set("staff", row);
await next();
return;
}
const jwt = c.get("jwtPayload");
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!row) {
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
c.set("staff", row);
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<AppEnv> {
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();
};
}
+10 -40
View File
@@ -7,15 +7,12 @@ import {
getDb,
impersonationSessions,
impersonationAuditLogs,
staff,
clients,
desc,
} from "@groombook/db";
import type { JwtPayload } from "../middleware/auth.js";
import type { AppEnv } from "../middleware/rbac.js";
type Env = { Variables: { jwtPayload: JwtPayload } };
export const impersonationRouter = new Hono<Env>();
export const impersonationRouter = new Hono<AppEnv>();
const SESSION_TIMEOUT_MINUTES = 30;
@@ -25,16 +22,6 @@ function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
return new Date(Date.now() + minutes * 60_000);
}
/** Resolve the staff row for the authenticated OIDC subject. */
async function resolveStaff(sub: string) {
const db = getDb();
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, sub));
return row ?? null;
}
/** Expire any timed-out active sessions for a given staff member. */
async function expireTimedOutSessions(staffId: string) {
const db = getDb();
@@ -76,7 +63,8 @@ async function checkAndExpireSession(
return true;
}
// ─── POST / — Start a new impersonation session ─────────────────────────────
// ─── POST /sessions — Start a new impersonation session ─────────────────────
// requireRole("manager") is enforced by index.ts middleware on /impersonation/*
const startSessionSchema = z.object({
clientId: z.string().uuid(),
@@ -88,16 +76,9 @@ impersonationRouter.post(
zValidator("json", startSessionSchema),
async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = c.get("staff");
const body = c.req.valid("json");
// Resolve authenticated staff
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
if (staffRow.role !== "manager") {
return c.json({ error: "Only managers can impersonate clients" }, 403);
}
// Verify client exists
const [client] = await db
.select()
@@ -150,9 +131,7 @@ impersonationRouter.post(
impersonationRouter.get("/sessions/:id", async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const staffRow = c.get("staff");
const [session] = await db
.select()
@@ -176,9 +155,7 @@ impersonationRouter.get("/sessions/:id", async (c) => {
impersonationRouter.post("/sessions/:id/extend", async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const staffRow = c.get("staff");
const [session] = await db
.select()
@@ -217,9 +194,7 @@ impersonationRouter.post("/sessions/:id/extend", async (c) => {
impersonationRouter.post("/sessions/:id/end", async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const staffRow = c.get("staff");
const [session] = await db
.select()
@@ -266,12 +241,9 @@ impersonationRouter.post(
zValidator("json", logEntrySchema),
async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = c.get("staff");
const body = c.req.valid("json");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db
.select()
.from(impersonationSessions)
@@ -307,9 +279,7 @@ impersonationRouter.post(
impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload;
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const staffRow = c.get("staff");
const [session] = await db
.select()