Files
283a410b76 fix(env): .env do Redis malformado para de se disfarçar de serviço caído
Extraído do PR #465 de @prevprocesso-maker. O achado, o arquivo e a forma da
validação são dele; o que mudou aqui foi onde ele é ligado e o que o vigia.

─── O defeito ──────────────────────────────────────────────────────────────────

`UPSTASH_REDIS_REST_URL` e `_TOKEN` são `required()` puro em `lib/env.ts` — sem
`.url()`, sem forma. Qualquer texto passa pelo Zod. E a forma errada mais comum
do self-host não é digitação: é o valor saindo do `.env` com o que estava em
volta dele.

    UPSTASH_REDIS_REST_URL="https://srh:80"                → as aspas entram
    UPSTASH_REDIS_REST_TOKEN=UPSTASH_REDIS_REST_TOKEN=abc  → a linha inteira
    UPSTASH_REDIS_REST_TOKEN=abc\n                         → a quebra do heredoc

Nenhum era rejeitado. Todos chegavam ao `fetch` e falhavam — com o MESMO
`reason` de alcance que sai quando o contêiner do Redis está parado de verdade.
As duas leituras mandam quem opera para lugares opostos: reiniciar um serviço
saudável, ou abrir o editor. É o modo de falha já catalogado nesta casa como
".env sem aspas", e ele custou uma sessão de QA de instalação em VPS.

─── Os três consumidores ───────────────────────────────────────────────────────

  app/api/v1/health/route.ts   `reason: "configuracao_invalida"`, e sem ida à
                               rede — um endereço que não é endereço não tem o
                               que ser tentado
  lib/ai/dispatcher/rate-limit.ts   o contador de login para de construir um
                                    cliente condenado a falhar em toda chamada
  lib/ai/rag/debounce.ts            idem, e sem pagar os 2s do `Promise.race`
                                    por evento para chegar ao mesmo fallback

O texto do check de saúde continua carregando o endereço de propósito:
`semAlvo()` o redige para quem não tem o segredo interno, e quem tem precisa ver
QUAL valor está malformado. As três guardas de `route.test.ts` — a que já vinha
deste mesmo PR — seguem verdes, inclusive a que prova que o modo verboso NÃO
redige.

─── O que NÃO veio do #465 junto ───────────────────────────────────────────────

O autor também trouxe `isOptionalEndpointUnconfigured()`, que trata
`localhost`/`127.0.0.1` como "não configurado" e rebaixa o check de `down` para
`degraded`. Isso resolve o deploy dele na Vercel, onde o endereço é um
placeholder — e cobra da VPS: numa instalação de nó único, onde WAHA e Redis
moram em `127.0.0.1` de verdade, uma queda REAL passaria a reportar "não
configurado / degradado", e o `/health` deixaria de virar 503. Monitoramento que
mente sobre uma queda é pior que monitoramento nenhum. Fica de fora.

Sabotagem, previsão escrita antes de rodar e conferida caso a caso:
  tirar o ramo configuracao_invalida do checkRedis   -> 1 vermelho  (previsto 1)
  validador devolvendo sempre `ok`                   -> 14 vermelhos (previsto 14)
  tirar a checagem SÓ do debounce (o irmão)          -> 1 vermelho  (previsto 1)

A terceira existe porque os dois `getRedis()` são irmãos que não se parecem por
fora: consertar um e deixar o outro é o desfecho natural, e sem esse caso ele
ficaria verde.

Co-Authored-By: prevprocesso-maker <prevprocesso-maker@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:26:10 -03:00

88 lines
3.7 KiB
TypeScript

/**
* A CONFIGURAÇÃO DO REDIS É CONFERIDA ANTES DE VIRAR CLIENTE.
*
* ─── O erro que isto nomeia ─────────────────────────────────────────────────
*
* `UPSTASH_REDIS_REST_URL` e `UPSTASH_REDIS_REST_TOKEN` são `required()` puro em
* `lib/env.ts` — sem `.url()`, sem forma. Qualquer texto passa pelo Zod. E a
* forma errada mais comum não é digitação: é o `.env` do self-host levando junto
* o que estava em volta do valor —
*
* UPSTASH_REDIS_REST_URL="https://srh:80" → as aspas entram no valor
* UPSTASH_REDIS_REST_TOKEN=UPSTASH_REDIS_REST_TOKEN=abc → a linha inteira
* UPSTASH_REDIS_REST_TOKEN=abc\n → a quebra vinda do heredoc
*
* Nenhum desses três é rejeitado por ninguém hoje. Os dois primeiros viram
* header HTTP inválido; o terceiro, dependendo do runtime, levanta na hora de
* montar o `fetch`. O sintoma é sempre o MESMO — o Redis "não responde" —, e ele
* aponta para o contêiner, que está de pé.
*
* ─── Por que a checagem vale mais que o retry ───────────────────────────────
*
* Sem ela, cada chamada paga uma ida à rede que não tinha como dar certo, e o
* aviso que sobra no log fala do Redis, não do arquivo que precisa ser editado.
* A diferença entre "o Redis caiu" e "o seu `.env` está malformado" é a
* diferença entre reiniciar um contêiner saudável e abrir o editor — e a casa já
* pagou por essa confusão uma vez, na QA de instalação em VPS.
*
* ─── Escopo, dito em voz alta ───────────────────────────────────────────────
*
* Isto valida a FORMA do valor, nunca se ele funciona. URL bem formada com host
* inexistente e token bem formado mas revogado passam por aqui e falham na rede,
* que é onde essas duas coisas se descobrem. O que se ganha é separar "não dá
* nem para tentar" de "tentei e não deu".
*
* Achado de @prevprocesso-maker no PR #465.
*/
export type RedisConfigReason = "ok" | "nao_configurado" | "configuracao_invalida";
export type RedisConfigStatus = {
ok: boolean;
reason: RedisConfigReason;
};
/**
* O valor tem de ser o valor PURO da variável — sem o que envolvia ele no
* arquivo. Espaço nas pontas, quebra de linha, tabulação e aspas sobrando são
* todos sinais de que o recorte pegou mais do que o valor.
*/
function valorSemFormatacaoExtra(value: string): boolean {
return (
value === value.trim() &&
!/[\r\n\t]/.test(value) &&
!value.startsWith('"') &&
!value.endsWith('"') &&
!value.startsWith("'") &&
!value.endsWith("'")
);
}
export function validarConfigRedisRest(
url: string | undefined,
token: string | undefined,
): RedisConfigStatus {
if (!url || !token) return { ok: false, reason: "nao_configurado" };
if (!valorSemFormatacaoExtra(url) || !valorSemFormatacaoExtra(token)) {
return { ok: false, reason: "configuracao_invalida" };
}
// O `.env` copiado com o nome da variável dentro do valor. Sem esta linha ele
// passaria como token opaco — que é exatamente o que um token parece.
if (token.startsWith("UPSTASH_REDIS_REST_TOKEN=") || url.startsWith("UPSTASH_REDIS_REST_URL=")) {
return { ok: false, reason: "configuracao_invalida" };
}
try {
const parsed = new URL(url);
if (!parsed.hostname || !["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, reason: "configuracao_invalida" };
}
} catch {
return { ok: false, reason: "configuracao_invalida" };
}
return { ok: true, reason: "ok" };
}