@hed-hog/core 0.0.385 β†’ 0.0.387

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.
Files changed (146) hide show
  1. package/dist/app-command/app-command.registry.d.ts +2 -0
  2. package/dist/app-command/app-command.registry.d.ts.map +1 -1
  3. package/dist/app-command/app-command.registry.js.map +1 -1
  4. package/dist/auth/auth.controller.d.ts +24 -1
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +27 -0
  7. package/dist/auth/auth.controller.js.map +1 -1
  8. package/dist/auth/auth.service.d.ts +52 -1
  9. package/dist/auth/auth.service.d.ts.map +1 -1
  10. package/dist/auth/auth.service.js +108 -0
  11. package/dist/auth/auth.service.js.map +1 -1
  12. package/dist/auth/dto/complete-account.dto.d.ts +9 -0
  13. package/dist/auth/dto/complete-account.dto.d.ts.map +1 -0
  14. package/dist/auth/dto/complete-account.dto.js +43 -0
  15. package/dist/auth/dto/complete-account.dto.js.map +1 -0
  16. package/dist/authorize/authorize.controller.d.ts +45 -0
  17. package/dist/authorize/authorize.controller.d.ts.map +1 -0
  18. package/dist/authorize/authorize.controller.js +120 -0
  19. package/dist/authorize/authorize.controller.js.map +1 -0
  20. package/dist/authorize/authorize.module.d.ts +8 -0
  21. package/dist/authorize/authorize.module.d.ts.map +1 -0
  22. package/dist/authorize/authorize.module.js +36 -0
  23. package/dist/authorize/authorize.module.js.map +1 -0
  24. package/dist/authorize/authorize.service.d.ts +79 -0
  25. package/dist/authorize/authorize.service.d.ts.map +1 -0
  26. package/dist/authorize/authorize.service.js +196 -0
  27. package/dist/authorize/authorize.service.js.map +1 -0
  28. package/dist/authorize/dto/authorize-info.dto.d.ts +8 -0
  29. package/dist/authorize/dto/authorize-info.dto.d.ts.map +1 -0
  30. package/dist/authorize/dto/authorize-info.dto.js +42 -0
  31. package/dist/authorize/dto/authorize-info.dto.js.map +1 -0
  32. package/dist/authorize/dto/consent.dto.d.ts +8 -0
  33. package/dist/authorize/dto/consent.dto.d.ts.map +1 -0
  34. package/dist/authorize/dto/consent.dto.js +42 -0
  35. package/dist/authorize/dto/consent.dto.js.map +1 -0
  36. package/dist/authorize/dto/token.dto.d.ts +8 -0
  37. package/dist/authorize/dto/token.dto.d.ts.map +1 -0
  38. package/dist/authorize/dto/token.dto.js +42 -0
  39. package/dist/authorize/dto/token.dto.js.map +1 -0
  40. package/dist/challenge/challenge.service.d.ts +7 -0
  41. package/dist/challenge/challenge.service.d.ts.map +1 -1
  42. package/dist/challenge/challenge.service.js +22 -0
  43. package/dist/challenge/challenge.service.js.map +1 -1
  44. package/dist/core.module.d.ts.map +1 -1
  45. package/dist/core.module.js +2 -0
  46. package/dist/core.module.js.map +1 -1
  47. package/dist/file/file.service.d.ts +7 -0
  48. package/dist/file/file.service.d.ts.map +1 -1
  49. package/dist/file/file.service.js +16 -0
  50. package/dist/file/file.service.js.map +1 -1
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/integration-profile/integration-profile.secrets.d.ts +6 -2
  56. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  57. package/dist/integration-profile/integration-profile.secrets.js +14 -3
  58. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  59. package/dist/integration-profile/integration-profile.service.d.ts +7 -0
  60. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  61. package/dist/integration-profile/integration-profile.service.js +91 -4
  62. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  63. package/dist/integration-profile/integration-profile.utils.d.ts +28 -0
  64. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  65. package/dist/integration-profile/integration-profile.utils.js +40 -1
  66. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  67. package/dist/oauth/interfaces/OAuthProvider.d.ts +1 -1
  68. package/dist/oauth/interfaces/OAuthProvider.d.ts.map +1 -1
  69. package/dist/oauth/oauth.controller.d.ts +26 -0
  70. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  71. package/dist/oauth/oauth.controller.js +94 -0
  72. package/dist/oauth/oauth.controller.js.map +1 -1
  73. package/dist/oauth/oauth.service.d.ts +39 -1
  74. package/dist/oauth/oauth.service.d.ts.map +1 -1
  75. package/dist/oauth/oauth.service.js +115 -7
  76. package/dist/oauth/oauth.service.js.map +1 -1
  77. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +5 -1
  78. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  79. package/dist/oauth/providers/microsoft-entra-id.provider.js +37 -6
  80. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  81. package/dist/profile/profile.controller.d.ts +4 -3
  82. package/dist/profile/profile.controller.d.ts.map +1 -1
  83. package/dist/profile/profile.controller.js.map +1 -1
  84. package/dist/profile/profile.service.d.ts +5 -4
  85. package/dist/profile/profile.service.d.ts.map +1 -1
  86. package/dist/profile/profile.service.js.map +1 -1
  87. package/dist/webhook-integration/webhook-integration.controller.d.ts +5 -8
  88. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
  89. package/dist/webhook-integration/webhook-integration.controller.js +31 -8
  90. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
  91. package/dist/webhook-integration/webhook-integration.module.d.ts.map +1 -1
  92. package/dist/webhook-integration/webhook-integration.module.js +5 -0
  93. package/dist/webhook-integration/webhook-integration.module.js.map +1 -1
  94. package/dist/webhook-integration/webhook-integration.service.d.ts +33 -1
  95. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  96. package/dist/webhook-integration/webhook-integration.service.js +116 -5
  97. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  98. package/dist/webhook-integration/webhook-protocol.registry.d.ts +63 -0
  99. package/dist/webhook-integration/webhook-protocol.registry.d.ts.map +1 -0
  100. package/dist/webhook-integration/webhook-protocol.registry.js +51 -0
  101. package/dist/webhook-integration/webhook-protocol.registry.js.map +1 -0
  102. package/hedhog/data/integration_provider.yaml +8 -0
  103. package/hedhog/data/integration_type.yaml +6 -0
  104. package/hedhog/data/mail.yaml +39 -0
  105. package/hedhog/data/route.yaml +12 -0
  106. package/hedhog/data/setting_group.yaml +15 -1
  107. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +11 -7
  108. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +9 -7
  109. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +6 -2
  110. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +130 -34
  111. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +16 -8
  112. package/hedhog/frontend/messages/en.json +55 -1
  113. package/hedhog/frontend/messages/pt.json +55 -1
  114. package/hedhog/table/webhook_integration.yaml +64 -43
  115. package/package.json +10 -5
  116. package/src/app-command/app-command.registry.ts +2 -0
  117. package/src/auth/auth.controller.ts +23 -1
  118. package/src/auth/auth.service.ts +135 -1
  119. package/src/auth/dto/complete-account.dto.ts +24 -0
  120. package/src/authorize/authorize.controller.ts +99 -0
  121. package/src/authorize/authorize.module.ts +23 -0
  122. package/src/authorize/authorize.service.spec.ts +307 -0
  123. package/src/authorize/authorize.service.ts +245 -0
  124. package/src/authorize/dto/authorize-info.dto.ts +23 -0
  125. package/src/authorize/dto/consent.dto.ts +23 -0
  126. package/src/authorize/dto/token.dto.ts +23 -0
  127. package/src/challenge/challenge.service.ts +28 -0
  128. package/src/core.module.ts +2 -0
  129. package/src/file/file.service.ts +19 -0
  130. package/src/index.ts +1 -0
  131. package/src/integration-profile/integration-profile.secrets.spec.ts +114 -0
  132. package/src/integration-profile/integration-profile.secrets.ts +14 -3
  133. package/src/integration-profile/integration-profile.service.ts +129 -3
  134. package/src/integration-profile/integration-profile.utils.spec.ts +75 -0
  135. package/src/integration-profile/integration-profile.utils.ts +79 -0
  136. package/src/oauth/interfaces/OAuthProvider.ts +5 -1
  137. package/src/oauth/oauth.controller.ts +89 -0
  138. package/src/oauth/oauth.service.ts +190 -8
  139. package/src/oauth/providers/microsoft-entra-id.provider.ts +45 -5
  140. package/src/profile/profile.controller.ts +6 -2
  141. package/src/profile/profile.service.ts +5 -3
  142. package/src/webhook-integration/webhook-integration.controller.ts +40 -2
  143. package/src/webhook-integration/webhook-integration.module.ts +5 -0
  144. package/src/webhook-integration/webhook-integration.service.ts +151 -2
  145. package/src/webhook-integration/webhook-protocol.registry.spec.ts +70 -0
  146. package/src/webhook-integration/webhook-protocol.registry.ts +93 -0
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export * from './webhook-integration/event-webhook.service';
45
45
  export * from './webhook-integration/integration-event-catalog.service';
