Files
DeskcommCRM/.env.example
T
Rafael MelgaçoandClaude Opus 5 6d62e7b7dd feat(retenção): a poda do histórico de captação — a dívida declarada na 0169, paga
O cabeçalho da migration 0169 já DESCREVIA esta poda quando ela não existia:
uma afirmação de estado falsa dentro do próprio artefato, que é o defeito que o
DoD 16 combate. Agora ela existe.

`lib/webhooks/retencao-da-captacao.ts`, chamada no MESMO tique do cron
`webhook-log-retention` — e não num cron novo, porque uma rota a mais seria
mais uma linha no `entrypoint.sh` do scheduler para alguém esquecer de agendar
(o defeito que já custou meses ao `risk-watcher` e ao `routing-worker`).

## Um horizonte só, e por que é diferente do arquivo forense

`webhook_events_log` é esvaziado em 7 dias e apagado em 90: lá o corpo é 97% do
peso e ninguém o lê depois de uma semana, então "esvaziar mantendo a linha" faz
sentido. Aqui a linha É o produto — é o que a aba "Leads recebidos" mostra
quando alguém pergunta de qual campanha vieram os clientes que fecharam. Ou o
registro serve inteiro, ou não serve. Default de 365 dias, ~1 kB por formulário:
300 leads/dia dão ~110 MB, e o ano fiscal cabe nos 500 MB do plano gratuito.

## Dois erros meus que o repo corrigiu, e um em que ele estava mais certo

  1. Escrevi um `Math.max(dias, 30)` PRÓPRIO antes de descobrir
     `lib/retencao/politica.ts` — o módulo canônico que a poda da fila e o
     expurgo da auditoria já usam. Duplicação sem fonte declarada: duas cópias
     do piso divergem no primeiro ajuste. Refatorado para o módulo, com as
     constantes novas (`RETENCAO_CAPTACAO_DIAS_PADRAO/PISO`) morando lá.

  2. Meu piso elevava o número em SILÊNCIO. `interpretarRetencao` devolve um
     AVISO, que a versão caseira jogava fora — sem ele, quem escreveu
     `LEAD_CAPTURE_RETENTION_DAYS=1` descobriria pela ausência de efeito. Falha
     fechada na ação e fechada também na informação, que é o pior dos dois
     mundos. A frase agora vai para o log.

  3. Escrevi o caso do `0` esperando que caísse no piso de 30; o módulo trata
     `<= 0` como LIXO e resolve para o PADRÃO de 365. Está certo — cair no piso
     encurtaria a retenção de um ano para um mês por causa de um zero digitado.
     O teste registra a correção em vez de escondê-la, e o caso do valor
     NEGATIVO entrou junto: sem a guarda, `-30` daria um limite 30 dias no
     FUTURO e o `lt(received_at, …)` apagaria o histórico INTEIRO, inclusive o
     de hoje — o pior desfecho possível desta função.

⚠️ DIFERENÇA DE ALCANCE DECLARADA: os pisos da fila e da auditoria moram DENTRO
de uma função do banco (`greatest(..., piso)`), o que os faz valer até para um
`psql` na mão. Este mora no TypeScript, porque a poda da captação é um DELETE do
admin client e não uma `security definer` — não há função onde enfiá-lo. Está
escrito em `lib/retencao/politica.ts` em vez de presumido.

## Gates

  typecheck            0 erros
  lint                 0 erros
  lint:channels        ok
  test:unit            477 de 478 arquivos, 5343 testes
  o teste novo         14 casos; sabotado (piso removido), 2 reprovam

O único arquivo falho é `lib/ai/dispatcher/rate-limit.test.ts`, com as MESMAS 5
falhas de 5 que dá na referência `e815b434` — estado idêntico.

E a atribuição anterior dele estava certa na conclusão e ERRADA no motivo: não é
"dependência de temporização". É o endereço do Redis no `.env.local` (que é
gitignored e não entra aqui). Isolado: `127.0.0.1:3998` — um
`serverless-redis-http` de outra sessão que morreu no meio do trabalho e deixou
um socket que ACEITA e não responde — dá 9 falhas de 15000ms; `localhost:8079`,
que RECUSA na hora, dá 9 passes com o mesmo código. Um socket que recusa faz o
`checkRateLimit` cair no contador em memória, que é o caminho que aqueles testes
declaram exercitar; um que aceita e cala faz esperar até o timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Rar8AjvB5rzqm8QKWeLMx
2026-08-24 20:23:28 -03:00

