@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.
- package/dist/app-command/app-command.registry.d.ts +2 -0
- package/dist/app-command/app-command.registry.d.ts.map +1 -1
- package/dist/app-command/app-command.registry.js.map +1 -1
- package/dist/auth/auth.controller.d.ts +24 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +27 -0
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts +52 -1
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.service.js +108 -0
- package/dist/auth/auth.service.js.map +1 -1
- package/dist/auth/dto/complete-account.dto.d.ts +9 -0
- package/dist/auth/dto/complete-account.dto.d.ts.map +1 -0
- package/dist/auth/dto/complete-account.dto.js +43 -0
- package/dist/auth/dto/complete-account.dto.js.map +1 -0
- package/dist/authorize/authorize.controller.d.ts +45 -0
- package/dist/authorize/authorize.controller.d.ts.map +1 -0
- package/dist/authorize/authorize.controller.js +120 -0
- package/dist/authorize/authorize.controller.js.map +1 -0
- package/dist/authorize/authorize.module.d.ts +8 -0
- package/dist/authorize/authorize.module.d.ts.map +1 -0
- package/dist/authorize/authorize.module.js +36 -0
- package/dist/authorize/authorize.module.js.map +1 -0
- package/dist/authorize/authorize.service.d.ts +79 -0
- package/dist/authorize/authorize.service.d.ts.map +1 -0
- package/dist/authorize/authorize.service.js +196 -0
- package/dist/authorize/authorize.service.js.map +1 -0
- package/dist/authorize/dto/authorize-info.dto.d.ts +8 -0
- package/dist/authorize/dto/authorize-info.dto.d.ts.map +1 -0
- package/dist/authorize/dto/authorize-info.dto.js +42 -0
- package/dist/authorize/dto/authorize-info.dto.js.map +1 -0
- package/dist/authorize/dto/consent.dto.d.ts +8 -0
- package/dist/authorize/dto/consent.dto.d.ts.map +1 -0
- package/dist/authorize/dto/consent.dto.js +42 -0
- package/dist/authorize/dto/consent.dto.js.map +1 -0
- package/dist/authorize/dto/token.dto.d.ts +8 -0
- package/dist/authorize/dto/token.dto.d.ts.map +1 -0
- package/dist/authorize/dto/token.dto.js +42 -0
- package/dist/authorize/dto/token.dto.js.map +1 -0
- package/dist/challenge/challenge.service.d.ts +7 -0
- package/dist/challenge/challenge.service.d.ts.map +1 -1
- package/dist/challenge/challenge.service.js +22 -0
- package/dist/challenge/challenge.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/file/file.service.d.ts +7 -0
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +16 -0
- package/dist/file/file.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts +6 -2
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +14 -3
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +7 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +91 -4
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +28 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +40 -1
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/oauth/interfaces/OAuthProvider.d.ts +1 -1
- package/dist/oauth/interfaces/OAuthProvider.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.d.ts +26 -0
- package/dist/oauth/oauth.controller.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.js +94 -0
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +39 -1
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +115 -7
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +5 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +37 -6
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +4 -3
- package/dist/profile/profile.controller.d.ts.map +1 -1
- package/dist/profile/profile.controller.js.map +1 -1
- package/dist/profile/profile.service.d.ts +5 -4
- package/dist/profile/profile.service.d.ts.map +1 -1
- package/dist/profile/profile.service.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.controller.d.ts +5 -8
- package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.controller.js +31 -8
- package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.module.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.module.js +5 -0
- package/dist/webhook-integration/webhook-integration.module.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.d.ts +33 -1
- package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.js +116 -5
- package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
- package/dist/webhook-integration/webhook-protocol.registry.d.ts +63 -0
- package/dist/webhook-integration/webhook-protocol.registry.d.ts.map +1 -0
- package/dist/webhook-integration/webhook-protocol.registry.js +51 -0
- package/dist/webhook-integration/webhook-protocol.registry.js.map +1 -0
- package/hedhog/data/integration_provider.yaml +8 -0
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/mail.yaml +39 -0
- package/hedhog/data/route.yaml +12 -0
- package/hedhog/data/setting_group.yaml +15 -1
- package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +11 -7
- package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +9 -7
- package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +6 -2
- package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +130 -34
- package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +16 -8
- package/hedhog/frontend/messages/en.json +55 -1
- package/hedhog/frontend/messages/pt.json +55 -1
- package/hedhog/table/webhook_integration.yaml +64 -43
- package/package.json +10 -5
- package/src/app-command/app-command.registry.ts +2 -0
- package/src/auth/auth.controller.ts +23 -1
- package/src/auth/auth.service.ts +135 -1
- package/src/auth/dto/complete-account.dto.ts +24 -0
- package/src/authorize/authorize.controller.ts +99 -0
- package/src/authorize/authorize.module.ts +23 -0
- package/src/authorize/authorize.service.spec.ts +307 -0
- package/src/authorize/authorize.service.ts +245 -0
- package/src/authorize/dto/authorize-info.dto.ts +23 -0
- package/src/authorize/dto/consent.dto.ts +23 -0
- package/src/authorize/dto/token.dto.ts +23 -0
- package/src/challenge/challenge.service.ts +28 -0
- package/src/core.module.ts +2 -0
- package/src/file/file.service.ts +19 -0
- package/src/index.ts +1 -0
- package/src/integration-profile/integration-profile.secrets.spec.ts +114 -0
- package/src/integration-profile/integration-profile.secrets.ts +14 -3
- package/src/integration-profile/integration-profile.service.ts +129 -3
- package/src/integration-profile/integration-profile.utils.spec.ts +75 -0
- package/src/integration-profile/integration-profile.utils.ts +79 -0
- package/src/oauth/interfaces/OAuthProvider.ts +5 -1
- package/src/oauth/oauth.controller.ts +89 -0
- package/src/oauth/oauth.service.ts +190 -8
- package/src/oauth/providers/microsoft-entra-id.provider.ts +45 -5
- package/src/profile/profile.controller.ts +6 -2
- package/src/profile/profile.service.ts +5 -3
- package/src/webhook-integration/webhook-integration.controller.ts +40 -2
- package/src/webhook-integration/webhook-integration.module.ts +5 -0
- package/src/webhook-integration/webhook-integration.service.ts +151 -2
- package/src/webhook-integration/webhook-protocol.registry.spec.ts +70 -0
- 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/
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|