@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.
- package/dist/auth/auth.controller.d.ts +2 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts +2 -1
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.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.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +3 -1
- 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 +73 -2
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +18 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +16 -0
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +3 -2
- 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 +4 -3
- 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 +109 -4
- 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/frontend/app/integration/profiles/page.tsx.ejs +16 -8
- package/hedhog/frontend/messages/en.json +22 -0
- package/hedhog/frontend/messages/pt.json +22 -0
- package/hedhog/table/webhook_integration.yaml +64 -43
- package/package.json +5 -5
- package/src/auth/auth.controller.ts +2 -1
- package/src/auth/auth.service.ts +2 -1
- package/src/index.ts +1 -0
- package/src/integration-profile/integration-profile.secrets.ts +3 -1
- package/src/integration-profile/integration-profile.service.ts +110 -3
- package/src/integration-profile/integration-profile.utils.spec.ts +75 -0
- package/src/integration-profile/integration-profile.utils.ts +39 -0
- 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 +133 -0
- package/src/webhook-integration/webhook-protocol.registry.spec.ts +70 -0
- 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
|
+
}
|