@hed-hog/core 0.0.386 → 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 (61) hide show
  1. package/dist/auth/auth.controller.d.ts +2 -1
  2. package/dist/auth/auth.controller.d.ts.map +1 -1
  3. package/dist/auth/auth.controller.js.map +1 -1
  4. package/dist/auth/auth.service.d.ts +2 -1
  5. package/dist/auth/auth.service.d.ts.map +1 -1
  6. package/dist/auth/auth.service.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  12. package/dist/integration-profile/integration-profile.secrets.js +3 -1
  13. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  14. package/dist/integration-profile/integration-profile.service.d.ts +7 -0
  15. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  16. package/dist/integration-profile/integration-profile.service.js +73 -2
  17. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  18. package/dist/integration-profile/integration-profile.utils.d.ts +18 -0
  19. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  20. package/dist/integration-profile/integration-profile.utils.js +16 -0
  21. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  22. package/dist/profile/profile.controller.d.ts +3 -2
  23. package/dist/profile/profile.controller.d.ts.map +1 -1
  24. package/dist/profile/profile.controller.js.map +1 -1
  25. package/dist/profile/profile.service.d.ts +4 -3
  26. package/dist/profile/profile.service.d.ts.map +1 -1
  27. package/dist/profile/profile.service.js.map +1 -1
  28. package/dist/webhook-integration/webhook-integration.controller.d.ts +5 -8
  29. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
  30. package/dist/webhook-integration/webhook-integration.controller.js +31 -8
  31. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
  32. package/dist/webhook-integration/webhook-integration.module.d.ts.map +1 -1
  33. package/dist/webhook-integration/webhook-integration.module.js +5 -0
  34. package/dist/webhook-integration/webhook-integration.module.js.map +1 -1
  35. package/dist/webhook-integration/webhook-integration.service.d.ts +33 -1
  36. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  37. package/dist/webhook-integration/webhook-integration.service.js +109 -4
  38. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  39. package/dist/webhook-integration/webhook-protocol.registry.d.ts +63 -0
  40. package/dist/webhook-integration/webhook-protocol.registry.d.ts.map +1 -0
  41. package/dist/webhook-integration/webhook-protocol.registry.js +51 -0
  42. package/dist/webhook-integration/webhook-protocol.registry.js.map +1 -0
  43. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +16 -8
  44. package/hedhog/frontend/messages/en.json +22 -0
  45. package/hedhog/frontend/messages/pt.json +22 -0
  46. package/hedhog/table/webhook_integration.yaml +64 -43
  47. package/package.json +5 -5
  48. package/src/auth/auth.controller.ts +2 -1
  49. package/src/auth/auth.service.ts +2 -1
  50. package/src/index.ts +1 -0
  51. package/src/integration-profile/integration-profile.secrets.ts +3 -1
  52. package/src/integration-profile/integration-profile.service.ts +110 -3
  53. package/src/integration-profile/integration-profile.utils.spec.ts +75 -0
  54. package/src/integration-profile/integration-profile.utils.ts +39 -0
  55. package/src/profile/profile.controller.ts +6 -2
  56. package/src/profile/profile.service.ts +5 -3
  57. package/src/webhook-integration/webhook-integration.controller.ts +40 -2
  58. package/src/webhook-integration/webhook-integration.module.ts +5 -0
  59. package/src/webhook-integration/webhook-integration.service.ts +133 -0
  60. package/src/webhook-integration/webhook-protocol.registry.spec.ts +70 -0
  61. package/src/webhook-integration/webhook-protocol.registry.ts +93 -0
@@ -98,6 +98,45 @@ export function getReplyToAddress(config: unknown): string | undefined {
98
98
  return replyToName ? `${replyToName} <${replyToEmail}>` : replyToEmail;
99
99
  }
100
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
+
101
140
  // ─── Storage utilities ────────────────────────────────────────────────────────
102
141
 
