Files
4ventures-client-portal/src/middleware.ts
4Ventures 4a1abd3318 feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:15:51 +02:00

199 lines
6.0 KiB
TypeScript

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<string, unknown> }[]) {
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).*)',
],
};