Documentação do app v1.0

Operação

Aqui você leva o Load Balance do código local até o ar: o caminho real do deploy, as migrations que valem para todo ambiente, a chave que cifra as chaves de provedor e os portões que provam que a mudança não quebrou nada. Você também encontra o que verificar quando o container não sobe ou quando uma chave aparece em texto puro.

O repo não tem CI

Os arquivos .github, .gitlab-ci.yml, .circleci e Jenkinsfile não existem no disco. Push em main não deploya nada: o deploy sai pelo painel, e nenhum gate roda sozinho. Isso muda o que é sua responsabilidade em cada deploy — você roda os portões antes de publicar.

Pré-requisitos#

Antes de qualquer operação você precisa destes quatro itens:

  • Node 24 (>=24.15.0 <25). É o mesmo major nos dois apps, nos dois Dockerfiles e no engines.node.
  • PostgreSQL alcançável por DATABASE_URL. É a única variável de ambiente sem default: z.string().min(1, 'DATABASE_URL is required'). Sem ela o boot cai.
  • Redis alcançável por REDIS_URL. Ele é fail-open, mas é o que segura os locks dos jobs.
  • As sete variáveis da Conta Nommand — OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_POST_LOGOUT_REDIRECT_URI, CONTA_PROVISIONING_SECRET e APP_PUBLIC_URL. A falha é checada em bloco e o erro nomeia exatamente quais faltam.
  • Tudo isso passa por um schema Zod único, executado no import do módulo de env: o ZodError vira Environment validation failed: e derruba o processo. Em produção quatro travas são throw, não warn — AUTH_DISABLED=true, COOKIE_SECRET vazia, COOKIE_SECRET menor que COOKIE_SECRET_MIN_LENGTH = 32 e CORS_ORIGINS vazia. O motivo está escrito no código: “operadores esquecem env vars e o serviço sobe silenciosamente aberto”.

    .env.example é documento, não configuração

    O backend/.env.example serve para você copiar e ajustar. O .env real é ignorado pelo git e nunca deve ser commitado: é onde vivem KEY_ENCRYPTION_KEY, COOKIE_SECRET e os client secrets.

    Topologia#

    São quatro processos e duas superfícies. O painel administra; o proxy e a API de painel vivem no mesmo processo Fastify, o que faz o /health cobrir os dois.

    ServiçoPapelPorta
    Painel (SPA React)Superfície de administração: cadastrar chaves, estratégias, logs, limites3016
    Backend (Fastify 5)Proxy + API de painel + /ws/requests + jobs de plataforma3018
    PostgreSQLEstado: chaves, grupos, requests, payloads, audit log5432
    RedisCache de apikey:* e locks dos jobs; fail-open6379

    O compose.override.yml da raiz define só três deles — postgres, redis e backend — e é explicitamente para dev local. Ele não declara command: de propósito, e leva AUTH_DISABLED: 'true' e TRUST_PROXY_HOPS: '0'. Não copie esse arquivo para um host de produção.

    Deploy#

    Não existe CI. O fluxo real tem quatro passos manuais, nesta ordem.

    1. Passe GIT_SHA como build-arg

      O backend/Dockerfile tem ARG GIT_SHA=unknown e o expõe como ENV GIT_SHA. Nada reescreve esse valor: o painel preenche o build-arg à mão, por deploy.

      bash
      # no painel, ao construir a imagem do backend
      --build-arg GIT_SHA=<sha curto da main>
    2. Passe os três build-args do painel, no mesmo deploy

      O frontend/Dockerfile precisa de VITE_API_URL, VITE_PROXY_PUBLIC_URL e GIT_SHA. O Dockerfile explica por que cada um existe: sem VITE_PROXY_PUBLIC_URL o painel mostra e copia a URL do SPA em vez da do proxy, que é o host do backend — foi um defeito reportado pelo dono. Sem GIT_SHA o buildStampPlugin grava desconhecido no index.html. Os dois args têm de entrar juntos: um build que carimba desconhecido e mostra a URL do SPA é o estado em que a validação de tela acontece com menos informação.

      bash
      --build-arg VITE_API_URL=https://painel.exemplo \
      --build-arg VITE_PROXY_PUBLIC_URL=https://proxy.exemplo \
      --build-arg GIT_SHA=<sha curto da main>
    3. Publique pelo painel, lado alterado

      Push em main não constrói imagem nem reinicia container. O build usa a branch configurada e dispara o deploy daquela imagem; se você mexeu no painel, publique só o painel, e vice-versa.

    4. Prove que o código no ar é o que você quer

      Com o CI desligado, o assert-build-fresh é a única resposta verificável para “qual código está no ar?”. Ele compara o sha que o /health devolve contra a sua ref local, por revisões git — não por hash de bundle, mtime nem artefato de build.

      bash
      curl -s https://proxy.exemplo/api/health > /tmp/health.json
      cd backend && npm run assert:build-fresh -- --health-json /tmp/health.json
      bash
      [assert-build-fresh] sha no ar: b71f9c3
        ref main -> b71f9c3
      [assert-build-fresh] VERDE: o build no ar é o mesmo código da ref (b71f9c3).

    O gate tem seis vereditos, e só um devolve 0 sem ressalva.

    VereditoExitQuando acontece
    fresh0Contagem zero: o sha no ar é a ponta da ref
    unknown0 com aviso; 1 com --strictO /health respondeu commit="unknown" — build sem build-arg
    stale1A ref andou depois do build no ar
    divergent1O sha não é ancestral da ref: branch lateral, ou ref local atrás do ar
    not-a-commit1O sha não existe neste repositório
    bad-ref1A ref local não resolve — o gate está cego, não verde
    unknown é aceito com aviso, nunca com verde silencioso

    Um sha velho é “confiável e errado” — bem pior que unknown, porque parece a resposta de quem checou. Por isso unknown sai com aviso explícito, e --strict (o modo que você deveria usar em produção) o reprova.

    No servidor de produção, o frontend/server.js serve o build/ como estático e faz proxy de /api, /auth/sso e /auth/callback. Sem API_ORIGIN ele serve só estáticos e /api devolve 404, esperando um reverse proxy externo.

    Migrations#

    As migrations rodam no CMD do Dockerfile do backend, não no compose e não num job separado:

    bash
    CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

    Esse é o único ponto que vale para todo ambiente — compose local, Easypanel, Fly, Railway, k8s, docker run puro. As migrations viviam só no command: do compose, que é lido apenas no dev; as plataformas que buildam pelo Dockerfile ignoravam aquele command: e subiam código novo contra banco antigo. Foi a causa do incidente B-A6-017: o backend passou a fazer SELECT de uma coluna que ainda não existia e derrubou /api/stats e todo o proxy com P2022.

    O && é deliberado: se a migration falhar, o servidor não sobe. O shell encerra com código diferente de zero e o node dist/index.js nunca executa.

    Não existe segunda defesa

    Não há retry nem re-bootstrap de schema em runtime, e não há verificação de versão do schema no boot. Se o banco estiver fora de sync, o container morre e não serve tráfego — o CMD é a única proteção, e ela é suficiente justamente por ser a única.

    São 21 entradas em backend/prisma/migrations/: 20 pastas de migration e o migration_lock.toml, de 20260922144123_init a 20261012120000_group_hedge_config. Duas compartilham o prefixo 20260929000000, o que o Prisma aceita porque ordena alfabeticamente pelo nome completo.

    Fora do container, o script npm equivalente roda o mesmo comando — ele serve para inspecionar o estado do schema sem subir aplicação:

    bash
    cd backend && npm run prisma:migrate

    Segredos#

    A variável que decide se as chaves de provedor são cifradas ou gravadas em claro é KEY_ENCRYPTION_KEY: base64 de 32 bytes.

    bash
    openssl rand -base64 32

    Quando presente e não-vazia, ela precisa decodificar para exatamente 32 bytes — senão o boot falha em qualquer ambiente. O complemento de rotação é KEY_ENCRYPTION_KEY_PREVIOUS.

    A cifragem é AES-256-GCM, com o formato armazenado enc:v1:::: em base64url sem padding e IV de 12 bytes aleatório por cifragem. O kid é k1 (atual) ou k0 (anterior, durante a rotação). São três alvos: ApiKey.key, ApiKey.refreshToken e os tokens dentro de ApiKey.metadata.

    O AAD amarra o valor cifrado à linha e ao campo (loadbalance:apikey::), então trocar um valor cifrado de linha ou de campo faz a decifragem falhar — não passa em silêncio.

    Perder a chave é perder todas as chaves de provedor

    A advertência está escrita literalmente no schema: “ATENÇÃO: perder a chave = perder TODAS as chaves de provedor, sem recuperação.” Não existe caminho de recuperação no código. Guarde em cofre e use a MESMA chave em todas as instâncias antes de rodar o backfill — a guarda de boot falha citando o id da linha, nunca o valor.

    Sem a chave, a escrita de segredo cai em texto puro (legado) com console.warn. A leitura de um valor cifrado falha fechada: o processo nunca manda enc:v1:... ao provedor. No boot, assertSecretsReadable roda logo após connectDatabase() e varre toda linha de api_keys com segredo cifrado, cobrindo a API e os jobs — que são o mesmo processo.

    O backfill e a ordem segura

    Cifrar um banco que já tem chaves em claro é feito por backend/scripts/backfill-secrets.ts, idempotente — rodar duas vezes dá o mesmo resultado.

    ScriptComando
    secrets:encrypt-backfilltsx scripts/backfill-secrets.ts --direction=encrypt
    secrets:decrypt-backfilltsx scripts/backfill-secrets.ts --direction=decrypt

    A chave é obrigatória inclusive no --dry-run: sem ela o plano de ida não cifra nada e contaria 0. O script lê e grava com o PrismaClient puro, sem extensions, sobre os valores crus; trabalha em lotes por cursor de id com uma transação por lote — ou o lote inteiro entra, ou nada entra. As flags são --dry-run e --batch=N, com 200 como default.

    A ordem de produção não é negociável:

  • Backup do banco.
  • A mesma env — KEY_ENCRYPTION_KEY idêntica — em todas as instâncias.
  • Restart das instâncias.
  • --dry-run: leia o plano antes de escrever.
  • Backfill.
  • Verificação em SQL.
  • A ordem do decrypt é diferente

    secrets:decrypt-backfill precisa rodar antes de qualquer rollback de código para a versão sem SEC-KEYS. O código antigo mandaria enc:v1:... cru ao provedor, e isso não é detectável pelo /health.

    Trabalhando no código#

    Os dois apps têm portões diferentes, porque são dois apps independentes — não é monorepo com workspaces, cada um tem seu package.json e seu package-lock.json.

    Painel: um portão só

    O gates é a porta única do frontend. Com o CI removido do repositório, ele é o único portão que existe:

    bash
    cd frontend && npm run gates
    # check-lock → typecheck → lint → cycles → format:check → test → test:a11y → build

    Backend: sem agregador

    O backend não tem comando agregador equivalente. Rode os que importam:

    ComandoO que rodaPor que importa
    npm run typechecktsc --noEmitErro de tipo no build
    npm run test:itnode scripts/run-integration-tests.mjsSuíte com gate de skips
    npm run typecheck:testnode scripts/typecheck-tests.mjsO vitest transpila com esbuild e não checa tipo
    npm run assert:no-skipsnode scripts/assert-no-skips.mjsReprova se algum teste foi pulado
    npm run assert:build-freshnode scripts/assert-build-fresh.mjsProva que o que está no ar é o da ref
    npm test sozinho não mede a suíte de segurança

    A suíte de segurança não é um diretório nem uma tag: são cerca de 27 arquivos em test/ que abrem com describe.skipIf(!url), condicionado a TENANCY_IT_DATABASE_URL e TENANCY_IT_REDIS_URL. O vitest.config.ts inclui esses arquivos, então npm test roda eles — executa com o skip ativo, conta tudo como pulado e sai 0. npm run test:it executa a mesma suíte e reprova nomeando cada arquivo.

    O test:it resolve as duas variáveis do ambiente e, se faltarem, de backend/.env — só as chaves TENANCY_IT_*; o resto do .env nunca entra no processo do teste. Ele não roda containers: quem cria o schema e o banco Redis é o test/globalSetup.ts, um por execução (o PID), justamente para que o TRUNCATE … CASCADE global de um arquivo não atravesse o outro.

    O relatório é apagado antes de cada rodada. Sem isso, um vitest que morre antes de escrever o JSON deixa o relatório da rodada anterior e o gate leria como se fosse o de agora.

    Se nenhuma das duas variáveis existir, o test:it imprime um aviso e roda a suíte assim mesmo — de propósito, “para que o gate possa dizer quantos testes foram deixados de fora”. Ele não faz exit. O banco de teste precisa ser descartável.

    Por que o gate é JSON e não grep

    O vitest marca teste pulado com um glifo, e casar esse glifo com grep -E depende de locale: sob LC_CTYPE=POSIX o bracket vira conjunto de bytes e a linha nunca casa. Medido em 7aaa1859: o gate por regex devolveu “0 pulados” com 135 testes pulados. O script conta por campo numérico e por conjunto de status, não por glifo.

    Os códigos de saída são 1 se reprovou, 2 para flag desconhecida e 0 no sucesso. Ele reprova em cinco casos independentes: relatório ausente, relatório sem os campos de contagem, contagem total zero (o “verde mais silencioso que existe”), teste pulado — nomeando cada arquivo — e teste falhando.

    Importar muitas chaves#

    Para subir um lote grande num grupo, o caminho é backend/scripts/import-keys-lote.ts. Ele usa o mesmo caminho da UI (loadBalancer.addKey por chave), então a tenancy e a cifragem do key no banco acontecem sozinhas, e os caches do pool em Redis são invalidados a cada inserção — depois do --apply as instâncias do LB passam a ver as chaves sem restart.

    O arquivo de chaves tem uma chave por linha. Linha vazia ou começando com # é ignorada; o valor pode vir cru ou como nome=valor / nome: valor, e nesse caso só o valor entra.

    bash
    cd backend
    npx tsx scripts/import-keys-lote.ts \
      --provider=openrouter \
      --group=lote-marco \
      --file=/tmp/chaves.txt
    bash
    org: workspace_nommand (…)
    grupo 'lote-marco' não existe — seria CRIADO (strategy round_robin) no --apply
    provider=openrouter baseUrl=https://openrouter.ai/api authType=api_key chaves=240 nomes=lote-marco-001..lote-marco-240 active=true cifragem=ON
    --- dry-run: nada foi escrito. Releia os prefixos abaixo e rode com --apply ---
      lote-marco-001  sk-or-…(68)
      … +230
    Dry-run é o default

    Sem --apply o script mostra o plano e não escreve nada — nem no grupo, nem nas chaves. Releia os prefixos mascarados, confirme que os nomes são os que você quer e só então aplique.

    O script aborta sem KEY_ENCRYPTION_KEY

    A checagem vem antes do dry-run e diz: ERRO: KEY_ENCRYPTION_KEY ausente — sem ela as chaves iriam em TEXTO PURO. Aborto. É o mesmo motivo que trava o backfill-secrets.ts: um caminho de importação que aceita gravar em claro é um caminho que alguém usa sem querer.

    Nenhum valor de chave aparece no log. O mascaramento mostra os 4 ou 6 primeiros caracteres mais o comprimento, e o relatório final mostra só contagens e prefixos. O próprio cabeçalho do script é explícito: o arquivo de chaves e as envs ficam só na máquina que executa — nunca cole chave em argumento de comando nem em log.

    O script também recusa, antes de escrever: chave repetida no arquivo, nome que já existe naquela org (nomeando até 10), provider desconhecido e --name-width fora da faixa 1 a 10. Os nomes saem numerados com o prefixo que você definir em --name-prefix, começando em --start; a org dona é --org.

    Depois do --apply, ele relê a coluna crua com um cliente puro e confirma que nada entrou em texto puro — se algo entrar, ele falha nomeando as linhas:

    bash
    OK: 240/240 chaves em 'lote-marco' em 4.7s; 240 linhas no banco, 0 em texto puro (esperado: 0)

    Para ver os ids e a baseUrl padrão dos providers antes de importar, rode --list-providers. Ele funciona sem banco: só precisa de um DATABASE_URL qualquer no ambiente para o schema Zod passar.

    Quando algo está errado#

    Os quatro sintomas que mais aparecem em produção, cada um com o que procurar e o que fazer.

    Chave de provedor aparece em texto puro

    Sintoma: a verificação final do import reporta texto puro, ou o seu próprio SQL — um SELECT LEFT(key, 7) — mostra algo que não começa com enc:v1.

    Chave em claro é comprometimento do segredo inteiro

    Se KEY_ENCRYPTION_KEY está vazia na instância que escreveu, a gravação foi passthrough legado com console.warn no log. Trate as chaves como expostas: ponha a chave em todas as instâncias, reinicie, rode secrets:encrypt-backfill e verifique em SQL. Se a chave está presente mas errada em alguma instância, a guarda de boot derruba aquele processo citando o id da linha.

    COOKIE_SECRET curta ou vazia

    Em produção isso impede o boot

    COOKIE_SECRET vazia em produção é throw, e abaixo de COOKIE_SECRET_MIN_LENGTH = 32 também. O /api/health expõe o modo em cookieSecret: 'env' | 'derived' — nunca o valor — e o padrão de transição é derivar o segredo do DATABASE_URL. Se você trocar o valor, todas as sessões abertas caem uma vez.

    CORS_ORIGINS vazia em produção

    O serviço recusa subir em vez de abrir o CORS para todos

    A trava é a mesma política das outras três: throw, não warn. Preencha a lista de origens e redeploye. Se o painel carregou mas as chamadas a /api falham no browser, comece por aqui.

    Banco fora de sync e o container não sobe

    Não existe retry: o container morre de propósito

    O && do CMD faz prisma migrate deploy rodar antes do servidor, e uma falha encerra o processo com código diferente de zero. O sintoma é um container reiniciando sem nenhuma linha do servidor no log. Rode npm run prisma:migrate de uma máquina com acesso ao banco e leia o erro do Prisma antes de investigar qualquer outra coisa.

    Próximos passos#

    Você já sabe publicar, cifrar e prová-lo. Para fechar a operação, guarde os nomes das variáveis e o comportamento de cada uma — é onde a maior parte dos incidentes de configuração nasce.

    Depois de dominar o básico, explore além:

    Documentação gerada com a skill docs-padrao.