46
46
  export * from './webhook-integration/webhook-integration.module';
47
47
  export * from './webhook-integration/webhook-integration.service';
48
+ export * from './webhook-integration/webhook-protocol.registry';
48
49
 
49
50
  // Integration Module
50
51
  export * from './integration/integration.module';
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { INTEGRATION_SECRET_KEYS, getSensitiveConfigKeys } from './integration-profile.secrets';
5
+
6
+ /**
7
+ * TRAVA DE SEGURANÇA: PROVIDER_FIELDS (admin) e INTEGRATION_SECRET_KEYS (aqui)
8
+ * sΓ£o dois mapas hardcoded que precisam ficar em sincronia manual. Se um campo
9
+ * sensΓ­vel do form nΓ£o estiver no allowlist, ele Γ© gravado NO BANCO EM TEXTO
10
+ * CLARO β€” sem erro, sem aviso.
11
+ *
12
+ * Esse sync jΓ‘ derrapou antes (o docblock do .secrets.ts apontava para um
13
+ * arquivo onde PROVIDER_FIELDS nΓ£o existe mais). Este teste transforma o
14
+ * combinado em falha de CI.
15
+ *
16
+ * LΓͺ o fonte do admin em vez de importΓ‘-lo: o arquivo Γ© um componente React/TSX
17
+ * com imports de UI que nΓ£o carregam sob o jest de backend.
18
+ */
19
+ const SHEET_PATH = join(
20
+ __dirname,
21
+ '../../../../apps/admin/src/components/integration-profile/integration-profile-sheet.tsx',
22
+ );
23
+
24
+ /** Extrai { providerSlug: [chaves sensΓ­veis] } do PROVIDER_FIELDS do admin. */
25
+ function parseSensitiveFieldsFromAdmin(): Record<string, string[]> {
26
+ const source = readFileSync(SHEET_PATH, 'utf8');
27
+
28
+ const start = source.indexOf('const PROVIDER_FIELDS');
29
+ expect(start).toBeGreaterThan(-1);
30
+
31
+ // Vai atΓ© o fecho do objeto no nΓ­vel zero: `\n};`
32
+ const end = source.indexOf('\n};', start);
33
+ expect(end).toBeGreaterThan(start);
34
+
35
+ const block = source.slice(start, end);
36
+ const result: Record<string, string[]> = {};
37
+
38
+ // Cada provider comeΓ§a com ` slug: [` ou ` 'slug': [` na indentaΓ§Γ£o de 2.
39
+ const providerRegex = /\n {2}'?([a-z0-9_-]+)'?: \[/g;
40
+ const providers: Array<{ slug: string; from: number }> = [];
41
+ let match: RegExpExecArray | null;
42
+
43
+ while ((match = providerRegex.exec(block)) !== null) {
44
+ providers.push({ slug: match[1], from: match.index });
45
+ }
46
+
47
+ providers.forEach((provider, index) => {
48
+ const chunk = block.slice(
49
+ provider.from,
50
+ index + 1 < providers.length ? providers[index + 1].from : block.length,
51
+ );
52
+
53
+ const fields: string[] = [];
54
+ // Cada field Γ© um objeto com key e type; casa os dois em qualquer ordem.
55
+ const fieldRegex = /key:\s*'([^']+)'[\s\S]*?type:\s*'([^']+)'/g;
56
+ let fieldMatch: RegExpExecArray | null;
57
+
58
+ while ((fieldMatch = fieldRegex.exec(chunk)) !== null) {
59
+ const [, key, type] = fieldMatch;
60
+ if (type === 'password' || type === 'file') {
61
+ fields.push(key);
62
+ }
63
+ }
64
+
65
+ result[provider.slug] = fields;
66
+ });
67
+
68
+ return result;
69
+ }
70
+
71
+ describe('INTEGRATION_SECRET_KEYS is in sync with the admin form', () => {
72
+ const adminSensitive = parseSensitiveFieldsFromAdmin();
73
+
74
+ it('parses the admin PROVIDER_FIELDS map', () => {
75
+ // Se o parser quebrar, o resto passaria vacuamente. Fixa um piso conhecido.
76
+ expect(Object.keys(adminSensitive).length).toBeGreaterThan(5);
77
+ expect(adminSensitive.smtp).toContain('password');
78
+ });
79
+
80
+ it.each(Object.entries(adminSensitive).filter(([, fields]) => fields.length))(
81
+ 'provider "%s" has every password/file field in the allowlist',
82
+ (providerSlug, fields) => {
83
+ const allowlisted = getSensitiveConfigKeys(providerSlug);
84
+ const missing = (fields as string[]).filter((key) => !allowlisted.includes(key));
85
+
86
+ // Uma chave listada aqui seria gravada em texto claro no banco.
87
+ expect(missing).toEqual([]);
88
+ },
89
+ );
90
+
91
+ it('keeps the A1 certificate and its password encrypted', () => {
92
+ // O caso que mais dΓ³i: quem tem o PFX + a senha emite nota como a empresa.
93
+ expect(getSensitiveConfigKeys('nfse-national')).toEqual(
94
+ expect.arrayContaining(['certificate_pfx_base64', 'certificate_password']),
95
+ );
96
+ });
97
+
98
+ it('does not treat public keys as secrets', () => {
99
+ // site_key/public_key/access_key_id contΓͺm "key" mas sΓ£o pΓΊblicos. CifrΓ‘-los
100
+ // por heurΓ­stica de nome quebraria integraΓ§Γ΅es silenciosamente.
101
+ expect(getSensitiveConfigKeys('recaptcha')).not.toContain('site_key');
102
+ expect(getSensitiveConfigKeys('s3')).not.toContain('access_key_id');
103
+ });
104
+
105
+ it('returns an empty list for an unknown provider', () => {
106
+ expect(getSensitiveConfigKeys('does-not-exist')).toEqual([]);
107
+ });
108
+
109
+ it('has no duplicate keys per provider', () => {
110
+ for (const [slug, keys] of Object.entries(INTEGRATION_SECRET_KEYS)) {
111
+ expect(new Set(keys).size).toBe(keys.length);
112
+ }
113
+ });
114
+ });
@@ -4,8 +4,12 @@
4
4
  * SEGURANΓ‡A-CRÍTICO: este mapa Γ© a fonte ΓΊnica de verdade do que Γ© cifrado em repouso
5
5
  * (integration-credential-crypto.service) e mascarado na exportaΓ§Γ£o/UI. Qualquer chave
6
6
  * esquecida aqui NΓƒO serΓ‘ cifrada. Deve ser mantido em sincronia com os campos
7
- * `type: 'password'` do form do admin (PROVIDER_FIELDS em
8
- * apps/admin/src/app/(app)/(libraries)/core/integration/profiles/page.tsx).
7
+ * `type: 'password'` e `type: 'file'` do form do admin (PROVIDER_FIELDS em
8
+ * apps/admin/src/components/integration-profile/integration-profile-sheet.tsx).
9
+ *
10
+ * O sync Γ© manual e jΓ‘ derrapou uma vez (este comentΓ‘rio apontava para um arquivo
11
+ * onde PROVIDER_FIELDS nΓ£o existe mais). Por isso existe um teste que falha se um
12
+ * campo sensΓ­vel do form nΓ£o tiver entrada aqui: integration-profile.secrets.spec.ts.
9
13
  *
10
14
  * NΓ£o usar heurΓ­stica por nome de chave: campos pΓΊblicos como `site_key`, `public_key` e
11
15
  * `access_key_id` contΓͺm "key" mas NΓƒO sΓ£o segredos.
@@ -15,9 +19,12 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
15
19
  smtp: ['password'],
16
20
  gmail: ['client_secret', 'refresh_token'],
17
21
  ses: ['secret_access_key'],
22
+ resend: ['api_token', 'webhook_secret'],
18
23
  // Mensageria
19
24
  'evolution-api': ['token'],
20
- 'whatsapp-official': ['access_token'],
25
+ // app_secret assina o HMAC do webhook (X-Hub-Signature-256) e verify_token Γ©
26
+ // comparado no handshake da Meta: quem tem qualquer um dos dois forja eventos.
27
+ 'whatsapp-official': ['access_token', 'app_secret', 'verify_token'],
21
28
  // Storage
22
29
  s3: ['secret_access_key'],
23
30
  gcs: ['key_file_json'],
@@ -47,6 +54,10 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
47
54
  // Infraestrutura (autoscaling)
48
55
  digitalocean: ['api_token'],
49
56
  kubernetes: ['token', 'ca_cert'],
57
+ // Fiscal
58
+ // O certificado A1 Γ© a chave privada da empresa: quem tem o PFX + a senha
59
+ // emite nota em nome dela. Os dois DEVEM estar aqui.
60
+ 'nfse-national': ['certificate_pfx_base64', 'certificate_password'],
50
61
  };
