import { randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema'; import { type RolePermissions } from '@/lib/db/schema/users'; import { createAuditLog } from '@/lib/audit'; import { errorResponse, ForbiddenError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { runWithRequestContext, getRequestContext } from '@/lib/request-context'; import { checkRateLimit, rateLimiters, rateLimitHeaders, type RateLimiterName, } from '@/lib/rate-limit'; // ─── Types ──────────────────────────────────────────────────────────────────── /** * Authenticated request context resolved by `withAuth`. * Passed as the second argument to every wrapped route handler. */ export interface AuthContext { userId: string; portId: string; portSlug: string; /** true for super_admin users - bypasses all permission checks. */ isSuperAdmin: boolean; /** * Effective permissions after role + port override deep-merge. * null when isSuperAdmin is true (super admins bypass permission checks). */ permissions: RolePermissions | null; user: { email: string; name: string; }; /** Client IP extracted from X-Forwarded-For header. */ ipAddress: string; userAgent: string; } /** * Route params type. Defaults to `Record` for the common * `[id]`-style routes. Catch-all routes (`[...slug]`) need to override * `TParams` so Next.js 15.5+'s stricter route-type checking accepts the * exported handler against the inferred `{ slug: string[] }` shape. */ export type RouteParams = Record; export type RouteHandler, T = unknown> = ( req: NextRequest, ctx: AuthContext, params: TParams, ) => Promise>; // ─── deepMerge ─────────────────────────────────────────────────────────────── /** * Recursively merges `source` into `target`. * Used to apply port-level role permission overrides on top of the base role. */ export function deepMerge( target: Record, source: Record, ): Record { const result = { ...target }; for (const key of Object.keys(source)) { const sourceVal = source[key]; const targetVal = result[key]; if ( typeof sourceVal === 'object' && sourceVal !== null && !Array.isArray(sourceVal) && typeof targetVal === 'object' && targetVal !== null && !Array.isArray(targetVal) ) { result[key] = deepMerge( targetVal as Record, sourceVal as Record, ); } else { result[key] = sourceVal; } } return result; } // ─── withAuth ──────────────────────────────────────────────────────────────── /** * Validates the session, loads the user profile, resolves port context and * applies port-level role overrides before calling the inner handler. * * Usage: * ```ts * export const GET = withAuth(handler); * export const POST = withAuth(withPermission('clients', 'create', handler)); * ``` */ export function withAuth>( handler: RouteHandler, ): (req: NextRequest, routeContext: { params: Promise }) => Promise { return async (req, routeContext) => { // Mint or accept a request id BEFORE entering the ALS frame so every // log line + the response header reference the same value. Clients // (or upstream proxies) may pre-supply via X-Request-Id; otherwise // generate a fresh UUID. Pattern-validated so a crafted header can't // smuggle log-injection chars. const incomingId = req.headers.get('x-request-id'); const requestId = incomingId && /^[A-Za-z0-9-]{8,64}$/.test(incomingId) ? incomingId : randomUUID(); /** Stamp `X-Request-Id` onto every response leaving the wrapper. */ const tag = (res: NextResponse): NextResponse => { res.headers.set('X-Request-Id', requestId); return res; }; return runWithRequestContext( { requestId, portId: '', userId: '', method: req.method, path: new URL(req.url).pathname, startedAt: Date.now(), }, async () => { try { // 1. Validate session via Better Auth. const session = await auth.api.getSession({ headers: req.headers }); if (!session?.user) { return tag(NextResponse.json({ error: 'Authentication required' }, { status: 401 })); } // 2. Load the CRM user profile (keyed on Better Auth user ID). const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, session.user.id), }); if (!profile || !profile.isActive) { return tag(NextResponse.json({ error: 'Account disabled' }, { status: 403 })); } // 3. Resolve port context. Port id comes from the X-Port-Id // header (set by the client after port selection), falling // back to the user's default port preference. NEVER from the // request body — SECURITY-GUIDELINES.md §2.1. const portIdFromHeader = req.headers.get('X-Port-Id'); const portId = portIdFromHeader ?? (profile.preferences as { defaultPortId?: string } | null)?.defaultPortId ?? null; if (!portId && !profile.isSuperAdmin) { return tag(NextResponse.json({ error: 'Port context required' }, { status: 400 })); } // 4. Resolve effective permissions. let permissions: RolePermissions | null = null; let portSlug = ''; if (!profile.isSuperAdmin && portId) { const portRole = await db.query.userPortRoles.findFirst({ where: and( eq(userPortRoles.userId, profile.userId), eq(userPortRoles.portId, portId), ), with: { role: true, port: true, }, }); if (!portRole) { return tag(NextResponse.json({ error: 'No access to this port' }, { status: 403 })); } permissions = { ...(portRole.role.permissions as RolePermissions) }; portSlug = (portRole.port as { slug: string } | null)?.slug ?? ''; // Apply port-specific role overrides (deep-merge on top of base role). const override = await db.query.portRoleOverrides.findFirst({ where: and( eq(portRoleOverrides.portId, portId), eq(portRoleOverrides.roleId, portRole.roleId), ), }); if (override?.permissionOverrides) { permissions = deepMerge( permissions as unknown as Record, override.permissionOverrides as Record, ) as RolePermissions; } // Per-user residential toggle. if (portRole.residentialAccess && permissions) { permissions = { ...permissions, residential_clients: { view: true, create: true, edit: true, delete: true }, residential_interests: { view: true, create: true, edit: true, delete: true, change_stage: true, }, }; } } else if (profile.isSuperAdmin && portId) { const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); if (!port) { return tag(NextResponse.json({ error: 'Port not found' }, { status: 404 })); } portSlug = port.slug; } // Now that the user + port are resolved, enrich the ALS frame // so log lines + error_events rows pick up the identifiers. const frame = getRequestContext(); if (frame) { frame.userId = profile.userId; frame.portId = portId ?? ''; } const ctx: AuthContext = { userId: profile.userId, portId: portId ?? '', portSlug, isSuperAdmin: profile.isSuperAdmin, permissions, user: { email: session.user.email, name: session.user.name, }, ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown', userAgent: req.headers.get('user-agent') ?? 'unknown', }; const params = await routeContext.params; return tag(await handler(req, ctx, params)); } catch (error) { return tag(errorResponse(error)); } }, ); }; } // ─── requireSuperAdmin ─────────────────────────────────────────────────────── /** * Throws ForbiddenError when the caller is not a super-admin. Use inside * route handlers (after withAuth) for endpoints that mutate global, cross- * tenant state — global roles, cross-port migrations, system jobs. * * Logs the denied attempt to the audit trail (mirrors withPermission). */ export function requireSuperAdmin(ctx: AuthContext, attemptedAction = 'super_admin_only'): void { if (ctx.isSuperAdmin) return; logger.warn({ userId: ctx.userId, attemptedAction }, 'Super-admin gate denied'); void createAuditLog({ userId: ctx.userId, portId: ctx.portId, action: 'permission_denied', entityType: 'super_admin', entityId: '', metadata: { attemptedAction }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); throw new ForbiddenError('Super admin access required'); } // ─── withPermission ────────────────────────────────────────────────────────── /** * Wraps a route handler with a permission gate. * Denied attempts are logged to the audit trail. * * Compose inside withAuth: * ```ts * export const DELETE = withAuth(withPermission('clients', 'delete', handler)); * ``` */ export function withPermission>( resource: keyof RolePermissions, action: string, handler: RouteHandler, ): RouteHandler { return async (req, ctx, params) => { if (!ctx.isSuperAdmin) { const resourcePerms = ctx.permissions?.[resource] as Record | undefined; if (!resourcePerms || !resourcePerms[action]) { logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied'); // Log the denied attempt - fire-and-forget; audit must never block response. void createAuditLog({ userId: ctx.userId, portId: ctx.portId, action: 'permission_denied', entityType: resource, entityId: '', metadata: { attemptedAction: action }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); } } return handler(req, ctx, params); }; } // ─── withRateLimit ─────────────────────────────────────────────────────────── /** * Wraps a route handler with a per-user rate-limit gate. Compose inside * withAuth so the userId is available - falls back to IP for anonymous * routes (we don't currently expose any). * * 429 responses include `X-RateLimit-Limit` / `Remaining` / `Reset` headers * and a `Retry-After` hint. * * ```ts * export const POST = withAuth( * withPermission('expenses', 'create', * withRateLimit('ocr', handler) * ) * ); * ``` */ export function withRateLimit(name: RateLimiterName, handler: RouteHandler): RouteHandler { const config = rateLimiters[name]; return async (req, ctx, params) => { const identifier = `${ctx.userId}`; const result = await checkRateLimit(identifier, config); if (!result.allowed) { const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); logger.warn( { userId: ctx.userId, limiter: name, limit: result.limit }, 'Rate limit exceeded', ); return NextResponse.json( { error: 'Rate limit exceeded', retryAfter: retryAfterSec }, { status: 429, headers: { ...rateLimitHeaders(result), 'Retry-After': String(retryAfterSec), }, }, ); } return handler(req, ctx, params); }; }