cdp-edge 2.3.1 → 2.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,12 @@ import {
23
23
  distanceBucketWeight,
24
24
  computeMetaSignalWeights,
25
25
  metaSignalBucket,
26
+ isValidEmail,
27
+ sanitizeString,
28
+ isValidUrl,
29
+ isValidValue,
30
+ isValidCurrency,
31
+ isValidUTM,
26
32
  } from './modules/utils';
27
33
 
28
34
  // ── Banco de dados (D1) ───────────────────────────────────────────────────────
@@ -39,6 +45,7 @@ import {
39
45
  resurrectUTM,
40
46
  upsertLtvProfile,
41
47
  recordLtvFeedback,
48
+ processWebhookDuplicateCheck,
42
49
  } from './modules/db';
43
50
 
44
51
  // ── Dispatch — plataformas de ads ─────────────────────────────────────────────
@@ -135,10 +142,10 @@ export default {
135
142
  const url = new URL(request.url);
136
143
 
137
144
  // ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
145
+ // Usa apenas CF-Connecting-IP (injetado pela Cloudflare, não pode ser spoofado)
146
+ // X-Forwarded-For pode ser falsificado por atacantes para bypass rate limiter
138
147
  if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
139
- const ip = request.headers.get('CF-Connecting-IP')
140
- || request.headers.get('X-Forwarded-For')?.split(',')[0].trim()
141
- || '0.0.0.0';
148
+ const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
142
149
  const { success } = await env.RATE_LIMITER.limit({ key: ip });
143
150
  if (!success) {
144
151
  return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
@@ -265,6 +272,7 @@ export default {
265
272
  const { eventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
266
273
  const trackPayload: TrackPayload = payload;
267
274
 
275
+ // ── Validação de eventName ────────────────────────────────────────
268
276
  if (!eventName) {
269
277
  return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
270
278
  }
@@ -273,20 +281,91 @@ export default {
273
281
  return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
274
282
  }
275
283
 
276
- for (const field of STR_FIELDS) {
277
- if (trackPayload[field as keyof TrackPayload] !== undefined && trackPayload[field as keyof TrackPayload] !== null) {
278
- if (typeof trackPayload[field as keyof TrackPayload] !== 'string' || String(trackPayload[field as keyof TrackPayload]).length > 512) {
279
- return new Response(JSON.stringify({ error: `Campo inválido: ${field}` }), { status: 400, headers });
284
+ // ── Sanitização e Validação de Campos String ──────────────────────
285
+ type SanitizeResult = { error?: string; sanitized: string | null };
286
+
287
+ const SANITIZE_FIELDS: Record<string, (val: string) => SanitizeResult> = {
288
+ email: (val: string) => {
289
+ if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)' };
290
+ return { sanitized: val.toLowerCase().trim() };
291
+ },
292
+ firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
293
+ lastName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
294
+ city: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
295
+ state: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
296
+ zip: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
297
+ dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
298
+ productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }),
299
+ pageUrl: (val: string) => {
300
+ if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)' };
301
+ return { sanitized: val.trim() };
302
+ },
303
+ currency: (val: string) => {
304
+ if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)' };
305
+ return { sanitized: val.trim().toUpperCase() };
306
+ },
307
+ };
308
+
309
+ // Sanitiza e valida campos específicos
310
+ for (const [field, validator] of Object.entries(SANITIZE_FIELDS)) {
311
+ const value = trackPayload[field as keyof TrackPayload];
312
+ if (value !== undefined && value !== null) {
313
+ if (typeof value !== 'string') {
314
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
315
+ }
316
+ const result = validator(value);
317
+ if (result.error) {
318
+ return new Response(JSON.stringify({ error: result.error }), { status: 400, headers });
319
+ }
320
+ if (result.sanitized !== null) {
321
+ trackPayload[field as keyof TrackPayload] = result.sanitized as any;
322
+ }
323
+ }
324
+ }
325
+
326
+ // Sanitiza campos de string genéricos
327
+ const GENERIC_SANITIZE_FIELDS = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm'];
328
+ for (const field of GENERIC_SANITIZE_FIELDS) {
329
+ const value = trackPayload[field as keyof TrackPayload];
330
+ if (value !== undefined && value !== null) {
331
+ if (typeof value !== 'string') {
332
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
333
+ }
334
+ const utmType = `utm_${field.replace('utm', '').toLowerCase()}`;
335
+ if (!isValidUTM(value, utmType)) {
336
+ return new Response(JSON.stringify({ error: `Campo ${field} contém caracteres perigosos` }), { status: 400, headers });
337
+ }
338
+ const sanitized = sanitizeString(value, 200);
339
+ if (sanitized === null) {
340
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
280
341
  }
342
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
281
343
  }
282
344
  }
283
345
 
346
+ // Sanitiza campos de string restantes
347
+ const TRACKING_ID_FIELDS = ['transactionId', 'fbclid', 'ttclid', 'gclid'];
348
+
349
+ for (const field of TRACKING_ID_FIELDS) {
350
+ const value = trackPayload[field as keyof TrackPayload];
351
+ if (value !== undefined && value !== null) {
352
+ if (typeof value !== 'string') {
353
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
354
+ }
355
+ const sanitized = sanitizeString(value, 512);
356
+ if (sanitized === null) {
357
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
358
+ }
359
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
360
+ }
361
+ }
362
+
363
+ // ── Validação de Valor Numérico ───────────────────────────────────
284
364
  if (trackPayload.value !== undefined && trackPayload.value !== null) {
285
- const v = Number(trackPayload.value);
286
- if (isNaN(v) || v < 0 || v > 9_999_999) {
287
- return new Response(JSON.stringify({ error: 'value fora do intervalo permitido' }), { status: 400, headers });
365
+ if (!isValidValue(trackPayload.value)) {
366
+ return new Response(JSON.stringify({ error: 'value fora do intervalo permitido (0-9,999,999)' }), { status: 400, headers });
288
367
  }
289
- trackPayload.value = v;
368
+ trackPayload.value = Number(trackPayload.value);
290
369
  }
291
370
 
292
371
  // ── Extrair dados comportamentais do browser ──────────────────────────
@@ -297,14 +376,46 @@ export default {
297
376
  // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
298
377
  payload.scrollScore = behavioral_data.scroll_score ?? null;
299
378
  payload.timeLevel = behavioral_data.time_level ?? null;
300
- payload.email = payload.email || behavioral_data.email || null;
301
- payload.phone = payload.phone || behavioral_data.phone || null;
302
- payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
303
- payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
304
- payload.city = payload.city || behavioral_data.city || null;
305
- payload.state = payload.state || behavioral_data.state || null;
306
- payload.zip = payload.zip || behavioral_data.zip || null;
307
- payload.dob = payload.dob || behavioral_data.dob || null;
379
+
380
+ // ── Sanitiza dados do behavioral_data ────────────────────────
381
+ // Os dados do behavioral_data podem vir do browser e ser manipulados
382
+ const sanitizedBehavioralEmail = behavioral_data.email && isValidEmail(behavioral_data.email)
383
+ ? behavioral_data.email.toLowerCase().trim()
384
+ : null;
385
+ const sanitizedBehavioralPhone = behavioral_data.phone
386
+ ? sanitizeString(behavioral_data.phone, 50)
387
+ : null;
388
+ const sanitizedBehavioralFirstName = behavioral_data.first_name || behavioral_data.firstName
389
+ ? sanitizeString(behavioral_data.first_name || behavioral_data.firstName, 100)
390
+ : null;
391
+ const sanitizedBehavioralLastName = behavioral_data.last_name || behavioral_data.lastName
392
+ ? sanitizeString(behavioral_data.last_name || behavioral_data.lastName, 100)
393
+ : null;
394
+ const sanitizedBehavioralCity = behavioral_data.city
395
+ ? sanitizeString(behavioral_data.city, 100)
396
+ : null;
397
+
398
+ // Usa dados sanitizados do behavioral_data se não existirem no payload principal
399
+ payload.email = payload.email || sanitizedBehavioralEmail;
400
+ payload.phone = payload.phone || sanitizedBehavioralPhone;
401
+ payload.firstName = payload.firstName || sanitizedBehavioralFirstName;
402
+ payload.lastName = payload.lastName || sanitizedBehavioralLastName;
403
+ payload.city = payload.city || sanitizedBehavioralCity;
404
+
405
+ // Sanitiza campos restantes do behavioral_data
406
+ const sanitizedBehavioralState = behavioral_data.state
407
+ ? sanitizeString(behavioral_data.state, 100)
408
+ : null;
409
+ const sanitizedBehavioralZip = behavioral_data.zip
410
+ ? sanitizeString(behavioral_data.zip, 20)
411
+ : null;
412
+ const sanitizedBehavioralDob = behavioral_data.dob
413
+ ? sanitizeString(behavioral_data.dob, 20)
414
+ : null;
415
+
416
+ payload.state = payload.state || sanitizedBehavioralState;
417
+ payload.zip = payload.zip || sanitizedBehavioralZip;
418
+ payload.dob = payload.dob || sanitizedBehavioralDob;
308
419
  }
309
420
 
310
421
  // ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
@@ -510,7 +621,13 @@ export default {
510
621
  try {
511
622
  const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(trackPayload.userId).first();
512
623
  if (profileRow) currentScore = Number(profileRow.score) || 0;
513
- } catch {}
624
+ } catch (err: any) {
625
+ console.error('[POST /track] Error fetching user profile score:', {
626
+ userId: trackPayload.userId,
627
+ error: err?.message || String(err),
628
+ stack: err?.stack,
629
+ });
630
+ }
514
631
  }
515
632
 
516
633
  const resHeaders = new Headers(headers);
@@ -549,15 +666,12 @@ export default {
549
666
  }
550
667
 
551
668
  const hmTxId = String(purchase.transaction || '');
552
- if (hmTxId && env.DB) {
553
- try {
554
- const dup = await env.DB.prepare(
555
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
556
- ).bind(hmTxId, 'processed').first();
557
- if (dup) {
558
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
559
- }
560
- } catch {}
669
+ const dupCheck = await processWebhookDuplicateCheck(env, 'hotmart', hmTxId, JSON.stringify(wh), {
670
+ email: buyer.email,
671
+ });
672
+
673
+ if (dupCheck.duplicate) {
674
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
561
675
  }
562
676
 
563
677
  const profile = await getProfileByEmail(env, buyer.email);
@@ -584,14 +698,6 @@ export default {
584
698
  country: profile?.country,
585
699
  };
586
700
 
587
- if (hmTxId && env.DB) {
588
- try {
589
- await env.DB.prepare(
590
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
591
- ).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
592
- } catch {}
593
- }
594
-
595
701
  ctx.waitUntil(Promise.allSettled([
596
702
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
597
703
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -622,15 +728,12 @@ export default {
622
728
  }
623
729
 
624
730
  const kwTxId = String(wh.order_id || '');
625
- if (kwTxId && env.DB) {
626
- try {
627
- const dup = await env.DB.prepare(
628
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
629
- ).bind(kwTxId, 'processed').first();
630
- if (dup) {
631
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
632
- }
633
- } catch {}
731
+ const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), {
732
+ email: customer.email,
733
+ });
734
+
735
+ if (dupCheck.duplicate) {
736
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
634
737
  }
635
738
 
636
739
  const customer = wh.Customer || {};
@@ -659,14 +762,6 @@ export default {
659
762
  country: profile?.country,
660
763
  };
661
764
 
662
- if (kwTxId && env.DB) {
663
- try {
664
- await env.DB.prepare(
665
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
666
- ).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
667
- } catch {}
668
- }
669
-
670
765
  ctx.waitUntil(Promise.allSettled([
671
766
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
672
767
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -716,15 +811,12 @@ export default {
716
811
  const transactionId = order.hash || order.transaction_hash || order.id;
717
812
  const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
718
813
 
719
- if (tcTxId && env.DB) {
720
- try {
721
- const dup = await env.DB.prepare(
722
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
723
- ).bind(tcTxId, 'processed').first();
724
- if (dup) {
725
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
726
- }
727
- } catch {}
814
+ const dupCheck = await processWebhookDuplicateCheck(env, 'ticto', tcTxId, rawBody, {
815
+ email: customer.email,
816
+ });
817
+
818
+ if (dupCheck.duplicate) {
819
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
728
820
  }
729
821
 
730
822
  const urlUserId = tracking.user_id || wh.url_params?.user_id;
@@ -734,7 +826,14 @@ export default {
734
826
  profile = await env.DB.prepare(
735
827
  'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
736
828
  ).bind(urlUserId).first();
737
- } catch {}
829
+ } catch (err: any) {
830
+ console.error('[POST /webhook/ticto] Error fetching user profile by userId:', {
831
+ userId: urlUserId,
832
+ email: customer.email,
833
+ error: err?.message || String(err),
834
+ stack: err?.stack,
835
+ });
836
+ }
738
837
  }
739
838
 
740
839
  const fbclid = tracking.fbclid || wh.url_params?.fbclid;
@@ -767,14 +866,6 @@ export default {
767
866
  utmContent: tracking.utm_content || '',
768
867
  };
769
868
 
770
- if (tcTxId && env.DB) {
771
- try {
772
- await env.DB.prepare(
773
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
774
- ).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
775
- } catch {}
776
- }
777
-
778
869
  ctx.waitUntil(Promise.allSettled([
779
870
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
780
871
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -332,7 +332,13 @@ export async function enrichGeoFromEdge(request: Request, env: Env, payload: Tra
332
332
  try {
333
333
  const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json') as GeoData | null;
334
334
  if (cached) geoData = cached;
335
- } catch {}
335
+ } catch (err: any) {
336
+ console.error('[DB] Error fetching geo data from cache:', {
337
+ ip,
338
+ error: err?.message || String(err),
339
+ stack: err?.stack,
340
+ });
341
+ }
336
342
  }
337
343
 
338
344
  if (!geoData) {
@@ -355,7 +361,14 @@ export async function enrichGeoFromEdge(request: Request, env: Env, payload: Tra
355
361
  if (env.GEO_CACHE && ip && geoData.country) {
356
362
  try {
357
363
  await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
358
- } catch {}
364
+ } catch (err: any) {
365
+ console.error('[DB] Error caching geo data:', {
366
+ ip,
367
+ country: geoData.country,
368
+ error: err?.message || String(err),
369
+ stack: err?.stack,
370
+ });
371
+ }
359
372
  }
360
373
  }
361
374
 
@@ -640,3 +653,50 @@ export async function logIntelligence(DB: D1Database, runType: string, platform:
640
653
  console.error('logIntelligence error:', err?.message || String(err));
641
654
  }
642
655
  }
656
+
657
+ // ── Webhook Processing Helper ─────────────────────────────────────────────────
658
+ /**
659
+ * Processa webhook de e-commerce (Hotmart, Kiwify, Ticto, etc.)
660
+ * - Verifica duplicatas
661
+ * - Registra evento em webhook_events
662
+ * - Retorna true se já foi processado, false se deve continuar
663
+ */
664
+ export async function processWebhookDuplicateCheck(
665
+ env: Env,
666
+ platform: string,
667
+ transactionId: string,
668
+ rawPayload: string,
669
+ context?: { email?: string; orderId?: string }
670
+ ): Promise<{ duplicate: boolean; error?: string }> {
671
+ if (!env.DB) {
672
+ return { duplicate: false };
673
+ }
674
+
675
+ try {
676
+ // 1. Verifica duplicatas
677
+ const dup = await env.DB.prepare(
678
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND platform = ? AND status = ?'
679
+ ).bind(transactionId, platform, 'processed').first();
680
+
681
+ if (dup) {
682
+ console.log(`[Webhook] Duplicate event skipped: ${platform}/${transactionId}`);
683
+ return { duplicate: true };
684
+ }
685
+
686
+ // 2. Registra o evento
687
+ await env.DB.prepare(
688
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
689
+ ).bind(platform, transactionId, context?.email || null, 'processed', rawPayload).run();
690
+
691
+ return { duplicate: false };
692
+ } catch (err: any) {
693
+ console.error(`[Webhook] Error processing ${platform}/${transactionId}:`, {
694
+ platform,
695
+ transactionId,
696
+ email: context?.email,
697
+ error: err?.message || String(err),
698
+ stack: err?.stack,
699
+ });
700
+ return { duplicate: false, error: err?.message || String(err) };
701
+ }
702
+ }
@@ -110,7 +110,15 @@ export async function logFraudSignal(env: Env, request: Request, payload: TrackP
110
110
 
111
111
  let emailHash = null;
112
112
  if (payload.email) {
113
- try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
113
+ try {
114
+ emailHash = await sha256(payload.email.trim().toLowerCase());
115
+ } catch (err: any) {
116
+ console.error('[Fraud] Error generating email hash:', {
117
+ email: payload.email,
118
+ error: err?.message || String(err),
119
+ stack: err?.stack,
120
+ });
121
+ }
114
122
  }
115
123
 
116
124
  await env.DB.prepare(`
@@ -177,7 +177,13 @@ export async function loadActiveWeights(env: Env): Promise<LogisticModel | null>
177
177
  try {
178
178
  const cached = await env.GEO_CACHE.get(LTV_WEIGHTS_KV_KEY, 'json') as LogisticModel | null;
179
179
  if (cached?.weights?.length) return cached;
180
- } catch {}
180
+ } catch (err: any) {
181
+ console.error('[Logistic] Error fetching LTV weights from KV cache:', {
182
+ key: LTV_WEIGHTS_KV_KEY,
183
+ error: err?.message || String(err),
184
+ stack: err?.stack,
185
+ });
186
+ }
181
187
  }
182
188
 
183
189
  // 2. Fallback: D1
@@ -378,13 +378,28 @@ export async function handleLtvAbTestResults(env: Env, request: Request, headers
378
378
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
379
379
 
380
380
  const url = new URL(request.url);
381
- const testId = url.searchParams.get('test_id') || null;
381
+ const testId = url.searchParams.get('test_id');
382
382
 
383
383
  try {
384
- const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
385
- const testBind = testId ? [parseInt(testId)] : [];
386
-
387
- const testRes = await env.DB.prepare(`SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1`).bind(...testBind).first();
384
+ let testRes: any;
385
+
386
+ if (testId) {
387
+ // Query específica para teste por ID
388
+ testRes = await env.DB.prepare(`
389
+ SELECT id, name, status, min_sample, winner_id, started_at
390
+ FROM ltv_ab_tests
391
+ WHERE test_id = ?
392
+ LIMIT 1
393
+ `).bind(parseInt(testId)).first();
394
+ } else {
395
+ // Query padrão: busca teste ativo em execução
396
+ testRes = await env.DB.prepare(`
397
+ SELECT id, name, status, min_sample, winner_id, started_at
398
+ FROM ltv_ab_tests
399
+ WHERE status = 'running'
400
+ LIMIT 1
401
+ `).first();
402
+ }
388
403
  if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
389
404
 
390
405
  const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind((testRes as any).id).all();
@@ -101,7 +101,14 @@ export async function autoEnrichPayload(env: Env, payload: TrackPayload): Promis
101
101
  if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc as string;
102
102
  if (profile.phone && !payload.phone) payload.phone = profile.phone as string;
103
103
  }
104
- } catch {}
104
+ } catch (err: any) {
105
+ console.error('[MatchQuality] Error enriching payload with profile data:', {
106
+ userId: payload.userId,
107
+ email: payload.email,
108
+ error: err?.message || String(err),
109
+ stack: err?.stack,
110
+ });
111
+ }
105
112
  }
106
113
 
107
114
  // 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored)
@@ -216,5 +223,10 @@ export async function purgeOldMatchQualityLogs(DB: D1Database): Promise<void> {
216
223
  await DB.prepare(
217
224
  `DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`
218
225
  ).run();
219
- } catch {}
226
+ } catch (err: any) {
227
+ console.error('[MatchQuality] Error purging old match quality logs:', {
228
+ error: err?.message || String(err),
229
+ stack: err?.stack,
230
+ });
231
+ }
220
232
  }
@@ -184,3 +184,126 @@ export function metaSignalBucket(score: number | null | undefined): MetaSignalBu
184
184
  if (score >= 0.6) return 'warm';
185
185
  return 'cold';
186
186
  }
187
+
188
+ // ── Input Validation & Sanitization — Segurança contra XSS/Injection ────────
189
+
190
+ /**
191
+ * Valida formato de email (basic RFC-compliant)
192
+ */
193
+ export function isValidEmail(email: string | null | undefined): boolean {
194
+ if (!email || typeof email !== 'string') return false;
195
+ const trimmed = email.trim();
196
+ if (trimmed.length > 256) return false; // Limite razoável
197
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
198
+ return emailRegex.test(trimmed);
199
+ }
200
+
201
+ /**
202
+ * Sanitiza string contra XSS/HTML injection
203
+ * Remove tags HTML, scripts, e caracteres perigosos
204
+ */
205
+ export function sanitizeString(input: string | null | undefined, maxLength: number = 512): string | null {
206
+ if (!input || typeof input !== 'string') return null;
207
+ let sanitized = String(input).trim();
208
+
209
+ // Remove HTML tags
210
+ sanitized = sanitized.replace(/<[^>]*>/g, '');
211
+
212
+ // Remove JavaScript event handlers
213
+ sanitized = sanitized.replace(/on\w+\s*=/gi, '');
214
+
215
+ // Remove javascript: protocol
216
+ sanitized = sanitized.replace(/javascript:/gi, '');
217
+
218
+ // Remove caracteres perigosos
219
+ sanitized = sanitized.replace(/[<>\"'`]/g, '');
220
+
221
+ // Remove caracteres Unicode perigosos
222
+ sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '');
223
+
224
+ // Limita comprimento
225
+ if (sanitized.length > maxLength) {
226
+ sanitized = sanitized.substring(0, maxLength);
227
+ }
228
+
229
+ return sanitized.length > 0 ? sanitized : null;
230
+ }
231
+
232
+ /**
233
+ * Valida e sanitiza URL (para pageUrl)
234
+ */
235
+ export function isValidUrl(url: string | null | undefined): boolean {
236
+ if (!url || typeof url !== 'string') return false;
237
+ const trimmed = url.trim();
238
+ if (trimmed.length > 2048) return false; // Limite razoável
239
+ try {
240
+ const parsed = new URL(trimmed);
241
+ return ['http:', 'https:'].includes(parsed.protocol);
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Valida formato de CPF (11 dígitos)
249
+ */
250
+ export function isValidCPF(cpf: string | null | undefined): boolean {
251
+ if (!cpf || typeof cpf !== 'string') return false;
252
+ const cleaned = cpf.replace(/\D/g, '');
253
+ return cleaned.length === 11 && /^\d+$/.test(cleaned);
254
+ }
255
+
256
+ /**
257
+ * Valida formato de CNPJ (14 dígitos)
258
+ */
259
+ export function isValidCNPJ(cnpj: string | null | undefined): boolean {
260
+ if (!cnpj || typeof cnpj !== 'string') return false;
261
+ const cleaned = cnpj.replace(/\D/g, '');
262
+ return cleaned.length === 14 && /^\d+$/.test(cleaned);
263
+ }
264
+
265
+ /**
266
+ * Valida formato de valor numérico (para value em Purchase)
267
+ */
268
+ export function isValidValue(value: number | null | undefined): boolean {
269
+ if (value === null || value === undefined) return true; // Valor opcional
270
+ const num = Number(value);
271
+ return !isNaN(num) && num >= 0 && num <= 9_999_999;
272
+ }
273
+
274
+ /**
275
+ * Valida moeda (currency field)
276
+ */
277
+ export function isValidCurrency(currency: string | null | undefined): boolean {
278
+ if (!currency || typeof currency !== 'string') return true; // Opcional
279
+ const trimmed = currency.trim().toUpperCase();
280
+ const validCurrencies = ['BRL', 'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'CHF'];
281
+ return trimmed.length === 3 && validCurrencies.includes(trimmed);
282
+ }
283
+
284
+ /**
285
+ * Sanitiza array de strings (para contentIds, etc.)
286
+ */
287
+ export function sanitizeStringArray(input: string[] | null | undefined, maxLength: number = 512): string[] | null {
288
+ if (!input || !Array.isArray(input)) return null;
289
+ const sanitized = input
290
+ .map(item => sanitizeString(item, maxLength))
291
+ .filter(item => item !== null) as string[];
292
+ return sanitized.length > 0 ? sanitized : null;
293
+ }
294
+
295
+ /**
296
+ * Valida UTM parameters (utmSource, utmMedium, utmCampaign, utmContent, utmTerm)
297
+ */
298
+ export function isValidUTM(param: string | null | undefined, paramType: string): boolean {
299
+ if (!param || typeof param !== 'string') return true; // Opcional
300
+ const trimmed = param.trim();
301
+ const maxLength = paramType === 'utm_source' ? 100 : 200;
302
+
303
+ if (trimmed.length > maxLength) return false;
304
+
305
+ // Verifica caracteres perigosos
306
+ const dangerousPatterns = ['<script', 'javascript:', 'onload=', 'onerror=', 'onclick='];
307
+ const lowerCase = trimmed.toLowerCase();
308
+ return !dangerousPatterns.some(pattern => lowerCase.includes(pattern));
309
+ }