51
62
 
52
63
  /** Chaves sensΓ­veis de um provider (vazio se desconhecido). */
@@ -9,7 +9,10 @@ import {
9
9
  forwardRef,
10
10
  } from '@nestjs/common';
11
11
  import * as pako from 'pako';
12
- import { buildMailConfigFromIntegration } from './integration-profile.utils';
12
+ import {
13
+ buildMailConfigFromIntegration,
14
+ buildWhatsappOfficialConfigFromIntegration,
15
+ } from './integration-profile.utils';
13
16
  import { CreateDTO } from './dto/create.dto';
14
17
  import {
15
18
  ConflictResolutionDTO,
@@ -451,9 +454,10 @@ export class IntegrationProfileService {
451
454
  );
452
455
  }
453
456
 
454
- if (provider.integration_type.slug !== 'email') {
457
+ const typeSlug = provider.integration_type.slug;
458
+ if (typeSlug !== 'email' && typeSlug !== 'whatsapp') {
455
459
  throw new BadRequestException(
456
- 'Integration profile test is only available for email type.',
460
+ 'Integration profile test is only available for email and whatsapp types.',
457
461
  );
458
462
  }
459
463
 
@@ -472,6 +476,33 @@ export class IntegrationProfileService {
472
476
  ) as Record<string, unknown>;
473
477
  }