103
142
  /**
@@ -2,6 +2,10 @@ import { NoRole, User } from '@hed-hog/api';
2
2
  import { Locale } from '@hed-hog/api-locale';
3
3
  import { Body, Controller, Delete, Get, Headers, Ip, Param, ParseIntPipe, Post, Put, UploadedFile, UseInterceptors } from '@nestjs/common';
4
4
  import { FileInterceptor } from '@nestjs/platform-express';
5
+ import type {
6
+ PublicKeyCredentialCreationOptionsJSON,
7
+ PublicKeyCredentialRequestOptionsJSON,
8
+ } from '@simplewebauthn/server';
5
9
  import { ChangeEmailDto } from './dto/change-email.dto';
6
10
  import { ChangePasswordDto } from './dto/change-password.dto';
7
11
  import { EmailVerificationDto } from './dto/email-verification-confirm.dto';
@@ -161,7 +165,7 @@ export class ProfileController {
161
165
  @Body() { name }: GenerateWebAuthnRegistrationOptionsDto,
162
166
  @Locale() locale: string,
163
167
  @Headers('origin') origin: string
164
- ) {
168
+ ): Promise<PublicKeyCredentialCreationOptionsJSON> {
165
169
  return this.profileService.generateWebAuthnRegistrationOptions(locale, id, name, origin);
166
170
  }
167
171
 
@@ -180,7 +184,7 @@ export class ProfileController {
180
184
  @User() { id },
181
185
  @Locale() locale: string,
182
186
  @Headers('origin') origin: string
183
- ) {
187
+ ): Promise<PublicKeyCredentialRequestOptionsJSON> {
184
188
  return this.profileService.generateWebAuthnAuthenticationOptions(locale, id, origin);
185
189
  }
186
190
 
@@ -5,6 +5,8 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException
5
5
  import { EventEmitter2 } from '@nestjs/event-emitter';
6
6
  import { JwtService } from '@nestjs/jwt';
7
7
  import type {
8
+ PublicKeyCredentialCreationOptionsJSON,
9
+ PublicKeyCredentialRequestOptionsJSON,
8
10
  VerifiedAuthenticationResponse,
9
11
  VerifiedRegistrationResponse,
10
12
  } from '@simplewebauthn/server';
@@ -1221,7 +1223,7 @@ export class ProfileService {
1221
1223
  }
1222
1224
  }
1223
1225
 
1224
- async generateWebAuthnRegistrationOptions(locale: string, userId: number, name: string, requestOrigin?: string) {
1226
+ async generateWebAuthnRegistrationOptions(locale: string, userId: number, name: string, requestOrigin?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
1225
1227
  try {
1226
1228
  const user = await this.prisma.user.findFirst({
1227
1229
  where: { id: userId },
@@ -1367,7 +1369,7 @@ export class ProfileService {
1367
1369
  }
1368
1370
  }
1369
1371
 
1370
- async generateWebAuthnAuthenticationOptions(locale: string, userId: number, requestOrigin?: string) {
1372
+ async generateWebAuthnAuthenticationOptions(locale: string, userId: number, requestOrigin?: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
1371
1373
  try {
1372
1374
  const user = await this.prisma.user.findFirst({
1373
1375
  where: { id: userId },
@@ -1500,7 +1502,7 @@ export class ProfileService {
1500
1502
  }
1501
1503
  }
1502
1504
 
1503
- async generateWebAuthnAuthenticationOptionsForLogin(locale: string, mfaToken: string) {
1505
+ async generateWebAuthnAuthenticationOptionsForLogin(locale: string, mfaToken: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
1504
1506
  try {
1505
1507
  const decoded = this.jwt.decode(mfaToken) as any;
1506
1508
  if (!decoded || !decoded.userId) {
@@ -15,6 +15,7 @@ import {
15
15
  Res,
16
16
  } from '@nestjs/common';
17
17
  import { Request, Response } from 'express';
18
+ import { WebhookProtocolResponse } from './webhook-protocol.registry';
18
19
  import { WebhookIntegrationService } from './webhook-integration.service';
19
20
  import {
20
21
  CreateWebhookActionDTO,
@@ -145,12 +146,24 @@ export class PublicWebhookController {
145
146
  constructor(private readonly service: WebhookIntegrationService) {}
146
147
 
147
148
  @Post(':uuid')
148
- execute(
149
+ async execute(
149
150
  @Param('uuid') uuid: string,
150
151
  @Body() body: any,
152
+ @Query() query: any,
151
153
  @Headers() headers: any,
152
154
  @Req() req: Request,
155
+ @Res({ passthrough: true }) res: Response,
153
156
  ) {
157
+ const handled = await this.service.executeProtocol(
158
+ uuid,
159
+ 'POST',
160
+ body,
161
+ query,
162
+ headers,
163
+ req,
164
+ );
165
+ if (handled) return sendProtocolResponse(res, handled);
166
+
154
167
  return this.service.executePublic(uuid, body, headers, req);
155
168
  }
156
169
 
@@ -158,15 +171,40 @@ export class PublicWebhookController {
158
171
  * GET variant so a webhook can be triggered by a plain clickable link (e.g.
159
172
  * approve/reject links sent by email/WhatsApp). Query params are used as the
160
173
  * payload for action templates (`{{body.decision}}` ← `?decision=approve`).
174
+ *
175
+ * Webhooks with a protocol take a different path: for them a GET is the
176
+ * provider's verification handshake, not a trigger — it must not run actions.
161
177
  */
