fix(security): harden auth, tenant scoping, and scrub secrets

Require session on APIs, scope KPIs/activos/simulate to the user tenant, strip real JWTs from .env.example, and add health/smoke/docs for 4Ventures ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-21 10:51:46 +02:00
parent e7b6ab71ca
commit 2df3fe6983
21 changed files with 621 additions and 276 deletions

View File

@@ -3,9 +3,26 @@ import { createClient } from '@/lib/supabase/server';
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
// Prefer user JWT for tenant-scoped reads; service role only if explicitly set (server)
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
// 1. Extraer mensaje del usuario con compatibilidad para cualquier estructura de body
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const companyName = user.user_metadata?.company || 'Cliente';
if (!tenantId) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
{ status: 403 }
);
}
// 1. Extraer mensaje del usuario
let userMessage = '';
try {
const body = await req.json();
@@ -23,7 +40,6 @@ export async function POST(req: NextRequest) {
console.error('Error parsing chat body:', e);
}
// Normalizar consulta eliminando tildes, signos de interrogación y puntuación
const normQuery = userMessage
.toLowerCase()
.normalize('NFD')
@@ -32,24 +48,11 @@ export async function POST(req: NextRequest) {
.replace(/\s+/g, ' ')
.trim();
// 2. Obtener tenant_id dinámico del usuario autenticado
let tenantId = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
let companyName = '4Ventures Central';
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
tenantId = user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || tenantId;
companyName = user.user_metadata?.company || companyName;
}
} catch (e) {
console.error('Error resolving user tenant in chat:', e);
}
// Use service role for RPC only after authn; still filter by tenant_id in every call
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
apikey: keyForRpc,
Authorization: `Bearer ${keyForRpc}`,
'Content-Type': 'application/json'
};

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
/** Liveness for Dokploy / uptime — no secrets. */
export async function GET() {
const hasAnon = Boolean(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim());
const hasUrl = Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL?.trim());
const hasService = Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY?.trim());
const ok = hasAnon && hasUrl;
return NextResponse.json(
{
ok,
service: '4ventures-client-portal',
supabaseConfigured: hasUrl && hasAnon,
serviceRoleConfigured: hasService,
ts: new Date().toISOString(),
},
{ status: ok ? 200 : 503 }
);
}

View File

@@ -1,9 +1,28 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
if (!tenantId) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id' },
{ status: 403 }
);
}
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
try {
const body = await req.json();
@@ -13,25 +32,25 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
'Content-Type': 'application/json'
apikey: keyForRpc,
Authorization: `Bearer ${keyForRpc}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
p_tenant_id: tenantId,
p_additional_spend: spend,
p_target_channel: channel === 'blended' ? null : channel
})
p_target_channel: channel === 'blended' ? null : channel,
}),
});
if (res.ok) {
const data = await res.json();
return Response.json(data);
} else {
const err = await res.text();
return Response.json({ error: err }, { status: 500 });
return NextResponse.json(data);
}
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500 });
const err = await res.text();
return NextResponse.json({ error: err }, { status: 500 });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Error';
return NextResponse.json({ error: message }, { status: 500 });
}
}