From 62d1f3a5f66f67ac3d68c5050fb5b2dbef57015a Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Mon, 12 Oct 2026 13:13:46 +0200 Subject: [PATCH] feat(creatives): task 2 - integrations auth and visibility scope with sanitization --- src/lib/creatives-visibility.test.ts | 117 +++++++++++++++++++++++++++ src/lib/creatives-visibility.ts | 102 +++++++++++++++++++++++ src/lib/integrations-auth.ts | 105 ++++++++++++++++++++++++ src/middleware.ts | 3 +- 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/lib/creatives-visibility.test.ts create mode 100644 src/lib/creatives-visibility.ts create mode 100644 src/lib/integrations-auth.ts diff --git a/src/lib/creatives-visibility.test.ts b/src/lib/creatives-visibility.test.ts new file mode 100644 index 0000000..48ccdbd --- /dev/null +++ b/src/lib/creatives-visibility.test.ts @@ -0,0 +1,117 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { sanitizeForRole, FORBIDDEN_CLIENT_FIELDS, applyVisibilityScope } from './creatives-visibility.ts'; + +describe('Creatives Visibility Scope & Serializer', () => { + const sampleCreative = { + id: '11111111-1111-1111-1111-111111111111', + tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450', + campaign_name: 'Campaña Matrículas', + format: 'estatico', + angle: 'prueba_social', + hook: 'Aprende inglés en 3 meses', + primary_text: 'El método que funciona para profesionales.', + headline: 'Clases online con nativos', + cta: 'Saber más', + asset_url: 'https://cdn.ejemplo.com/ad1.jpg', + thumbnail_url: 'https://cdn.ejemplo.com/thumb1.jpg', + status: 'publicada', + visibility: 'client', + parent_id: '00000000-0000-0000-0000-000000000000', + generation_request_id: '22222222-2222-2222-2222-222222222222', + created_by: 'claude', + created_at: '2026-10-12T10:00:00Z', + updated_at: '2026-10-12T10:00:00Z', + }; + + const sampleMetrics = { + id: '33333333-3333-3333-3333-333333333333', + tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450', + creative_id: '11111111-1111-1111-1111-111111111111', + meta_ad_id: 'meta_123', + date: '2026-10-12', + impressions: 5000, + clicks: 120, + spend: 45.5, + raw: { meta_response_debug: true, internal_details: 'sensitive' }, + }; + + const sampleGenerationRequest = { + id: '44444444-4444-4444-4444-444444444444', + tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450', + reason: 'fatiga', + brief: { client_slug: 'bps', variants_requested: 2 }, + cost_estimate: { tokens: 12000, credits: 1 }, + }; + + it('Agency role receives all fields unmodified', () => { + const res = sanitizeForRole(sampleCreative, 'agency'); + assert.strictEqual(res.angle, 'prueba_social'); + assert.strictEqual(res.created_by, 'claude'); + assert.strictEqual(res.parent_id, '00000000-0000-0000-0000-000000000000'); + assert.strictEqual(res.generation_request_id, '22222222-2222-2222-2222-222222222222'); + + const metricsRes = sanitizeForRole(sampleMetrics, 'admin'); + assert.ok(metricsRes.raw); + }); + + it('Client role NEVER receives forbidden fields in creatives', () => { + const res = sanitizeForRole(sampleCreative, 'client'); + for (const field of FORBIDDEN_CLIENT_FIELDS) { + assert.strictEqual( + (res as any)[field], + undefined, + `Field "${field}" must not be present for client` + ); + } + // Permitted fields remain + assert.strictEqual(res.headline, 'Clases online con nativos'); + assert.strictEqual(res.hook, 'Aprende inglés en 3 meses'); + assert.strictEqual(res.primary_text, 'El método que funciona para profesionales.'); + assert.strictEqual(res.status, 'publicada'); + }); + + it('Client role NEVER receives raw in metrics', () => { + const res = sanitizeForRole(sampleMetrics, 'client'); + assert.strictEqual(res.raw, undefined); + assert.strictEqual(res.impressions, 5000); + assert.strictEqual(res.clicks, 120); + }); + + it('Client role NEVER receives brief or cost_estimate in generation requests', () => { + const res = sanitizeForRole(sampleGenerationRequest, 'client'); + assert.strictEqual((res as any).brief, undefined); + assert.strictEqual((res as any).cost_estimate, undefined); + assert.strictEqual(res.reason, 'fatiga'); + }); + + it('Sanitizes arrays of objects properly for client role', () => { + const list = [sampleCreative, sampleCreative]; + const sanitizedList = sanitizeForRole(list, 'client'); + assert.strictEqual(sanitizedList.length, 2); + sanitizedList.forEach((item: any) => { + assert.strictEqual(item.angle, undefined); + assert.strictEqual(item.created_by, undefined); + assert.strictEqual(item.hook, 'Aprende inglés en 3 meses'); + }); + }); + + it('Query builder applies strict client filters for creatives', () => { + const mockQuery = { + filters: [] as string[], + eq(k: string, v: any) { + this.filters.push(`${k}=${v}`); + return this; + }, + in(k: string, values: any[]) { + this.filters.push(`${k} IN (${values.join(',')})`); + return this; + }, + }; + + applyVisibilityScope(mockQuery, 'creatives', 'client', '8bec977a-6032-4759-aa7f-b5042081f450'); + assert.ok(mockQuery.filters.includes('tenant_id=8bec977a-6032-4759-aa7f-b5042081f450')); + assert.ok(mockQuery.filters.includes('visibility=client')); + assert.ok(mockQuery.filters.some((f) => f.startsWith('status IN'))); + }); +}); diff --git a/src/lib/creatives-visibility.ts b/src/lib/creatives-visibility.ts new file mode 100644 index 0000000..430f68a --- /dev/null +++ b/src/lib/creatives-visibility.ts @@ -0,0 +1,102 @@ +import { isAgencyRole } from './agency.ts'; + +/** + * Campos estrictamente prohibidos para el rol 'client' + * Según Sección 4 del brief: + * "Campos que el cliente nunca recibe en ninguna respuesta de API: + * angle, generation_request_id, parent_id, raw, cost_estimate, brief, created_by." + */ +export const FORBIDDEN_CLIENT_FIELDS = [ + 'angle', + 'generation_request_id', + 'parent_id', + 'raw', + 'cost_estimate', + 'brief', + 'created_by', +] as const; + +export type UserRoleType = 'admin' | 'team' | 'agency' | 'client'; + +/** + * Normaliza el rol del usuario a 'agency' o 'client' + */ +export function normalizeRole(role: string | null | undefined): 'agency' | 'client' { + if (role === 'agency' || isAgencyRole(role)) return 'agency'; + return 'client'; +} + +/** + * Aplica los filtros de visibilidad de la BD de acuerdo al rol y al tenant_id + */ +export function applyVisibilityScope>( + query: any, + resource: 'creatives' | 'creative_metrics_daily' | 'approvals' | 'creative_events', + role: string | null | undefined, + tenantId?: string | null +) { + const normRole = normalizeRole(role); + + if (tenantId) { + query = query.eq('tenant_id', tenantId); + } + + if (normRole === 'agency') { + // La agencia ve todo dentro del tenant (o global si no hay tenantId) + return query; + } + + // Rol CLIENT: reglas estrictas de la sección 4 + switch (resource) { + case 'creatives': + // Solo status in ('pendiente_aprobacion','aprobada','publicada','retirada') Y visibility = 'client' + query = query + .eq('visibility', 'client') + .in('status', ['pendiente_aprobacion', 'aprobada', 'publicada', 'retirada']); + break; + + case 'creative_events': + // Solo visibility = 'client' + query = query.eq('visibility', 'client'); + break; + + case 'approvals': + // El cliente ve aprobaciones relacionadas con creatividades visibles + // (al consultar con join a creatives visibles o filtro directo) + break; + + case 'creative_metrics_daily': + // En metrics_daily el serializador elimina 'raw' y la query solo debe incluir creatividades visibles + break; + } + + return query; +} + +/** + * Serializador que garantiza que ningún campo prohibido llegue al cliente. + * Opera recursivamente sobre objetos o arrays. + */ +export function sanitizeForRole(data: T, role: string | null | undefined): T { + const normRole = normalizeRole(role); + if (normRole === 'agency') { + return data; + } + + if (Array.isArray(data)) { + return data.map((item) => sanitizeForRole(item, role)) as unknown as T; + } + + if (data !== null && typeof data === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(data)) { + if ((FORBIDDEN_CLIENT_FIELDS as readonly string[]).includes(key)) { + continue; // omitir campo prohibido + } + sanitized[key] = sanitizeForRole(value, role); + } + return sanitized as T; + } + + return data; +} diff --git a/src/lib/integrations-auth.ts b/src/lib/integrations-auth.ts new file mode 100644 index 0000000..e838e73 --- /dev/null +++ b/src/lib/integrations-auth.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; + +export type IntegrationCaller = 'n8n' | 'claude'; + +export interface IntegrationAuthResult { + ok: boolean; + caller?: IntegrationCaller; + tenantId?: string; + errorResponse?: NextResponse; +} + +/** + * Valida la autenticación de llamadas a /api/integrations/* + * Revisa X-Api-Key contra N8N_API_KEY y CLAUDE_API_KEY. + * Valida que el tenant (obtenido por tenant_id o client_id en query/body) tenga creatives_enabled = true. + */ +export async function authenticateIntegrationRequest( + req: NextRequest, + options: { requireTenant?: boolean } = { requireTenant: true } +): Promise { + const apiKey = req.headers.get('x-api-key') || req.headers.get('X-Api-Key'); + const n8nKey = process.env.N8N_API_KEY?.trim(); + const claudeKey = process.env.CLAUDE_API_KEY?.trim(); + + let caller: IntegrationCaller | undefined; + + if (apiKey && n8nKey && apiKey === n8nKey) { + caller = 'n8n'; + } else if (apiKey && claudeKey && apiKey === claudeKey) { + caller = 'claude'; + } + + if (!caller) { + return { + ok: false, + errorResponse: NextResponse.json( + { error: 'Unauthorized: clave de integración inválida o no proporcionada' }, + { status: 401 } + ), + }; + } + + if (!options.requireTenant) { + return { ok: true, caller }; + } + + // Extraer tenant_id o client_id de query params o body + let tenantId = + req.nextUrl.searchParams.get('tenant_id') || + req.nextUrl.searchParams.get('client_id'); + + if (!tenantId && req.method !== 'GET') { + try { + const cloned = req.clone(); + const body = await cloned.json(); + tenantId = body.tenant_id || body.client_id; + } catch { + // Ignorar error al parsear body si no viene JSON + } + } + + if (!tenantId) { + return { + ok: false, + errorResponse: NextResponse.json( + { error: 'Bad Request: client_id o tenant_id requerido' }, + { status: 400 } + ), + }; + } + + // Verificar en base de datos si tiene creatives_enabled + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + const { data: tenant, error } = await adminClient + .from('tenants') + .select('id, creatives_enabled') + .eq('id', tenantId) + .maybeSingle(); + + if (error || !tenant) { + return { + ok: false, + errorResponse: NextResponse.json( + { error: 'Not Found: cliente no encontrado' }, + { status: 404 } + ), + }; + } + + if (!tenant.creatives_enabled) { + return { + ok: false, + errorResponse: NextResponse.json( + { error: 'Forbidden: el cliente no tiene habilitado el módulo de creatividades' }, + { status: 403 } + ), + }; + } + + return { ok: true, caller, tenantId: tenant.id }; +} diff --git a/src/middleware.ts b/src/middleware.ts index 1d4c8d5..ae24a4d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -59,12 +59,13 @@ export async function middleware(req: NextRequest) { const url = req.nextUrl.clone(); const path = url.pathname; - // Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET) + // Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET), integrations (API Key) const isPublic = 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' ||