162
178
  @Get(':uuid')
163
- executeGet(
179
+ async executeGet(
164
180
  @Param('uuid') uuid: string,
165
181
  @Query() query: any,
166
182
  @Headers() headers: any,
167
183
  @Req() req: Request,
184
+ @Res({ passthrough: true }) res: Response,
168
185
  ) {
186
+ const handled = await this.service.executeProtocol(
187
+ uuid,
188
+ 'GET',
189
+ query ?? {},
190
+ query,
191
+ headers,
192
+ req,
193
+ );
194
+ if (handled) return sendProtocolResponse(res, handled);
195
+
169
196
  return this.service.executePublic(uuid, query ?? {}, headers, req);
170
197
  }
171
198
  }
172
199
 
200
+ /**
201
+ * Aplica status/content-type que o adaptador pediu. Devolver a string crua importa:
202
+ * provedores como a Meta comparam o corpo da resposta literalmente, então um
203
+ * envelope JSON reprovaria a verificação.
204
+ */
205
+ function sendProtocolResponse(res: Response, result: WebhookProtocolResponse) {
206
+ res.status(result.status);
207
+ if (result.contentType) res.type(result.contentType);
208
+ return result.body;
209
+ }
210
+
@@ -16,6 +16,7 @@ import {
16
16
  WebhookIntegrationController,
17
17
  } from './webhook-integration.controller';
18
18
  import { WebhookIntegrationService } from './webhook-integration.service';
19
+ import { WebhookProtocolRegistry } from './webhook-protocol.registry';
19
20
 
20
21
  @Module({
21
22
  imports: [
@@ -38,12 +39,16 @@ import { WebhookIntegrationService } from './webhook-integration.service';
38
39
  IntegrationEventCatalogService,
39
40
  WebhookIntegrationService,
40
41
  EventWebhookService,
42
+ WebhookProtocolRegistry,
41
43
  ],
42
44
  exports: [
43
45
  IntegrationEventCatalogService,
44
46
  AppCommandModule,
45
47
  WebhookIntegrationService,
46
48
  EventWebhookService,
49
+ // Exportado para que outros módulos registrem protocolos de entrada
50
+ // (ex.: a library whatsapp registra o protocolo da Meta).
51
+ WebhookProtocolRegistry,
47
52
  ],
48
53
  })
49
54
  export class WebhookIntegrationModule {}
@@ -7,6 +7,7 @@ import {
7
7
  BadRequestException,
8
8
  HttpException,
9
9
  Injectable,
10
+ Logger,
10
11
  NotFoundException,
11
12
  } from '@nestjs/common';
12
13
  import { EventEmitter } from 'events';
@@ -25,6 +26,11 @@ import {
25
26
  UpdateWebhookActionDTO,
26
27
  } from './dto/webhook-action.dto';
27
28
  import { AppCommandRegistry } from '../app-command/app-command.registry';
