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 } from '@/lib/errors'; import { logger } from '@/lib/logger'; 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; } export type RouteHandler = ( req: NextRequest, ctx: AuthContext, params: Record, ) => 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) => { try { // 1. Validate session via Better Auth. const session = await auth.api.getSession({ headers: req.headers }); if (!session?.user) { return 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 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 from preferences. // It NEVER comes 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 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 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 — flips the residential domain on // top of whatever the role grants. We never use it to *revoke* // residential access from a role that already grants it. 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) { // Super admin still needs portSlug for response context. // We also validate the portId actually exists — a super-admin session // must not be able to operate against a fabricated portId. const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); if (!port) { return NextResponse.json({ error: 'Port not found' }, { status: 404 }); } portSlug = port.slug; } 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 await handler(req, ctx, params); } catch (error) { return errorResponse(error); } }; } // ─── 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); }; }