474
478
 
479
+ if (typeSlug === 'whatsapp') {
480
+ return this.testWhatsappProfile(
481
+ provider.slug,
482
+ data.slug.trim(),
483
+ effectiveConfig,
484
+ );
485
+ }
486
+
487
+ // Resend nΓ£o usa SMTP/from_email: valida credenciais/domΓ­nio prΓ³prios.
488
+ if (provider.slug === 'resend') {
489
+ const resendCfg = (effectiveConfig as Record<string, unknown> | null) ?? {};
490
+ const apiToken = String(resendCfg.api_token ?? '').trim();
491
+ const domain = String(resendCfg.domain ?? '').trim();
492
+
493
+ if (!apiToken) {
494
+ throw new BadRequestException('Resend API token is required.');
495
+ }
496
+ if (!domain) {
497
+ throw new BadRequestException('Resend sending domain is required.');
498
+ }
499
+
500
+ return {
501
+ success: true,
502
+ destination: domain,
503
+ };
504
+ }
505
+
475
506
  buildMailConfigFromIntegration(provider.slug, effectiveConfig);
476
507
 
477
508
  const destination = String(
@@ -489,6 +520,101 @@ export class IntegrationProfileService {
489
520
  };
490
521
  }
491
522
 
523
+ /**
524
+ * Valida um perfil whatsapp-official contra a Graph API (lΓͺ o WABA com o token)
525
+ * e registra o resultado no config. Nunca devolve o token/secret na resposta.
526
+ */
527
+ private async testWhatsappProfile(
528
+ providerSlug: string,
529
+ slug: string,
530
+ effectiveConfig: Record<string, unknown> | null,
531
+ ) {
532
+ if (providerSlug !== 'whatsapp-official') {
533
+ throw new BadRequestException(
534
+ `Integration profile test is not available for provider "${providerSlug}".`,
535
+ );
536
+ }
537
+
538
+ const cfg = buildWhatsappOfficialConfigFromIntegration(effectiveConfig);
539
+
540
+ if (!cfg.accessToken) {
541
+ throw new BadRequestException('WhatsApp access token is required.');
542
+ }
543
+ if (!cfg.wabaId) {
544
+ throw new BadRequestException('WhatsApp Business Account ID is required.');
545
+ }
546
+ if (!cfg.appSecret) {
547
+ throw new BadRequestException(
548
+ 'Meta App Secret is required to validate webhook signatures.',
549
+ );
550
+ }
551
+ if (!cfg.verifyToken) {
552
+ throw new BadRequestException('Webhook verify token is required.');
553
+ }
554
+
555
+ const url = `https://graph.facebook.com/${cfg.graphApiVersion}/${encodeURIComponent(
556
+ cfg.wabaId,
557
+ )}?fields=id,name`;
558
+
559
+ let name = '';
560
+ let failure: string | null = null;
561
+ try {
562
+ const response = await fetch(url, {
563
+ headers: { Authorization: `Bearer ${cfg.accessToken}` },
564
+ });
565
+ const body = (await response.json().catch(() => ({}))) as {
566
+ name?: string;
567
+ error?: { message?: string };
568
+ };
569
+ if (!response.ok) {
570
+ // A mensagem da Meta nΓ£o contΓ©m o token; ainda assim sΓ³ propagamos o texto dela.
571
+ failure =
572
+ body?.error?.message ?? `Graph API returned HTTP ${response.status}.`;
573
+ } else {
574
+ name = String(body?.name ?? '');
575
+ }
576
+ } catch (error) {
577
+ failure = error instanceof Error ? error.message : 'Graph API is unreachable.';
578
+ }
579
+
580
+ await this.recordWhatsappValidation(providerSlug, slug, failure);
581
+
582
+ if (failure) {
583
+ throw new BadRequestException(`WhatsApp validation failed: ${failure}`);
584
+ }
585
+
586
+ return { success: true, destination: name || cfg.wabaId };
587
+ }
588
+
589
+ /** Grava metadados (nΓ£o-secretos) da ΓΊltima validaΓ§Γ£o no config do perfil. */
590
+ private async recordWhatsappValidation(
591
+ providerSlug: string,
592
+ slug: string,
593
+ failure: string | null,
594
+ ) {
595
+ const stored = await this.prismaService.integration_profile.findUnique({
596
+ where: { slug },
597
+ select: { id: true, config: true },
598
+ });
599
+ // Perfil ainda nΓ£o salvo (teste no fluxo de criaΓ§Γ£o): nada a registrar.
600
+ if (!stored) return;
601
+
602
+ // `stored.config` vem decifrado pelo middleware; recifra-mos ao gravar.
603
+ const plain = {
604
+ ...((stored.config as Record<string, unknown>) ?? {}),
605
+ last_validated_at: new Date().toISOString(),
606
+ last_validation_status: failure ? 'error' : 'ok',
607
+ last_error: failure ?? null,
608
+ };
609
+
610
+ await this.prismaService.integration_profile.update({
611
+ where: { id: stored.id },
612
+ data: {
613
+ config: this.credentialCrypto.encryptConfig(providerSlug, plain) as object,
614
+ },
615
+ });
616
+ }
617
+
492
618
  async exportProfiles({
493
619
  ids,
494
620
  includeSecrets = false,
@@ -12,6 +12,8 @@ import {
12
12
  buildDigitalOceanConfigFromIntegration,
13
13
  buildKubernetesConfigFromIntegration,
14
14
  buildAiConfigFromIntegration,
15
+ buildWhatsappOfficialConfigFromIntegration,
16
+ DEFAULT_GRAPH_API_VERSION,
15
17
  } from './integration-profile.utils';
16
18
 
17
19
  describe('integration-profile.utils', () => {
@@ -329,4 +331,77 @@ describe('integration-profile.utils', () => {
329
331
  );
330
332
  });
331
333
  });
334
+
335
+ describe('buildWhatsappOfficialConfigFromIntegration', () => {
336
+ const fullConfig = {
337
+ app_id: '123456',
338
+ app_secret: 'secret',
339
+ business_id: 'BIZ',
340
+ waba_id: 'WABA',
341
+ access_token: 'token',
342
+ graph_api_version: 'v22.0',
343
+ verify_token: 'verify',
344
+ environment: 'test',
345
+ };
346
+
347
+ it('mapeia todos os campos do perfil', () => {
348
+ expect(buildWhatsappOfficialConfigFromIntegration(fullConfig)).toEqual({
349
+ appId: '123456',
350
+ appSecret: 'secret',
351
+ businessId: 'BIZ',
352
+ wabaId: 'WABA',
353
+ accessToken: 'token',
354
+ graphApiVersion: 'v22.0',
355
+ verifyToken: 'verify',
356
+ environment: 'test',
357
+ });
358
+ });
359
+
360
+ it('assume a versΓ£o default da Graph API quando ausente ou vazia', () => {
361
+ expect(
362
+ buildWhatsappOfficialConfigFromIntegration({ ...fullConfig, graph_api_version: '' })
363
+ .graphApiVersion,
364
+ ).toBe(DEFAULT_GRAPH_API_VERSION);
365
+ expect(
366
+ buildWhatsappOfficialConfigFromIntegration({}).graphApiVersion,
367
+ ).toBe(DEFAULT_GRAPH_API_VERSION);
368
+ });
369
+
370
+ it('assume production quando o ambiente nΓ£o Γ© "test"', () => {
371
+ // Default seguro: um ambiente invΓ‘lido nΓ£o deve virar sandbox silenciosamente.
372
+ expect(
373
+ buildWhatsappOfficialConfigFromIntegration({ environment: 'qualquer' })
374
+ .environment,
375
+ ).toBe('production');
376
+ expect(
377
+ buildWhatsappOfficialConfigFromIntegration({}).environment,
378
+ ).toBe('production');
379
+ });
380
+
381
+ it('omite business_id quando nΓ£o informado (Γ© opcional)', () => {
382
+ const result = buildWhatsappOfficialConfigFromIntegration({});
383
+ expect(result.businessId).toBeUndefined();
384
+ });
385
+
386
+ it('nΓ£o quebra com config nulo e devolve campos vazios', () => {
387
+ expect(buildWhatsappOfficialConfigFromIntegration(null)).toMatchObject({
388
+ appId: '',
389
+ appSecret: '',
390
+ wabaId: '',
391
+ accessToken: '',
392
+ verifyToken: '',
393
+ });
394
+ });
395
+
396
+ it('remove espaΓ§os em volta dos segredos (copiar/colar do painel da Meta)', () => {
397
+ const result = buildWhatsappOfficialConfigFromIntegration({
398
+ access_token: ' token ',
399
+ app_secret: ' secret ',
400
+ verify_token: ' verify ',
401
+ });
402
+ expect(result.accessToken).toBe('token');
403
+ expect(result.appSecret).toBe('secret');
404
+ expect(result.verifyToken).toBe('verify');
405
+ });
406
+ });
332
407
  });