29
+ import {
30
+ WebhookProtocolRegistry,
31
+ WebhookProtocolRequest,
32
+ WebhookProtocolResponse,
33
+ } from './webhook-protocol.registry';
28
34
  import {
29
35
  createPlainToken,
30
36
  createPublicUuid,
@@ -58,6 +64,7 @@ type WebhookActionPayload =
58
64
 
59
65
  @Injectable()
60
66
  export class WebhookIntegrationService {
67
+ private readonly logger = new Logger(WebhookIntegrationService.name);
61
68
  private readonly logEmitter = new EventEmitter();
62
69
  private readonly webhookActionSelect = {
63
70
  id: true,
@@ -105,6 +112,7 @@ export class WebhookIntegrationService {
105
112
  private readonly settingService: SettingService,
106
113
  private readonly commandRegistry: AppCommandRegistry,
107
114
  private readonly mcpApiKeyService: McpApiKeyService,
115
+ private readonly protocolRegistry: WebhookProtocolRegistry,
108
116
  ) {}
109
117
 
110
118
  private get integrations() {
@@ -551,6 +559,131 @@ export class WebhookIntegrationService {
551
559
  });
552
560
  }
553
561
 
562
+ /**
563
+ * Cria (ou devolve) o webhook de um perfil de integração que fala um protocolo
564
+ * próprio. Idempotente: a chave é (protocol, integration_profile_id), então
565
+ * chamar de novo devolve o mesmo UUID — e a URL já registrada no painel do
566
+ * provedor continua válida.
567
+ *
568
+ * Existe para que módulos não precisem montar a URL pública por conta própria:
569
+ * `/webhook/:uuid` e a base vinda dos settings ficam sob responsabilidade do core.
570
+ */
571
+ async provisionProtocolWebhook(input: {
572
+ slug: string;
573
+ name: string;
574
+ protocol: string;
575
+ integrationProfileId: number;
576
+ description?: string;
577
+ }) {
578
+ const existing = await this.integrations.findFirst({
579
+ where: {
580
+ protocol: input.protocol,
581
+ integration_profile_id: input.integrationProfileId,
582
+ },
583
+ });
584
+
585
+ if (existing) return this.withPublicUrl(existing);
586
+
587
+ const created = await this.integrations.create({
588
+ data: {
589
+ slug: await this.ensureProvisionSlug(input.slug),
590
+ name: input.name,
591
+ description: input.description ?? null,
592
+ protocol: input.protocol,
593
+ integration_profile_id: input.integrationProfileId,
594
+ public_uuid: createPublicUuid(),
595
+ status: 'active',
596
+ // Webhooks de protocolo não usam o token estático do core: o adaptador
597
+ // faz a própria autenticação (HMAC, no caso da Meta).
598
+ require_token: false,
599
+ },
600
+ });
601
+
602
+ this.logger.log(
603
+ `Provisioned "${input.protocol}" webhook ${created.id} for integration profile ${input.integrationProfileId}`,
604
+ );
605
+
606
+ return this.withPublicUrl(created);
607
+ }
608
+
609
+ /** Sufixa o slug até achar um livre (o slug é unique na tabela). */
610
+ private async ensureProvisionSlug(base: string): Promise<string> {
611
+ const slug = base.trim();
612
+ if (!(await this.integrations.findUnique({ where: { slug } }))) return slug;
613
+
614
+ for (let i = 2; ; i++) {
615
+ const candidate = `${slug}-${i}`;
616
+ if (!(await this.integrations.findUnique({ where: { slug: candidate } }))) {
617
+ return candidate;
618
+ }
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Entrega a chamada ao adaptador de protocolo do webhook, se houver um.
624
+ *
625
+ * Devolve `null` quando o webhook é clássico (protocol nulo) ou quando nenhum
626
+ * módulo registrou o protocolo — nesse caso o chamador segue pelo fluxo normal
627
+ * de token + actions.
628
+ *
629
+ * Webhooks com protocolo NÃO passam por validateToken/validateIp/validateBodySchema:
630
+ * o adaptador faz a própria autenticação (a Meta, por exemplo, assina com HMAC).
631
+ */
632
+ async executeProtocol(
633
+ uuid: string,
634
+ method: 'GET' | 'POST',
635
+ body: any,
636
+ query: any,
637
+ headers: any,
638
+ req: Request,
639
+ ): Promise<WebhookProtocolResponse | null> {
640
+ const integration = await this.integrations.findUnique({
641
+ where: { public_uuid: uuid },
642
+ select: {
643
+ id: true,
644
+ slug: true,
645
+ status: true,
646
+ protocol: true,
647
+ integration_profile_id: true,
648
+ },
649
+ });
650
+
651
+ if (!integration?.protocol) return null;
652
+
653
+ const adapter = this.protocolRegistry.get(integration.protocol);
654
+ if (!adapter) {
655
+ // Protocolo gravado mas nenhum módulo o registrou (library desabilitada,
656
+ // por exemplo). Não cai no fluxo de actions: seria autenticar com o token
657
+ // clássico um webhook que deveria exigir a auth do provedor.
658
+ this.logger.error(
659
+ `Webhook ${integration.id} declares protocol "${integration.protocol}", but no adapter is registered.`,
660
+ );
661
+ throw new NotFoundException('Webhook not found.');
662
+ }
663
+
664
+ if (integration.status !== 'active') {
665
+ throw new NotFoundException('Webhook not found.');
666
+ }
667
+
668
+ const request: WebhookProtocolRequest = {
669
+ integration: {
670
+ id: integration.id,
671
+ slug: integration.slug,
672
+ integration_profile_id: integration.integration_profile_id ?? null,
673
+ },
674
+ body,
675
+ query: (query ?? {}) as Record<string, string>,
676
+ headers: (headers ?? {}) as Record<string, string | string[] | undefined>,
677
+ rawBody: (req as Request & { rawBody?: Buffer }).rawBody,
678
+ remoteIp: this.getRemoteIp(req),
679
+ req,
680
+ };
681
+
682
+ return method === 'GET'
683
+ ? adapter.verify(request)
684
+ : adapter.handle(request);
685
+ }
686
+
554
687
  async executePublic(uuid: string, body: any, headers: any, req: Request) {
555
688
  const startedAt = Date.now();
556
689
  const remoteIp = this.getRemoteIp(req);
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { WebhookProtocolRegistry } from './webhook-protocol.registry';
3
+
4
+ const makeAdapter = () =>
5
+ ({
6
+ verify: jest.fn(),
7
+ handle: jest.fn(),
8
+ }) as any;
9
+
10
+ describe('WebhookProtocolRegistry', () => {
11
+ it('devolve o adaptador registrado', () => {
12
+ const registry = new WebhookProtocolRegistry();
13
+ const adapter = makeAdapter();
14
+
15
+ registry.register('meta-whatsapp', adapter);
16
+
17
+ expect(registry.get('meta-whatsapp')).toBe(adapter);
18
+ expect(registry.has('meta-whatsapp')).toBe(true);
19
+ });
20
+
21
+ it('normaliza a chave (case e espaços)', () => {
22
+ const registry = new WebhookProtocolRegistry();
23
+ const adapter = makeAdapter();
24
+
25
+ registry.register(' Meta-WhatsApp ', adapter);
26
+
27
+ expect(registry.get('meta-whatsapp')).toBe(adapter);
28
+ expect(registry.get('META-WHATSAPP')).toBe(adapter);
29
+ });
30
+
31
+ it('devolve null para protocolo desconhecido, nulo ou vazio', () => {
32
+ const registry = new WebhookProtocolRegistry();
33
+
34
+ expect(registry.get('nao-existe')).toBeNull();
35
+ expect(registry.get(null)).toBeNull();
36
+ expect(registry.get(undefined)).toBeNull();
37
+ expect(registry.get('')).toBeNull();
38
+ expect(registry.has(null)).toBe(false);
39
+ });
40
+
41
+ it('recusa registrar com chave vazia', () => {
42
+ const registry = new WebhookProtocolRegistry();
43
+
44
+ expect(() => registry.register(' ', makeAdapter())).toThrow();
45
+ });
46
+
47
+ it('avisa ao sobrescrever um protocolo já registrado', () => {
48
+ const registry = new WebhookProtocolRegistry();
49
+ const warn = jest
50
+ .spyOn((registry as any).logger, 'warn')
51
+ .mockImplementation(() => undefined);
52
+ const first = makeAdapter();
53
+ const second = makeAdapter();
54
+
55
+ registry.register('dup', first);
56
+ registry.register('dup', second);
57
+
58
+ // Sobrescrever silenciosamente esconderia dois módulos brigando pelo protocolo.
59
+ expect(warn).toHaveBeenCalled();
60
+ expect(registry.get('dup')).toBe(second);
61
+ });
62
+
63
+ it('lista os protocolos registrados em ordem', () => {
64
+ const registry = new WebhookProtocolRegistry();
65
+ registry.register('zeta', makeAdapter());
66
+ registry.register('alpha', makeAdapter());
67
+
68
+ expect(registry.list()).toEqual(['alpha', 'zeta']);
69
+ });
70
+ });
@@ -0,0 +1,93 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { Request } from 'express';
3
+
4
+ /**
5
+ * Resposta que um adaptador devolve ao controller público.
6
+ *
7
+ * `contentType` existe porque nem todo provedor aceita JSON: o handshake da Meta,
8
+ * por exemplo, compara o corpo com o challenge literalmente — um envelope JSON
9
+ * reprova a verificação.
10
+ */
11
+ export type WebhookProtocolResponse = {
12
+ status: number;
13
+ contentType?: string;
14
+ body: string | object;
15
+ };
16
+
17
+ export type WebhookProtocolRequest = {
18
+ /** O registro de webhook_integration que casou com o uuid da URL. */
19
+ integration: {
20
+ id: number;
21
+ slug: string;
22
+ integration_profile_id: number | null;
23
+ };
24
+ /** Body já parseado (POST) ou query params (GET). */
25
+ body: unknown;
26
+ query: Record<string, string>;
27
+ headers: Record<string, string | string[] | undefined>;
28
+ /**
29
+ * Bytes originais da requisição. Obrigatório para validar assinaturas HMAC:
30
+ * reserializar o JSON reordena chaves e invalida a assinatura.
31
+ */
32
+ rawBody?: Buffer;
33
+ remoteIp: string;
34
+ req: Request;
35
+ };
36
+
37
+ /**
38
+ * Adaptador de um protocolo de webhook de entrada.
39
+ *
40
+ * O framework de webhooks do core é um disparador de ações genérico, autenticado
41
+ * por token estático. Provedores como a Meta falam um protocolo próprio (handshake
42
+ * de verificação + assinatura HMAC) que não cabe nesse modelo.
43
+ *
44
+ * Em vez de espalhar `if (provider === 'meta')` pelo core, um módulo registra aqui
45
+ * um adaptador e passa a responder pela mesma URL pública (`/webhook/:uuid`). O core
46
+ * continua sem conhecer nenhum provedor específico.
47
+ */
48
+ export interface IWebhookProtocolAdapter {
49
+ /** GET na URL pública (tipicamente o handshake de verificação). */
50
+ verify(request: WebhookProtocolRequest): Promise<WebhookProtocolResponse>;
51
+ /** POST na URL pública (o evento em si). */
52
+ handle(request: WebhookProtocolRequest): Promise<WebhookProtocolResponse>;
53
+ }
54
+
55
+ /**
56
+ * Registro dos adaptadores de protocolo, no mesmo padrão do QueueHandlerRegistry
57
+ * e do FiscalProviderRegistry: o módulo dono do protocolo se registra no boot.
58
+ */
59
+ @Injectable()
60
+ export class WebhookProtocolRegistry {
61
+ private readonly logger = new Logger(WebhookProtocolRegistry.name);
62
+ private readonly adapters = new Map<string, IWebhookProtocolAdapter>();
63
+
64
+ register(protocol: string, adapter: IWebhookProtocolAdapter): void {
65
+ const key = normalizeProtocol(protocol);
66
+ if (!key) throw new Error('Webhook protocol key is required.');
67
+
68
+ if (this.adapters.has(key)) {
69
+ this.logger.warn(`Webhook protocol "${key}" was overwritten.`);
70
+ }
71
+
72
+ this.adapters.set(key, adapter);
73
+ this.logger.log(`Registered webhook protocol "${key}"`);
74
+ }
75
+
76
+ get(protocol: string | null | undefined): IWebhookProtocolAdapter | null {
77
+ const key = normalizeProtocol(protocol);
78
+ return key ? (this.adapters.get(key) ?? null) : null;
79
+ }
80
+
81
+ has(protocol: string | null | undefined): boolean {
82
+ return this.get(protocol) !== null;
83
+ }
84
+
85
+ /** Protocolos registrados (para a UI listar o que existe). */
86
+ list(): string[] {
87
+ return [...this.adapters.keys()].sort();
88
+ }
89
+ }
90
+
91
+ function normalizeProtocol(protocol: string | null | undefined): string {
92
+ return String(protocol ?? '').trim().toLowerCase();
93
+ }