import { createServerClient } from '@supabase/ssr'; import { NextRequest, NextResponse } from 'next/server'; import { VIEW_TENANT_COOKIE, VIEW_TENANT_NAME_COOKIE, VIEW_TENANT_SLUG_COOKIE, clearedViewTenantCookieOpts, isAgencyRole, userRole, } from '@/lib/agency'; function withSecurityHeaders(res: NextResponse, path?: string) { res.headers.set('X-Frame-Options', 'DENY'); res.headers.set('X-Content-Type-Options', 'nosniff'); res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); if (!path?.startsWith('/legal')) { res.headers.set('X-Robots-Tag', 'noindex, nofollow'); } res.headers.set( 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()' ); if (process.env.NODE_ENV === 'production') { res.headers.set( 'Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload' ); } return res; } export async function middleware(req: NextRequest) { let res = NextResponse.next({ request: { headers: req.headers }, }); // Public Supabase URL only — Docker-internal Kong rewrite made getUser() fail // after login (cookie set, middleware treats user as logged out → stuck verifying). const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return req.cookies.getAll(); }, setAll(cookiesToSet: { name: string; value: string; options?: Record }[]) { cookiesToSet.forEach(({ name, value }) => req.cookies.set(name, value)); res = NextResponse.next({ request: { headers: req.headers }, }); cookiesToSet.forEach(({ name, value, options }) => res.cookies.set(name, value, options) ); }, }, } ); // Prefer local session from cookie. getUser() revalidates via Auth network and // fails in this Edge runtime when Kong/hairpin is broken → endless login loop. const { data: { session }, } = await supabase.auth.getSession(); const user = session?.user ?? null; const url = req.nextUrl.clone(); const path = url.pathname; const apiKeyHeader = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace(/^Bearer\s+/i, ''); const isApiKeyAuth = Boolean(apiKeyHeader) && apiKeyHeader === (process.env.INTEGRATIONS_API_KEY || process.env.N8N_API_KEY || 'n8n_sec_4v_9a7d3b2c1f8e4a5d6c7b8a9e0f1a2b3c'); // Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET), integrations (API Key) const isPublic = (isApiKeyAuth && path.startsWith('/api/')) || path === '/login' || path === '/api/health' || path.startsWith('/api/webhooks/') || path.startsWith('/api/cron/') || path.startsWith('/api/integrations/') || path === '/_4v_attr.js' || path.startsWith('/_next') || path === '/favicon.ico' || path === '/favicon.png' || path === '/icon.png'; if (!user && !isPublic && (path === '/' || path.startsWith('/dashboard') || path.startsWith('/api/'))) { if (path.startsWith('/api/')) { return withSecurityHeaders( NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) ); } const loginUrl = req.nextUrl.clone(); loginUrl.pathname = '/login'; if (path.startsWith('/dashboard')) { loginUrl.searchParams.set('next', path); } return withSecurityHeaders(NextResponse.redirect(loginUrl)); } const role = userRole(user); const agency = isAgencyRole(role); if (user && path === '/login') { const dashboardUrl = req.nextUrl.clone(); dashboardUrl.pathname = agency ? '/dashboard/agencia' : '/dashboard/kpis'; return withSecurityHeaders(NextResponse.redirect(dashboardUrl)); } if (user && !agency && path.startsWith('/dashboard/agencia')) { const forbiddenUrl = req.nextUrl.clone(); forbiddenUrl.pathname = '/dashboard/kpis'; return withSecurityHeaders(NextResponse.redirect(forbiddenUrl)); } if (user && !agency && path.startsWith('/api/admin/')) { return withSecurityHeaders( NextResponse.json( { error: 'Forbidden: solo usuarios agencia (admin/team) pueden usar esta acción. Entra con marketing@4ventures.es.', }, { status: 403 } ) ); } let tenantSlug = user?.user_metadata?.slug || user?.app_metadata?.slug || '4ventures-hub'; const hostname = req.headers.get('host') || ''; const parts = hostname.split('.'); if (parts.length > 2) { const sub = parts[0].toLowerCase(); if ( sub !== 'app' && sub !== 'git' && sub !== 'supabase' && sub !== 'n8n' && sub !== 'n8n-agencia' && sub !== 'www' ) { tenantSlug = sub; } } const queryTenant = url.searchParams.get('tenant'); if (queryTenant) { tenantSlug = queryTenant.toLowerCase(); } else { const cookieTenant = req.cookies.get('4v_tenant')?.value; if (cookieTenant && !user?.user_metadata?.slug) { tenantSlug = cookieTenant; } } res.headers.set('x-tenant-slug', tenantSlug); res.headers.set('x-user-role', role); if (user?.email) { res.headers.set('x-user-email', user.email); } res.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', }); // Clientes: no deben conservar cookies de "vista cliente" de una sesión agencia. if (user && !agency) { const hasView = req.cookies.get(VIEW_TENANT_COOKIE)?.value || req.cookies.get(VIEW_TENANT_NAME_COOKIE)?.value; if (hasView) { const opts = clearedViewTenantCookieOpts( process.env.NODE_ENV === 'production' ); res.cookies.set(VIEW_TENANT_COOKIE, '', opts); res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', opts); res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', opts); } } return withSecurityHeaders(res); } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|favicon.png|icon.png|_4v_attr.js).*)', ], };