Skip to content

feat(evohub): registra webhook inbound no hub durante o link-existing#2636

Merged
gomessguii merged 6 commits into
developfrom
feat/EVO-2098-evohub-link-existing-webhook
Jul 13, 2026
Merged

feat(evohub): registra webhook inbound no hub durante o link-existing#2636
gomessguii merged 6 commits into
developfrom
feat/EVO-2098-evohub-link-existing-webhook

Conversation

@pastoriniMatheus

Copy link
Copy Markdown

Empilhada sobre a #2635 (correção de build da EVO-2097) — mergear aquela primeiro; depois esta PR fica só com o commit 9095da7e.

Problema

No canal EvoHub há dois fluxos para plugar um canal do hub numa Instance (ambos disparados pelo manager via rotas /evohub/*):

  • POST /evohub/provision (canal novo) → registra o webhook inbound no hub via single-shot do POST /api/v1/channels
  • POST /evohub/link-existing (canal já existente no hub) → criava a Instance sem registrar webhook nenhum

Resultado: canal vinculado envia mensagens normalmente (data-plane ${HUB}/meta/*), mas nunca recebe — o hub não tem para onde entregar os eventos e nenhum erro aparece em lado algum. A Instance nasce "surda" silenciosamente.

Implementação

evohub.client.ts — novo ensureChannelWebhook(channelId, webhookUrl) idempotente, com resolução em 3 níveis para nunca duplicar webhooks no hub:

  1. Já existe webhook associado ao canal com a mesma URL (não-inactive) → no-op (GET /api/v1/channels/:id/webhooks);
  2. Existe webhook do usuário com a mesma URL → associa via POST /api/v1/webhooks/:id/associate (se for all_channels, nem associa — já cobre);
  3. Não existe → cria via POST /api/v1/webhooks single-shot com channels: [channelId] e events: [] (vazio = todos os eventos, contrato do hub em webhook_service.go:98). O secret vai junto quando EVOLUTION_HUB_WEBHOOK_SECRET está setado (recipe register-with-own-secret — o hub assina X-Hub-Signature-256 e o verifyHmac do inbound valida).

Suporte: interface HubWebhookInfo, listChannelWebhooks(), listWebhooks(), associateWebhook() e normalização de lista (webhooks | data | array nu).

evohub.controlplane.router.tslink-existing garante o webhook ANTES de criar a Instance:

const serverUrl = configService.get<HttpServer>('SERVER').URL;
if (serverUrl) {
  try {
    await evoHubClient.ensureChannelWebhook(hub_channel_id, `${serverUrl}/webhook/evohub`);
  } catch (e) {
    return res.status(502).json({
      error: 'failed to register inbound webhook on hub',
      detail: e?.response?.data ?? e?.message,
    });
  }
}

Decisões:

  • Webhook antes da Instance: se o hub recusar (quota do plano, API key inválida), o request falha com 502 e nada fica pela metade — retry seguro. Antes, a Instance nasceria surda sem indício.
  • SERVER_URL vazio → segue sem webhook: paridade com o provision (que também omite webhook_url nesse caso).

.env.example — documentação de preenchimento das variáveis EvoHub (as variáveis já existiam; faltava orientação): aviso de que o inbound chega em {SERVER_URL}/webhook/evohub e o SERVER_URL precisa ser alcançável pelo hub (host.docker.internal:8080 com hub em Docker), valores típicos de EVOLUTION_HUB_URL (SaaS × local :8086), formato evh_pk_... da API key (obrigatória para /evohub/*) e semântica do WEBHOOK_SECRET (soft mode quando vazio).

Validação

  • tsc --noEmit, eslint e npm run build verdes (hooks de pre-commit/pre-push do repo)
  • Checklist end-to-end na EVO-2098 (exige hub de pé + API key + SERVER_URL alcançável): link → webhook criado no hub; segundo link → webhook reaproveitado via associate; mensagem inbound chega na Instance; API key inválida → 502 sem Instance criada

Refs EVO-2098

O fluxo link-existing criava a Instance mas não registrava webhook no hub
(só o provision registrava, via single-shot do POST /channels). Resultado:
canal vinculado enviava mensagens mas nunca recebia — nenhum evento inbound
chegava em /webhook/evohub e não havia erro em lugar algum.

- evohub.client.ts: novo ensureChannelWebhook(channelId, url) idempotente —
  (1) webhook já associado ao canal com a mesma URL: no-op; (2) webhook do
  usuário com a mesma URL: associa via POST /webhooks/:id/associate (se
  all_channels, nem associa); (3) cria via POST /webhooks single-shot com
  channels [channelId] e events [] (vazio = todos). Secret enviado quando
  EVOLUTION_HUB_WEBHOOK_SECRET setado (register-with-own-secret). Inclui
  HubWebhookInfo, listChannelWebhooks, listWebhooks e associateWebhook.
- evohub.controlplane.router.ts: link-existing garante o webhook ANTES de
  criar a Instance — falha do hub responde 502 sem efeito colateral (retry
  seguro); SERVER_URL vazio mantém paridade com o provision (segue sem
  webhook).
- .env.example: documenta o preenchimento das variáveis EvoHub (formato
  evh_pk_ da API key, SERVER_URL alcançável pelo hub, host local 8086,
  soft mode do webhook secret).

Refs EVO-2098

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @pastoriniMatheus, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

Comment thread src/api/integrations/channel/evohub/evohub.client.ts Fixed
@pastoriniMatheus

Copy link
Copy Markdown
Author

Validação end-to-end executada em ambiente local (hub via docker-compose + esta branch): link-existing cria a Instance (201), registra o webhook no hub com secret, não duplica no segundo canal (reuso via associate — 1 webhook, 2 associações), mensagem Meta simulada atravessa hub → /webhook/evohub → HMAC validado → Instance correta por phone_number_id, e API key inválida falha limpa sem criar Instance. Detalhes e evidências no comentário da EVO-2098.

Achado colateral: bug pré-existente de invalidação de cache no hub segura a entrega inbound por até 5min após a associação — registrado como EVO-2099 (fix no repo evolution-hub; não bloqueia esta PR).

ensureChannelWebhook treated any webhook whose status was not exactly
'inactive' as good enough and no-oped. But the hub also auto-moves a webhook
to status 'disabled' after repeated delivery failures, and its dispatcher
delivers ONLY when status == 'active' (webhook_dispatcher.go:294). So a
disabled webhook matched the URL, was accepted, and the channel stayed deaf —
the exact "silently not receiving" symptom this feature exists to fix, in a
common scenario (evolution-api was down long enough for the hub to disable the
webhook; the operator re-links and gets 201 but still receives nothing).

Now the guard keys on status === 'active' and reactivates a matching but
non-active webhook via PUT /webhooks/:id/status (the hub's official path out of
'disabled'), for both the channel-associated and the user-level webhook.

Also validates channel/webhook ids as UUID before interpolating them into the
hub request path (assertHubId), closing the CodeQL js/request-forgery paths the
new listChannelWebhooks method added and hardening getChannel/connectToMeta.
Hub ids are UUIDs (uuid.Parse server-side), so this rejects nothing legitimate.

Verified: tsc/eslint/build green, plus a stubbed-http harness driving
ensureChannelWebhook through the disabled/active/missing/user-inactive/
all_channels branches and the id guard (10/10 assertions).
… webhook

ensureChannelWebhook matched an existing hub webhook by URL alone and reused it
as-is. The hub only signs the inbound delivery when the webhook has a secret
stored (webhook_dispatcher.go:983) and its WebhookResponse exposes neither the
secret nor a has_secret flag (webhook.go:116), so drift is undetectable from the
client side.

A deployment that ran EvoHub phase 1 in soft mode (empty secret — the documented
default) has a hub webhook with NO secret. The moment the operator follows the
guidance this branch adds to .env.example and sets EVOLUTION_HUB_WEBHOOK_SECRET,
the next link-existing reuses that secret-less webhook, the hub delivers
unsigned, verifyHmac answers 401 and the channel is deaf — the very symptom this
feature exists to kill. Rotating the secret does the same. Worse: the webhook is
shared across channels, so the repeated 401 makes the hub auto-disable it and
every EvoHub channel stops receiving.

Now every reuse path rewrites the configured secret (PUT /webhooks/:id/secret)
BEFORE reactivating the webhook — reactivating first would just take another 401
and get auto-disabled again. Empty secret stays a no-op: the inbound accepts
unsigned payloads in soft mode.

Verified: tsc/eslint/build green, plus a stubbed-http harness driving the real
client through reuse-with-secret, disabled-webhook ordering, soft mode,
user-level webhook and the id guard (12/12 assertions).

Refs EVO-2098
The UUID guard lived in assertHubId(), a separate method. It blocks path/URL
injection at runtime just fine, but CodeQL's taint analysis does not carry a
throwing guard across a function boundary, so js/request-forgery kept flagging
every method that interpolates a hub id into the request path — the alerts the
guard was added to close stayed open.

Same check, now inline at each sink (HUB_ID.test), which is what both the
scanner and the runtime accept. Behaviour is unchanged: hub ids are UUIDs
(uuid.Parse server-side), so nothing legitimate is rejected.

Refs EVO-2098
Repo convention is English code comments; the EvoHub comments introduced by this
branch were in Portuguese. No behaviour change.

Refs EVO-2098
@gomessguii gomessguii merged commit 545e0a1 into develop Jul 13, 2026
5 checks passed
@gomessguii gomessguii deleted the feat/EVO-2098-evohub-link-existing-webhook branch July 13, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants