From 2577e33c50a1d9bc8e0968f6a15228d12103fd1e Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:20:36 +0000 Subject: [PATCH] feat(GRO-653): add portal session middleware and server-side audit logging (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GRO-653): add portal session middleware and server-side audit logging - Add validatePortalSession middleware that reads X-Impersonation-Session-Id header, queries impersonationSessions, and sets portalClientId + portalSessionId on the context - Add portalAudit middleware that logs all portal requests to impersonationAuditLogs table - Apply both middlewares to the portalRouter - Replace all getClientIdFromSession() calls with c.get("portalClientId") - Remove getClientIdFromSession() helper and inline session checks in waitlist routes - Consistent session.expiry > new Date() check across all routes Co-Authored-By: Paperclip * fix(GRO-653): remove unused sessionId variable and and import Fix lint errors flagged by QA: - Remove unused `sessionId` variable from PATCH waitlist handler - Remove unused `and` import from portal.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Paperclip --------- Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: Flea Flicker --- apps/api/src/middleware/portalAudit.ts | 45 +++++++ apps/api/src/middleware/portalSession.ts | 40 +++++++ apps/api/src/routes/portal.ts | 145 ++++------------------- 3 files changed, 108 insertions(+), 122 deletions(-) create mode 100644 apps/api/src/middleware/portalAudit.ts create mode 100644 apps/api/src/middleware/portalSession.ts diff --git a/apps/api/src/middleware/portalAudit.ts b/apps/api/src/middleware/portalAudit.ts new file mode 100644 index 0000000..a18129d --- /dev/null +++ b/apps/api/src/middleware/portalAudit.ts @@ -0,0 +1,45 @@ +import type { MiddlewareHandler } from "hono"; +import { getDb, impersonationAuditLogs } from "@groombook/db"; +import type { PortalEnv } from "./portalSession.js"; + +/** + * Server-side audit logging middleware for portal routes. + * Applied after validatePortalSession in the middleware chain. + * + * After the route handler completes (await next()), inserts an audit log entry + * into impersonationAuditLogs: + * - sessionId: from c.get("portalSessionId") + * - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments") + * - pageVisited: c.req.path + * - metadata: { method, statusCode: c.res.status } + * + * Log entries are written for both success and error responses. + * Does NOT throw if audit logging fails — errors are logged but the user's + * request is not affected. + */ +export const portalAudit: MiddlewareHandler = async (c, next) => { + await next(); + + const sessionId = c.get("portalSessionId"); + if (!sessionId) return; + + const method = c.req.method; + const routePath = c.req.path; + const pageVisited = c.req.path; + const statusCode = c.res.status; + + try { + const db = getDb(); + await db + .insert(impersonationAuditLogs) + .values({ + sessionId, + action: `${method} ${routePath}`, + pageVisited, + metadata: { method, statusCode }, + }) + .returning(); + } catch (err) { + console.error("[portalAudit] Failed to write audit log:", err); + } +}; diff --git a/apps/api/src/middleware/portalSession.ts b/apps/api/src/middleware/portalSession.ts new file mode 100644 index 0000000..6dfdb03 --- /dev/null +++ b/apps/api/src/middleware/portalSession.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, impersonationSessions } from "@groombook/db"; + +export interface PortalEnv { + Variables: { + portalClientId: string; + portalSessionId: string; + }; +} + +/** + * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. + * Must be applied to all portal routes. + * + * Reads x-session-id from request headers, queries impersonationSessions for a row where + * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). + * Returns 401 if session is invalid/missing/expired. + * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + */ +export const validatePortalSession: MiddlewareHandler = async (c, next) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("portalClientId", session.clientId); + c.set("portalSessionId", session.id); + await next(); +}; diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 8b10b56..d768bc8 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,33 +1,22 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, inArray } from "@groombook/db"; +import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import type { AppEnv } from "../middleware/rbac.js"; +import { validatePortalSession } from "../middleware/portalSession.js"; +import { portalAudit } from "../middleware/portalAudit.js"; +import type { PortalEnv } from "../middleware/portalSession.js"; -export const portalRouter = new Hono(); +export const portalRouter = new Hono(); -// ─── Session helper ─────────────────────────────────────────────────────────── - -async function getClientIdFromSession(sessionId: string | null | undefined): Promise { - if (!sessionId) return null; - const db = getDb(); - const [session] = await db - .select() - .from(impersonationSessions) - .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) - .limit(1); - if (!session || session.expiresAt <= new Date()) return null; - return session.clientId; -} +// Apply middleware to all portal routes +portalRouter.use("/*", validatePortalSession, portalAudit); // ─── GET routes ────────────────────────────────────────────────────────────── portalRouter.get("/me", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); if (!client) return c.json({ error: "Not found" }, 404); @@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => { portalRouter.get("/appointments", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const now = new Date(); const allAppts = await db @@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => { portalRouter.get("/pets", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); @@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => { portalRouter.get("/invoices", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); @@ -148,12 +131,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -196,12 +174,7 @@ portalRouter.patch( portalRouter.post("/appointments/:id/confirm", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -319,28 +287,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - let clientId: string | null = null; - if (sessionId) { - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - if (session && session.expiresAt > new Date()) { - clientId = session.clientId; - } - } - - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .insert(waitlistEntries) @@ -364,26 +311,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [existing] = await db .select() @@ -392,7 +320,7 @@ portalRouter.patch( .limit(1); if (!existing) return c.json({ error: "Not found" }, 404); - if (existing.clientId !== session.clientId) { + if (existing.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -414,26 +342,7 @@ portalRouter.patch( portalRouter.delete("/waitlist/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .select() @@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => { .limit(1); if (!entry) return c.json({ error: "Not found" }, 404); - if (entry.clientId !== session.clientId) { + if (entry.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -475,9 +384,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const invoiceRows = await db .select() @@ -514,9 +421,7 @@ portalRouter.post( ); portalRouter.get("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const methods = await listPaymentMethods(clientId); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); @@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => { }); portalRouter.post("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const customerId = await getOrCreateStripeCustomer(clientId); @@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => { }); portalRouter.delete("/payment-methods/:id", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const paymentMethodId = c.req.param("id");