@@ -38,11 +38,51 @@ export function buildMailConfigFromIntegration(
38
38
  secretAccessKey: String(cfg.secret_access_key ?? ''),
39
39
  from: String(cfg.from_email ?? ''),
40
40
  };
41
+ case 'resend':
42
+ // Resend usa a API HTTP prΓ³pria (nΓ£o nodemailer). Retornamos um marcador
43
+ // tipado para os consumidores ramificarem; o inbox usa
44
+ // `buildResendConfigFromIntegration` diretamente.
45
+ return {
46
+ global: true,
47
+ type: 'RESEND' as const,
48
+ apiToken: String(cfg.api_token ?? ''),
49
+ domain: String(cfg.domain ?? ''),
50
+ region: String(cfg.region ?? ''),
51
+ from: String(cfg.from_email ?? ''),
52
+ };
41
53
  default:
42
54
  throw new BadRequestException(`Unsupported mail provider: ${providerSlug}`);
43
55
  }
44
56
  }
45
57
 
58
+ export type ResendIntegrationConfig = {
59
+ apiToken: string;
60
+ domain: string;
61
+ region?: string;
62
+ webhookSecret?: string;
63
+ providerDomainId?: string;
64
+ fromName?: string;
65
+ };
66
+
67
+ /** Maps an integration_profile config JSON to the Resend credentials/domain. */
68
+ export function buildResendConfigFromIntegration(
69
+ config: unknown,
70
+ ): ResendIntegrationConfig {
71
+ const cfg = (config as Cfg) ?? {};
72
+ return {
73
+ apiToken: String(cfg.api_token ?? '').trim(),
74
+ domain: String(cfg.domain ?? '').trim(),
75
+ ...(cfg.region ? { region: String(cfg.region).trim() } : {}),
76
+ ...(cfg.webhook_secret
77
+ ? { webhookSecret: String(cfg.webhook_secret).trim() }
78
+ : {}),
79
+ ...(cfg.provider_domain_id
80
+ ? { providerDomainId: String(cfg.provider_domain_id).trim() }
81
+ : {}),
82
+ ...(cfg.from_name ? { fromName: String(cfg.from_name).trim() } : {}),
83
+ };
84
+ }
85
+
46
86
  export function getFromAddress(config: unknown): string {
47
87
  const cfg = (config as Cfg) ?? {};
48
88
  const fromEmail = String(cfg.from_email ?? '');
@@ -58,6 +98,45 @@ export function getReplyToAddress(config: unknown): string | undefined {
58
98
  return replyToName ? `${replyToName} <${replyToEmail}>` : replyToEmail;
59
99
  }
60
100
 
101
+ // ─── Messaging utilities ──────────────────────────────────────────────────────
102
+
103
+ export const DEFAULT_GRAPH_API_VERSION = 'v21.0';
104
+
105
+ export type WhatsappOfficialConfig = {
106
+ appId: string;
107
+ appSecret: string;
108
+ businessId?: string;
109
+ wabaId: string;
110
+ accessToken: string;
111
+ graphApiVersion: string;
112
+ verifyToken: string;
113
+ environment: 'test' | 'production';
114
+ };
115
+
116
+ /**
117
+ * Maps an integration_profile config JSON to the Meta WhatsApp Cloud API credentials.
118
+ *
119
+ * Os nΓΊmeros nΓ£o vΓͺm daqui: um WABA pode ter vΓ‘rios e cada um tem estado prΓ³prio,
120
+ * entΓ£o eles vivem na tabela whatsapp_phone_number (library whatsapp).
121
+ */
122
+ export function buildWhatsappOfficialConfigFromIntegration(
123
+ config: unknown,
124
+ ): WhatsappOfficialConfig {
125
+ const cfg = (config as Cfg) ?? {};
126
+ const environment = String(cfg.environment ?? 'production').trim();
127
+ return {
128
+ appId: String(cfg.app_id ?? '').trim(),
129
+ appSecret: String(cfg.app_secret ?? '').trim(),
130
+ ...(cfg.business_id ? { businessId: String(cfg.business_id).trim() } : {}),
131
+ wabaId: String(cfg.waba_id ?? '').trim(),
132
+ accessToken: String(cfg.access_token ?? '').trim(),
133
+ graphApiVersion:
134
+ String(cfg.graph_api_version ?? '').trim() || DEFAULT_GRAPH_API_VERSION,
135
+ verifyToken: String(cfg.verify_token ?? '').trim(),
136
+ environment: environment === 'test' ? 'test' : 'production',
137
+ };
138
+ }
139
+
61
140
  // ─── Storage utilities ────────────────────────────────────────────────────────
62
141
 
63
142
  /**
@@ -1,5 +1,9 @@
1
1
  export interface OAuthProvider {
2
- getAuthUrl(callbackPath: string): Promise<string>;
2
+ getAuthUrl(
3
+ callbackPath: string,
4
+ flow?: 'login' | 'register' | 'connect',
5
+ app?: string,
6
+ ): Promise<string>;
3
7
  getProfile(code: string, type?: string, redirectUri?: string): Promise<any>;
4
8
  getProviderType(): string;
5
9
  }
@@ -97,6 +97,95 @@ export class OAuthController {
97
97
  return res.redirect(redirectURL);
98
98
  }
99
99
 
100
+ /**
101
+ * Server-side bounce for Microsoft Entra ID. Entra's authorization `code` is ~2KB
102
+ * (forced `offline_access`), which trips front-end ingress buffers when forwarded
103
+ * in an app URL. So Entra's web `redirect_uri` points here: we exchange the code
104
+ * server-side and redirect the browser to the initiating app with only a short
105
+ * single-use `ref` (never the giant code).
106
+ */
107
+ @Public()
108
+ @Get('microsoft-entra-id/callback')
109
+ async microsoftEntraIdCallback(
110
+ @Query('code') code: string,
111
+ @Query('state') state: string,
112
+ @Headers('user-agent') userAgent: string,
113
+ @Ip() ipAddress: string,
114
+ @Res() res,
115
+ ) {
116
+ this.logOAuthRequest('callback-web-ref', 'microsoft-entra-id', {
117
+ ipAddress,
118
+ userAgent,
119
+ code,
120
+ });
121
+
122
+ const forward = await this.service.resolveWebForward(state);
123
+ const flow = (forward?.flow ?? 'login') as 'login' | 'register' | 'connect';
124
+ const { url: frontendURL = '' } = await this.settingService.getSettingValues(['url']);
125
+ const base = forward?.origin || frontendURL;
126
+
127
+ const ref = await this.service.beginWebRefCallback({
128
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
129
+ code,
130
+ state,
131
+ flow,
132
+ appOrigin: forward?.origin ?? null,
133
+ });
134
+
135
+ return res.redirect(
136
+ `${base}/callback/microsoft-entra-id/${flow}?ref=${encodeURIComponent(ref)}`,
137
+ );
138
+ }
139
+
140
+ /** Redeems a login/register `ref` from the Entra server-side bounce (public). */
141
+ @Public()
142
+ @Get('microsoft-entra-id/redeem')
143
+ async microsoftEntraIdRedeem(
144
+ @Locale() locale: string,
145
+ @Ip() ipAddress: string,
146
+ @Headers('user-agent') userAgent: string,
147
+ @Headers('origin') origin: string,
148
+ @Headers('referer') referer: string,
149
+ @Query('ref') ref: string,
150
+ @Res({ passthrough: true }) res,
151
+ ) {
152
+ return this.service.redeemWebRef({
153
+ res,
154
+ ref,
155
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
156
+ locale,
157
+ ipAddress,
158
+ userAgent,
159
+ requestOrigin: origin,
160
+ requestReferer: referer,
161
+ });
162
+ }
163
+
164
+ /** Redeems a connect `ref` (authenticated: connect binds to the current user). */
165
+ @Get('microsoft-entra-id/redeem/connect')
166
+ async microsoftEntraIdRedeemConnect(
167
+ @Locale() locale: string,
168
+ @Ip() ipAddress: string,
169
+ @Headers('user-agent') userAgent: string,
170
+ @Headers('origin') origin: string,
171
+ @Headers('referer') referer: string,
172
+ @User() { id },
173
+ @Query('ref') ref: string,
174
+ @Res({ passthrough: true }) res,
175
+ ) {
176
+ return this.service.redeemWebRef({
177
+ res,
178
+ ref,
179
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
180
+ locale,
181
+ ipAddress,
182
+ userAgent,
183
+ userId: id,
184
+ requestOrigin: origin,
185
+ requestReferer: referer,
186
+ });
187
+ }
188
+
100
189
  @Public()
101
190
  @Get(':provider/login')
102
191
  async login(