Harden per-client isolation for calendars, Meta, creatives, and KPIs.

Validate view-as tenants, scope service-role mutations by tenant_id, add documents RLS, refuse portfolio-wide data dumps, and block Meta publish when a Page/IG is linked to another client.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-07 15:57:21 +02:00
parent 83de3fb3aa
commit e3fe157fa5
10 changed files with 347 additions and 58 deletions

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Apply sql/documents_rls.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "documents_rls.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 1
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/cpl.b64 /tmp/cpl.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/cpl.b64", 400)
run(
"base64 -d /tmp/cpl.b64 > /tmp/cpl.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cpl.sql; echo EXIT:$?",
12000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-500:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

44
sql/documents_rls.sql Normal file
View File

@@ -0,0 +1,44 @@
-- Tenant isolation for editorial calendars and other documents.
-- Clients only see their own tenant_id; agency uses service role / viaView APIs.
alter table public.documents enable row level security;
drop policy if exists documents_tenant_select on public.documents;
drop policy if exists documents_tenant_insert on public.documents;
drop policy if exists documents_tenant_update on public.documents;
drop policy if exists documents_tenant_delete on public.documents;
create policy documents_tenant_select on public.documents
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy documents_tenant_insert on public.documents
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy documents_tenant_update on public.documents
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy documents_tenant_delete on public.documents
for delete using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
comment on table public.documents is
'Tenant-scoped docs (type=calendar RRSS plans, etc.). RLS: JWT tenant only; agency writes via service role.';

View File

@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
const ctx = await resolveActiveTenant(user); const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user)); const agency = isAgencyRole(userRole(user));
const tenantId = ctx.tenantId || // Never fall back to agency home tenant while "vista global" — that mixes clients.
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; const tenantId = ctx.tenantId;
const companyName = ctx.label || user.user_metadata?.company || 'Cliente'; const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
if (!tenantId && !ctx.portfolioWide) { if (!tenantId && !ctx.portfolioWide) {
return NextResponse.json( return NextResponse.json(
@@ -30,7 +30,6 @@ export async function POST(req: NextRequest) {
{ status: 403 } { status: 403 }
); );
} }
// 1. Extraer mensaje del usuario // 1. Extraer mensaje del usuario
let userMessage = ''; let userMessage = '';
try { try {
@@ -92,6 +91,11 @@ export async function POST(req: NextRequest) {
normQuery.includes('instrucciones'); normQuery.includes('instrucciones');
if (isHelpQuery) { if (isHelpQuery) {
if (agency && ctx.portfolioWide) {
return reply(
'Selecciona un **cliente en Contexto** para usar el asistente con los datos de esa cuenta. En vista global no mezclo información entre clientes.'
);
}
return reply( return reply(
`👋 **¡Hola! Soy tu Asistente Financiero & de Crecimiento de 4Ventures** para **${companyName}**.\n\n` + `👋 **¡Hola! Soy tu Asistente Financiero & de Crecimiento de 4Ventures** para **${companyName}**.\n\n` +
`Estoy conectado directamente a tu base de datos analítica y ejecuto cálculos matemáticos deterministas (temperatura 0.0, sin alucinaciones). Aquí tienes todo lo que puedes preguntarme:\n\n` + `Estoy conectado directamente a tu base de datos analítica y ejecuto cálculos matemáticos deterministas (temperatura 0.0, sin alucinaciones). Aquí tienes todo lo que puedes preguntarme:\n\n` +
@@ -121,6 +125,12 @@ export async function POST(req: NextRequest) {
); );
} }
if (ctx.portfolioWide || !tenantId) {
return reply(
'Selecciona un **cliente en Contexto** para consultar sus datos (calendario, redes, KPIs). En vista global no mezclo información entre cuentas.'
);
}
// 4. Intención: Simulación Determinista "¿Qué pasa si...?" // 4. Intención: Simulación Determinista "¿Qué pasa si...?"
if ( if (
normQuery.includes('simul') || normQuery.includes('simul') ||
@@ -226,7 +236,10 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, { const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ p_month: currentMonth }) body: JSON.stringify({
p_month: currentMonth,
p_tenant_id: tenantId,
}),
}); });
if (res.ok) { if (res.ok) {

View File

@@ -103,6 +103,7 @@ export async function GET(req: NextRequest) {
let query = supabase let query = supabase
.from('creative_pipeline') .from('creative_pipeline')
.select(SELECT) .select(SELECT)
.eq('tenant_id', ctx.tenantId)
.order('month', { ascending: true }) .order('month', { ascending: true })
.order('created_at', { ascending: true }) .order('created_at', { ascending: true })
.limit(1000); .limit(1000);

View File

@@ -1,14 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { import {
VIEW_TENANT_COOKIE,
homeTenantId,
isAgencyRole, isAgencyRole,
serviceHeaders, serviceHeaders,
supabaseUrl, supabaseUrl,
userRole, userRole,
} from '@/lib/agency'; } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
const SELECT = const SELECT =
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at'; 'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at';
@@ -51,19 +49,6 @@ const CLIENT_ALLOWED_TRANSITIONS: Record<string, string[]> = {
in_review: ['approved', 'rejected'], in_review: ['approved', 'rejected'],
}; };
async function resolveTenantId(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
}): Promise<{ tenantId: string | null; viaView: boolean }> {
const role = userRole(user);
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) {
return { tenantId: viewId, viaView: true };
}
return { tenantId: homeTenantId(user), viaView: false };
}
function str(v: unknown, max = 2000): string | null { function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null; if (typeof v !== 'string') return null;
const t = v.trim(); const t = v.trim();
@@ -114,7 +99,9 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { tenantId, viaView } = await resolveTenantId(user); const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
const viaView = ctx.viaView;
if (viaView && tenantId) { if (viaView && tenantId) {
const headers = serviceHeaders(); const headers = serviceHeaders();
@@ -194,9 +181,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { tenantId, viaView } = await resolveTenantId(user); const ctx = await resolveActiveTenant(user);
if (!tenantId) { const tenantId = ctx.tenantId;
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 }); if (!tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
} }
if (!isAgencyRole(userRole(user))) { if (!isAgencyRole(userRole(user))) {
return NextResponse.json( return NextResponse.json(
@@ -224,7 +215,7 @@ export async function POST(req: NextRequest) {
}, },
}; };
if (viaView) { if (ctx.viaView) {
const headers = serviceHeaders(); const headers = serviceHeaders();
if (!headers) { if (!headers) {
return NextResponse.json( return NextResponse.json(
@@ -321,7 +312,14 @@ export async function PATCH(req: NextRequest) {
const agency = isAgencyRole(userRole(user)); const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders(); const headers = serviceHeaders();
const { tenantId } = await resolveTenantId(user); const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId || (agency && ctx.portfolioWide)) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
if (!agency) { if (!agency) {
const current = await fetchCurrentStatus(id, tenantId, headers); const current = await fetchCurrentStatus(id, tenantId, headers);
@@ -376,7 +374,9 @@ export async function PATCH(req: NextRequest) {
if (agency && headers) { if (agency && headers) {
const r = await fetch( const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`, `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(tenantId)}`,
{ {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
@@ -391,13 +391,21 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 }); return NextResponse.json({ error: await r.text() }, { status: 500 });
} }
const data = await r.json(); const data = await r.json();
return NextResponse.json({ creative: data[0] || data }); const row = Array.isArray(data) ? data[0] : data;
if (!row) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
);
}
return NextResponse.json({ creative: row });
} }
const { data, error } = await supabase const { data, error } = await supabase
.from('creatives') .from('creatives')
.update(clean) .update(clean)
.eq('id', id) .eq('id', id)
.eq('tenant_id', tenantId)
.select(SELECT) .select(SELECT)
.single(); .single();
if (error) { if (error) {
@@ -427,20 +435,46 @@ export async function DELETE(req: NextRequest) {
{ status: 403 } { status: 403 }
); );
} }
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const headers = serviceHeaders(); const headers = serviceHeaders();
if (agency && headers) { if (agency && headers) {
const r = await fetch( const del = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`, `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
{ method: 'DELETE', headers } id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'DELETE',
headers: {
...headers,
Prefer: 'return=representation',
},
}
);
if (!del.ok) {
return NextResponse.json({ error: await del.text() }, { status: 500 });
}
const rows = await del.json().catch(() => []);
if (Array.isArray(rows) && rows.length === 0) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
); );
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const { error } = await supabase.from('creatives').delete().eq('id', id); const { error } = await supabase
.from('creatives')
.delete()
.eq('id', id)
.eq('tenant_id', ctx.tenantId);
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }

View File

@@ -121,6 +121,7 @@ export default async function CalendarioPage({
.select( .select(
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status' 'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status'
) )
.eq('tenant_id', ctx.tenantId)
.order('created_at', { ascending: true }) .order('created_at', { ascending: true })
.limit(2000); .limit(2000);
pipe = (data as PipelineCalendarRow[]) || []; pipe = (data as PipelineCalendarRow[]) || [];

View File

@@ -13,24 +13,38 @@ export async function GET() {
const ctx = await resolveActiveTenant(user); const ctx = await resolveActiveTenant(user);
const tenant_id = ctx.tenantId; const tenant_id = ctx.tenantId;
// Never dump all tenants' ads/pipeline/integrations into one HTML payload.
if (!tenant_id || ctx.portfolioWide) {
return new NextResponse(
'Selecciona un cliente en Contexto para ver KPIs.',
{ status: 400 }
);
}
const adminSupabase = createSupabaseClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!); const adminSupabase = createSupabaseClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
let queryAd = adminSupabase.from('ad_daily').select('*').order('date', { ascending: true }); const { data: adDaily } = await adminSupabase
if (tenant_id) queryAd = queryAd.eq('tenant_id', tenant_id); .from('ad_daily')
const { data: adDaily } = await queryAd; .select('*')
.eq('tenant_id', tenant_id)
.order('date', { ascending: true });
let queryOrg = adminSupabase.from('organic_daily').select('*').order('day', { ascending: true }); const { data: orgDaily } = await adminSupabase
if (tenant_id) queryOrg = queryOrg.eq('tenant_id', tenant_id); .from('organic_daily')
const { data: orgDaily } = await queryOrg; .select('*')
.eq('tenant_id', tenant_id)
.order('day', { ascending: true });
let queryPipe = adminSupabase.from('creative_pipeline').select('*').order('created_at', { ascending: true }); const { data: creativePipeline } = await adminSupabase
if (tenant_id) queryPipe = queryPipe.eq('tenant_id', tenant_id); .from('creative_pipeline')
const { data: creativePipeline } = await queryPipe; .select('*')
.eq('tenant_id', tenant_id)
.order('created_at', { ascending: true });
let queryIntegrations = adminSupabase.from('integrations').select('*'); const { data: integrations } = await adminSupabase
if (tenant_id) queryIntegrations = queryIntegrations.eq('tenant_id', tenant_id); .from('integrations')
const { data: integrations } = await queryIntegrations; .select('*')
.eq('tenant_id', tenant_id);
const igIntegration = integrations?.find((i: any) => i.provider === 'instagram'); const igIntegration = integrations?.find((i: any) => i.provider === 'instagram');
const fbIntegration = integrations?.find((i: any) => i.provider === 'facebook'); const fbIntegration = integrations?.find((i: any) => i.provider === 'facebook');

View File

@@ -34,26 +34,31 @@ export function isAgencyRole(role: string | undefined | null): boolean {
return role === 'admin' || role === 'team'; return role === 'admin' || role === 'team';
} }
/**
* Authorization role. Privileged roles (admin/team) ONLY from app_metadata
* so clients cannot self-elevate via user_metadata.updateUser.
*/
export function userRole(user: { export function userRole(user: {
app_metadata?: Record<string, unknown>; app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>;
} | null): string { } | null): string {
return ( const app = (user?.app_metadata?.role as string | undefined)?.trim();
(user?.app_metadata?.role as string) || if (app === 'admin' || app === 'team' || app === 'client') return app;
(user?.user_metadata?.role as string) || // Legacy clients may only have user_metadata.role=client — never admin/team.
'client' const um = (user?.user_metadata?.role as string | undefined)?.trim();
); if (um === 'client') return 'client';
return 'client';
} }
export function homeTenantId(user: { export function homeTenantId(user: {
app_metadata?: Record<string, unknown>; app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>;
} | null): string | null { } | null): string | null {
return ( const app = (user?.app_metadata?.tenant_id as string | undefined)?.trim();
(user?.app_metadata?.tenant_id as string) || if (app) return app;
(user?.user_metadata?.tenant_id as string) || // Legacy client JWT may still carry tenant only in user_metadata.
null const um = (user?.user_metadata?.tenant_id as string | undefined)?.trim();
); return um || null;
} }
export function serviceHeaders(): export function serviceHeaders():

View File

@@ -205,6 +205,46 @@ async function loadIntegrations(tenantId: string): Promise<{
return { pageId, igUserId }; return { pageId, igUserId };
} }
/** Block publish if FB Page / IG user id is also stored on another tenant. */
async function assertMetaAccountsExclusive(
tenantId: string,
pageId: string | null,
igUserId: string | null
): Promise<{ ok: boolean; error?: string }> {
const ids = [pageId, igUserId].filter(
(x): x is string => Boolean(x && x.trim())
);
if (!ids.length) return { ok: true };
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return { ok: true };
const or = ids
.map((id) => `external_account_id.eq.${encodeURIComponent(id)}`)
.join(',');
const r = await fetch(
`${base}/rest/v1/integrations?or=(${or})&provider=in.(facebook,instagram)&status=eq.connected&select=tenant_id,provider,external_account_id`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return { ok: true };
const rows = (await r.json()) as {
tenant_id: string;
provider: string;
external_account_id?: string | null;
}[];
const foreign = rows.filter((row) => row.tenant_id !== tenantId);
if (!foreign.length) return { ok: true };
const detail = foreign
.map(
(f) =>
`${f.provider}:${f.external_account_id || '?'}→tenant ${f.tenant_id.slice(0, 8)}`
)
.join(', ');
return {
ok: false,
error: `Cuenta Meta también vinculada a otro cliente (${detail}). Corrige Integraciones antes de publicar.`,
};
}
/** Find Facebook Page that owns an IG business account. */ /** Find Facebook Page that owns an IG business account. */
async function findPageForIg( async function findPageForIg(
token: string, token: string,
@@ -249,6 +289,16 @@ export async function resolveTenantMetaTargets(
}; };
} }
// Refuse if the same Page/IG id is linked to another tenant (cross-client leak).
const exclusive = await assertMetaAccountsExclusive(
tenantId,
integPage,
igUserId
);
if (!exclusive.ok) {
return { targets: null, error: exclusive.error };
}
let pageId = integPage; let pageId = integPage;
let pageToken: string | null = null; let pageToken: string | null = null;
let working: string | null = null; let working: string | null = null;

View File

@@ -18,6 +18,28 @@ export type ResolvedTenant = {
label: string; label: string;
}; };
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/** Re-validate view-as cookie against tenants table (defense vs forged cookie). */
async function lookupTenant(
tenantId: string
): Promise<{ id: string; name: string } | null> {
if (!UUID_RE.test(tenantId)) return null;
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return null;
const r = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(
tenantId
)}&select=id,name&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as { id: string; name?: string }[];
return rows[0] ? { id: rows[0].id, name: rows[0].name || 'Cliente' } : null;
}
export async function resolveActiveTenant(user: { export async function resolveActiveTenant(user: {
app_metadata?: Record<string, unknown>; app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>;
@@ -28,11 +50,21 @@ export async function resolveActiveTenant(user: {
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null; const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) { if (isAgencyRole(role) && viewId) {
const tenant = await lookupTenant(viewId);
if (!tenant) {
// Forged / deleted tenant cookie → fall back to portfolio (safe).
return { return {
tenantId: viewId, tenantId: null,
viaView: false,
portfolioWide: true,
label: 'Portafolio (todos los clientes)',
};
}
return {
tenantId: tenant.id,
viaView: true, viaView: true,
portfolioWide: false, portfolioWide: false,
label: viewName || 'Cliente', label: viewName || tenant.name || 'Cliente',
}; };
} }
if (isAgencyRole(role) && !viewId) { if (isAgencyRole(role) && !viewId) {