331 lines
18 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# DeskcommCRM — variáveis de ambiente
# =============================================================================
# Copie para .env.local e preencha. NUNCA commitar .env*.
# Variáveis NEXT_PUBLIC_* são expostas ao browser; o resto fica server-side.
# =============================================================================
# --- Supabase ----------------------------------------------------------------
# URL pública do projeto Supabase (ex: https://xxxx.supabase.co)
NEXT_PUBLIC_SUPABASE_URL=
# anon key (safe pra browser; RLS é o gatekeeper real)
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# service role key — BYPASSA RLS. Apenas server. Nunca commitar. Rotação trimestral.
SUPABASE_SERVICE_ROLE_KEY=
# --- Cron / interno ----------------------------------------------------------
# Bearer secret pros endpoints /api/v1/cron/*. DIFERENTE do service role key.
INTERNAL_SECRET=
# Secret dedicado opcional pros endpoints de cron. Se vazio, cai pra INTERNAL_SECRET.
INTERNAL_CRON_SECRET=
# --- Janela do retorno agendado ----------------------------------------------
# Quão cedo e quão tarde o agente pode marcar um retorno ao cliente. Lidas pelo
# WORKER (agent-engine) e pelo APP (a capacidade que o dono liga na tela) — as
# duas leituras têm de bater, senão o mesmo horário seria aceito num caminho e
# recusado no outro. Defaults em lib/followup/janela.ts; deixe vazio para usá-los.
# FOLLOWUP_MIN_AHEAD_MS=300000 # 5 min
# FOLLOWUP_MAX_AHEAD_MS=15552000000 # 180 dias
# CRON_STAGGER_WINDOW_MS=60000 # espalha disparos do mesmo minuto (0 desliga)
# --- Encryption keys (pgcrypto) ----------------------------------------------
# Chave separada pra criptografar CPF em contacts (PII sensível LGPD).
CPF_ENCRYPTION_KEY=
# Chave pra criptografar tokens OAuth Nuvemshop em tenant_integrations.
NUVEMSHOP_OAUTH_ENCRYPTION_KEY=
# Chave pra criptografar credenciais BYO-WAHA (cliente que roda WAHA próprio).
WAHA_BYO_ENCRYPTION_KEY=
# Chave AES-256 (32 bytes em base64) pra criptografar API keys de provedores LLM
# (ai_provider_credentials). Gerar: openssl rand -base64 32
AI_CRED_AES_KEY=
# --- WAHA --------------------------------------------------------------------
# URL base da instância WAHA (ex: http://localhost:3000 em dev; https://waha.deskcomm.com em prod)
WAHA_API_BASE_URL=http://localhost:3000
# Plaintext da API key do WAHA. O ENV do container WAHA recebe o hash SHA512 hex desta.
# Gerar: echo -n "minha-chave-aqui" | shasum -a 512
WAHA_API_KEY=
# Segredo com que o WAHA assina os webhooks (header X-Webhook-Hmac). O app usa
# para CONFERIR a assinatura; o container do WAHA recebe o mesmo valor.
WAHA_HMAC_SECRET=
# "true" exige assinatura válida em TODO webhook do WAHA. Desligado por padrão
# porque o WAHA Core não assina — exigir derrubaria a ingestão de mensagens.
# Ligue se roda WAHA Plus (ou um proxy que assine).
WAHA_WEBHOOK_REQUIRE_SIGNATURE=false
# URL pública do app que o WAHA chama em webhooks (precisa ser HTTPS público; use ngrok em dev).
WAHA_WEBHOOK_BASE_URL=
# Retomar as sessões já pareadas quando o contêiner do WAHA reinicia. É lido
# pelo WAHA, não pelo app (por isso não está em lib/env.ts). O default do WAHA é
# False: o volume guarda a credencial, mas a sessão não sobe, e o número fica
# "conectado" no disco e mudo na prática até alguém abrir o front e clicar
# Reconectar. Só mexa aqui se quiser o comportamento antigo de propósito.
WHATSAPP_RESTART_ALL_SESSIONS=True
# --- OpenRouter: atribuição OPCIONAL ----------------------------------------
# `HTTP-Referer` e `X-Title` que a OpenRouter usa para atribuir o consumo e
# montar o ranking público do site dela. São OPCIONAIS — a doc deles diz
# "optional headers to identify your app", e a chamada funciona sem. Vazios,
# nenhum header é enviado. Preencha com o SEU domínio e o SEU nome se quiser
# que o consumo desta instalação apareça atribuído a você.
OPENROUTER_APP_URL=
OPENROUTER_APP_TITLE=
# --- Upstash Redis (rate limit, idempotency) ---------------------------------
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# --- AI providers ------------------------------------------------------------
# Vercel AI Gateway (preferencial — fallback de provedor + observability + ZDR)
# AI_GATEWAY_API_KEY: chave do gateway. Quando ausente, ai-response-worker
# pula com skip="ai_gateway_key_missing" (não bloqueia boot do app).
AI_GATEWAY_API_KEY=
AI_GATEWAY_BASE_URL=
VERCEL_AI_GATEWAY_URL=
# Fallback direto Anthropic (caso AI Gateway indisponível ou desabilitado pra um tenant)
ANTHROPIC_API_KEY=
# OpenRouter (OPCIONAL) — alternativa ao gateway da Vercel, compatível com a API
# da OpenAI, com catálogo grande de modelos num único faturamento.
# Ordem de resolução do chat: AI_GATEWAY_API_KEY > OPENROUTER_API_KEY > provider
# direto (ANTHROPIC_API_KEY / OPENAI_API_KEY). Vazio = nada muda.
#
# ALCANCE — leia antes de trocar. Esta chave alcança a classificação de
# sentimento, o bot de resposta antigo E, desde que a organização esteja em
# OpenRouter, o AGENTE do CRM: quando não há credencial cadastrada na tela, o
# agente cai nesta chave como credencial de plataforma. É o caso normal de quem
# instalou pelo kit escolhendo OpenRouter e nunca abriu a tela de Provedores.
#
# O que ela NÃO faz é ESCOLHER o provedor. Quem escolhe é a instalação
# (`AI_PROVIDER`, gravado em settings.llm.provider) ou a tela Agente de IA →
# Provedores. Preencher a chave aqui com a organização em Anthropic não migra
# nada; migrar é trocar o provedor, e aí esta chave passa a ser a que paga.
#
# ATENÇÃO ao escolher o modelo do agente na tela: ele opera por FERRAMENTAS
# (contatos, leads, funil, catálogo, transferência para humano). Modelo sem
# suporte a ferramentas NÃO dá erro — responde um texto plausível ao cliente e
# nunca cria o lead nem move o card. Por isso o painel RECUSA salvar um modelo
# sem ferramentas nos pontos "Responder o cliente" e "Trabalhar o funil", usando
# o que o próprio fabricante declara no catálogo.
# O invariante que segura estas afirmações: tests/unit/openrouter-alcance.test.ts
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL= # vazio = https://openrouter.ai/api/v1
# Pra embeddings (text-embedding-3-small) usados no RAG por tenant
OPENAI_API_KEY=
# --- Teto de gasto de IA: a alavanca de emergência ----------------------------
# Quem escolhe o teto é cada organização, na tela Uso de IA › Orçamento (teto,
# aviso, e se a IA deve PARAR ao chegar nele). Esta chave não escolhe nada: ela
# só sabe AFROUXAR o que já foi escolhido.
# on (default) respeite o que cada organização escolheu
# avisar rebaixa qualquer "parar a IA" para apenas avisar
# off desliga a proteção nesta instalação inteira
# Também valem, como off: false, 0, no, nao, não, disabled. Valor não
# reconhecido cai em `on` — de propósito: a alavanca de emergência NUNCA pode
# derrubar o app por um typo. Mudou? reinicie o app e o worker.
AI_BUDGET_ENFORCEMENT=on
# --- Workers -----------------------------------------------------------------
# Opt-in pra rodar consumers de event_log. Default false em dev; true em prod cron.
EVENT_LOG_WORKER_ENABLED=false
# Stub do endpoint de teste do agente: 'true' devolve trace fabricado em vez de
# executar o agente. Deixe false — o runtime real já existe. Só ligue para
# exercitar o render da UI sem gastar token.
INTERNAL_AGENT_RUN_STUB=false
# --- Sentry ------------------------------------------------------------------
SENTRY_DSN=
# --- E-mail transacional (Resend) ---------------------------------------------
# Convite de time, entrega de export LGPD e alarme de SLA saem por aqui.
#
# RESEND_FROM_EMAIL precisa ser um endereço de um domínio VERIFICADO na SUA
# conta Resend. Vazio = e-mail desligado (o convite mostra o link de aceite na
# tela e o export de LGPD fica em `pending_review`), e isso é de propósito:
# herdar um domínio que não é seu faz todo envio falhar lá na Resend, com uma
# mensagem que não diz que o problema é configuração.
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# --- Impersonate / suporte ----------------------------------------------------
# Segredo HMAC do cookie de impersonate. Mínimo 32 chars quando a feature é usada.
IMPERSONATE_COOKIE_SECRET=
# Endereço de suporte mostrado ao CLIENTE FINAL (conta suspensa, cobrança).
# Quem instala para terceiros põe o próprio endereço aqui. Vazio = a tela não
# mostra endereço nenhum, e nunca cai num endereço do produto.
SUPPORT_EMAIL=
# --- LGPD ---------------------------------------------------------------------
# Chave usada para assinar exports de dados LGPD.
LGPD_SIGNING_KEY=
# E-mail do Encarregado (DPO). Impresso no rodapé do relatório entregue ao
# titular (`lib/lgpd/pdf-renderer.tsx`) quando a organização não tem
# `dpo_email` própria, e é o destinatário do alarme de SLA.
LGPD_DPO_EMAIL=
# Prazo, em horas, antes de um export LGPD expirar.
LGPD_EXPORT_EXPIRES_HOURS=72
# --- Nuvemshop OAuth ---------------------------------------------------------
# Obtenha em https://partners.tiendanube.com/ (criar app → copiar credenciais).
# Deixe vazio em dev/staging se ainda não tiver app aprovado — a integração
# degrada graciosamente e a UI mostra "Integração não configurada".
# Liga a integração Nuvemshop. Quando true, exige as credenciais abaixo.
NUVEMSHOP_ENABLED=false
# APP_ID aparece na URL do painel do app no portal de parceiros.
NUVEMSHOP_APP_ID=
NUVEMSHOP_CLIENT_ID=
NUVEMSHOP_CLIENT_SECRET=
# Para testar webhooks em dev exponha o app via ngrok/cloudflared (HTTPS) e
# aponte NEXT_PUBLIC_APP_URL para o túnel antes de clicar "Conectar".
# --- App URLs ----------------------------------------------------------------
# URL canônica do app (tenant). Ex: https://app.deskcomm.com
NEXT_PUBLIC_APP_URL=http://localhost:3000
# URL canônica do super-admin. Ex: https://admin.deskcomm.com
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000
# =============================================================================
# AGENT ENGINE (fusão Vendaval) — worker 24/7 do agente SDR
# =============================================================================
# Connection string do Postgres do Supabase (Settings → Database). O worker usa
# Postgres direto (fila FOR UPDATE SKIP LOCKED, locks, FTS). Recomendado: role
# dedicada com login (ex.: agent_worker) — nunca a service_role key aqui.
SUPABASE_DB_URL=
# A conexão que MEXE NO SCHEMA — e SÓ o kit (install.sh/update.sh/backup.sh) a
# usa; nenhum código do app lê esta chave. OPCIONAL: vazia, tudo roda pela de
# cima, que é o caso da nuvem (a string do pooler já vem privilegiada).
# Preencha quando o banco for um Supabase PRÓPRIO e a de cima for a role menor:
# `create extension`, o `baseline.sql` e a promoção do dono exigem o dono do
# banco. Ver docs/deploy-selfhost/README.md §2.
SUPABASE_DB_ADMIN_URL=
# --- Ritmo da fila do worker (custo de banco) --------------------------------
# Os dois governam quanto o worker conversa com o Postgres quando NÃO há trabalho
# — a conta que estourou a cota de egress do plano free do Supabase na issue #258.
# Ambos são opcionais: sem eles valem os defaults abaixo, que são o comportamento
# recomendado. Como ver o que está em vigor: a linha `agent-engine pronto` no log
# do worker imprime os dois. Quando mexer e o que medir:
# docs/runbooks/custo-e-cota-do-supabase.md
#
# TETO de espera do laço da fila. Com a fila vazia, é quanto o worker dorme entre
# uma consulta e a próxima; com job agendado, ele acorda no vencimento e este
# valor só limita a soneca. Não passe de 10000: acima disso a conexão ociosa
# expira entre as rodadas e cada consulta volta a pagar TCP+TLS — gasta MAIS.
#QUEUE_POLL_INTERVAL_MS=2000
# Ritmo do "havia trabalho e eu não peguei" (todas as vagas de
# QUEUE_MAX_CONCURRENCY ocupadas, ou outro turno rodando para o mesmo contato).
# Aqui há job vencido esperando vaga, então este valor é curto de propósito:
# aumentá-lo não economiza nada no ocioso e custa throughput no pico.
#QUEUE_CLAIM_RETRY_INTERVAL_MS=250
# ── Retenção do histórico (poda diária, cron `data-retention`) ───────────────
# Espaço em disco é uma cota diferente da de tráfego: o plano free do Supabase
# limita 500 MB de BANCO, e `job_queue` + `api_audit_log` são as duas que crescem
# sozinhas. Os dois valores abaixo JÁ SÃO os defaults do código: apagar as linhas
# de um `.env` existente não muda nada, e quem atualiza sem tocar no arquivo
# recebe exatamente este comportamento. Quanto cada tabela ocupa hoje e o que
# fazer com o número: docs/runbooks/custo-e-cota-do-supabase.md
#
# Idade a partir da qual um job TERMINAL (done/failed/dead) é apagado. Job
# `pending`/`running` NUNCA é tocado — tem trabalho dentro. Apagar um job leva
# junto o `send_ledger` e o `before_send_traces` daquele run. Piso de 7 dias,
# aplicado dentro da função do banco (valor menor é elevado, com aviso no log).
JOB_QUEUE_RETENTION_DAYS=90
# Idade a partir da qual uma linha de auditoria é expurgada. O default é a
# retenção de 5 anos da regra L-10. Piso de 90 dias: nem com a chave de serviço
# esta poda alcança rastro recente. Diminuir aqui é a alavanca de quem está
# apertado de espaço e aceita guardar menos histórico.
AUDIT_LOG_RETENTION_DAYS=1825
# Chave LLM de plataforma (fallback quando a org não tem credencial BYOK
# cadastrada em /app/ai/credentials). Opcional com BYOK.
ANTHROPIC_API_KEY=
# Dono ÚNICO dos eventos de despacho do agente. 'engine' (default) = o worker
# consome; 'native' = o dispatcher interno do app consome (deploy sem worker).
# NUNCA os dois — dois consumidores = turno duplicado ou perdido.
AGENT_DISPATCH_CONSUMER=engine
# Watchdog de sessão: reconcilia channel_sessions × WAHA e reenvia mensagens
# presas em queued. Usa WAHA_API_BASE_URL/WAHA_API_KEY acima (no compose o
# worker já recebe http://waha:3000). Knobs opcionais:
#WATCHDOG_INTERVAL_MS=60000
#WATCHDOG_REDRIVE_BATCH_SIZE=10
# Flywheel agendado (judge → proposta de melhoria; promoção SEMPRE manual na
# tela de Agentes IA). 0 = desligado; default 6h.
#FLYWHEEL_INTERVAL_MS=21600000
#FLYWHEEL_BATCH_LIMIT=10
# ── Marca da instalação (white-label) ───────────────────────────────────────
# Quem instala o CRM para clientes (agência, revendedor) troca a marca aqui, sem
# editar código — patch em código se perde no próximo update. Vazio = padrão.
# APP_LOGO_URL aceita qualquer URL pública de imagem; sem ela, o nome aparece
# como texto. Ver lib/branding.ts.
APP_NAME=
APP_LOGO_URL=
# Cor da marca, em hex (ex.: #506d48). Dela sai a rampa de 11 tons que pinta o
# produto inteiro — botão, anel de foco, chip, hover — nos dois temas. Vazio = a
# cor do produto. Cor sem contraste suficiente é DESLOCADA em vez de recusada, e
# marca acromática (cinza/preto/branco) não vira accent: em ambos os casos o
# motivo fica registrado. Ver lib/branding/contraste.ts.
APP_ACCENT_HEX=
# --- WhatsApp Cloud API (Meta) — Fase 3a/3b do seam de canais ---
# Opcionais: sem elas o canal oficial simplesmente não é configurado, e o WAHA
# segue funcionando. Nada aqui adiciona container ao compose — o servidor é a
# nuvem da Meta.
META_APP_ID=
# App Secret: chave do HMAC SHA-256 que valida os webhooks (X-Hub-Signature-256).
META_APP_SECRET=
META_WABA_ID=
META_PHONE_NUMBER_ID=
META_SYSTEM_USER_TOKEN=
# Token que VOCÊ escolhe; a Meta o devolve no handshake (hub.verify_token) para
# provar que o endpoint é seu. Sem ele o GET de verificação responde 403.
META_WEBHOOK_VERIFY_TOKEN=
# Versão da Graph API. Explícita de propósito: bump é decisão, não deriva.
META_GRAPH_VERSION=v22.0
# --- Canal via intermediário (BSP) — opcional ---
# Deixe em branco se você não usa este canal; nada quebra sem estas três.
#
# As DUAS de baixo andam juntas: o adapter só se considera configurado com as
# duas preenchidas. Preencher só uma não é meio-caminho — é o estado que fazia a
# mensagem ser marcada como enviada sem sair, e por isso a checagem agora exige
# o par. A credencial também pode viver na SESSÃO (cifrada), e aí estas ficam
# vazias.
ZERNIO_ACCOUNT_ID=
ZERNIO_API_KEY=
# Só para apontar para homologação. Vazio usa a produção do provedor.
ZERNIO_API_BASE_URL=
# --- Teto de login por IP (SÓ para CI de e2e) ---
# NÃO defina isto em produção nem numa VPS. O default (60 tentativas por IP a
# cada 5 min) é o valor correto para uso real e já é folgado por causa de NAT.
#
# Existe porque no CI todos os testes saem de UM IP (o runner): 28 specs de e2e
# estouram o teto e o login passa a responder "Muitas tentativas", reprovando
# specs que não têm nada a ver com autenticação.
#
# O teto por CONTA (5 falhas na mesma conta em 5 min) NÃO é configurável de
# propósito — é ele que barra brute force, inclusive distribuído por vários IPs.
# Valor inválido ou ausente cai no default: a falha é fechada.
# AUTH_RATE_LIMIT_LOGIN_IP=1000
# Retenção do arquivo do corpo cru dos webhooks (webhook_events_log).
# 7 dias mantém o banco dentro dos 500 MB do plano gratuito do Supabase; o
# arquivo era 86% do banco numa instalação real, crescendo ~23 MB/dia.
WEBHOOK_LOG_BODY_RETENTION_DAYS=7
# Quando a LINHA some (não só o corpo). Até aqui ela custa ~200 B e ainda
# responde quantos eventos chegaram, de que tipo e se a assinatura conferia.
WEBHOOK_LOG_ROW_RETENTION_DAYS=90
# Retenção do HISTÓRICO de leads captados (webhook_lead_captures) — a aba
# "Leads recebidos". Bem mais longa que a do arquivo acima porque aqui a linha
# É o produto: é ela que responde de qual campanha veio o cliente que fechou.
# ~1 kB por formulário preenchido, então 300/dia por um ano dão ~110 MB.
# O código que apaga aplica um piso de 30 dias — número menor não desarma a
# evidência de origem de um contato.
LEAD_CAPTURE_RETENTION_DAYS=365