Compare commits

...
8 Commits
Author SHA1 Message Date
camilafagundesandClaude Opus 5 5b11496cd9 Runbook único de homologação, checklist de evidências e ADR do ambiente
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled
Havia dois runbooks sobrepostos, escritos para o piloto, e nenhum documento do
ambiente de homologação.

- `20-homologacao-runbook.md` consolida os dois e cobre DNS, TLS, Nginx,
  Supabase de homologação, banco com a decisão do shim, bucket privado,
  containers, worker, observabilidade, flag, rollback, diagnóstico e smoke.
  Os dois runbooks antigos ficam no repositório marcados como histórico,
  preservando o registro das decisões.
- Rollback de imagem com comandos: identificar o que está no ar, listar tags,
  desligar a flag antes de trocar, subir só web e worker com `--no-deps`,
  conferir saúde, religar a flag. As migrations de segurança — RLS e auditoria
  append-only — estão declaradas sem caminho de volta: desfazê-las afrouxa o
  isolamento entre workspaces, então um rollback que precise disso está errado
  por definição.
- `21-checklist-validacao.md` separa o que foi executado do que depende de
  infraestrutura, com a evidência de cada item e a tabela dos quatro defeitos
  encontrados. Nada está marcado PASS por inspeção de código.
- ADR 0007 registra o ambiente lógico e a autenticação de homologação, com as
  alternativas descartadas e o motivo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:40:08 -03:00
camilafagundesandClaude Opus 5 a1ceef6c5f CI com serviços reais e imagem Linux; deploy só manual
O CI rodava lint, typecheck, testes e build, e nada mais: os drivers de
Postgres e Redis nunca eram exercitados, e não havia build de imagem nem
pipeline de deploy.

- Job `services`: Postgres e Redis efêmeros. Roda a suíte de serviços, aplica
  as quatro migrations, confere idempotência, confere que `--dry-run` não
  altera nada e que o shim é pulado com ADAPTER_APPLY_IDENTITY_SHIM=0.
- Job `image`: build da imagem Linux e validação do artefato. Conteúdo de
  runtime, usuário não-root, ausência de .env, docs, testes e vitest na
  imagem, os oito scripts operacionais transformando e executando, entrypoint
  recusando subcomando desconhecido, fail-fast com código diferente de zero
  na web e no worker, liveness com cabeçalhos de segurança, compose completo
  até `ready`, heartbeat do worker, jornada assíncrona entre processos e
  desligamento gracioso exigindo `SIGTERM recebido` em menos de 25s.
- `.github/scripts/async-journey.sh` percorre web → Redis → worker → download
  dos três formatos, atravessando a fronteira de processo que a suíte de
  testes não cobre.
- Deploy com gatilho único `workflow_dispatch`: não há deploy automático em
  push, tag ou merge. Migration, deploy e liberação da feature flag são jobs
  separados, cada um com seu ambiente de aprovação manual.
- Nenhum segredo entra em comando de shell. Um input de workflow interpolado
  em `run:` é injeção, então `workspace_id` passa por variável de ambiente e
  é validado como UUID antes de qualquer escrita. O deploy usa heredoc citado
  e `permissions: contents: read` no topo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:56 -03:00
camilafagundesandClaude Opus 5 ce11a50091 Cobre rotas HTTP, proxy, worker assíncrono e barreiras do dev auth
Os 34 testes existentes batiam só na camada de serviço: as rotas, o proxy e o
loop do worker não tinham cobertura alguma. A suíte vai de 34 para 102.

- `tests/http/`: rotas reais com o caminho real de autenticação, sem subir
  servidor. Cobre 401 sem sessão, módulo desligado, papel sem permissão, 403
  ao tentar gravar workspace de terceiro, e 429 com Retry-After.
- `tests/http/proxy.test.ts`: o proxy não propaga query da requisição para o
  login, o que impediria um returnTo forjado.
- `tests/integration/worker.test.ts`: jornada assíncrona pelo loop real. A web
  enfileira e devolve `queued`, o download falha antes do tick, o tick conclui
  e os três formatos saem. Também: job sobrevive à perda da fila do Redis,
  worker relê o contexto e não processa com a flag desligada, TTL expira
  temporários sem tocar o modelo, e desligamento gracioso.
- `tests/unit/dev-auth-guard.test.ts`: matriz de ambientes das três barreiras
  do login local. Um token de dev válido é recusado quando o ambiente é
  staging. Espaço em volta de um valor de ADAPTER_ENV é tolerado de propósito,
  e isso está declarado no teste; valor desconhecido nunca vira development.
- `tests/services/`: Postgres e Redis reais. Confere `app_user` sem
  BYPASSRLS, FORCE RLS em todas as tabelas, isolamento entre workspaces,
  auditoria não alterável pelo papel da aplicação, e lock entre conexões
  diferentes. Sem as variáveis de serviço os casos são IGNORADOS, nunca
  aprovados, e ficam fora de `npm test` para a suíte padrão passar em
  qualquer máquina.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:43 -03:00
camilafagundesandClaude Opus 5 dea510c3b4 Imagem Docker e compose reproduzíveis para homologação
Não existia nenhum artefato de deploy: sem Dockerfile, sem compose, sem pin de
Node. O runbook pedia `npm run start` e `npm run worker` à mão, o que não é um
ambiente reproduzível, e o worker dependia de `tsx`, uma devDependency, que
`npm ci --omit=dev` removeria.

- Dockerfile multistage. Web e worker saem da mesma imagem: mesmo código,
  mesmas dependências, só o subcomando difere.
- `tsx` foi para `dependencies`. Conferido em diretório limpo: os 13 pacotes
  externos que src e scripts usam existem no install de produção.
- A sobreposição das duas árvores de node_modules é segura e foi medida: 318
  pacotes em produção contra 153 traçados, zero divergência de versão entre os
  compartilhados, e 3 que existem só na traçada, que o COPY mescla sem
  remover. O package.json do standalone não é sobrescrito.
- Entrypoint com subcomandos, que garante `node --import tsx` em todos os
  scripts. Usar `node` direto convidaria de volta o defeito do SIGTERM.
- Healthcheck de liveness na web e de heartbeat no worker.
- Compose local reproduz a topologia com Postgres e Redis de verdade. Compose
  de homologação não declara Postgres (é o banco gerenciado), usa Redis
  isolado com senha e volume próprios, publica porta só em 127.0.0.1 e tem
  nome de projeto, rede e volume exclusivos. Nenhum serviço de outro
  aplicativo do ecossistema é declarado, tocado ou reiniciado.
- `.env.staging.example` sem segredo, `.nvmrc`, `engines` e `packageManager`.
- `config/staging-seed.json` e `.env.staging` no gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:29 -03:00
camilafagundesandClaude Opus 5 ca8a1112f0 Corrige quatro defeitos de runtime do worker e dos scripts operacionais
Os quatro apareceram ao exercitar o layout de runtime da imagem — apenas
node_modules de produção, src, scripts e migrations — e nenhum aparecia na
suíte de testes nem no build. Os dois primeiros formavam uma armadilha mútua.

1. O CLI do `tsx` cria processo filho (PIDs medidos: 20840 contra 21096).
   O SIGTERM do `docker stop` pararia no pai, o worker nunca rodaria o
   handler, o lock esperaria o TTL de 120s e o `stop_grace_period` só
   atrasaria um SIGKILL. Passa a ser `node --import tsx`, mesmo processo.
2. Top-level await nos scripts falha com `--import tsx`, porque sem
   `"type": "module"` o esbuild gera CJS. `migrate`, `worker`, `seed-staging`,
   `flag`, `cleanup` e `check-bucket` não executariam: a migração não rodaria
   e o worker não subiria. `runScript`/`runWithDb` resolvem sem mudar o
   module type do projeto.
3. `import "server-only"` em `worker-loop.ts`: o pacote lança fora do bundler
   do Next, e o worker morria no start.
4. `@react-pdf/renderer` carregado no import de `process-job.ts` falha com
   ERR_PACKAGE_PATH_NOT_EXPORTED ao resolver a hifenização fora do bundler,
   derrubando worker e cleanup. O PDF sai sob demanda no download, então o
   import passou a ser tardio, dentro do ramo que o usa.

Além disso:

- O loop do worker saiu do script para `worker-loop.ts`, testável sem subir
  processo. O script cuida só de sinais, cadência e bootstrap.
- Desligamento gracioso espera o tick corrente para o `finally` liberar o
  lock. Um job que ainda não começou fica `queued`: nada pela metade.
- Heartbeat por tick, lido pelo healthcheck do contêiner. Processo vivo mas
  travado passa em qualquer verificação de PID.
- O worker recusa iniciar com migration pendente, e relê o contexto do dono a
  cada job: papel e feature flag podem ter mudado desde o enfileiramento.
- `verify-config`, `check-bucket`, `seed-staging` e `smoke-staging` novos. O
  smoke tem fase autenticada que só roda com credencial fornecida pela
  operação e, sem ela, reporta PENDENTE em vez de aprovado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:15 -03:00
camilafagundesandClaude Opus 5 d43f370359 Health checks reais, cabeçalhos de segurança e Sentry por ambiente
- `/api/health/live` não toca dependência: distingue processo vivo de
  dependência fora do ar. `/api/health/ready` passou a tocar banco, migrations
  pendentes, Redis com PING, bucket com listagem, configuração e modo de job,
  e reporta em `reasons` só o nome do erro — nunca connection string.
- Configuração inválida encerra o processo. Antes o servidor ficava no ar
  respondendo 500 em todas as rotas, inclusive no liveness, enganando o
  orquestrador: pior que não subir.
- CSP, HSTS, Permissions-Policy e COOP, além dos três cabeçalhos que já
  existiam. `unsafe-inline` em script-src é exigência do bootstrap do App
  Router sem nonce; o resto é fechado e connect-src só aceita o próprio host,
  o Supabase do ambiente e o Sentry.
- `onRequestError` registrado: sem esse export os erros de rota e de render
  do servidor não chegavam ao Sentry. O ambiente do evento deriva de
  ADAPTER_ENV, então homologação e produção não se misturam.
- `withSentryConfig` só atua quando organização, projeto e token existem no
  build, para não quebrar build local nem CI.
- `output: "standalone"` para a imagem de runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:38:58 -03:00
camilafagundesandClaude Opus 5 f5560cc4ac Autentica homologação com Supabase real e resolve workspace no servidor
Duas lacunas impediam qualquer uso de homologação. Primeira: sem SSO do Elo
Pro e sem o login local, não havia como entrar. Segunda, mais grave: nada no
repositório gravava o cookie `elo_pro_workspace_id`, que era lido como origem
do workspace ativo — mesmo com sessão válida a resposta era sempre "sem
workspace".

- `identity-provider.ts` define o contrato de identidade. Trocar o Supabase de
  homologação pelo SSO do Elo Pro passa a ser acrescentar uma implementação;
  nenhum outro arquivo conhece o mecanismo.
- `/api/auth/staging/session` autentica com `signInWithPassword` no projeto
  Supabase de homologação. Há senha, emitida e validada pelo Supabase, e o
  Adaptador continua sem tabela de usuário, senha ou sessão própria.
- O login local sem senha existe apenas em `ADAPTER_ENV=development`, e não
  por configuração: fora de development o provedor nem entra na lista.
- `workspace.ts` resolve o workspace por associação real lida sob RLS. O
  cookie passa a ser preferência, não prova: aceitá-lo como autorização
  transformava um valor editável pelo cliente em credencial.
- `/api/auth/workspace` grava a preferência só depois de confirmar a
  associação, e `/selecionar-workspace` existe para quem participa de mais de
  um workspace.
- Rate limiting por usuário e bucket nas rotas do módulo, com Retry-After.
  Upload e processamento custam muito mais que leitura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:38:45 -03:00
camilafagundesandClaude Opus 5 b972589949 Introduz ADAPTER_ENV e endurece configuração e infraestrutura
`next start` força NODE_ENV=production, então homologação não tinha como se
distinguir de produção: o login local ficava desligado (correto) e o módulo
exigia o SSO do Elo Pro, que ainda não existe. Não havia terceiro caminho.

- `ADAPTER_ENV` com development, staging e production, independente de
  NODE_ENV. Toda política passa a derivar dele.
- Fail-fast de configuração em `fail-fast.ts`, separado de `env.ts` porque
  `process.exit` não existe no Edge Runtime.
- `requireDatabaseUrl` elimina o fallback silencioso para PGlite: um segredo
  ausente em staging migrava um arquivo local e parecia sucesso.
- Shim de identidade passa a ser decisão explícita via
  `ADAPTER_APPLY_IDENTITY_SHIM`; aplicá-lo sobre o schema real do Elo Pro é
  destrutivo em potencial.
- `migration-manifest.ts` responde o estado do schema consultando só o banco.
  Ler o diretório numa rota fazia o Next tracear o projeto inteiro para a
  saída standalone, levando docs, testes e fontes para a imagem.
- Redis e Supabase viram singleton por processo. Criar um ioredis por
  requisição vazava conexão: o cliente é lazy, abre no primeiro uso e nunca
  fecha.
- Namespace de chaves deriva de ADAPTER_NAMESPACE e do ambiente lógico, não
  de SENTRY_ENVIRONMENT. Observabilidade e isolamento de dados são decisões
  diferentes, e a variável única fazia homologação e produção colidirem.
- Listagem do bucket passa a paginar e descer em subpastas. Parando em 1000
  entradas, a limpeza por TTL deixava de ver arquivos e temporários venciam
  sem nunca ser removidos.
- `ping` e `hit` no coordenador, e `healthcheck` no armazenamento, para o
  health check tocar as dependências de verdade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:38:32 -03:00
91 changed files with 5233 additions and 307 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
node_modules
.next
.data
dist
coverage
.git
.github
.cursor
docs
tests
_prompts_tmp
_escopo_extraido.txt
*.tsbuildinfo
.env
.env.*
!.env.staging.example
config/staging-seed.json
Dockerfile
docker-compose*.yml
README.md
+61 -28
View File
@@ -1,44 +1,77 @@
# Identidade Elo Pro (Supabase Auth). Sem estes valores o módulo não autentica em produção.
# Desenvolvimento local. Para homologação use .env.staging.example.
# Confira a qualquer momento com: npm run verify:config
# --- Ambiente lógico -------------------------------------------------------
# development | staging | production. Independente de NODE_ENV.
ADAPTER_ENV=development
# Prefixo das chaves externas (Redis). Separa este aplicativo e este ambiente.
ADAPTER_NAMESPACE=adaptador-planilhas
# --- Sessão local sem senha (SOMENTE development) --------------------------
# Em staging e produção o provedor de desenvolvimento não existe no caminho de
# código, e ADAPTER_DEV_AUTH=1 faz o start ser recusado.
ADAPTER_DEV_AUTH=1
ADAPTER_DEV_AUTH_SECRET=troque-este-segredo-local
# --- Identidade Supabase (opcional em development) -------------------------
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_.
SUPABASE_SERVICE_ROLE_KEY=
# Bucket privado. O valor literal "public" é recusado.
SUPABASE_STORAGE_BUCKET=
# Login próprio de homologação com Supabase Auth real. Só vale em staging.
ADAPTER_STAGING_AUTH=0
# PostgreSQL do Elo Pro. Obrigatório em produção.
# --- Banco ----------------------------------------------------------------
# Ausente em development usa PGlite em .data/pglite.
# Em staging e produção é obrigatória: não há fallback local.
DATABASE_URL=
# Decisão explícita, obrigatória fora de development.
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
# 0 = o schema do Elo Pro já existe; não recriar
ADAPTER_APPLY_IDENTITY_SHIM=1
# Redis. Obrigatório em produção para lock. Sem REDIS_URL o processamento recusa executar.
# --- Redis ----------------------------------------------------------------
# Ausente em development usa coordenação em memória.
# Em staging e produção é obrigatória: sem ela o processamento é recusado.
REDIS_URL=
# Sentry. Sem DSN a instrumentação fica inerte.
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ENVIRONMENT=development
SENTRY_RELEASE=
# --- Processamento --------------------------------------------------------
# inline processa no mesmo processo (somente development).
# worker enfileira no Redis e no Postgres. Obrigatório fora de development.
ADAPTER_JOB_MODE=inline
# TTL de fontes e saídas, em segundos (1 a 604800). Obrigatório fora de
# development; ausente em development usa 3600.
SPREADSHEET_TEMP_TTL_SECONDS=3600
# Teto de um processamento, em milissegundos. Ausente usa 120000.
SPREADSHEET_JOB_TIMEOUT_MS=
ADAPTER_WORKER_TICK_MS=2000
ADAPTER_WORKER_BATCH=20
# Login central do ecossistema. O Adaptador não tem formulário de senha.
# --- Login central do ecossistema -----------------------------------------
# O Adaptador não tem formulário de senha própria.
ELO_PRO_LOGIN_URL=
# URL pública deste aplicativo. Usada para montar returnTo no mesmo host.
ELO_PRO_APP_URL=
# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido.
ELO_PRO_APP_URL=http://localhost:3000
# Hosts adicionais aceitos no returnTo, separados por vírgula.
AUTH_ALLOWED_RETURN_HOSTS=
# Cookie de workspace ativo, definido pela plataforma: elo_pro_workspace_id.
# É preferência, não autorização: o servidor revalida a associação sempre.
# Cookie de workspace ativo definido pela plataforma Elo Pro.
# Nome lido pelo servidor: elo_pro_workspace_id
# --- Observabilidade ------------------------------------------------------
# Sem DSN a instrumentação fica inerte.
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# Ausente cai em ADAPTER_ENV.
SENTRY_ENVIRONMENT=
SENTRY_RELEASE=
# Upload de sourcemap só acontece com os três presentes no build.
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# TTL de fontes e saídas, em segundos. Obrigatório em produção. Inteiro de 1 a 604800.
# Em dev/teste, a ausência usa 3600. O número de produto continua configurável.
SPREADSHEET_TEMP_TTL_SECONDS=
# Tempo máximo de um processamento, em milissegundos. Ausente usa 120000.
SPREADSHEET_JOB_TIMEOUT_MS=
# inline = processa no mesmo processo (somente dev/teste). worker = enfileira no Redis e no Postgres.
# Em produção, inline é recusado.
ADAPTER_JOB_MODE=worker
# Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro.
# Produção recusa ADAPTER_DEV_AUTH=1.
ADAPTER_DEV_AUTH=0
ADAPTER_DEV_AUTH_SECRET=
# --- Testes com serviços reais (npm run test:services) --------------------
# Banco descartável: a suíte recria o schema public.
ADAPTER_TEST_DATABASE_URL=
ADAPTER_TEST_REDIS_URL=
+85
View File
@@ -0,0 +1,85 @@
# Homologação — hml-ada.elopro.app
# Copie para .env.staging no servidor e preencha. NÃO versione .env.staging.
# Confira com: npm run verify:config
# --- Ambiente lógico -------------------------------------------------------
# development | staging | production. Em homologação é sempre staging, mesmo
# com NODE_ENV=production (o build é o otimizado).
ADAPTER_ENV=staging
# Prefixo das chaves externas. Separa este aplicativo dos demais do ecossistema
# no Redis compartilhado e separa homologação de produção.
ADAPTER_NAMESPACE=adaptador-planilhas
# --- Identidade (Supabase Auth do projeto de HOMOLOGAÇÃO) ------------------
# Projeto Supabase próprio de homologação. Nunca o projeto de produção:
# credencial administrativa não é compartilhada entre aplicações nem ambientes.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Somente servidor. Nunca prefixar com NEXT_PUBLIC_.
SUPABASE_SERVICE_ROLE_KEY=
# Bucket PRIVADO e exclusivo deste aplicativo neste ambiente.
# Confira com: npm run check:bucket
SUPABASE_STORAGE_BUCKET=adaptador-planilhas-hml
# Login próprio de homologação com Supabase Auth real (com senha, emitida e
# validada pelo Supabase). O dev auth sem senha NÃO funciona aqui.
# As contas são criadas pela operação no painel do Supabase de homologação.
ADAPTER_STAGING_AUTH=1
# Alternativa a ADAPTER_STAGING_AUTH: encaminhar ao SSO do Elo Pro quando existir.
ELO_PRO_LOGIN_URL=
ELO_PRO_APP_URL=https://hml-ada.elopro.app
AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app
# --- Banco ----------------------------------------------------------------
# Banco de HOMOLOGAÇÃO. Nunca o de produção.
DATABASE_URL=
# Decisão explícita e obrigatória:
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
# 0 = o schema do Elo Pro já existe nesse banco; não recriar
# Rode `npm run migrate:check` antes de decidir.
ADAPTER_APPLY_IDENTITY_SHIM=1
# --- Redis (contêiner isolado deste aplicativo) ---------------------------
# Porta publicada só em 127.0.0.1 pelo docker-compose.staging.yml.
ADAPTER_REDIS_PASSWORD=
ADAPTER_REDIS_PORT=56380
REDIS_URL=redis://:SENHA_DO_REDIS@redis:6379
# --- Processamento --------------------------------------------------------
# worker é obrigatório fora de development. inline é recusado no start.
ADAPTER_JOB_MODE=worker
# Vida dos arquivos temporários, em segundos (1 a 604800). Obrigatório.
SPREADSHEET_TEMP_TTL_SECONDS=86400
# Teto de um processamento, em milissegundos. Ausente usa 120000.
SPREADSHEET_JOB_TIMEOUT_MS=120000
ADAPTER_WORKER_TICK_MS=2000
ADAPTER_WORKER_BATCH=20
# Carência do desligamento gracioso: espera o tick corrente liberar o lock.
# Precisa ser MENOR que o stop_grace_period do compose (30s).
ADAPTER_WORKER_GRACE_MS=20000
# Heartbeat lido pelo healthcheck do contêiner do worker.
ADAPTER_WORKER_HEARTBEAT=/tmp/adaptador-worker-heartbeat
# --- Observabilidade ------------------------------------------------------
# Projeto Sentry separado ou ambiente separado no mesmo projeto.
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ENVIRONMENT=staging
# Preenchido pelo pipeline com o SHA do commit.
SENTRY_RELEASE=
# --- Imagem e portas do compose -------------------------------------------
ADAPTER_IMAGE=ghcr.io/elopro/adaptador-planilhas:staging
ADAPTER_WEB_PORT=3110
# --- Smoke (fase 2, opcional; nunca comitar valores) ----------------------
# Conta de teste criada no painel do Supabase de HOMOLOGAÇÃO. Sem estas duas,
# a fase autenticada do smoke é reportada como PENDENTE, nunca aprovada.
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app
ADAPTER_SMOKE_EMAIL=
ADAPTER_SMOKE_PASSWORD=
ADAPTER_SMOKE_WORKSPACE=
# --- Nunca definir em homologação -----------------------------------------
# ADAPTER_DEV_AUTH: o start é recusado com valor 1 fora de development.
# ADAPTER_DEV_AUTH_SECRET: não é usado fora de development.
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Jornada assíncrona ponta a ponta contra o compose, ENTRE PROCESSOS:
# a web (um contêiner) enfileira no Redis, o worker (outro contêiner) processa,
# e o download sai pela rota autenticada. É o que a suíte de testes não cobre:
# lá o tick roda no mesmo processo.
#
# Roda contra docker-compose.yml, que sobe em ADAPTER_ENV=development com
# ADAPTER_JOB_MODE=worker. Nenhuma credencial real é usada.
set -euo pipefail
BASE="${BASE:-http://127.0.0.1:3000}"
JAR="$(mktemp)"
ANA="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
ALPHA="11111111-1111-4111-8111-111111111111"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP" "$JAR"' EXIT
jq_get() { python3 -c "import json,sys;d=json.load(sys.stdin);print(eval(sys.argv[1],{},{'d':d}))" "$1"; }
echo "== 1. sessão local =="
curl -fsS -c "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"userId\":\"$ANA\",\"workspaceId\":\"$ALPHA\"}" \
"$BASE/api/dev/session" > /dev/null
echo "ok"
echo "== 2. modelo =="
printf 'Nome,Valor\n' > "$TMP/modelo.csv"
MODEL_ID=$(curl -fsS -b "$JAR" -X POST \
-F "name=Clientes CI" \
-F "requiredColumns=[\"Nome\",\"Valor\"]" \
-F "file=@$TMP/modelo.csv;type=text/csv" \
"$BASE/api/spreadsheet-adapter/models" | jq_get "d['model']['id']")
echo "modelo=$MODEL_ID"
echo "== 3. operação =="
JOB_ID=$(curl -fsS -b "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"modelId\":\"$MODEL_ID\",\"idempotencyKey\":\"ci-job\"}" \
"$BASE/api/spreadsheet-adapter/jobs" | jq_get "d['job']['id']")
echo "job=$JOB_ID"
echo "== 4. fonte =="
printf 'Nome,Valor\nAna,10\nBruno,20\n' > "$TMP/fonte.csv"
SOURCE_ID=$(curl -fsS -b "$JAR" -X POST \
-F "jobId=$JOB_ID" \
-F "file=@$TMP/fonte.csv;type=text/csv" \
"$BASE/api/spreadsheet-adapter/sources/validate" | jq_get "d['job']['sources'][0]['id']")
echo "fonte=$SOURCE_ID"
echo "== 5. mapeamento =="
curl -fsS -b "$JAR" -X PUT -H 'Content-Type: application/json' \
-d "{\"sourceId\":\"$SOURCE_ID\",\"mappings\":[{\"sourceColumn\":\"Nome\",\"modelColumn\":\"Nome\"},{\"sourceColumn\":\"Valor\",\"modelColumn\":\"Valor\"}]}" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/mappings" > /dev/null
echo "ok"
echo "== 6. processar: a web só enfileira =="
STATUS=$(curl -fsS -b "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"sourceId\":\"$SOURCE_ID\",\"idempotencyKey\":\"ci-proc\"}" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/process" | jq_get "d['job']['status']")
echo "status imediato=$STATUS"
if [ "$STATUS" != "queued" ] && [ "$STATUS" != "processing" ]; then
echo "ERRO: a web deveria enfileirar, não processar. status=$STATUS"
exit 1
fi
echo "== 7. aguardando o WORKER (outro contêiner) concluir =="
FINAL=""
for i in $(seq 1 60); do
FINAL=$(curl -fsS -b "$JAR" "$BASE/api/spreadsheet-adapter/jobs/$JOB_ID" | jq_get "d['job']['status']")
echo " tentativa $i: $FINAL"
[ "$FINAL" = "completed" ] && break
[ "$FINAL" = "failed" ] && break
sleep 2
done
if [ "$FINAL" != "completed" ]; then
echo "ERRO: o worker não concluiu. status=$FINAL"
docker compose logs worker | tail -40
exit 1
fi
echo "== 8. downloads =="
for fmt in xlsx csv pdf; do
curl -fsS -b "$JAR" -o "$TMP/out.$fmt" \
"$BASE/api/spreadsheet-adapter/jobs/$JOB_ID/download?format=$fmt&sourceId=$SOURCE_ID"
size=$(wc -c < "$TMP/out.$fmt")
[ "$size" -gt 0 ] || { echo "ERRO: $fmt vazio"; exit 1; }
echo " $fmt: $size bytes"
done
head -c 2 "$TMP/out.xlsx" | grep -q 'PK' || { echo "ERRO: xlsx não é zip"; exit 1; }
head -c 4 "$TMP/out.pdf" | grep -q '%PDF' || { echo "ERRO: pdf inválido"; exit 1; }
grep -q 'Ana' "$TMP/out.csv" || { echo "ERRO: csv sem os dados"; exit 1; }
echo "== 9. o worker registrou o processamento =="
docker compose logs worker | tail -30 > "$TMP/worker.log"
grep -q "\[worker\] pronto" "$TMP/worker.log" || { echo "ERRO: worker não reportou start"; exit 1; }
echo "== 10. o modelo original não foi alterado =="
curl -fsS -b "$JAR" "$BASE/api/spreadsheet-adapter/models" | grep -q "Clientes CI"
echo ""
echo "JORNADA ASSÍNCRONA ENTRE PROCESSOS: OK"
+278 -1
View File
@@ -6,18 +6,295 @@ on:
jobs:
verify:
name: lint, typecheck, testes e build
runs-on: ubuntu-latest
env:
ADAPTER_ENV: development
ADAPTER_JOB_MODE: inline
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
services:
name: Postgres e Redis reais
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: adaptador
POSTGRES_PASSWORD: adaptador_ci
POSTGRES_DB: adaptador_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U adaptador -d adaptador_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
# Banco descartável do runner. A suíte recria o schema public.
ADAPTER_TEST_DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
ADAPTER_TEST_REDIS_URL: redis://localhost:6379
ADAPTER_ENV: development
ADAPTER_APPLY_IDENTITY_SHIM: "1"
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Migrations, shim, FORCE RLS e isolamento em Postgres real
run: npm run test:services
- name: As quatro migrations aplicam e o dry-run não altera nada
env:
DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
run: |
set -e
psql "$DATABASE_URL" -c 'drop schema if exists public cascade; create schema public;'
npm run migrate:check | tee /tmp/dry.log
grep -q "0001_platform_shim.sql" /tmp/dry.log
grep -q "0004_audit_append_only.sql" /tmp/dry.log
# dry-run não aplica: a tabela de controle não existe ainda
test "$(psql "$DATABASE_URL" -tAc "select count(*) from information_schema.tables where table_name='schema_migrations'")" = "0"
npm run migrate
test "$(psql "$DATABASE_URL" -tAc 'select count(*) from public.schema_migrations')" = "4"
# idempotente
npm run migrate | tee /tmp/again.log
grep -q "nada pendente" /tmp/again.log
- name: Shim explícito pode ser recusado sobre schema existente
env:
DATABASE_URL: postgres://adaptador:adaptador_ci@localhost:5432/adaptador_ci
ADAPTER_APPLY_IDENTITY_SHIM: "0"
run: |
set -e
psql "$DATABASE_URL" -c 'drop schema if exists public cascade; create schema public;'
npm run migrate:check | tee /tmp/noshim.log
if grep -q "0001_platform_shim.sql" /tmp/noshim.log; then
echo "ERRO: o shim apareceria como pendente com ADAPTER_APPLY_IDENTITY_SHIM=0"
exit 1
fi
grep -q "NÃO será aplicado" /tmp/noshim.log
image:
name: imagem Docker Linux
runs-on: ubuntu-latest
env:
ADAPTER_IMAGE: adaptador-planilhas:ci
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build da imagem de runtime
uses: docker/build-push-action@v6
with:
context: .
target: runner
push: false
load: true
tags: adaptador-planilhas:ci
cache-from: type=gha
cache-to: type=gha,mode=max
# ------------------------------------------------------------------
# Conteúdo da imagem
# ------------------------------------------------------------------
- name: Arquivos de runtime presentes
run: |
set -e
docker run --rm adaptador-planilhas:ci exec ls server.js .next/static migrations scripts src tsconfig.json
docker run --rm adaptador-planilhas:ci exec node -e "require.resolve('tsx')"
docker run --rm adaptador-planilhas:ci exec sh -c 'ls migrations/*.sql | wc -l | grep -qx 4'
- name: Imagem não roda como root
run: |
set -e
test "$(docker run --rm adaptador-planilhas:ci exec id -u)" = "1001"
- name: Imagem não carrega segredo, .env, docs nem testes
run: |
set -e
for proibido in .env .env.local .env.staging tests docs .git node_modules/vitest; do
if docker run --rm adaptador-planilhas:ci exec sh -c "test -e '$proibido'"; then
echo "ERRO: $proibido está na imagem"
exit 1
fi
done
echo "ok: nada indevido na imagem"
- name: Scripts operacionais transformam e executam sem devDependencies
run: |
set -e
# Cada script precisa TRANSFORMAR e EXECUTAR com o node_modules de
# produção. `node --check` não serve: não parseia TypeScript. Então
# rodamos de verdade e exigimos a saída de recusa do nosso próprio
# código — se a transformação falhasse, a mensagem seria outra.
# Isto é a prova do defeito do top-level await em CJS, que impedia
# `node --import tsx`.
verifica() {
nome="$1"; esperado="$2"; shift 2
saida=$(docker run --rm "$@" adaptador-planilhas:ci 2>&1 || true)
if printf '%s' "$saida" | grep -q "$esperado"; then
echo " ok: $nome"
else
echo "ERRO: $nome não produziu a saída esperada ($esperado)"
echo "$saida"
exit 1
fi
}
# Scripts com bootstrap: recusam a configuração de staging ausente,
# o que só acontece depois de transformar e executar nosso código.
verifica "migrate" "Configuração de staging recusada" -e ADAPTER_ENV=staging migrate
verifica "worker" "Configuração de staging recusada" -e ADAPTER_ENV=staging worker
verifica "cleanup" "Configuração de staging recusada" -e ADAPTER_ENV=staging cleanup
verifica "seed-staging" "Configuração de staging recusada" -e ADAPTER_ENV=staging seed-staging
verifica "flag" "uso: npm run flag" -e ADAPTER_ENV=staging flag
# Scripts sem bootstrap: recusam pela ausência das próprias variáveis.
verifica "check-bucket" "Defina NEXT_PUBLIC_SUPABASE_URL" check-bucket
verifica "smoke" "Defina ADAPTER_SMOKE_URL" smoke
# E o caminho feliz do verificador de configuração.
docker run --rm -e ADAPTER_ENV=development adaptador-planilhas:ci verify-config | grep -q "configuração aceita"
echo "ok: os 8 scripts operacionais transformam e executam"
- name: Entrypoint responde aos subcomandos e recusa o desconhecido
run: |
set -e
docker run --rm adaptador-planilhas:ci --help | grep -q "worker-health"
if docker run --rm adaptador-planilhas:ci comando-que-nao-existe; then
echo "ERRO: subcomando desconhecido foi aceito"
exit 1
fi
echo "ok: subcomando desconhecido recusado"
# O worker precisa rodar no MESMO processo: o CLI do tsx cria filho e
# o SIGTERM do docker stop pararia nele.
docker run --rm adaptador-planilhas:ci exec grep -q 'import=tsx' docker/entrypoint.sh
if docker run --rm adaptador-planilhas:ci exec grep -q 'tsx/dist/cli' docker/entrypoint.sh; then
echo "ERRO: o entrypoint usa o CLI do tsx, que cria processo filho"
exit 1
fi
echo "ok: entrypoint usa node --import=tsx"
# ------------------------------------------------------------------
# Fail-fast de configuração
# ------------------------------------------------------------------
- name: Configuração ausente encerra com código diferente de zero
run: |
set -e
# web
docker run --name ff-web -e ADAPTER_ENV=staging adaptador-planilhas:ci web > /tmp/ff-web.log 2>&1 && {
echo "ERRO: a web subiu sem configuração"; exit 1; }
code=$(docker inspect ff-web --format '{{.State.ExitCode}}')
echo "web exit=$code"; test "$code" != "0"
grep -q "Configuração de staging recusada" /tmp/ff-web.log
grep -q "DATABASE_URL" /tmp/ff-web.log
docker rm -f ff-web
# worker
docker run --name ff-worker -e ADAPTER_ENV=staging adaptador-planilhas:ci worker > /tmp/ff-worker.log 2>&1 && {
echo "ERRO: o worker subiu sem configuração"; exit 1; }
code=$(docker inspect ff-worker --format '{{.State.ExitCode}}')
echo "worker exit=$code"; test "$code" != "0"
docker rm -f ff-worker
# dev auth em staging precisa ser recusado
docker run --rm -e ADAPTER_ENV=staging -e ADAPTER_DEV_AUTH=1 adaptador-planilhas:ci verify-config 2>&1 \
| grep -q "ADAPTER_DEV_AUTH" || { echo "ERRO: dev auth aceito em staging"; exit 1; }
echo "ok: fail-fast confirmado"
- name: Liveness responde e não mente sobre dependência
run: |
set -e
docker run -d --name ci-web -e ADAPTER_ENV=development -p 3000:3000 adaptador-planilhas:ci
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:3000/api/health/live && break || sleep 2; done
curl -fsS http://127.0.0.1:3000/api/health/live | grep -q '"status":"live"'
curl -fsS http://127.0.0.1:3000/api/health/live | grep -q '"environment":"development"'
# Cabeçalhos de segurança na resposta real
curl -fsSI http://127.0.0.1:3000/api/health/live | grep -qi 'content-security-policy'
curl -fsSI http://127.0.0.1:3000/api/health/live | grep -qi 'strict-transport-security'
# Dev auth não existe quando a flag não foi ligada
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
-d '{"userId":"00000000-0000-4000-8000-000000000000","workspaceId":"00000000-0000-4000-8000-000000000000"}' \
http://127.0.0.1:3000/api/dev/session)" = "404"
docker rm -f ci-web
# ------------------------------------------------------------------
# Topologia completa: web + worker + Postgres + Redis
# ------------------------------------------------------------------
- name: Compose sobe, migra e fica ready
run: |
set -e
docker compose up -d
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:3000/api/health/live >/dev/null 2>&1; then break; fi
sleep 3
done
docker compose exec -T web migrate
for i in $(seq 1 40); do
if curl -fsS http://127.0.0.1:3000/api/health/ready | grep -q '"status":"ready"'; then break; fi
sleep 3
done
curl -fsS http://127.0.0.1:3000/api/health/ready | tee /tmp/ready.json
grep -q '"status":"ready"' /tmp/ready.json
grep -q '"redis":"ok"' /tmp/ready.json
grep -q '"database":"ok"' /tmp/ready.json
grep -q '"migrations":"ok"' /tmp/ready.json
# Nenhuma connection string na resposta
if grep -q 'postgres://' /tmp/ready.json; then echo "ERRO: vazou connection string"; exit 1; fi
- name: Worker vivo, com heartbeat
run: |
set -e
docker compose logs worker | tee /tmp/worker.log
grep -q "\[worker\] pronto" /tmp/worker.log
docker compose exec -T worker /app/docker/entrypoint.sh worker-health
- name: Jornada assíncrona ponta a ponta entre processos
run: |
set -e
# Sessão local (o compose local roda em ADAPTER_ENV=development),
# upload de modelo e de fonte, mapeamento, processamento pelo WORKER
# em outro contêiner, e download dos três formatos.
bash .github/scripts/async-journey.sh
- name: Desligamento gracioso do worker
run: |
set -e
start=$(date +%s)
docker compose stop -t 30 worker
end=$(date +%s)
code=$(docker inspect "$(docker compose ps -aq worker)" --format '{{.State.ExitCode}}')
docker compose logs worker | tail -20 | tee /tmp/stop.log
echo "parada em $((end - start))s, exit=$code"
# SIGTERM precisa CHEGAR ao processo: o entrypoint usa `node --import tsx`
# justamente porque o CLI do tsx cria processo filho e o sinal pararia nele.
grep -q "SIGTERM recebido" /tmp/stop.log
grep -q "\[worker\] encerrado" /tmp/stop.log
test "$code" = "0"
# Se tivesse levado SIGKILL, a parada bateria no limite de 30s.
test $((end - start)) -lt 25
- name: Limpeza
if: always()
env:
ADAPTER_IMAGE: adaptador-planilhas:ci
run: docker compose down -v || true
+206
View File
@@ -0,0 +1,206 @@
name: deploy homologacao
# Cada passo é um job separado com aprovação própria. Nada roda sozinho:
# migration, deploy e liberação da feature flag são decisões distintas, na
# ordem do runbook docs/spreadsheet-adapter/20-homologacao-runbook.md.
#
# O único gatilho é workflow_dispatch: não existe deploy automático em nenhum
# push, tag ou merge.
#
# Ambientes do GitHub a criar com "required reviewers":
# hml-migracao → aplica migrations no banco de homologação
# hml-deploy → sobe web e worker em hml-ada.elopro.app
# hml-flag → liga a feature flag do workspace de homologação
#
# Segredos esperados nos ambientes (nenhum compartilhado com produção):
# HML_DATABASE_URL (hml-migracao, hml-flag)
# HML_SSH_HOST, HML_SSH_USER, HML_SSH_KEY, HML_DEPLOY_PATH (hml-deploy)
# HML_SMOKE_EMAIL, HML_SMOKE_PASSWORD (hml-deploy, opcionais)
# Variáveis:
# HML_APPLY_IDENTITY_SHIM, HML_SMOKE_WORKSPACE
on:
workflow_dispatch:
inputs:
workspace_id:
description: "workspace de homologação para a feature flag (job flag)"
required: false
type: string
concurrency:
group: deploy-homologacao
cancel-in-progress: false
# Mínimo necessário. O job de build eleva só o que precisa.
permissions:
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: 1. build e publicação da imagem
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: |
image="${REGISTRY}/${IMAGE_NAME}:staging-${GITHUB_SHA::12}"
echo "image=${image,,}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
target: runner
push: true
tags: ${{ steps.meta.outputs.image }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Release do Sentry igual ao SHA: o stack trace do servidor fica legível.
build-args: |
SENTRY_RELEASE=${{ github.sha }}
migrate:
name: 2. migrations (aprovação manual)
needs: build
runs-on: ubuntu-latest
environment: hml-migracao
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: O que vai rodar, antes de rodar
env:
ADAPTER_ENV: staging
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
run: npm run migrate:check
- name: Aplicar migrations
env:
ADAPTER_ENV: staging
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
run: npm run migrate
deploy:
name: 3. deploy web e worker (aprovação manual)
needs: [build, migrate]
runs-on: ubuntu-latest
environment: hml-deploy
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Confere que os segredos do destino existem
env:
SSH_HOST: ${{ secrets.HML_SSH_HOST }}
SSH_USER: ${{ secrets.HML_SSH_USER }}
SSH_KEY: ${{ secrets.HML_SSH_KEY }}
DEPLOY_PATH: ${{ secrets.HML_DEPLOY_PATH }}
run: |
missing=""
[ -z "$SSH_HOST" ] && missing="$missing HML_SSH_HOST"
[ -z "$SSH_USER" ] && missing="$missing HML_SSH_USER"
[ -z "$SSH_KEY" ] && missing="$missing HML_SSH_KEY"
[ -z "$DEPLOY_PATH" ] && missing="$missing HML_DEPLOY_PATH"
if [ -n "$missing" ]; then
echo "Segredos ausentes:$missing"
echo "Configure o ambiente hml-deploy antes de usar este workflow."
exit 1
fi
echo "ok: segredos presentes"
- name: Sobe apenas os serviços deste módulo
# Segredos entram por variável de ambiente, nunca interpolados no
# comando: um valor com metacaractere viraria injeção de shell. O
# heredoc é citado, então o script chega literal ao destino.
env:
IMAGE: ${{ needs.build.outputs.image }}
SSH_HOST: ${{ secrets.HML_SSH_HOST }}
SSH_USER: ${{ secrets.HML_SSH_USER }}
SSH_KEY: ${{ secrets.HML_SSH_KEY }}
DEPLOY_PATH: ${{ secrets.HML_DEPLOY_PATH }}
run: |
set -euo pipefail
install -m 700 -d ~/.ssh
printf '%s' "$SSH_KEY" > ~/.ssh/id_deploy
echo >> ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# O --project-name embutido no compose isola este stack dos outros
# aplicativos do servidor, e --no-deps não reinicia nada além de web
# e worker. Nenhum `down`, nenhum `prune`, nenhum serviço de terceiro.
ssh -i ~/.ssh/id_deploy "$SSH_USER@$SSH_HOST" \
ADAPTER_IMAGE="$IMAGE" DEPLOY_PATH="$DEPLOY_PATH" bash -s <<'REMOTE'
set -euo pipefail
cd "$DEPLOY_PATH"
export ADAPTER_IMAGE
C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
$C pull web worker
$C up -d redis
$C up -d --no-deps web worker
$C ps
REMOTE
- name: Smoke de homologação
env:
ADAPTER_SMOKE_URL: https://hml-ada.elopro.app
# Fase 2 só roda se a operação cadastrar estes segredos, com uma conta
# criada no painel do Supabase de homologação. Sem eles o script
# reporta PENDENTE, nunca aprovado.
ADAPTER_SMOKE_EMAIL: ${{ secrets.HML_SMOKE_EMAIL }}
ADAPTER_SMOKE_PASSWORD: ${{ secrets.HML_SMOKE_PASSWORD }}
ADAPTER_SMOKE_WORKSPACE: ${{ vars.HML_SMOKE_WORKSPACE }}
run: |
set -e
npm ci
npm run smoke:staging
flag:
name: 4. liberar a feature flag (aprovação manual)
needs: deploy
runs-on: ubuntu-latest
environment: hml-flag
if: inputs.workspace_id != ''
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Ligar o módulo somente no workspace informado
env:
ADAPTER_ENV: staging
DATABASE_URL: ${{ secrets.HML_DATABASE_URL }}
ADAPTER_APPLY_IDENTITY_SHIM: ${{ vars.HML_APPLY_IDENTITY_SHIM }}
# Pelo ambiente, não interpolado no comando: um input é texto de quem
# dispara o workflow, e interpolá-lo em shell é injeção.
WORKSPACE_ID: ${{ inputs.workspace_id }}
run: |
set -euo pipefail
UUID='^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
if ! printf '%s' "$WORKSPACE_ID" | grep -qiE "$UUID"; then
echo "workspace_id não é um UUID: recusado antes de qualquer escrita"
exit 1
fi
npm run flag -- "$WORKSPACE_ID" on
+3
View File
@@ -5,6 +5,9 @@ dist
coverage
.env
.env.local
.env.staging
.env.production
*.tsbuildinfo
_prompts_tmp
_escopo_extraido.txt
config/staging-seed.json
+1
View File
@@ -0,0 +1 @@
24.11.1
+86
View File
@@ -0,0 +1,86 @@
# Imagem única para os dois processos do módulo: web (Next standalone) e worker.
# Rodar os dois do mesmo artefato garante que web e worker compartilhem
# exatamente o mesmo código e as mesmas dependências em homologação.
# ---------------------------------------------------------------------------
# Base
# ---------------------------------------------------------------------------
FROM node:24-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
# ---------------------------------------------------------------------------
# Dependências de produção. Inclui `tsx`, que o worker e os scripts
# operacionais usam em runtime — por isso `tsx` está em `dependencies`.
# `--ignore-scripts` é seguro aqui: nenhuma dependência de produção depende de
# postinstall, e o binário do esbuild vem do pacote de plataforma.
# ---------------------------------------------------------------------------
FROM base AS deps-prod
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
# ---------------------------------------------------------------------------
# Dependências completas e build
# ---------------------------------------------------------------------------
FROM base AS builder
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json next.config.ts postcss.config.mjs eslint.config.mjs next-env.d.ts ./
COPY src ./src
COPY migrations ./migrations
COPY scripts ./scripts
# Release do Sentry: o SHA do commit entra no bundle do cliente e no servidor,
# para o stack trace do evento apontar o código certo. Não é segredo.
ARG SENTRY_RELEASE=""
ENV SENTRY_RELEASE=$SENTRY_RELEASE
ENV NEXT_PUBLIC_SENTRY_RELEASE=$SENTRY_RELEASE
# O build não recebe segredo: `collectConfigIssues` é ignorado na fase de build
# e a validação acontece no start do container.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---------------------------------------------------------------------------
# Runtime
# ---------------------------------------------------------------------------
FROM base AS runner
ARG SENTRY_RELEASE=""
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV SENTRY_RELEASE=$SENTRY_RELEASE
ENV ADAPTER_WORKER_HEARTBEAT=/tmp/adaptador-worker-heartbeat
RUN groupadd --system --gid 1001 adaptador \
&& useradd --system --uid 1001 --gid adaptador adaptador
# 1) node_modules de produção primeiro. Traz `tsx` para o worker e os scripts.
COPY --from=deps-prod --chown=adaptador:adaptador /app/node_modules ./node_modules
# 2) Saída autocontida do Next por cima. Onde um pacote existe nas duas árvores,
# a versão traçada do build é a que vale para o servidor. Conferido: as duas
# árvores vêm do mesmo lockfile e não divergem em nenhuma versão; a árvore
# traçada tem 3 pacotes que não ficam no topo da de produção
# (@emnapi/runtime, isarray, supports-color), e o COPY mescla sem removê-los.
# O package.json do standalone NÃO é sobrescrito: é o que o server.js espera.
COPY --from=builder --chown=adaptador:adaptador /app/.next/standalone ./
COPY --from=builder --chown=adaptador:adaptador /app/.next/static ./.next/static
# 3) Arquivos lidos em runtime: migrations pelo migrate, src e scripts pelo
# worker, tsconfig pelos aliases `@/` do tsx.
COPY --chown=adaptador:adaptador migrations ./migrations
COPY --chown=adaptador:adaptador scripts ./scripts
COPY --chown=adaptador:adaptador src ./src
COPY --chown=adaptador:adaptador tsconfig.json ./
COPY --chown=adaptador:adaptador docker ./docker
USER adaptador
EXPOSE 3000
# Liveness não toca dependência externa: distingue processo vivo de dependência
# fora do ar, que é o papel de /api/health/ready. O contêiner do worker troca
# este healthcheck por `worker-health` no compose.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["web"]
+69 -7
View File
@@ -1,17 +1,26 @@
# Adaptador de Planilhas
Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria.
Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria: a identidade é a do ecossistema.
| Ambiente | Host | `ADAPTER_ENV` | Autenticação |
| --- | --- | --- | --- |
| Desenvolvimento | `localhost:3000` | `development` | sessão local assinada, sem senha |
| Homologação | `hml-ada.elopro.app` | `staging` | Supabase Auth do projeto de homologação |
| Produção | `ada.elopro.app` | `production` | SSO do Elo Pro |
`ADAPTER_ENV` é independente de `NODE_ENV`: homologação roda com `NODE_ENV=production` (build otimizado) e política própria. Ver [ADR 0007](docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md).
## Desenvolvimento local
```bash
npm install
copy .env.example .env.local
cp .env.example .env.local
```
No `.env.local`, para o piloto sem o host do Elo Pro:
No `.env.local`:
```
ADAPTER_ENV=development
ADAPTER_DEV_AUTH=1
ADAPTER_DEV_AUTH_SECRET=um-segredo-local
ADAPTER_JOB_MODE=inline
@@ -21,15 +30,58 @@ ADAPTER_JOB_MODE=inline
npm run dev
```
Abra `/login` e escolha uma identidade de desenvolvimento. Isso não pede senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`.
Abra `/login` e escolha uma identidade de desenvolvimento. Não há senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`.
### Com Docker, igual ao artefato de homologação
Mesma imagem, dois processos, Postgres e Redis de verdade:
```bash
docker compose up --build
```
```bash
docker compose exec web node node_modules/tsx/dist/cli.mjs scripts/migrate.ts
```
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
## Homologação
O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md).
Resumo:
```bash
cp .env.staging.example .env.staging # preencher; não versionar
npm run verify:config # recusa configuração incompleta
npm run check:bucket # o bucket precisa existir e ser privado
npm run migrate:check # mostra o que vai rodar
npm run migrate # aplica (passo com aprovação manual)
npm run seed:staging # liga identidades do Supabase a workspaces
docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --no-deps web worker
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app npm run smoke:staging
npm run flag -- <workspace_id> on # último passo, depois do smoke
```
`ADAPTER_APPLY_IDENTITY_SHIM` é obrigatória fora de development: `1` cria o shim local de identidade, `0` usa o schema existente do Elo Pro. Aplicar o shim sobre um schema real é destrutivo em potencial, por isso a escolha nunca é inferida.
## Produção
Obrigatórios: `DATABASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_STORAGE_BUCKET`, `REDIS_URL`, `SPREADSHEET_TEMP_TTL_SECONDS`, `ELO_PRO_LOGIN_URL`.
Além das variáveis de homologação, produção exige `SENTRY_DSN` e `ELO_PRO_LOGIN_URL`, e recusa `ADAPTER_STAGING_AUTH=1`. A feature flag `spreadsheet_adapter` nasce desligada e é ligada por workspace com `npm run flag`, usando a conexão de serviço, nunca pelo browser.
A feature flag `spreadsheet_adapter` nasce desligada. Habilite por workspace com `npm run flag -- <workspace_id> on` usando a conexão de serviço, nunca pelo browser.
O processamento usa `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. `inline` é recusado fora de development.
O processamento de produção deve usar `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. O modo `inline` existe para desenvolvimento.
Produção não é liberada sem smoke real e ensaio de rollback registrados no checklist.
## Saúde
| Rota | O que responde |
| --- | --- |
| `GET /api/health/live` | processo vivo, sem tocar dependência |
| `GET /api/health/ready` | banco, migrations pendentes, Redis (PING), bucket, configuração e modo de job |
Configuração inválida encerra o processo com código diferente de zero, em vez de deixar o servidor no ar respondendo erro.
## Verificação
@@ -39,3 +91,13 @@ npm run typecheck
npm test
npm run build
```
Suíte que exige Postgres e Redis reais (roda no CI, e localmente com os dois serviços em pé):
```bash
ADAPTER_TEST_DATABASE_URL=postgres://... ADAPTER_TEST_REDIS_URL=redis://... npm run test:services
```
Sem essas variáveis os casos são **ignorados**, nunca aprovados.
O CI tem três jobs: `verify` (lint, typecheck, testes, build), `services` (Postgres e Redis reais: as quatro migrations, shim explícito, `FORCE RLS`, isolamento) e `image` (build da imagem Linux, conteúdo, fail-fast dos dois processos, compose completo, jornada assíncrona entre processos e desligamento gracioso do worker).
+43
View File
@@ -0,0 +1,43 @@
{
"_comentario": [
"Modelo do seed de homologacao. Copie para config/staging-seed.json e preencha.",
"users[].id e o UUID do usuario no Supabase Auth do projeto de homologacao.",
"Crie as contas no painel do Supabase: este arquivo nao guarda senha nem e-mail.",
"workspaces[].id pode ser qualquer UUID v4 estavel escolhido pela operacao.",
"Nao versione config/staging-seed.json."
],
"workspaces": [
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "Homologacao Alpha",
"moduleEnabled": false,
"allowNonAdminModelUpload": false
},
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "Homologacao Beta",
"moduleEnabled": false
}
],
"users": [
{ "id": "00000000-0000-4000-8000-0000000000a1", "displayName": "Owner de homologacao" },
{ "id": "00000000-0000-4000-8000-0000000000a2", "displayName": "Agente de homologacao" }
],
"members": [
{
"workspaceId": "00000000-0000-4000-8000-000000000001",
"userId": "00000000-0000-4000-8000-0000000000a1",
"role": "owner"
},
{
"workspaceId": "00000000-0000-4000-8000-000000000001",
"userId": "00000000-0000-4000-8000-0000000000a2",
"role": "agent"
},
{
"workspaceId": "00000000-0000-4000-8000-000000000002",
"userId": "00000000-0000-4000-8000-0000000000a2",
"role": "agent"
}
]
}
+118
View File
@@ -0,0 +1,118 @@
# Homologação em hml-ada.elopro.app, no servidor compartilhado do ecossistema.
#
# Regras que este arquivo respeita, por construção:
# - não declara, não reinicia e não depende de nenhum serviço de outro
# aplicativo: rede, volume e nome de projeto são exclusivos deste módulo;
# - não expõe porta em 0.0.0.0: só 127.0.0.1, para o Nginx fazer o proxy;
# - Redis é um contêiner isolado deste aplicativo, com senha e volume próprios,
# nunca o Redis compartilhado dos outros aplicativos;
# - Postgres não é declarado aqui: é o banco gerenciado do Elo Pro, informado
# em DATABASE_URL. Nenhuma migration roda no start — é passo manual aprovado.
#
# docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --build
# docker compose -f docker-compose.staging.yml --env-file .env.staging \
# run --rm web migrate --dry-run
name: adaptador-planilhas-hml
services:
redis:
image: redis:7-alpine
restart: unless-stopped
command:
- "redis-server"
- "--requirepass"
- "${ADAPTER_REDIS_PASSWORD:?defina ADAPTER_REDIS_PASSWORD}"
- "--save"
- "900"
- "1"
- "--appendonly"
- "no"
- "--maxmemory"
- "256mb"
- "--maxmemory-policy"
- "noeviction"
ports:
- "127.0.0.1:${ADAPTER_REDIS_PORT:-56380}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 10
environment:
REDIS_PASSWORD: ${ADAPTER_REDIS_PASSWORD}
networks: [adaptador-hml]
web:
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
build:
context: .
target: runner
restart: unless-stopped
command: ["web"]
env_file:
- .env.staging
environment:
ADAPTER_ENV: staging
NODE_ENV: production
PORT: "3000"
HOSTNAME: 0.0.0.0
ports:
- "127.0.0.1:${ADAPTER_WEB_PORT:-3110}:3000"
depends_on:
redis:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: [adaptador-hml]
worker:
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
build:
context: .
target: runner
restart: unless-stopped
command: ["worker"]
env_file:
- .env.staging
environment:
ADAPTER_ENV: staging
NODE_ENV: production
depends_on:
redis:
condition: service_healthy
# SIGTERM chega ao processo do worker porque o entrypoint usa
# `node --import tsx` (sem processo filho). O handler espera o tick corrente
# para o `finally` liberar o lock. 30s cobrem um job médio.
stop_grace_period: 30s
healthcheck:
test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
logging: *logging
networks: [adaptador-hml]
networks:
adaptador-hml:
name: adaptador-planilhas-hml
volumes:
redis-data:
name: adaptador-planilhas-hml-redis
+111
View File
@@ -0,0 +1,111 @@
# Reprodução local da topologia de homologação: mesma imagem, dois processos,
# Postgres e Redis de verdade. Serve para validar o artefato antes de subir ao
# servidor. Aqui o ambiente é `development`: não há Supabase local, então o
# armazenamento é um volume privado e a sessão usa o login local sem senha.
#
# docker compose up --build
# docker compose exec web /app/docker/entrypoint.sh migrate
#
# Nenhuma porta de serviço do ecossistema é usada: tudo em 127.0.0.1 e em
# portas altas, com nomes próprios de projeto, rede e volume.
name: adaptador-planilhas-local
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: adaptador
POSTGRES_PASSWORD: adaptador_local
POSTGRES_DB: adaptador
ports:
- "127.0.0.1:55432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U adaptador -d adaptador"]
interval: 5s
timeout: 3s
retries: 20
networks: [adaptador]
redis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "127.0.0.1:56379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
networks: [adaptador]
web:
# Com ADAPTER_IMAGE definido, usa a imagem já construída (o CI faz isso).
# Sem ela, `up --build` constrói e marca com este nome.
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
build:
context: .
target: runner
command: ["web"]
environment: &app-env
ADAPTER_ENV: development
ADAPTER_NAMESPACE: adaptador-planilhas
ADAPTER_JOB_MODE: worker
ADAPTER_APPLY_IDENTITY_SHIM: "1"
ADAPTER_DEV_AUTH: "1"
ADAPTER_DEV_AUTH_SECRET: segredo-apenas-local
DATABASE_URL: postgres://adaptador:adaptador_local@postgres:5432/adaptador
REDIS_URL: redis://redis:6379
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
ELO_PRO_APP_URL: http://localhost:3000
PORT: "3000"
HOSTNAME: 0.0.0.0
ports:
- "127.0.0.1:3000:3000"
volumes:
- app-storage:/app/.data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [adaptador]
worker:
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
build:
context: .
target: runner
command: ["worker"]
environment: *app-env
volumes:
- app-storage:/app/.data
# O worker grava heartbeat ao fim de cada tick; o healthcheck detecta
# processo vivo mas travado, que um check de PID não pega.
healthcheck:
test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
stop_grace_period: 30s
depends_on:
web:
condition: service_started
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [adaptador]
networks:
adaptador:
name: adaptador-planilhas-local
volumes:
postgres-data:
name: adaptador-planilhas-local-postgres
app-storage:
name: adaptador-planilhas-local-storage
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Entrada única da imagem. Mantém os comandos curtos e garante que todos os
# scripts operacionais rodem no MESMO processo do contêiner.
#
# Por que `node --import tsx` e não o CLI do tsx: o CLI cria um processo filho,
# e o SIGTERM do `docker stop` pararia no pai. O worker precisa receber o sinal
# para esperar o tick corrente e liberar o lock no `finally`.
set -eu
TSX="--import=tsx"
usage() {
cat <<'EOF'
uso: entrypoint.sh <comando> [args]
web servidor Next (saída standalone)
worker processo de fila
migrate [--dry-run]
seed-staging
flag <workspace_id> on|off
cleanup
verify-config
check-bucket
smoke
worker-health confere o heartbeat do worker (healthcheck do contêiner)
exec <cmd...> qualquer outro comando
EOF
}
cmd="${1:-web}"
[ $# -gt 0 ] && shift || true
case "$cmd" in
web) exec node server.js ;;
worker) exec node $TSX scripts/worker.ts ;;
migrate) exec node $TSX scripts/migrate.ts "$@" ;;
seed-staging) exec node $TSX scripts/seed-staging.ts "$@" ;;
flag) exec node $TSX scripts/set-flag.ts "$@" ;;
cleanup) exec node $TSX scripts/cleanup-temp.ts "$@" ;;
verify-config) exec node $TSX scripts/verify-config.ts ;;
check-bucket) exec node $TSX scripts/check-bucket.ts ;;
smoke) exec node $TSX scripts/smoke-staging.ts ;;
worker-health) exec node docker/worker-health.mjs ;;
exec) exec "$@" ;;
-h|--help|help) usage ;;
*) echo "comando desconhecido: $cmd" >&2; usage >&2; exit 2 ;;
esac
+23
View File
@@ -0,0 +1,23 @@
// Healthcheck do contêiner do worker. O worker grava o heartbeat ao fim de cada
// tick; se o arquivo envelhecer além da janela, o processo está vivo mas travado,
// que é o caso que um healthcheck de PID não detecta.
import { stat } from "node:fs/promises";
const file = process.env.ADAPTER_WORKER_HEARTBEAT ?? "/tmp/adaptador-worker-heartbeat";
const tick = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
// Tolera dez ticks ou 60s, o que for maior: um job grande ocupa o tick inteiro.
const maxAgeMs = Math.max(tick * 10, 60_000);
try {
const info = await stat(file);
const age = Date.now() - info.mtimeMs;
if (age > maxAgeMs) {
console.error(`heartbeat com ${Math.round(age / 1000)}s, limite ${Math.round(maxAgeMs / 1000)}s`);
process.exit(1);
}
console.log(`heartbeat com ${Math.round(age / 1000)}s`);
process.exit(0);
} catch {
console.error(`heartbeat ausente em ${file}`);
process.exit(1);
}
@@ -0,0 +1,32 @@
# 0007 — Ambiente lógico e autenticação de homologação
## Contexto
`next start` força `NODE_ENV=production`. Até aqui o módulo só conhecia dois estados: `NODE_ENV=production` ou não. Consequência prática: em homologação a autenticação local sem senha ficava desligada (correto) e o módulo exigia o login central do Elo Pro (que ainda não existe), sem terceira opção. Homologação ficava sem caminho de entrada.
Além disso, nada no repositório gravava o cookie `elo_pro_workspace_id`, lido como origem do workspace ativo. Mesmo com sessão válida, a resposta seria sempre "sem workspace".
## Decisão
1. **`ADAPTER_ENV` com três valores**: `development`, `staging`, `production`. É independente de `NODE_ENV`, que continua `production` em homologação para manter o build otimizado. Toda política passa a derivar de `ADAPTER_ENV`.
2. **Homologação autentica com Supabase Auth real**, em um projeto Supabase exclusivo de homologação, via `signInWithPassword` em `/api/auth/staging/session`. Há senha, emitida e validada pelo Supabase. O Adaptador continua sem tabela de usuário, senha, cadastro ou sessão própria.
3. **`ADAPTER_DEV_AUTH` só existe em `development`.** Não é configuração: em `staging` e `production` o provedor de desenvolvimento não entra na lista de provedores, e `ADAPTER_DEV_AUTH=1` faz o start ser recusado.
4. **Contrato de identidade explícito** em `src/server/auth/identity-provider.ts`. Trocar o Supabase de homologação pelo SSO do Elo Pro é adicionar uma implementação; nenhum outro arquivo conhece o mecanismo.
5. **Workspace é resolvido no servidor, por associação real.** O cookie é preferência, não prova: `resolveWorkspace` só o aceita quando corresponde a uma linha de `workspace_members` lida sob RLS. Sem associação, não há workspace. Com várias, a pessoa escolhe em `/selecionar-workspace`, e `POST /api/auth/workspace` grava o cookie apenas depois de confirmar a associação.
## Alternativas descartadas
- **Habilitar o dev auth em homologação.** Rejeitada: homologação é pública, e autenticação sem senha em host público é uma porta aberta.
- **Aceitar o cookie de workspace como autorização.** Rejeitada: transformaria um valor editável pelo cliente em credencial. O custo de reler a associação por requisição é uma consulta indexada.
- **Usar o projeto Supabase de produção com usuários de teste.** Rejeitada: compartilharia a service role entre ambientes.
- **Inferir o ambiente de `SENTRY_ENVIRONMENT`.** Rejeitada: observabilidade e política de segurança são decisões diferentes; a mesma variável governando as duas foi o que fez o namespace do Redis colidir entre ambientes.
## Consequências
- `ADAPTER_ENV` passa a ser obrigatória nos ambientes implantados, e `ADAPTER_APPLY_IDENTITY_SHIM` passa a ser uma decisão explícita.
- Homologação tem seu próprio projeto Supabase, bucket, banco, Redis e ambiente Sentry.
- O `0002-sso-topology.md` continua valendo para produção. Esta decisão acrescenta o degrau de homologação, sem substituí-lo.
@@ -1,5 +1,11 @@
# Deploy e operação
> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de
> homologação existir. Não siga este arquivo. O procedimento vigente está em
> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e
> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões.
## Ordem
1. Conferir envs sem colar segredo no repositório.
@@ -1,5 +1,11 @@
# Runbook de implantação do piloto
> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de
> homologação existir. Não siga este arquivo. O procedimento vigente está em
> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e
> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões.
Execute nesta ordem. Não apague auditoria, banco ou arquivos como parte do rollback.
1. Preparar o projeto Supabase da Elo Pro e anotar URL, anon key e service role.
@@ -0,0 +1,368 @@
# Runbook de homologação — hml-ada.elopro.app
Runbook único do Adaptador de Planilhas em homologação. Substitui `07-deployment-runbook.md` e `16-pilot-deployment-runbook.md`, que ficam no repositório como histórico e não devem ser seguidos.
| | |
| --- | --- |
| Homologação | `hml-ada.elopro.app` |
| Produção futura | `ada.elopro.app` |
| Servidor | compartilhado com os outros aplicativos do ecossistema |
| Projeto do compose | `adaptador-planilhas-hml` |
| Rede Docker | `adaptador-planilhas-hml` |
| Volume | `adaptador-planilhas-hml-redis` |
| Portas publicadas | só em `127.0.0.1`: web `3110`, Redis `56380` |
| Namespace de chaves | `adaptador-planilhas:staging` |
## Princípios que o procedimento respeita
1. Nenhum serviço de outro aplicativo é criado, alterado ou reiniciado. O compose tem nome de projeto, rede e volume próprios, e o deploy usa `up -d --no-deps web worker`.
2. DNS, serviços e banco de produção não são tocados. Homologação tem subdomínio, banco, bucket, Redis, projeto Supabase e projeto/ambiente Sentry próprios.
3. Não há autenticação de desenvolvimento em homologação. `ADAPTER_DEV_AUTH=1` faz o processo recusar o start fora de `development`.
4. Nenhuma credencial administrativa é compartilhada entre aplicações ou entre ambientes.
5. RLS e as validações multi-tenant não são afrouxadas em nenhum passo.
6. Migrations destrutivas não rodam automaticamente. `npm run migrate` é um job separado, com aprovação manual, precedido de `npm run migrate:check`.
7. Produção não é liberada sem smoke real e ensaio de rollback registrados em `21-checklist-validacao.md`.
---
## 1. DNS
Registro novo, sem mexer em nada existente:
```
hml-ada.elopro.app. A <IP do servidor compartilhado>
```
Se o ecossistema usa CNAME para um host único, siga o mesmo padrão dos outros aplicativos. Não altere o registro de `ada.elopro.app` nem o apex `elopro.app`.
Certificado TLS: inclua apenas `hml-ada.elopro.app` na emissão. Com Certbot e Nginx:
```bash
sudo certbot --nginx -d hml-ada.elopro.app
```
Isso cria um bloco novo. Não use `--expand` em um certificado que atende aplicativos existentes: uma renovação com falha derrubaria o TLS deles.
## 2. Nginx
Arquivo novo em `/etc/nginx/sites-available/hml-ada.elopro.app`, sem editar nenhum `server` existente:
```nginx
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hml-ada.elopro.app;
ssl_certificate /etc/letsencrypt/live/hml-ada.elopro.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hml-ada.elopro.app/privkey.pem;
# Upload de modelo e de fonte: o limite do produto é 10 MB por arquivo.
# A margem cobre o envelope multipart.
client_max_body_size 12m;
# Processamento assíncrono responde rápido, mas a geração de PDF e XLSX
# do download é síncrona. O teto do job é SPREADSHEET_JOB_TIMEOUT_MS.
proxy_read_timeout 180s;
proxy_send_timeout 180s;
location / {
proxy_pass http://127.0.0.1:3110;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Sondas internas: o monitoramento usa estas duas rotas.
location = /api/health/live { proxy_pass http://127.0.0.1:3110; access_log off; }
location = /api/health/ready { proxy_pass http://127.0.0.1:3110; }
}
server {
listen 80;
listen [::]:80;
server_name hml-ada.elopro.app;
return 301 https://$host$request_uri;
}
```
`X-Forwarded-For` é obrigatório: o rate limiting da tela de login identifica o chamador por ele.
Os cabeçalhos de segurança (CSP, HSTS, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) vêm da aplicação, em `next.config.ts`. Não duplique no Nginx: `add_header` no servidor substituiria o conjunto da aplicação.
```bash
sudo ln -s /etc/nginx/sites-available/hml-ada.elopro.app /etc/nginx/sites-enabled/
sudo nginx -t # precisa passar antes de recarregar
sudo systemctl reload nginx # reload, nunca restart: não derruba os outros sites
```
## 3. Identidade (Supabase Auth de homologação)
O Adaptador não tem tabela de usuário, senha, cadastro ou sessão própria. Em homologação a identidade é um **projeto Supabase separado**, só de homologação.
1. Crie o projeto Supabase de homologação. Anote URL, anon key e service role.
2. A service role de homologação não é a de produção e não é compartilhada com nenhum outro aplicativo.
3. Crie as contas de teste no painel do Supabase (Authentication → Users). Nenhuma senha passa pelo repositório, pelo seed ou por este runbook.
4. Anote o UUID de cada conta criada: é o que liga a identidade ao workspace.
5. Em `.env.staging`, deixe `ADAPTER_STAGING_AUTH=1`. Isso habilita `/api/auth/staging/session`, que faz `signInWithPassword` contra esse projeto. A sessão é emitida e validada pelo Supabase.
6. Quando o SSO do Elo Pro existir, troque para `ADAPTER_STAGING_AUTH=0` e `ELO_PRO_LOGIN_URL=<login central>`. Nada mais muda: o contrato está em `src/server/auth/identity-provider.ts`.
### Workspace ativo
O workspace **não** vem de cookie como prova. O cookie `elo_pro_workspace_id` é preferência; toda requisição relê as associações reais em `workspace_members` sob RLS (`src/server/auth/workspace.ts`):
- nenhuma associação → a pessoa vê "nenhum workspace";
- uma associação → escolha automática;
- várias → tela `/selecionar-workspace`, e `POST /api/auth/workspace` só grava o cookie depois de confirmar a associação.
### Seed de homologação
```bash
cp config/staging-seed.example.json config/staging-seed.json
# preencha users[].id com os UUID do Supabase Auth e escolha os UUID de workspace
npm run seed:staging
```
Idempotente: rodar de novo produz o mesmo estado. Não cria conta nem senha, só liga identidades a workspaces e papéis. A feature flag nasce desligada mesmo aqui.
## 4. Banco de homologação
```bash
# 1. Veja o que vai rodar, antes de rodar.
ADAPTER_ENV=staging DATABASE_URL=... ADAPTER_APPLY_IDENTITY_SHIM=? npm run migrate:check
```
A decisão do shim é **explícita e obrigatória**: sem `ADAPTER_APPLY_IDENTITY_SHIM` o start é recusado.
| Situação do banco | Valor | Efeito |
| --- | --- | --- |
| Banco novo, sem `profiles`/`workspaces`/`workspace_members` | `1` | `0001_platform_shim.sql` cria o shim e o papel `app_user` |
| Banco já com o schema do Elo Pro | `0` | `0001` é pulado; só `0002`, `0003` e `0004` rodam |
Com `0`, antes de migrar: confirme que existem `public.profiles(id, display_name, is_superadmin)`, `public.workspaces(id, name)` e `public.workspace_members(workspace_id, user_id, role)`, e que o papel `app_user` existe sem `BYPASSRLS`. Se os nomes ou as colunas diferirem, adapte `src/server/auth/identity-port.ts` **antes** — é o único arquivo que precisa mudar.
```bash
# 2. Aplicar (job com aprovação manual no workflow).
npm run migrate
```
Nenhum processo aplica migration sozinho: o worker recusa iniciar com migration pendente e a web não migra em ambiente implantado.
## 5. Bucket privado
```bash
# Crie o bucket no painel do Supabase de homologação, com visibilidade privada.
# Nome sugerido: adaptador-planilhas-hml
npm run check:bucket
```
`check:bucket` falha se o bucket não existir ou for público. A aplicação também recusa o valor literal `public` em `SUPABASE_STORAGE_BUCKET`. Nenhuma URL pública é gerada: o download passa por `/api/spreadsheet-adapter/jobs/[id]/download`, autenticado e sob RLS.
## 6. Containers
A imagem tem um entrypoint com subcomandos. Use sempre o subcomando, nunca `node` direto: ele garante `node --import tsx`, que é o que faz o SIGTERM do `docker stop` chegar ao processo do worker. O CLI do `tsx` cria um processo filho e o sinal pararia nele.
```bash
cd <HML_DEPLOY_PATH>
cp .env.staging.example .env.staging
# preencha todos os campos e confira
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
$C run --rm web verify-config
$C up -d redis
$C up -d --no-deps web worker
$C ps
```
| Subcomando | O que faz |
| --- | --- |
| `web` | servidor Next (saída `standalone`) |
| `worker` | processo de fila |
| `migrate [--dry-run]` | migrations |
| `seed-staging` | associa identidades do Supabase a workspaces |
| `flag <workspace_id> on off` | feature flag do módulo |
| `cleanup` | expira temporários fora do ciclo do worker |
| `verify-config` | confere a configuração sem tocar em recurso externo |
| `check-bucket` | confere que o bucket existe e é privado |
| `smoke` | smoke do ambiente |
| `worker-health` | confere o heartbeat do worker |
`--no-deps` e o `--project-name` embutido (`adaptador-planilhas-hml`) garantem que nenhum stack de outro aplicativo é tocado. Nunca rode `docker compose down` sem `-f docker-compose.staging.yml`, e nunca `docker system prune` neste servidor.
Web e worker saem da **mesma imagem**: mesmo código, mesmas dependências. A diferença é só o subcomando. O `node_modules` de produção e o traçado pelo `standalone` vêm do mesmo lockfile e não divergem em nenhuma versão; a imagem sobrepõe as duas árvores nessa ordem, com a traçada por último, que é a que o `server.js` espera.
O Redis é um contêiner exclusivo deste aplicativo, com senha, volume e porta próprios. Mesmo assim as chaves são prefixadas com `adaptador-planilhas:staging`, então um Redis compartilhado por engano não misturaria homologação com produção.
## 7. Worker
```bash
$C logs -f worker
$C exec worker /app/docker/entrypoint.sh worker-health
```
O start imprime `[worker] pronto. intervalo=…ms lote=… carencia=…ms alvo=postgres heartbeat=…`.
- `ADAPTER_JOB_MODE=worker` é obrigatório. `inline` faz o start ser recusado.
- Migration pendente faz o worker sair com código 1, com a lista no log.
- `SIGTERM` espera o tick corrente (até `ADAPTER_WORKER_GRACE_MS`, padrão 20 s, dentro do `stop_grace_period` de 30 s) para o `finally` liberar o lock. Sem isso o job ficaria em `processing` até o lock vencer por TTL. O log de uma parada limpa traz `SIGTERM recebido` e `[worker] encerrado`; se faltarem, o contêiner levou SIGKILL e o lock vai esperar o TTL de 120 s.
- Um job que estava apenas na fila, e não começou, permanece `queued`: nada pela metade. O worker seguinte o conclui.
- O worker grava um heartbeat ao fim de cada tick (`ADAPTER_WORKER_HEARTBEAT`, padrão `/tmp/adaptador-worker-heartbeat`). O healthcheck do contêiner usa esse arquivo, porque um processo vivo mas travado passa em qualquer verificação de PID.
- Cada tick também expira temporários (`cleanupExpired`) e recupera jobs órfãos (`recoverStaleJobs`). Não há cron separado.
- O log traz nome e código do erro. Nunca conteúdo de célula.
## 8. Observabilidade
| Item | Onde |
| --- | --- |
| Ambiente no Sentry | `SENTRY_ENVIRONMENT=staging`; sem a variável, cai em `ADAPTER_ENV` |
| Release | `SENTRY_RELEASE` = SHA do commit, injetado como build-arg da imagem |
| Sourcemap | `withSentryConfig` só atua com `SENTRY_ORG`, `SENTRY_PROJECT` e `SENTRY_AUTH_TOKEN` no build |
| Erros de rota e render | `onRequestError` em `src/instrumentation.ts` |
| Sanitização | `scrubSentryEvent` descarta texto livre, cookie, header de autorização e qualquer campo de célula |
| Liveness | `GET /api/health/live` — não toca dependência |
| Readiness | `GET /api/health/ready` — banco, migrations pendentes, Redis (PING), bucket (listagem), configuração e modo de job |
| Auditoria | `public.audit_log`, append-only para `app_user`, sem conteúdo de célula |
Teste a sanitização provocando um erro controlado e confirmando no Sentry que o evento não traz célula, cookie nem nome de pessoa.
## 9. Liberação da feature flag
Último passo, depois do smoke:
```bash
npm run flag -- <workspace_id> on
```
O script recusa um workspace que não existe e registra o evento em `audit_log`. A flag nasce desligada, inclusive em homologação.
## 10. Rollback
### Aplicação: rollback de imagem (reversível, é o caminho normal)
O artefato é imutável e marcado com o SHA do commit: `ghcr.io/<org>/<repo>:staging-<sha>`. Voltar é apontar o compose para a tag anterior. Nada de banco, bucket ou auditoria é tocado.
```bash
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
# 1. Qual imagem está no ar agora. Anote: é o ponto de retorno se o rollback falhar.
$C ps --format '{{.Service}} {{.Image}}'
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
# 2. Quais tags existem para voltar (mais recentes primeiro).
docker image ls 'ghcr.io/<org>/<repo>' --format '{{.Tag}} {{.CreatedAt}}' | sort -k2 -r | head
# 3. Desligar o módulo para os usuários. Efeito imediato, sem deploy.
# Faça isto ANTES de trocar a imagem: encerra o incidente de produto em segundos.
$C run --rm web flag <workspace_id> off
# 4. Trocar para a imagem anterior. Só web e worker; o Redis fica de pé.
export ADAPTER_IMAGE=ghcr.io/<org>/<repo>:staging-<sha-anterior>
docker pull "$ADAPTER_IMAGE"
$C up -d --no-deps web worker
# 5. Conferir que voltou e está saudável.
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
curl -fsS https://hml-ada.elopro.app/api/health/live
curl -fsS https://hml-ada.elopro.app/api/health/ready
$C exec worker /app/docker/entrypoint.sh worker-health
# 6. Religar a flag só depois de confirmar o ambiente.
$C run --rm web flag <workspace_id> on
```
Para parar apenas o processamento, sem derrubar a interface:
```bash
$C stop -t 30 worker # respeita a carência: o lock é liberado
```
Jobs `queued` param de avançar e são retomados quando o worker volta. Jobs `processing` sem lock viram `INTERRUPTED` na próxima varredura e podem ser repetidos pelo usuário. Nenhuma saída já gerada é apagada: ela vence pelo TTL.
Fixe a tag no `.env.staging` (`ADAPTER_IMAGE=...:staging-<sha>`) para que um `up -d` acidental não traga de volta a imagem com defeito.
**Migrations não voltam no rollback de imagem.** As migrations do Adaptador são aditivas: a imagem anterior convive com o schema novo. Se a imagem anterior não conviver, o problema é de compatibilidade de schema e vira operação planejada, não rollback às pressas.
### Schema (não automatizado, de propósito)
Não existe rollback automático de schema e não deve existir.
| Migration | Caminho de volta |
| --- | --- |
| `0002_spreadsheet_adapter.sql` | `migrations/down/0002_spreadsheet_adapter_down.sql`, aplicado à mão, com backup antes |
| `0003_rls.sql` | sem down. Remover policy é afrouxar isolamento: não se desfaz, corrige-se para frente |
| `0004_audit_append_only.sql` | sem down. A trava de auditoria não se remove em rollback |
| `0001_platform_shim.sql` | sem down. O shim só existe em banco que não tem o schema do Elo Pro |
Regra: desligar a flag resolve o incidente de produto. Reverter imagem resolve o incidente de código. Mexer em schema é operação planejada com backup, nunca parte de um rollback às pressas.
**Migrations de segurança são preservadas em qualquer rollback.** `0003_rls.sql` (RLS e `FORCE RLS`) e `0004_audit_append_only.sql` (auditoria append-only) não têm e não terão caminho de volta: desfazê-las afrouxa isolamento entre workspaces e abre a auditoria para alteração pelo papel da aplicação. Um rollback que precise disso está errado por definição.
**Não apague `audit_log`, modelos, banco ou arquivos em nenhum rollback.**
## 11. Diagnóstico
| Sintoma | Causa provável | Verificação |
| --- | --- | --- |
| Container sai no start com código 1 | configuração recusada | `docker compose logs web` traz a lista de variáveis |
| `/api/health/live` não responde | processo caiu | configuração é a primeira hipótese: veja o log do start |
| `ready` com `config: missing` | variável ausente ou inválida | `configIssues` na resposta nomeia cada uma |
| `ready` com `redis: down` | senha, host ou contêiner | `docker compose ps redis` e o PING |
| `ready` com `storage: down` | bucket, nome ou service role | `npm run check:bucket` |
| `ready` com `migrations: missing` | migration pendente | `npm run migrate:check` |
| `ready` com `job_mode: missing` | `ADAPTER_JOB_MODE=inline` | corrija e suba de novo |
| Login cai em branco | `ADAPTER_STAGING_AUTH` desligado e sem `ELO_PRO_LOGIN_URL` | `/login` explica qual falta |
| Fica pedindo workspace | identidade sem associação | `npm run seed:staging` ou incluir em `workspace_members` |
| Job preso em `processing` | worker parado ou lock vencido | log do worker; a varredura marca `INTERRUPTED` |
| 429 nas chamadas | rate limiting | `Retry-After` na resposta; tetos em `src/server/rate-limit.ts` |
## 12. Ordem completa, do zero
1. DNS de `hml-ada.elopro.app`.
2. Certificado TLS só desse host.
3. `server` novo no Nginx, `nginx -t`, `systemctl reload nginx`.
4. Projeto Supabase de homologação; contas de teste no painel.
5. Bucket privado; `npm run check:bucket`.
6. `.env.staging` preenchido; `npm run verify:config`.
7. `npm run migrate:check` e depois `npm run migrate` (aprovação manual).
8. `config/staging-seed.json` e `npm run seed:staging`.
9. `up -d redis`, depois `up -d --no-deps web worker`.
10. `/api/health/live` responde `live`; `/api/health/ready` responde `ready`.
11. Smoke, nas duas fases (ver seção 13).
12. Roteiro manual de `21-checklist-validacao.md`: login real, isolamento, processamento assíncrono, download XLSX/CSV/PDF, erro no Sentry.
13. `npm run flag -- <workspace_id> on`.
14. Ensaio de rollback, com evidência.
15. Só então discutir produção.
## 13. Smoke de homologação
Fase 1 roda sem nenhuma credencial: saúde, recusa sem sessão, cabeçalhos de segurança e ausência de dev auth.
```bash
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app npm run smoke:staging
```
Fase 2 só roda quando a operação fornece uma conta de teste **criada no painel do Supabase de homologação**. Nenhuma credencial fica no repositório, e o script não inventa conta: sem estas variáveis a fase 2 é reportada como PENDENTE, nunca como aprovada.
| Variável | Obrigatória | O que é |
| --- | --- | --- |
| `ADAPTER_SMOKE_URL` | sim | `https://hml-ada.elopro.app` |
| `ADAPTER_SMOKE_EMAIL` | fase 2 | e-mail de uma conta de teste do Supabase de homologação |
| `ADAPTER_SMOKE_PASSWORD` | fase 2 | senha dessa conta |
| `ADAPTER_SMOKE_WORKSPACE` | opcional | UUID do workspace que deve aparecer associado |
```bash
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app ADAPTER_SMOKE_EMAIL=... ADAPTER_SMOKE_PASSWORD=... ADAPTER_SMOKE_WORKSPACE=... npm run smoke:staging
```
A fase 2 confere: login real emitindo sessão, associações de workspace resolvidas no servidor, seleção de workspace aceita e leitura autenticada do módulo. Com a feature flag ainda desligada, `FEATURE_DISABLED` é resposta correta e passa.
Continua fora do smoke, no roteiro manual, porque exige arquivo e flag ligada:
- envio de modelo e de fonte pela interface;
- processamento assíncrono com o worker e download XLSX, CSV e PDF;
- troca de workspace e sessão expirada no navegador;
- erro controlado no Sentry, confirmado sem conteúdo de célula;
- ensaio de rollback de imagem.
@@ -0,0 +1,163 @@
# Checklist de validação e evidências — homologação
Estado em 15 de outubro de 2026, nesta máquina de desenvolvimento, sem acesso ao servidor do ecossistema, ao Supabase de homologação nem ao banco do Elo Pro.
**Sem runtime de contêiner nesta máquina.** O Docker Desktop está instalado e foi iniciado, mas o WSL2 não tem distribuição instalada (`wsl --list` reporta nenhuma), então o engine Linux não sobe. Não há Postgres, Redis, Podman nem nerdctl locais. O que exige contêiner ou serviço real foi validado por **simulação fiel do layout de runtime da imagem** (apenas `node_modules` de produção + `src` + `scripts` + `migrations` + `tsconfig.json`, executado com `node --import tsx`) e, onde nem isso alcança, está PENDENTE para o CI.
Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutura ou credencial, o item é **PENDENTE** — nunca aprovado por inspeção de código.
## A. Verificação local do repositório
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| A1 | `npm run lint` | PASS | sem apontamentos |
| A2 | `npm run typecheck` | PASS | `tsc --noEmit` sem erro |
| A3 | `npm test` | PASS | 17 arquivos, **102 testes**, 1 skip proposital (smoke real) |
| A4 | `npm run build` | PASS | 25 rotas + Proxy, zero avisos, saída `standalone` com 70 MB contendo só `.next`, `node_modules`, `migrations`, `src`, `package.json` e `server.js` |
| A5 | Testes HTTP das rotas (novos) | PASS | `tests/http/routes.test.ts`, 12 casos |
| A6 | Testes do proxy (novos) | PASS | `tests/http/proxy.test.ts`, 7 casos |
| A7 | Política de ambiente e workspace (novos) | PASS | `tests/unit/staging-policy.test.ts`, 22 casos |
| A8 | Suíte com Postgres e Redis reais | PENDENTE | `npm run test:services` reporta 15 ignorados sem `ADAPTER_TEST_DATABASE_URL`/`ADAPTER_TEST_REDIS_URL`. O CI executa no job `services` |
| A9 | Sintaxe do compose local | PASS | `docker compose -f docker-compose.yml config` sem erro |
| A10 | Sintaxe do compose de homologação | PASS | `docker compose -f docker-compose.staging.yml config` resolve todas as variáveis |
| A11 | Build da imagem Docker | PENDENTE | Docker Desktop está instalado nesta máquina, foi iniciado durante o trabalho e o engine Linux não chegou a responder (`500` no pipe `dockerDesktopLinuxEngine`). O CI executa no job `image` |
| A12 | `docker compose up` local | PENDENTE | mesmo motivo de A11 |
| A13 | Artefato `standalone` roda de verdade | PASS | `node .next/standalone/server.js` sobe, responde e atende as rotas (ver B6, C14, D3) |
| A14 | Deps de produção cobrem a árvore traçada | PASS | `npm ci --omit=dev` em diretório limpo: 318 pacotes contra 153 traçados, **zero divergência de versão** entre os compartilhados; 3 pacotes existem só na traçada (`@emnapi/runtime`, `isarray`, `supports-color`) e o `COPY` do Docker mescla sem removê-los |
| A15 | Worker e scripts rodam sem devDependencies | PASS | os 13 pacotes externos usados por `src/` e `scripts/` existem no install de produção; `tsx` 4.23.15 e `esbuild` 0.28.2 presentes |
| A16 | Scripts operacionais executam no layout de runtime | PASS | no layout simulado: `verify-config`, `migrate`, `migrate --dry-run`, `seed-dev`, `cleanup-temp`, `set-flag` (recusa workspace inexistente), `seed-staging` (recusa em development) — todos com o código de saída correto |
## B. Fail-fast de configuração
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| B1 | Configuração incompleta é recusada | PASS | `tests/unit/staging-policy.test.ts`: `assertDeployedEnv` lista `DATABASE_URL` e `REDIS_URL` ausentes |
| B2 | `ADAPTER_DEV_AUTH=1` recusado fora de development | PASS | teste de política e `devAuthEnabled()` |
| B3 | `ADAPTER_JOB_MODE=inline` recusado em staging e produção | PASS | teste de política; `jobRunsInline` retorna `false` |
| B4 | Shim de identidade exige decisão explícita | PASS | ausência de `ADAPTER_APPLY_IDENTITY_SHIM` entra em `configIssues` |
| B5 | Sem `DATABASE_URL` nunca cai em PGlite | PASS | `requireDatabaseUrl` lança em staging e produção |
| B6 | O processo **sai** com código 1, em vez de responder 500 | PASS | executado no artefato construído: `ADAPTER_ENV=staging node .next/standalone/server.js` imprimiu a lista de variáveis, saiu com **código 1** e a porta ficou sem nada ouvindo. Antes desta entrega o mesmo comando deixava o servidor no ar respondendo 500 em todas as rotas, inclusive `/api/health/live` |
| B7 | `npm run verify:config` recusa e explica | PASS | em `ADAPTER_ENV=staging` sem variáveis: 9 itens nomeados, saída com código 1 |
| B8 | O worker também recusa configuração ausente | PASS | no layout de runtime, `ADAPTER_ENV=staging` sem variáveis: saída com código 1 antes de tocar banco ou Redis |
| B9 | Contêiner da web e do worker saem com código ≠ 0 | PENDENTE | passo `Configuração ausente encerra com código diferente de zero` do job `image`, que inspeciona `.State.ExitCode` dos dois |
## C. Autenticação e multi-tenant
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| C1 | API do módulo sem sessão responde 401 | PASS | `tests/http/routes.test.ts` |
| C2 | Página protegida sem sessão vai ao login | PASS | `tests/http/proxy.test.ts` |
| C3 | Cookie de workspace não é prova de autorização | PASS | `resolveWorkspace` ignora dica sem associação real; teste com dica forjada e com injeção |
| C4 | Gravar workspace de terceiro é recusado | PASS | `POST /api/auth/workspace` → 403 e cookie não gravado |
| C5 | Associações listadas são só as do usuário | PASS | teste com Eva, que só vê Beta |
| C6 | Módulo desligado bloqueia o workspace | PASS | teste com Diego em Beta |
| C7 | Papel sem permissão não altera configuração | PASS | teste com Bruno (agent) |
| C8 | Login de homologação indisponível fora de staging | PASS | `/api/auth/staging/session` → 404 |
| C16 | Dev auth não liga em homologação, em nenhuma combinação | PASS | `tests/unit/dev-auth-guard.test.ts`, 18 casos: matriz de ambientes na função, recusa na validação de configuração, e o provedor local fora da lista de provedores em staging e produção. Um token de dev válido é recusado quando o ambiente é staging |
| C9 | Isolamento entre workspaces na suíte de aplicação | PASS | `tests/security/isolation.test.ts`, 6 casos |
| C10 | RLS em PostgreSQL real, papel `app_user` sem BYPASSRLS | PENDENTE | `tests/services/postgres.test.ts` escrito; roda no CI e no banco de homologação |
| C11 | Login real no navegador, com conta do Supabase de homologação | PENDENTE | exige o projeto Supabase de homologação. O caminho equivalente foi executado no nível de development (C14) |
| C14 | Login real e isolamento no artefato em execução | PASS | contra `node server.js`: sessão emitida, `GET /api/auth/workspace` devolveu só o workspace da identidade, `POST` com workspace de terceiro → **403** sem gravar cookie, `POST` com o próprio → 200, `/adaptador` → 200 |
| C15 | Cookie de workspace forjado é ignorado em execução | PASS | três tentativas com a sessão de Ana (Alpha): cookie apontando Beta, cookie com `' or 1=1 --` e cookie com UUID inexistente. Em todas a resolução permaneceu em Alpha; Beta nunca foi alcançado |
| C12 | Troca de workspace na interface | PENDENTE | exige duas associações reais em homologação |
| C13 | Sessão expirada volta ao login | PENDENTE | exige sessão real |
## D. Infraestrutura e processamento
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| D1 | Rate limiting devolve 429 com `Retry-After` | PASS | `tests/http/routes.test.ts` |
| D2 | Cabeçalhos de segurança configurados | PASS | `next.config.ts`: CSP, HSTS, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, COOP |
| D18 | Smoke prepara a fase autenticada do Supabase | PASS no script / PENDENTE na execução | `scripts/smoke-staging.ts` tem as duas fases; sem `ADAPTER_SMOKE_EMAIL`/`PASSWORD` a fase 2 imprime PENDENTE. Credenciais listadas na seção 13 do runbook |
| D3 | Cabeçalhos presentes na resposta HTTP real | PASS no artefato / PENDENTE atrás do Nginx | resposta de `/api/health/live` trouxe CSP, HSTS, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, COOP, `X-Content-Type-Options` e `Cache-Control: no-store`. Falta confirmar que o Nginx não sobrescreve |
| D4 | Redis e Supabase reutilizados por processo | PASS | singleton em `src/infra/redis/index.ts` e `src/infra/storage/index.ts` |
| D5 | Namespace separado por aplicativo e ambiente | PASS | `resourceNamespace`; teste confirma que staging ≠ production |
| D6 | Listagem de bucket paginada | PASS | `src/infra/storage/supabase.ts` pagina com `offset` e desce em subpastas |
| D7 | Bucket privado conferido | PENDENTE | `npm run check:bucket` exige o bucket real |
| D8 | Lock entre processos com Redis real | PENDENTE | `tests/services/redis.test.ts`; roda no CI |
| D9 | Processamento assíncrono entre processos | PENDENTE | `.github/scripts/async-journey.sh` percorre web → Redis → worker → download no compose; roda no job `image` |
| D9b | Jornada assíncrona pelo loop real do worker | PASS | `tests/integration/worker.test.ts`: a web enfileira e devolve `queued`, o download falha antes do tick, o tick conclui e os três formatos saem; o modelo original não muda |
| D9c | Job sobrevive à perda da fila do Redis | PASS | fila drenada à mão: a varredura do Postgres acha o job `queued` e conclui |
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
| D14 | TTL de temporários aplicado | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts`; falta observar o vencimento real |
| D15 | Health check responde `ready` com dependências satisfeitas | PASS | primeira chamada com banco frio: `{"status":"ready"}` com `config`, `database`, `migrations`, `redis`, `storage` e `job_mode` em `ok`, sem connection string na resposta |
| D16 | Health check aponta a dependência que falhou | PASS | campo `reasons` traz só o nome do erro e os ids de migration pendente |
## E. Observabilidade
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| E1 | Payload do Sentry sem conteúdo de célula | PASS | `tests/unit/pilot-policy.test.ts` com sentinela |
| E2 | Ambiente e release por ambiente | PASS | `sentryInitOptions` usa `ADAPTER_ENV` e `SENTRY_RELEASE` |
| E3 | `onRequestError` registrado | PASS | export em `src/instrumentation.ts` |
| E4 | Evento real chega ao projeto Sentry de homologação | PENDENTE | exige DSN real |
| E5 | Sourcemap com stack legível | PENDENTE | exige `SENTRY_ORG`, `SENTRY_PROJECT` e `SENTRY_AUTH_TOKEN` no build |
| E6 | Auditoria sem conteúdo de célula | PASS | `tests/integration/rls.test.ts` e `sanitizeAuditMetadata` |
## F. Ambiente e deploy
| # | Item | Resultado | Evidência |
| --- | --- | --- | --- |
| F1 | Dockerfile multistage com `standalone` | PASS | `Dockerfile` + `output: "standalone"` |
| F2 | Scripts operacionais funcionam sem devDependencies | PASS | executados no layout de runtime simulado (ver A15 e A16), não apenas por construção |
| F12 | Job de build Docker Linux no CI com validação da imagem | PASS no workflow / PENDENTE na execução | job `image`: build, conteúdo, usuário não-root, ausência de segredo/docs/testes, carga dos scripts, fail-fast dos dois processos, liveness com cabeçalhos, compose completo, jornada assíncrona entre processos e desligamento gracioso |
| F13 | Rollback de imagem documentado | PASS | runbook seção 10: identificar a imagem no ar, listar tags, desligar a flag antes, trocar só web e worker com `--no-deps`, conferir saúde, religar a flag. `0003_rls.sql` e `0004_audit_append_only.sql` declaradas sem caminho de volta |
| F3 | Node e gerenciador fixados | PASS | `.nvmrc` 24.11.1, `engines.node >=22.12 <25`, `packageManager npm@11.6.2` |
| F4 | Compose isolado dos outros aplicativos | PASS | nome de projeto, rede e volume próprios; portas só em `127.0.0.1`; deploy com `--no-deps` |
| F5 | `.env.staging.example` sem segredo | PASS | todos os campos sensíveis vazios |
| F6 | Migration, deploy e flag em jobs separados com aprovação | PASS | `.github/workflows/deploy-staging.yml`, ambientes `hml-migracao`, `hml-deploy`, `hml-flag` |
| F7 | Deploy real em `hml-ada.elopro.app` | PENDENTE | exige servidor, DNS e segredos |
| F8 | DNS e TLS do subdomínio | PENDENTE | exige acesso ao DNS |
| F9 | Nginx do novo `server` | PENDENTE | exige acesso ao servidor |
| F10 | Smoke de homologação | PENDENTE | `npm run smoke:staging` exige o ambiente |
| F11 | Ensaio de rollback | PENDENTE | exige duas imagens publicadas e o ambiente |
## Resumo
| | Quantidade |
| --- | --- |
| PASS com evidência executada | 54 |
| PENDENTE por falta de infraestrutura ou credencial | 20 |
| FAIL | 0 |
Nada foi marcado PASS por inspeção de código. Os PENDENTE que dependem só de Docker (A11, A12, A8, B9, C10, D8, D9, F12) executam no CI no primeiro push; os demais dependem de infraestrutura do ecossistema.
## Defeitos encontrados nesta rodada de validação
Os quatro apareceram justamente ao exercitar o layout de runtime da imagem, e nenhum aparecia na suíte de testes ou no build.
| # | Defeito | Efeito em homologação | Correção |
| --- | --- | --- | --- |
| 1 | O CLI do `tsx` cria processo filho | SIGTERM do `docker stop` pararia no pai; o worker nunca rodaria o handler, o lock esperaria o TTL de 120 s e o `stop_grace_period` só atrasaria um SIGKILL | entrypoint usa `node --import tsx`; PID conferido igual |
| 2 | Top-level await nos scripts com `--import tsx` | `migrate`, `worker`, `seed-staging`, `set-flag`, `cleanup` e `check-bucket` falhariam com `Transform failed`. O worker não subiria e a migração não rodaria | `runScript`/`runWithDb` em `scripts/_bootstrap.ts`; nenhum script usa top-level await |
| 3 | `import "server-only"` em `worker-loop.ts` | o pacote lança fora do bundler do Next: o worker morreria no start | removido do módulo compartilhado, com o motivo registrado no arquivo |
| 4 | `@react-pdf/renderer` carregado no import de `process-job.ts` | `ERR_PACKAGE_PATH_NOT_EXPORTED` ao resolver a hifenização fora do bundler: derrubava o worker e o `cleanup` | import tardio dentro do ramo de PDF, que só o download usa |
## Bloqueios para liberar homologação
Todos externos ao repositório. Nenhum depende de código.
1. DNS de `hml-ada.elopro.app` e certificado TLS.
2. Projeto Supabase de homologação, com contas de teste e bucket privado.
3. Banco de homologação com `DATABASE_URL` e a decisão do shim conferida.
4. Servidor com Docker, `HML_DEPLOY_PATH` e os segredos dos ambientes do GitHub.
5. DSN do Sentry de homologação.
## Bloqueios para discutir produção
Além de tudo acima, concluído:
1. Smoke real completo, com evidência, em homologação.
2. Ensaio de rollback executado, com evidência.
3. SSO do Elo Pro integrado e validado no navegador.
4. Schema real do Elo Pro confirmado e `identity-port.ts` apontando para ele.
5. Tokens oficiais do Design System. O azul `#0B3D91` continua provisório.
## Evidências a anexar
Para cada item PENDENTE que for executado, registre aqui: data, quem executou, comando ou tela, e saída obtida. Resposta de `GET /api/health/ready` (sem segredo), log do worker concluindo um job, print do download dos três formatos, print do evento sanitizado no Sentry e log do ensaio de rollback.
+82 -7
View File
@@ -1,11 +1,66 @@
import type { NextConfig } from "next";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.url));
function hostOf(value: string | undefined): string | null {
if (!value) return null;
try {
return new URL(value).origin;
} catch {
return null;
}
}
/**
* Content-Security-Policy. `unsafe-inline` em script-src é necessário para o
* bootstrap do App Router sem nonce; o restante é fechado. `connect-src` só
* aceita o próprio host, o Supabase do ambiente e o Sentry.
*/
function contentSecurityPolicy(): string {
const supabase = hostOf(process.env.NEXT_PUBLIC_SUPABASE_URL);
const connect = ["'self'", supabase, supabase?.replace("https://", "wss://"), "https://*.sentry.io"].filter(
Boolean,
) as string[];
return [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
`connect-src ${connect.join(" ")}`,
"worker-src 'self' blob:",
"upgrade-insecure-requests",
].join("; ");
}
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: contentSecurityPolicy() },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "X-DNS-Prefetch-Control", value: "off" },
// HSTS só faz sentido atrás de TLS. O Nginx do ecossistema termina o TLS.
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
];
const nextConfig: NextConfig = {
turbopack: { root },
// Saída autocontida para a imagem de runtime.
output: "standalone",
outputFileTracingRoot: root,
// `migrations/` é lido em runtime pelo migrate e pelo health check.
outputFileTracingIncludes: {
"/api/health/ready": ["./migrations/**/*.sql"],
},
serverExternalPackages: [
"exceljs",
"@react-pdf/renderer",
@@ -16,16 +71,36 @@ const nextConfig: NextConfig = {
poweredByHeader: false,
async headers() {
return [
{ source: "/:path*", headers: securityHeaders },
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
],
source: "/api/:path*",
headers: [...securityHeaders, { key: "Cache-Control", value: "no-store" }],
},
];
},
};
export default nextConfig;
/**
* Upload de sourcemap e release só acontece quando a organização, o projeto e o
* token existem no ambiente de build. Sem isto o build local e o CI continuam
* funcionando, e o stack trace do servidor chega legível ao Sentry quando o
* pipeline fornece os segredos.
*/
function withSentryIfConfigured(config: NextConfig): NextConfig {
const org = process.env.SENTRY_ORG;
const project = process.env.SENTRY_PROJECT;
if (!org || !project || !process.env.SENTRY_AUTH_TOKEN) return config;
const nodeRequire = createRequire(import.meta.url);
const { withSentryConfig } = nodeRequire("@sentry/nextjs") as typeof import("@sentry/nextjs");
return withSentryConfig(config, {
org,
project,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: true,
widenClientFileUpload: true,
disableLogger: true,
sourcemaps: { deleteSourcemapsAfterUpload: true },
});
}
export default withSentryIfConfigured(nextConfig);
+18 -7
View File
@@ -2,19 +2,30 @@
"name": "adaptador-planilhas",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.12 <25"
},
"packageManager": "npm@11.6.2",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"start:standalone": "node .next/standalone/server.js",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"migrate": "tsx scripts/migrate.ts",
"seed": "tsx scripts/seed-dev.ts",
"worker": "tsx scripts/worker.ts",
"cleanup": "tsx scripts/cleanup-temp.ts",
"flag": "tsx scripts/set-flag.ts"
"test:services": "vitest run --config vitest.services.config.ts",
"migrate": "node --import=tsx scripts/migrate.ts",
"migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
"seed": "node --import=tsx scripts/seed-dev.ts",
"seed:staging": "node --import=tsx scripts/seed-staging.ts",
"worker": "node --import=tsx scripts/worker.ts",
"cleanup": "node --import=tsx scripts/cleanup-temp.ts",
"flag": "node --import=tsx scripts/set-flag.ts",
"verify:config": "node --import=tsx scripts/verify-config.ts",
"check:bucket": "node --import=tsx scripts/check-bucket.ts",
"smoke:staging": "node --import=tsx scripts/smoke-staging.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -31,7 +42,8 @@
"postgres": "^3.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"server-only": "^0.0.1"
"server-only": "^0.0.1",
"tsx": "^4.20.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
@@ -46,7 +58,6 @@
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
+67
View File
@@ -0,0 +1,67 @@
import path from "node:path";
import { adapterEnv, identityShimEnabled, isDeployedEnv, requireDatabaseUrl } from "../src/config/env";
import { exitOnInvalidConfig } from "../src/config/fail-fast";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import type { Db } from "../src/infra/db/types";
/**
* Banco para os scripts operacionais. Em staging e produção `DATABASE_URL` é
* obrigatória: cair em PGlite com um segredo ausente faria uma migração
* silenciosa num arquivo local parecer sucesso.
*/
export async function openOperationalDb(): Promise<Db> {
const databaseUrl = requireDatabaseUrl();
if (databaseUrl) return createPostgresDb(databaseUrl);
if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatória fora de development.");
}
const dataDir = path.join(process.cwd(), ".data", "pglite");
const { mkdir } = await import("node:fs/promises");
await mkdir(dataDir, { recursive: true });
return createFileDb(dataDir);
}
/** Recusa configuração inválida antes de tocar em qualquer recurso externo. */
export function bootstrapScript(name: string): void {
exitOnInvalidConfig(process.env);
console.log(`[${name}] ambiente=${adapterEnv()} shim_identidade=${identityShimEnabled() ? "sim" : "nao"}`);
}
export function describeTarget(): string {
return process.env.DATABASE_URL ? "postgres" : "pglite local";
}
/**
* Executa o corpo do script.
*
* Existe para que nenhum script use top-level await: `tsx` compila `.ts` como
* CommonJS quando o `package.json` não declara `"type": "module"`, e aí top-level
* await é erro de transformação. Isso impedia rodar os scripts com
* `node --import tsx`, que é a forma que entrega SIGTERM ao nosso processo — o
* CLI do `tsx` cria um processo filho e o sinal não chega ao handler.
*/
export function runScript(name: string, body: () => Promise<void>): void {
body().then(
() => {
process.exitCode ??= 0;
},
(error: unknown) => {
const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro";
console.error(`[${name}] ${label}`);
process.exit(1);
},
);
}
/** Abre o banco, executa e fecha, mesmo em caso de falha. */
export function runWithDb(name: string, body: (db: Db) => Promise<void>): void {
runScript(name, async () => {
const db = await openOperationalDb();
try {
await body(db);
} finally {
await db.close().catch(() => undefined);
}
});
}
+16
View File
@@ -0,0 +1,16 @@
import { assertPrivateBucket } from "../src/infra/storage/supabase";
import { runScript } from "./_bootstrap";
/** Confere que o bucket existe e é privado. Não cria bucket. */
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
if (!url || !key || !bucket) {
console.error("Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.");
process.exit(1);
}
runScript("check-bucket", async () => {
await assertPrivateBucket(url, key, bucket);
console.log(`bucket "${bucket}" existe e é privado`);
});
+8 -9
View File
@@ -1,12 +1,11 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { MemoryCoordinator } from "../src/infra/redis/memory";
import { createCoordinator } from "../src/infra/redis";
import { createStorage } from "../src/infra/storage";
import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job";
import { bootstrapScript, runWithDb } from "./_bootstrap";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() });
console.log(`temporários expirados: ${removed}`);
await db.close();
bootstrapScript("cleanup-temp");
runWithDb("cleanup-temp", async (db) => {
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
console.log(`temporários expirados: ${removed}`);
});
+27 -9
View File
@@ -1,10 +1,28 @@
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { migrate } from "../src/infra/db/migrate";
import path from "node:path";
import { identityShimEnabled } from "../src/config/env";
import { migrate, pendingMigrations } from "../src/infra/db/migrate";
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
const ran = await migrate(db);
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente");
await db.close();
/**
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline
* antes do passo manual de migração, para que ninguém descubra o que vai rodar
* só depois de ter rodado.
*/
const dryRun = process.argv.includes("--dry-run");
bootstrapScript("migrate");
runWithDb("migrate", async (db) => {
const pending = await pendingMigrations(db);
console.log(`alvo: ${describeTarget()}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
if (pending.length === 0) {
console.log("migrations: nada pendente");
return;
}
if (dryRun) {
console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`);
return;
}
const ran = await migrate(db);
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
});
+13 -9
View File
@@ -1,12 +1,16 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { adapterEnv } from "../src/config/env";
import { migrate } from "../src/infra/db/migrate";
import { ensureDevFixtures } from "../src/dev/fixtures";
import { bootstrapScript, runWithDb } from "./_bootstrap";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await migrate(db);
await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas");
await db.close();
bootstrapScript("seed-dev");
if (adapterEnv() !== "development") {
console.error("seed-dev só roda em development. Para homologação use `npm run seed:staging`.");
process.exit(1);
}
runWithDb("seed-dev", async (db) => {
await migrate(db);
await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas");
});
+134
View File
@@ -0,0 +1,134 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { adapterEnv, isProductionEnv } from "../src/config/env";
import { migrate } from "../src/infra/db/migrate";
import type { Db } from "../src/infra/db/types";
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
/**
* Seed idempotente de homologação.
*
* Não cria conta, senha ou sessão: as identidades vivem no Supabase Auth do
* projeto de homologação e são criadas pela operação no painel do Supabase.
* Este script apenas liga aqueles `user_id` já existentes a workspaces e
* papéis. Rodar duas vezes produz o mesmo estado.
*
* Arquivo: `ADAPTER_STAGING_SEED_FILE` (padrão `config/staging-seed.json`).
* Modelo sem segredo em `config/staging-seed.example.json`.
*/
type SeedWorkspace = { id: string; name: string; moduleEnabled: boolean; allowNonAdminModelUpload?: boolean };
type SeedUser = { id: string; displayName: string; isSuperadmin?: boolean };
type SeedMember = { workspaceId: string; userId: string; role: "owner" | "admin" | "gestor" | "agent" };
type SeedFile = { workspaces: SeedWorkspace[]; users: SeedUser[]; members: SeedMember[] };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ROLES = new Set(["owner", "admin", "gestor", "agent"]);
function assertSeed(seed: SeedFile): void {
const workspaceIds = new Set<string>();
for (const workspace of seed.workspaces) {
if (!UUID_RE.test(workspace.id)) throw new Error(`workspace.id não é UUID: ${workspace.id}`);
if (!workspace.name?.trim()) throw new Error(`workspace ${workspace.id} sem nome`);
workspaceIds.add(workspace.id.toLowerCase());
}
const userIds = new Set<string>();
for (const user of seed.users) {
if (!UUID_RE.test(user.id)) {
throw new Error(`users[].id deve ser o UUID do usuário no Supabase Auth: ${user.id}`);
}
if (!user.displayName?.trim()) throw new Error(`usuário ${user.id} sem displayName`);
userIds.add(user.id.toLowerCase());
}
for (const member of seed.members) {
if (!ROLES.has(member.role)) throw new Error(`papel inválido: ${member.role}`);
if (!workspaceIds.has(member.workspaceId.toLowerCase())) {
throw new Error(`membro aponta workspace fora do seed: ${member.workspaceId}`);
}
if (!userIds.has(member.userId.toLowerCase())) {
throw new Error(`membro aponta usuário fora do seed: ${member.userId}`);
}
}
}
async function applySeed(db: Db, seed: SeedFile): Promise<void> {
await db.withService(async (query) => {
for (const workspace of seed.workspaces) {
await query.query(
`insert into public.workspaces (id, name) values ($1, $2)
on conflict (id) do update set name = excluded.name`,
[workspace.id, workspace.name],
);
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspace.id, workspace.moduleEnabled],
);
if (workspace.allowNonAdminModelUpload !== undefined) {
await query.query(
`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload)
values ($1, $2)
on conflict (workspace_id) do update set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload`,
[workspace.id, workspace.allowNonAdminModelUpload],
);
}
}
for (const user of seed.users) {
await query.query(
`insert into public.profiles (id, display_name, is_superadmin) values ($1, $2, $3)
on conflict (id) do update set display_name = excluded.display_name, is_superadmin = excluded.is_superadmin`,
[user.id, user.displayName, user.isSuperadmin ?? false],
);
}
for (const member of seed.members) {
await query.query(
`insert into public.workspace_members (workspace_id, user_id, role) values ($1, $2, $3)
on conflict (workspace_id, user_id) do update set role = excluded.role`,
[member.workspaceId, member.userId, member.role],
);
}
});
}
bootstrapScript("seed-staging");
if (isProductionEnv()) {
console.error("seed-staging não roda em produção. As associações de produção são da plataforma Elo Pro.");
process.exit(1);
}
if (adapterEnv() === "development" && process.env.ADAPTER_SEED_FORCE !== "1") {
console.error("Em development use `npm run seed`. Para forçar este seed, defina ADAPTER_SEED_FORCE=1.");
process.exit(1);
}
const file = process.env.ADAPTER_STAGING_SEED_FILE ?? path.join("config", "staging-seed.json");
runScript("seed-staging", async () => {
let seed: SeedFile;
try {
seed = JSON.parse(await readFile(path.resolve(process.cwd(), file), "utf8")) as SeedFile;
} catch (error) {
console.error(
`Não foi possível ler o seed em "${file}". Copie config/staging-seed.example.json e preencha com os UUID do Supabase Auth.`,
);
console.error(error instanceof Error ? error.name : "erro");
process.exit(1);
}
seed.workspaces ??= [];
seed.users ??= [];
seed.members ??= [];
assertSeed(seed);
const db = await openOperationalDb();
try {
const ran = await migrate(db);
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
await applySeed(db, seed);
console.log(
`seed de homologação aplicado em ${describeTarget()}: ${seed.workspaces.length} workspace(s), ${seed.users.length} identidade(s), ${seed.members.length} associação(ões)`,
);
} finally {
await db.close().catch(() => undefined);
}
});
+30 -23
View File
@@ -1,30 +1,37 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
const workspaceId = process.argv[2];
const enabled = process.argv[3] !== "off";
if (!workspaceId) {
const action = process.argv[3];
if (!workspaceId || !action || !["on", "off"].includes(action)) {
console.error("uso: npm run flag -- <workspace_id> on|off");
process.exit(1);
}
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await db.withService(async (query) => {
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspaceId, enabled],
);
await query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
from public.workspace_members m
where m.workspace_id = $1 and m.role = 'owner'
limit 1`,
[workspaceId, enabled],
const enabled = action === "on";
bootstrapScript("set-flag");
runWithDb("set-flag", async (db) => {
const existing = await db.withService((query) =>
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
);
if (!existing[0]) {
throw new Error(`workspace ${workspaceId} não existe em ${describeTarget()}. Nada foi alterado.`);
}
await db.withService(async (query) => {
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspaceId, enabled],
);
await query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
from public.workspace_members m
where m.workspace_id = $1 and m.role = 'owner'
limit 1`,
[workspaceId, enabled],
);
});
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`);
});
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
await db.close();
+259
View File
@@ -0,0 +1,259 @@
import { runScript } from "./_bootstrap";
/**
* Smoke de homologação contra o ambiente em pé.
*
* Fase 1 (sempre): saúde, recusa sem sessão, cabeçalhos, ausência de dev auth.
* Fase 2 (só com credencial fornecida): login real no Supabase Auth de
* homologação, resolução de workspace e leitura autenticada do módulo.
*
* Nenhuma credencial é inventada aqui. A fase 2 só roda quando a operação
* fornece as variáveis abaixo, com uma conta criada no painel do Supabase:
*
* ADAPTER_SMOKE_URL URL pública, ex. https://hml-ada.elopro.app
* ADAPTER_SMOKE_EMAIL e-mail de uma conta de teste de homologação
* ADAPTER_SMOKE_PASSWORD senha dessa conta
* ADAPTER_SMOKE_WORKSPACE (opcional) UUID do workspace esperado
*
* Sem ADAPTER_SMOKE_EMAIL/PASSWORD a fase 2 é reportada como PENDENTE, nunca
* como aprovada. O envio de planilha, o processamento e o download seguem no
* roteiro manual do runbook: exigem arquivo e a flag do módulo ligada.
*/
type Check = { name: string; ok: boolean; detail: string; pending?: boolean };
const base = (process.env.ADAPTER_SMOKE_URL ?? "").replace(/\/$/, "");
const email = process.env.ADAPTER_SMOKE_EMAIL;
const password = process.env.ADAPTER_SMOKE_PASSWORD;
const expectedWorkspace = process.env.ADAPTER_SMOKE_WORKSPACE;
if (!base) {
console.error("Defina ADAPTER_SMOKE_URL com a URL pública do ambiente.");
process.exit(1);
}
const checks: Check[] = [];
function record(name: string, ok: boolean, detail: string) {
checks.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`);
}
function pending(name: string, detail: string) {
checks.push({ name, ok: false, detail, pending: true });
console.log(`PENDENTE ${name} — ${detail}`);
}
async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown; response: Response }> {
const response = await fetch(`${base}${path}`, {
redirect: "manual",
...init,
headers: { accept: "application/json", ...(init?.headers ?? {}) },
});
const text = await response.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
/* resposta não-JSON entra como texto */
}
return { status: response.status, body, response };
}
/** Junta os cookies de Set-Cookie para a próxima requisição. */
function collectCookies(response: Response, jar: Map<string, string>): void {
const raw = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
for (const line of raw) {
const [pair] = line.split(";");
const index = pair.indexOf("=");
if (index > 0) jar.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
}
}
function cookieHeader(jar: Map<string, string>): string {
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
runScript("smoke-staging", async () => {
// ---------------------------------------------------------------- fase 1
console.log(`== fase 1: ambiente em ${base} ==`);
try {
const live = await fetchJson("/api/health/live");
const body = live.body as { status?: string };
record("health/live", live.status === 200 && body?.status === "live", `status ${live.status}`);
} catch (error) {
record("health/live", false, error instanceof Error ? error.message : "falha de rede");
}
let environment: string | undefined;
try {
const ready = await fetchJson("/api/health/ready");
const body = ready.body as {
status?: string;
environment?: string;
checks?: Record<string, string>;
reasons?: Record<string, string>;
configIssues?: string[];
};
environment = body?.environment;
record(
"health/ready",
ready.status === 200 && body?.status === "ready",
`status ${ready.status} checks=${JSON.stringify(body?.checks ?? {})} reasons=${JSON.stringify(body?.reasons ?? {})}`,
);
record("ambiente é staging", body?.environment === "staging", `environment=${body?.environment ?? "?"}`);
record(
"sem pendência de configuração",
(body?.configIssues ?? []).length === 0,
(body?.configIssues ?? []).join("; ") || "nenhuma",
);
} catch (error) {
record("health/ready", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const anon = await fetchJson("/api/spreadsheet-adapter/models");
record("API sem sessão recusa", anon.status === 401, `status ${anon.status}`);
} catch (error) {
record("API sem sessão recusa", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const page = await fetch(`${base}/adaptador`, { redirect: "manual" });
const location = page.headers.get("location") ?? "";
record(
"/adaptador sem sessão redireciona ao login",
page.status >= 300 && page.status < 400 && location.includes("/login"),
`status ${page.status} location=${location || "(vazio)"}`,
);
} catch (error) {
record("/adaptador sem sessão redireciona ao login", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const response = await fetch(`${base}/api/health/live`, { redirect: "manual" });
const required = [
"content-security-policy",
"strict-transport-security",
"x-content-type-options",
"x-frame-options",
"referrer-policy",
"permissions-policy",
];
const missing = required.filter((header) => !response.headers.get(header));
record("cabeçalhos de segurança", missing.length === 0, missing.length ? `faltam: ${missing.join(", ")}` : "todos presentes");
} catch (error) {
record("cabeçalhos de segurança", false, error instanceof Error ? error.message : "falha de rede");
}
// Dev auth não pode existir em homologação pública, em nenhuma circunstância.
try {
const dev = await fetch(`${base}/api/dev/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: "00000000-0000-4000-8000-000000000000",
workspaceId: "00000000-0000-4000-8000-000000000000",
}),
});
record("dev auth indisponível", dev.status === 404, `status ${dev.status}`);
} catch (error) {
record("dev auth indisponível", false, error instanceof Error ? error.message : "falha de rede");
}
// ---------------------------------------------------------------- fase 2
console.log("");
console.log("== fase 2: login real no Supabase Auth de homologação ==");
if (!email || !password) {
pending(
"login real",
"defina ADAPTER_SMOKE_EMAIL e ADAPTER_SMOKE_PASSWORD de uma conta criada no painel do Supabase de homologação",
);
pending("resolução de workspace autenticada", "depende do login real");
pending("leitura autenticada do módulo", "depende do login real");
} else {
const jar = new Map<string, string>();
let logged = false;
try {
const login = await fetch(`${base}/api/auth/staging/session`, {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
collectCookies(login, jar);
logged = login.status === 200 && jar.size > 0;
record("login real", logged, `status ${login.status} cookies=${jar.size}`);
} catch (error) {
record("login real", false, error instanceof Error ? error.message : "falha de rede");
}
if (logged) {
try {
const workspaces = await fetchJson("/api/auth/workspace", { headers: { cookie: cookieHeader(jar) } });
const body = workspaces.body as { memberships?: { workspaceId: string; workspaceName: string; role: string }[] };
const list = body?.memberships ?? [];
record(
"resolução de workspace autenticada",
workspaces.status === 200 && list.length > 0,
`status ${workspaces.status} associações=${list.length}`,
);
if (expectedWorkspace) {
record(
"workspace esperado está associado",
list.some((item) => item.workspaceId.toLowerCase() === expectedWorkspace.toLowerCase()),
`esperado=${expectedWorkspace}`,
);
}
if (list.length > 0) {
const chosen = expectedWorkspace ?? list[0].workspaceId;
const select = await fetch(`${base}/api/auth/workspace`, {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json", cookie: cookieHeader(jar) },
body: JSON.stringify({ workspaceId: chosen }),
});
collectCookies(select, jar);
record("seleção de workspace aceita", select.status === 200, `status ${select.status}`);
}
} catch (error) {
record("resolução de workspace autenticada", false, error instanceof Error ? error.message : "falha de rede");
}
try {
const models = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: cookieHeader(jar) } });
// 200 com o módulo ligado; 403 FEATURE_DISABLED é resposta correta
// enquanto a feature flag do workspace continua desligada.
const body = models.body as { error?: { code?: string } };
const aceitavel = models.status === 200 || body?.error?.code === "FEATURE_DISABLED";
record(
"leitura autenticada do módulo",
aceitavel,
`status ${models.status} code=${body?.error?.code ?? "ok"}`,
);
} catch (error) {
record("leitura autenticada do módulo", false, error instanceof Error ? error.message : "falha de rede");
}
} else {
pending("resolução de workspace autenticada", "login não concluiu");
pending("leitura autenticada do módulo", "login não concluiu");
}
}
// ---------------------------------------------------------------- resumo
const failed = checks.filter((check) => !check.ok && !check.pending);
const pendentes = checks.filter((check) => check.pending);
console.log("");
console.log(`${checks.length - failed.length - pendentes.length}/${checks.length} aprovadas, ${pendentes.length} pendentes, ${failed.length} falhas.`);
console.log(`ambiente reportado: ${environment ?? "desconhecido"}`);
console.log("");
console.log("Continua fora deste script e obrigatório no runbook:");
console.log(" - envio de modelo e de fonte pela interface");
console.log(" - processamento assíncrono com o worker e download XLSX, CSV e PDF");
console.log(" - troca de workspace e sessão expirada no navegador");
console.log(" - evento controlado no Sentry, confirmado sem conteúdo de célula");
console.log(" - ensaio de rollback de imagem");
if (failed.length > 0) process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
import { adapterEnv, collectConfigIssues, identityShimEnabled, jobRunsInline, stagingAuthEnabled } from "../src/config/env";
/**
* Confere a configuração sem tocar em recurso externo. Usado no pipeline antes
* do deploy e dentro do container no primeiro start.
*/
const issues = collectConfigIssues(process.env);
console.log(`ambiente: ${adapterEnv()}`);
console.log(`modo de job: ${jobRunsInline() ? "inline" : "worker"}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "aplicar" : "não aplicar"}`);
console.log(`login próprio de homologação: ${stagingAuthEnabled() ? "ligado" : "desligado"}`);
if (issues.length === 0) {
console.log("configuração aceita");
process.exit(0);
}
console.error("configuração recusada:");
for (const issue of issues) console.error(` - ${issue.variable}: ${issue.message}`);
process.exit(1);
+65 -68
View File
@@ -1,76 +1,73 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { jobRunsInline } from "../src/config/env";
import { pendingMigrations } from "../src/infra/db/migration-manifest";
import { createCoordinator } from "../src/infra/redis";
import { createStorage } from "../src/infra/storage";
import { migrate } from "../src/infra/db/migrate";
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job";
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop";
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
import { assertProductionEnv } from "../src/config/env";
/**
* Processo do worker. Só bootstrap, sinais e cadência: a lógica do tick está em
* `src/server/spreadsheet-adapter/worker-loop.ts`, que é testável sem subir
* processo.
*
* Precisa ser executado com `node --import tsx scripts/worker.ts`, não com o
* CLI do `tsx`: o CLI cria um processo filho e o SIGTERM do contêiner pararia
* no pai, sem nunca chegar ao handler que espera o lock ser liberado.
*/
assertProductionEnv(process.env);
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl && process.env.NODE_ENV === "production") {
throw new Error("DATABASE_URL é obrigatório para o worker.");
}
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await migrate(db);
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
const TICK_MS = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
const BATCH = Number(process.env.ADAPTER_WORKER_BATCH ?? 20);
const GRACE_MS = Number(process.env.ADAPTER_WORKER_GRACE_MS ?? 20000);
const HEARTBEAT = process.env.ADAPTER_WORKER_HEARTBEAT ?? path.join("/tmp", "adaptador-worker-heartbeat");
async function tick() {
await recoverStaleJobs(deps).catch((error) => {
console.error("worker", error instanceof Error ? error.name : "error");
});
await cleanupExpired(deps).catch((error) => {
console.error("worker", "cleanup", error instanceof Error ? error.name : "error");
});
const queued = new Set(await listQueuedJobIds(deps));
for (let index = 0; index < 20; index += 1) {
const jobId = await deps.coordinator.dequeue().catch(() => null);
if (!jobId) break;
queued.add(jobId);
}
for (const jobId of queued) {
const rows = await readJobForWorker(deps, jobId);
const row = rows[0];
if (!row) continue;
const progress = asJson<JobProgress | null>(row.progress, null);
if (!progress?.sourceId) continue;
const profile = await db.withService((query) =>
query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>(
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
coalesce(f.enabled, false) as module_enabled,
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
from public.profiles p
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
join public.workspaces w on w.id = $2
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[row.user_id, row.workspace_id],
),
);
const found = profile[0];
if (!found) continue;
const ctx: RequestContext = {
userId: row.user_id,
displayName: found.display_name,
isSuperadmin: found.is_superadmin,
workspaceId: row.workspace_id,
workspaceName: found.workspace_name,
role: found.role,
moduleEnabled: found.module_enabled,
allowNonAdminModelUpload: found.allow_upload,
};
await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => {
console.error("worker", error instanceof Error ? error.name : "error");
});
}
bootstrapScript("worker");
if (jobRunsInline()) {
console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
}
await tick();
setInterval(() => {
void tick();
}, 2000);
runScript("worker", async () => {
const db = await openOperationalDb();
// O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
const pending = await pendingMigrations(db);
if (pending.length > 0) {
await db.close().catch(() => undefined);
throw new Error(`migrations pendentes em ${describeTarget()}: ${pending.join(", ")}. Rode npm run migrate antes.`);
}
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
const worker = createWorker(deps, {
batch: BATCH,
heartbeatPath: HEARTBEAT,
onError: (scope, error) => {
// Nome e escopo do erro. Nunca conteúdo de célula.
console.error("worker", scope, error instanceof Error ? error.name : "error");
},
});
const timer = setInterval(() => {
void worker.tick();
}, TICK_MS);
let stopping = false;
async function shutdown(signal: string) {
if (stopping) return;
stopping = true;
console.log(`[worker] ${signal} recebido. Encerrando.`);
clearInterval(timer);
const clean = await worker.shutdown(GRACE_MS);
if (!clean) console.warn(`[worker] tick não terminou em ${GRACE_MS}ms. Encerrando de todo modo.`);
await db.close().catch(() => undefined);
console.log("[worker] encerrado.");
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
console.log(
`[worker] pronto. intervalo=${TICK_MS}ms lote=${BATCH} carencia=${GRACE_MS}ms alvo=${describeTarget()} heartbeat=${HEARTBEAT}`,
);
await worker.tick();
});
+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Fconfiguracoes");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
if (!canManageSpreadsheetModels(auth.ctx)) {
return (
+1
View File
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
export default async function OperationPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Foperacao");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
return (
<AppShell ctx={auth.ctx}>
+8 -1
View File
@@ -10,10 +10,17 @@ export const dynamic = "force-dynamic";
export default async function AdapterHome() {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador");
if (auth.kind !== "ok") {
return (
<main className="p-8">
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
<ErrorState
message={
auth.kind === "no_workspace"
? "Sua identidade não está associada a nenhum workspace."
: "Você não é membro deste workspace."
}
/>
</main>
);
}
+6 -15
View File
@@ -1,24 +1,15 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
import { supabaseServerClient, DEV_COOKIE, WORKSPACE_COOKIE } from "@/server/auth/identity-provider";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST() {
const jar = await cookies();
jar.delete("adapter_dev_session");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (url && key) {
const supabase = createServerClient(url, key, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) jar.set(item.name, item.value, item.options);
},
},
});
await supabase.auth.signOut();
}
jar.delete(DEV_COOKIE);
jar.delete(WORKSPACE_COOKIE);
const supabase = supabaseServerClient(jar);
if (supabase) await supabase.auth.signOut();
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { stagingAuthEnabled } from "@/config/env";
import { supabaseServerClient } from "@/server/auth/identity-provider";
import { hitRateLimit, requestSubject } from "@/server/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Login de homologação com Supabase Auth real do projeto de homologação.
* Não é o dev auth: há senha, a sessão é emitida e validada pelo Supabase e o
* Adaptador continua sem tabela de usuário ou de senha própria. Indisponível
* em development e em produção.
*/
export async function POST(request: Request) {
if (!stagingAuthEnabled()) {
return NextResponse.json({ error: { code: "NOT_FOUND", message: "Indisponível." } }, { status: 404 });
}
const limit = await hitRateLimit("login", requestSubject(request));
if (!limit.allowed) {
return NextResponse.json(
{ error: { code: "RATE_LIMITED", message: "Muitas tentativas. Aguarde e tente de novo." } },
{ status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } },
);
}
let body: { email?: string; password?: string };
try {
body = (await request.json()) as { email?: string; password?: string };
} catch {
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
}
const email = body.email?.trim();
const password = body.password;
if (!email || !password) {
return NextResponse.json(
{ error: { code: "UNAUTHENTICATED", message: "Informe e-mail e senha de homologação." } },
{ status: 400 },
);
}
const jar = await cookies();
const supabase = supabaseServerClient(jar);
if (!supabase) {
return NextResponse.json(
{ error: { code: "MISCONFIGURED", message: "Supabase de homologação não configurado." } },
{ status: 503 },
);
}
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error || !data.user) {
// Nunca devolve o motivo exato: não confirma existência de conta.
return NextResponse.json(
{ error: { code: "UNAUTHENTICATED", message: "Credenciais não reconhecidas." } },
{ status: 401 },
);
}
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { isProductionEnv } from "@/config/env";
import { getDb } from "@/infra/db/client";
import { currentMemberships, WORKSPACE_COOKIE } from "@/server/auth/context";
import { assertMembership } from "@/server/auth/workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Associações do usuário autenticado. */
export async function GET() {
const current = await currentMemberships();
if (!current) {
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
}
return NextResponse.json({ memberships: current.memberships });
}
/**
* Grava a preferência de workspace ativo. O cookie resultante é só preferência:
* toda requisição revalida a associação no servidor. Gravar aqui sem checar
* membership transformaria o cookie em credencial.
*/
export async function POST(request: Request) {
const current = await currentMemberships();
if (!current) {
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
}
let body: { workspaceId?: string };
try {
body = (await request.json()) as { workspaceId?: string };
} catch {
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
}
if (!body.workspaceId) {
return NextResponse.json(
{ error: { code: "WORKSPACE_REQUIRED", message: "Informe o workspace." } },
{ status: 400 },
);
}
const db = await getDb();
try {
await assertMembership(db, current.userId, body.workspaceId);
} catch {
return NextResponse.json(
{ error: { code: "WORKSPACE_FORBIDDEN", message: "Você não é membro deste workspace." } },
{ status: 403 },
);
}
const jar = await cookies();
jar.set(WORKSPACE_COOKIE, body.workspaceId, {
httpOnly: true,
sameSite: "lax",
secure: isProductionEnv() || process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 12,
});
return NextResponse.json({ ok: true, workspaceId: body.workspaceId });
}
+10 -1
View File
@@ -1,8 +1,17 @@
import { NextResponse } from "next/server";
import { adapterEnv } from "@/config/env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Liveness: não toca em nenhuma dependência. Se o processo responde aqui, ele
* está vivo. Configuração inválida derruba o processo no start (ver
* `exitOnInvalidConfig`), então esta rota nunca mente sobre o estado.
*/
export function GET() {
return NextResponse.json({ status: "live", module: "spreadsheet-adapter" });
return NextResponse.json(
{ status: "live", module: "spreadsheet-adapter", environment: adapterEnv() },
{ headers: { "Cache-Control": "no-store" } },
);
}
+64 -8
View File
@@ -1,24 +1,80 @@
import { NextResponse } from "next/server";
import { collectProductionIssues } from "@/config/env";
import { adapterEnv, collectConfigIssues, isDeployedEnv, jobRunsInline } from "@/config/env";
import { getDb } from "@/infra/db/client";
import { pendingMigrations } from "@/infra/db/migration-manifest";
import { createCoordinator } from "@/infra/redis";
import { createStorage } from "@/infra/storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type CheckState = "ok" | "missing" | "down";
/**
* Prontidão real: cada dependência é tocada de verdade. Não basta a variável
* existir — Redis responde PING, o bucket aceita listagem e o banco responde
* uma consulta. Nenhuma connection string aparece na resposta.
*/
export async function GET() {
const checks: Record<string, "ok" | "missing" | "down"> = {
const checks: Record<string, CheckState> = {
config: "ok",
database: "down",
redis: process.env.REDIS_URL ? "ok" : "missing",
storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing",
config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing",
migrations: "ok",
redis: "down",
storage: "down",
job_mode: "ok",
};
const issues = collectConfigIssues(process.env);
if (issues.length > 0) {
checks.config = "missing";
}
// Só o nome do erro e, para migrations, os ids pendentes. Nunca connection
// string, credencial ou mensagem com host.
const reasons: Record<string, string> = {};
try {
const db = await getDb();
await db.withService((query) => query.query("select 1 as ok"));
checks.database = "ok";
} catch {
const pending = await pendingMigrations(db);
checks.migrations = pending.length === 0 ? "ok" : "missing";
if (pending.length > 0) reasons.migrations = pending.join(", ");
} catch (error) {
checks.database = "down";
checks.migrations = "down";
reasons.database = error instanceof Error ? error.name : "erro";
}
const ready = checks.database === "ok" && (process.env.NODE_ENV !== "production" || (checks.redis === "ok" && checks.storage === "ok" && checks.config === "ok"));
return NextResponse.json({ status: ready ? "ready" : "not_ready", checks }, { status: ready ? 200 : 503 });
try {
checks.redis = (await createCoordinator().ping()) ? "ok" : "down";
} catch (error) {
checks.redis = "down";
reasons.redis = error instanceof Error ? error.name : "erro";
}
try {
checks.storage = (await createStorage().healthcheck()) ? "ok" : "down";
} catch (error) {
checks.storage = "down";
reasons.storage = error instanceof Error ? error.name : "erro";
}
if (isDeployedEnv() && jobRunsInline()) checks.job_mode = "missing";
const required = isDeployedEnv()
? (["config", "database", "migrations", "redis", "storage", "job_mode"] as const)
: (["database"] as const);
const ready = required.every((key) => checks[key] === "ok");
return NextResponse.json(
{
status: ready ? "ready" : "not_ready",
environment: adapterEnv(),
checks,
reasons,
configIssues: issues.map((issue) => `${issue.variable}: ${issue.message}`),
},
{ status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } },
);
}
+14 -10
View File
@@ -1,20 +1,24 @@
import { NextResponse } from "next/server";
import { getDb } from "@/infra/db/client";
import { adapterEnv } from "@/config/env";
import { probeDb } from "@/infra/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Atalho histórico, mantido para quem já aponta monitoramento aqui.
* O detalhe está em /api/health/ready; a vida do processo, em /api/health/live.
*/
export async function GET() {
try {
const db = await getDb();
await db.withService((query) => query.query("select 1 as ok"));
return NextResponse.json({
ok: true,
module: "spreadsheet-adapter",
redis: process.env.REDIS_URL ? "configured" : "not_configured",
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured",
});
await probeDb();
} catch {
return NextResponse.json({ ok: false }, { status: 503 });
return NextResponse.json({ ok: false, environment: adapterEnv() }, { status: 503 });
}
return NextResponse.json({
ok: true,
module: "spreadsheet-adapter",
environment: adapterEnv(),
detail: "/api/health/ready",
});
}
+18 -2
View File
@@ -3,6 +3,7 @@ import { presentError } from "@/domain/spreadsheet-adapter/error-catalog";
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { resolveAuth } from "@/server/auth/context";
import { enforceRateLimit, type RateLimitBucket } from "@/server/rate-limit";
import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
export const runtime = "nodejs";
@@ -11,9 +12,12 @@ export const dynamic = "force-dynamic";
export function errorResponse(error: unknown): NextResponse {
if (isAdapterError(error)) {
const view = presentError(error.code);
const headers: Record<string, string> = {};
const retryAfter = error.details?.retryAfterSeconds;
if (typeof retryAfter === "number") headers["Retry-After"] = String(retryAfter);
return NextResponse.json(
{ error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, details: error.details ?? null } },
{ status: error.status },
{ status: error.status, headers },
);
}
console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error");
@@ -24,12 +28,24 @@ export function errorResponse(error: unknown): NextResponse {
);
}
export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> {
/**
* Sessão, workspace e dependências da requisição. O `bucket` aplica o teto de
* abuso por usuário: upload e processamento custam muito mais que leitura.
*/
export async function requireReady(bucket: RateLimitBucket = "read"): Promise<{ ctx: RequestContext; deps: AppDeps }> {
const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") {
throw new AdapterError("UNAUTHENTICATED", auth.kind === "session_expired" ? "Sessão expirada. Entre novamente." : "Sessão Elo Pro necessária.", 401);
}
if (auth.kind === "workspace_choice" || auth.kind === "no_workspace") {
throw new AdapterError(
"WORKSPACE_REQUIRED",
auth.kind === "no_workspace" ? "Sua identidade não está em nenhum workspace." : "Selecione o workspace ativo.",
409,
);
}
if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403);
await enforceRateLimit(bucket, `${auth.ctx.workspaceId}:${auth.ctx.userId}`);
const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
return { ctx: auth.ctx, deps: await runtimeDeps() };
}
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("download");
const { id } = await context.params;
const url = new URL(request.url);
const format = url.searchParams.get("format") as OutputFormat | null;
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("process");
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("read");
const { id } = await context.params;
return NextResponse.json({ job: await getJob(deps, ctx, id) });
} catch (error) {
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const { id, sourceId } = await context.params;
const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
@@ -20,7 +20,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s
export async function DELETE(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const { id, sourceId } = await context.params;
const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const body = (await request.json()) as { modelId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.modelId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Selecione um modelo." } }, { status: 400 });
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params;
const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("read");
return NextResponse.json({ models: await listModels(deps, ctx) });
} catch (error) {
return errorResponse(error);
@@ -16,7 +16,7 @@ export async function GET() {
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("upload");
const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file");
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("read");
return NextResponse.json(await getSettings(deps, ctx));
} catch (error) {
return errorResponse(error);
@@ -16,7 +16,7 @@ export async function GET() {
export async function PATCH(request: Request) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("mutation");
const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (typeof body.allowNonAdminModelUpload !== "boolean") {
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const { ctx, deps } = await requireReady("upload");
const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file");
+23 -4
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
import { adapterEnv } from "@/config/env";
import { DEV_IDENTITIES } from "@/dev/fixtures";
import { resolveAuth } from "@/server/auth/context";
import { devAuthEnabled } from "@/server/auth/dev-session";
import { authMode } from "@/server/auth/identity-provider";
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
import { DevLogin } from "@/components/dev-login";
import { StagingLogin } from "@/components/staging-login";
export const dynamic = "force-dynamic";
@@ -12,10 +14,14 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
const auth = await resolveAuth();
const nextPath = safeReturnPath(params.next);
if (auth.kind === "ok") redirect(nextPath);
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
if (auth.kind === "workspace_choice") redirect(`/selecionar-workspace?next=${encodeURIComponent(nextPath)}`);
const mode = authMode();
if (mode === "elo-pro-sso" && process.env.ELO_PRO_LOGIN_URL) {
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
}
const expired = auth.kind === "session_expired" || params.reason === "expired";
return (
<main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
@@ -23,8 +29,21 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<p className="mt-3 text-sm text-ink/80">
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
</p>
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
{adapterEnv() === "staging" ? (
<p className="mt-3 inline-flex rounded-full bg-warning/15 px-3 py-1 text-xs font-semibold">
Ambiente de homologação
</p>
) : null}
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente.</p> : null}
{mode === "dev" ? <DevLogin identities={DEV_IDENTITIES} /> : null}
{mode === "staging-supabase" ? <StagingLogin next={nextPath} /> : null}
{mode === "unconfigured" ? (
<p className="mt-6 text-sm">
Autenticação não configurada. Em produção defina <code>ELO_PRO_LOGIN_URL</code>; em homologação defina{" "}
<code>ADAPTER_STAGING_AUTH=1</code> com o projeto Supabase de homologação.
</p>
) : null}
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { currentMemberships } from "@/server/auth/context";
import { safeReturnPath } from "@/server/auth/return-to";
import { WorkspacePicker } from "@/components/workspace-picker";
export const dynamic = "force-dynamic";
export default async function SelecionarWorkspacePage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
const params = await searchParams;
const nextPath = safeReturnPath(params.next);
const current = await currentMemberships();
if (!current) redirect(`/login?next=${encodeURIComponent(nextPath)}`);
if (current.memberships.length === 0) {
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-semibold">Nenhum workspace</h1>
<p className="mt-3 text-sm text-ink/80">
Sua identidade não está associada a nenhum workspace. Peça a um administrador do Elo Pro para incluir seu
acesso.
</p>
</main>
);
}
return (
<main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
<h1 className="mt-2 text-2xl font-semibold">Workspace ativo</h1>
<WorkspacePicker memberships={current.memberships} next={nextPath} />
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, TextInput } from "./ui";
/**
* Login de homologação. A credencial vai direto ao Supabase Auth do projeto de
* homologação: o Adaptador não guarda senha nem cria cadastro.
*/
export function StagingLogin({ next }: { next: string }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [pending, setPending] = useState(false);
return (
<form
className="mt-6 space-y-3"
onSubmit={async (event) => {
event.preventDefault();
setPending(true);
setMessage(null);
try {
const response = await fetch("/api/auth/staging/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: { message?: string } } | null;
setMessage(body?.error?.message ?? "Não foi possível entrar.");
return;
}
router.push(next);
router.refresh();
} finally {
setPending(false);
}
}}
>
<label className="block text-sm font-medium" htmlFor="staging-email">
E-mail de homologação
</label>
<TextInput
id="staging-email"
name="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<label className="block text-sm font-medium" htmlFor="staging-password">
Senha
</label>
<TextInput
id="staging-password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<Button type="submit" loading={pending} className="w-full">
Entrar na homologação
</Button>
{message ? (
<p role="alert" className="text-sm text-danger">
{message}
</p>
) : null}
<p className="text-xs text-ink/70">
Contas de homologação são criadas no projeto Supabase de homologação pela operação. Em produção esta tela não
existe: a identidade é a do Elo Pro.
</p>
</form>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card } from "./ui";
export type WorkspaceOption = {
workspaceId: string;
workspaceName: string;
role: string;
moduleEnabled: boolean;
};
/**
* Escolha do workspace ativo. A seleção grava apenas preferência: o servidor
* revalida a associação em toda requisição.
*/
export function WorkspacePicker({ memberships, next }: { memberships: WorkspaceOption[]; next: string }) {
const router = useRouter();
const [pending, setPending] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
return (
<Card className="mt-6">
<h2 className="text-lg font-semibold">Selecione o workspace</h2>
<p className="mt-1 text-sm text-ink/80">
Você participa de mais de um workspace. O Adaptador trabalha em um por vez.
</p>
<div className="mt-4 space-y-2">
{memberships.map((membership) => (
<Button
key={membership.workspaceId}
variant="secondary"
className="w-full justify-between"
loading={pending === membership.workspaceId}
onClick={async () => {
setPending(membership.workspaceId);
setMessage(null);
try {
const response = await fetch("/api/auth/workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: membership.workspaceId }),
});
if (!response.ok) {
setMessage("Não foi possível selecionar este workspace.");
return;
}
router.push(next);
router.refresh();
} finally {
setPending(null);
}
}}
>
<span>{membership.workspaceName}</span>
<span className="text-xs font-normal text-ink/70">
{membership.role}
{membership.moduleEnabled ? "" : " · módulo desligado"}
</span>
</Button>
))}
</div>
{message ? (
<p role="alert" className="mt-3 text-sm text-danger">
{message}
</p>
) : null}
</Card>
);
}
+143 -18
View File
@@ -2,31 +2,110 @@ export type EnvMap = Record<string, string | undefined>;
export type EnvIssue = { variable: string; message: string };
const REQUIRED_IN_PRODUCTION = [
/**
* Ambiente lógico do Adaptador. É independente de NODE_ENV porque homologação
* roda com NODE_ENV=production (build otimizado) mas com política própria.
*/
export const ADAPTER_ENVS = ["development", "staging", "production"] as const;
export type AdapterEnv = (typeof ADAPTER_ENVS)[number];
const DEFAULT_NAMESPACE = "adaptador-planilhas";
/** Variáveis exigidas em qualquer ambiente implantado (staging e produção). */
const REQUIRED_WHEN_DEPLOYED = [
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_STORAGE_BUCKET",
"DATABASE_URL",
"REDIS_URL",
"SENTRY_DSN",
"ELO_PRO_LOGIN_URL",
"SPREADSHEET_TEMP_TTL_SECONDS",
] as const;
export function collectProductionIssues(env: EnvMap): EnvIssue[] {
if (env.NODE_ENV !== "production") return [];
/** Variáveis exigidas somente em produção. */
const REQUIRED_IN_PRODUCTION_ONLY = ["SENTRY_DSN", "ELO_PRO_LOGIN_URL"] as const;
export function adapterEnv(env: EnvMap = process.env): AdapterEnv {
const raw = env.ADAPTER_ENV?.trim();
if (raw && (ADAPTER_ENVS as readonly string[]).includes(raw)) return raw as AdapterEnv;
if (raw) return "production";
return env.NODE_ENV === "production" ? "production" : "development";
}
/** Ambiente servido para outras pessoas. Exige configuração completa. */
export function isDeployedEnv(env: EnvMap = process.env): boolean {
return adapterEnv(env) !== "development";
}
export function isProductionEnv(env: EnvMap = process.env): boolean {
return adapterEnv(env) === "production";
}
export function isStagingEnv(env: EnvMap = process.env): boolean {
return adapterEnv(env) === "staging";
}
/** Namespace de chaves externas. Separa aplicativo e ambiente no servidor compartilhado. */
export function resourceNamespace(env: EnvMap = process.env): string {
const app = env.ADAPTER_NAMESPACE?.trim() || DEFAULT_NAMESPACE;
return `${app}:${adapterEnv(env)}`;
}
/**
* Decisão explícita sobre o shim de identidade (`profiles`, `workspaces`,
* `workspace_members`). Em ambiente implantado nunca é implícita: aplicar o shim
* sobre o schema real do Elo Pro é destrutivo em potencial.
*/
export function identityShimEnabled(env: EnvMap = process.env): boolean {
const raw = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim();
if (raw === "1" || raw === "true") return true;
if (raw === "0" || raw === "false") return false;
return !isDeployedEnv(env);
}
/** Login próprio de homologação, com Supabase Auth real. Nunca em produção. */
export function stagingAuthEnabled(env: EnvMap = process.env): boolean {
return (
isStagingEnv(env) &&
env.ADAPTER_STAGING_AUTH === "1" &&
Boolean(env.NEXT_PUBLIC_SUPABASE_URL) &&
Boolean(env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
);
}
export function collectConfigIssues(env: EnvMap = process.env): EnvIssue[] {
const target = adapterEnv(env);
const raw = env.ADAPTER_ENV?.trim();
const issues: EnvIssue[] = [];
for (const variable of REQUIRED_IN_PRODUCTION) {
if (raw && !(ADAPTER_ENVS as readonly string[]).includes(raw)) {
issues.push({ variable: "ADAPTER_ENV", message: `valor inválido. Use ${ADAPTER_ENVS.join(", ")}` });
}
if (target === "development") return issues;
for (const variable of REQUIRED_WHEN_DEPLOYED) {
if (!env[variable] || env[variable]?.trim() === "") {
issues.push({ variable, message: "obrigatória em produção" });
issues.push({ variable, message: `obrigatória em ${target}` });
}
}
if (target === "production") {
for (const variable of REQUIRED_IN_PRODUCTION_ONLY) {
if (!env[variable] || env[variable]?.trim() === "") {
issues.push({ variable, message: "obrigatória em produção" });
}
}
}
if (env.ADAPTER_DEV_AUTH === "1") {
issues.push({ variable: "ADAPTER_DEV_AUTH", message: "não pode estar ligado em produção" });
issues.push({ variable: "ADAPTER_DEV_AUTH", message: `só existe em development, nunca em ${target}` });
}
if (env.ADAPTER_JOB_MODE === "inline") {
issues.push({ variable: "ADAPTER_JOB_MODE", message: "inline não é permitido em produção" });
issues.push({ variable: "ADAPTER_JOB_MODE", message: `inline não é permitido em ${target}` });
}
if (env.ADAPTER_APPLY_IDENTITY_SHIM === undefined || env.ADAPTER_APPLY_IDENTITY_SHIM.trim() === "") {
issues.push({
variable: "ADAPTER_APPLY_IDENTITY_SHIM",
message: "decisão explícita obrigatória. 1 cria o shim de identidade, 0 usa o schema existente",
});
}
const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
if (ttl && ttl.trim() !== "") {
@@ -38,22 +117,68 @@ export function collectProductionIssues(env: EnvMap): EnvIssue[] {
if (env.SUPABASE_STORAGE_BUCKET === "public") {
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" });
}
if (target === "production" && env.ADAPTER_STAGING_AUTH === "1") {
issues.push({ variable: "ADAPTER_STAGING_AUTH", message: "login próprio de homologação não vai a produção" });
}
if (target === "staging" && env.ADAPTER_STAGING_AUTH !== "1" && !env.ELO_PRO_LOGIN_URL) {
issues.push({
variable: "ADAPTER_STAGING_AUTH",
message: "homologação precisa de ADAPTER_STAGING_AUTH=1 ou de ELO_PRO_LOGIN_URL",
});
}
return issues;
}
export function assertProductionEnv(env: EnvMap = process.env): void {
if (env.NODE_ENV !== "production") return;
if (env.NEXT_PHASE === "phase-production-build") return;
if (env.npm_lifecycle_event === "build") return;
const issues = collectProductionIssues(env);
/** Mantido para compatibilidade com chamadas e testes existentes. */
export function collectProductionIssues(env: EnvMap = process.env): EnvIssue[] {
return collectConfigIssues(env);
}
export function formatConfigIssues(issues: EnvIssue[]): string {
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
}
function skipAssertion(env: EnvMap): boolean {
if (env.NEXT_PHASE === "phase-production-build") return true;
if (env.npm_lifecycle_event === "build") return true;
return env.VITEST === "true" || env.NODE_ENV === "test";
}
/**
* Recusa a configuração inválida. Lança: quem chama decide entre encerrar o
* processo (servidor e worker) ou apenas reportar (health check).
*/
export function assertDeployedEnv(env: EnvMap = process.env): void {
if (!isDeployedEnv(env)) return;
if (skipAssertion(env)) return;
const issues = collectConfigIssues(env);
if (issues.length === 0) return;
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
throw new Error(`Configuração de produção recusada. ${summary}`);
throw new Error(`Configuração de ${adapterEnv(env)} recusada. ${formatConfigIssues(issues)}`);
}
/** Mantido para compatibilidade. */
export function assertProductionEnv(env: EnvMap = process.env): void {
assertDeployedEnv(env);
}
export function jobRunsInline(env: EnvMap = process.env): boolean {
if (env.NODE_ENV === "production" && env.ADAPTER_JOB_MODE === "inline") return false;
if (isDeployedEnv(env)) return false;
if (env.ADAPTER_JOB_MODE === "worker") return false;
if (env.ADAPTER_JOB_MODE === "inline") return true;
return env.NODE_ENV !== "production";
return true;
}
/**
* Connection string obrigatória fora de development. Sem isto um segredo
* ausente viraria uma migração silenciosa num arquivo PGlite local.
*/
export function requireDatabaseUrl(env: EnvMap = process.env): string | null {
const databaseUrl = env.DATABASE_URL?.trim();
if (databaseUrl) return databaseUrl;
if (isDeployedEnv(env)) {
throw new Error(
`DATABASE_URL é obrigatória em ${adapterEnv(env)}. O banco local PGlite só existe em development.`,
);
}
return null;
}
+19
View File
@@ -0,0 +1,19 @@
import { assertDeployedEnv, type EnvMap } from "./env";
/**
* Encerra o processo com código diferente de zero quando a configuração é
* inválida. Um servidor que fica no ar respondendo 500 em todas as rotas engana
* o orquestrador e o health check: é pior que não subir.
*
* Mora fora de `env.ts` porque `process.exit` não existe no Edge Runtime, e
* `env.ts` é importado também pelo bundle de edge.
*/
export function exitOnInvalidConfig(env: EnvMap = process.env): void {
try {
assertDeployedEnv(env);
} catch (error) {
const message = error instanceof Error ? error.message : "Configuração recusada.";
console.error(`[adaptador-planilhas] ${message}`);
process.exit(1);
}
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { adapterEnv, isDeployedEnv } from "./env";
/** TTL de temporários. O número de produto ainda é decisão aberta. */
export function tempTtlSeconds(): number {
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
@@ -8,9 +10,9 @@ export function tempTtlSeconds(): number {
}
return value;
}
if (process.env.NODE_ENV === "production") {
if (isDeployedEnv() || process.env.NODE_ENV === "production") {
throw new Error(
"SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em produção. A retenção de temporários continua decisão aberta.",
`SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em ${adapterEnv()}. A retenção de temporários continua decisão aberta.`,
);
}
return 60 * 60;
@@ -8,7 +8,8 @@ export type ErrorCategory =
| "processing"
| "timeout"
| "storage"
| "worker";
| "worker"
| "rate_limit";
export type PublicErrorView = {
category: ErrorCategory;
@@ -38,6 +39,8 @@ const VIEWS: Record<string, PublicErrorView> = {
STORAGE_FAILURE: { category: "storage", action: "Não foi possível guardar o arquivo. Tente novamente." },
INVALID_SETTING: { category: "invalid_configuration", action: "Revise a configuração com um administrador." },
PROCESS_FAILED: { category: "processing", action: "Tente processar novamente." },
RATE_LIMITED: { category: "rate_limit", action: "Aguarde o tempo indicado e tente de novo." },
WORKSPACE_REQUIRED: { category: "authorization", action: "Selecione o workspace para continuar." },
INTERNAL: { category: "temporary", action: "Tente novamente. Se continuar, informe a referência ao suporte." },
};
+25 -6
View File
@@ -1,8 +1,9 @@
import "server-only";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
import { createFileDb } from "./pglite";
import { createPostgresDb } from "./postgres";
import { migrate } from "./migrate";
import type { Db } from "./types";
let singleton: Promise<Db> | null = null;
@@ -10,15 +11,33 @@ let singleton: Promise<Db> | null = null;
export async function getDb(): Promise<Db> {
if (!singleton) {
singleton = (async () => {
const databaseUrl = process.env.DATABASE_URL;
// Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite.
const databaseUrl = requireDatabaseUrl();
if (databaseUrl) return createPostgresDb(databaseUrl);
if (process.env.NODE_ENV === "production") {
throw new Error("DATABASE_URL é obrigatório em produção.");
if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatória fora de development.");
}
const db = await createFileDb(path.join(process.cwd(), ".data", "pglite"));
// Cria o diretório antes: na primeira execução o PGlite falhava ao criar
// a árvore sozinho, e o health check reportava o banco fora do ar.
const dataDir = path.join(process.cwd(), ".data", "pglite");
await mkdir(dataDir, { recursive: true });
const db = await createFileDb(dataDir);
// Migrations automáticas existem apenas no banco local de development.
// Import dinâmico: o executor lê o diretório `migrations/`, e manter essa
// leitura fora do grafo das rotas evita o tracing do projeto inteiro.
const { migrate } = await import("./migrate");
await migrate(db);
return db;
})();
})().catch((error) => {
singleton = null;
throw error;
});
}
return singleton;
}
/** Usado pelos health checks. Não cria o banco local nem aplica migrations. */
export async function probeDb(): Promise<void> {
const db = await getDb();
await db.withService((query) => query.query("select 1 as ok"));
}
+53 -4
View File
@@ -1,28 +1,77 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { identityShimEnabled } from "@/config/env";
import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest";
import type { Db } from "./types";
/**
* Executor de migrations. Lê o diretório `migrations/`, por isso só é usado
* pelos scripts operacionais e pelo banco local de development — nunca por uma
* rota. Quem precisa saber o estado do schema em runtime usa
* `migration-manifest.ts`, que só consulta o banco.
*
* O caminho é estaticamente limitado a `<cwd>/migrations`: montar o caminho a
* partir de uma variável fazia o Next tracear o projeto inteiro para dentro da
* saída `standalone`, levando docs, testes e fontes para a imagem.
*/
const MIGRATIONS_FOLDER = "migrations";
export type MigrateOptions = {
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
applyIdentityShim?: boolean;
};
export function migrationsDir(): string {
return path.join(process.cwd(), "migrations");
return path.join(process.cwd(), MIGRATIONS_FOLDER);
}
export async function migrate(db: Db, directory = migrationsDir()): Promise<string[]> {
export async function listMigrationFiles(): Promise<string[]> {
const entries = await readdir(path.join(process.cwd(), MIGRATIONS_FOLDER));
return entries.filter((file) => file.endsWith(".sql")).sort();
}
/**
* O manifesto estático e o diretório precisam contar a mesma história. Um
* arquivo novo sem entrada no manifesto passaria em branco pelo health check.
*/
export function assertManifestMatches(files: string[]): void {
const expected: string[] = [...EXPECTED_MIGRATIONS];
const extra = files.filter((file) => !expected.includes(file));
const missing = expected.filter((file) => !files.includes(file));
if (extra.length === 0 && missing.length === 0) return;
const parts = [
extra.length ? `fora do manifesto: ${extra.join(", ")}` : "",
missing.length ? `ausentes no diretório: ${missing.join(", ")}` : "",
].filter(Boolean);
throw new Error(
`migrations/ e EXPECTED_MIGRATIONS divergiram (${parts.join("; ")}). Atualize src/infra/db/migration-manifest.ts.`,
);
}
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
await db.exec(`
create table if not exists public.schema_migrations (
id text primary key,
applied_at timestamptz not null default now()
);
`);
const files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort();
const files = await listMigrationFiles();
assertManifestMatches(files);
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
const applied = new Set(appliedRows.map((row) => row.id));
const ran: string[] = [];
for (const file of files) {
if (applied.has(file)) continue;
const sql = await readFile(path.join(directory, file), "utf8");
if (isShimMigration(file) && !applyShim) continue;
const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8");
await db.exec(sql);
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
ran.push(file);
}
return ran;
}
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
+39
View File
@@ -0,0 +1,39 @@
import { identityShimEnabled } from "@/config/env";
import type { Db } from "./types";
/**
* Migrations que **este build** espera encontrar aplicadas.
*
* É uma lista estática de propósito. O health check e o worker precisam
* responder "o banco está na versão que este código espera?", não "o banco
* combina com os arquivos que por acaso estão no disco". Ler o diretório aqui
* também fazia o Next tracear o projeto inteiro para dentro da saída
* `standalone`.
*
* `migrate()` confere que esta lista e o diretório `migrations/` não divergiram.
*/
export const EXPECTED_MIGRATIONS = [
"0001_platform_shim.sql",
"0002_spreadsheet_adapter.sql",
"0003_rls.sql",
"0004_audit_append_only.sql",
] as const;
/** Migrations que criam o shim local de identidade do Elo Pro. */
export const SHIM_MIGRATIONS: ReadonlySet<string> = new Set(["0001_platform_shim.sql"]);
export function isShimMigration(file: string): boolean {
return SHIM_MIGRATIONS.has(file);
}
export type PendingOptions = { applyIdentityShim?: boolean };
/** Migrations esperadas que ainda não constam em `schema_migrations`. */
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
const appliedRows = await db
.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"))
.catch(() => [] as { id: string }[]);
const applied = new Set(appliedRows.map((row) => row.id));
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
}
+6 -2
View File
@@ -1,3 +1,4 @@
import { adapterEnv } from "@/config/env";
import { scrubSentryEvent, sentrySafeError } from "./scrub";
export { scrubSentryEvent, sentrySafeError };
@@ -16,6 +17,7 @@ export function captureAdapterException(error: unknown, tags: Record<string, str
void import("@sentry/nextjs").then((Sentry) => {
Sentry.withScope((scope) => {
scope.setTag("module", "spreadsheet-adapter");
scope.setTag("environment_tier", adapterEnv());
for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
Sentry.captureException(sentrySafeError(error));
});
@@ -26,8 +28,10 @@ export function sentryInitOptions(dsn: string | undefined) {
if (!dsn) return null;
return {
dsn,
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
release: process.env.SENTRY_RELEASE,
// Ambiente lógico do Adaptador: homologação e produção nunca se misturam
// no mesmo grupo de eventos, mesmo com NODE_ENV=production nos dois.
environment: process.env.SENTRY_ENVIRONMENT || adapterEnv(),
release: process.env.SENTRY_RELEASE || process.env.NEXT_PUBLIC_SENTRY_RELEASE,
sendDefaultPii: false,
};
}
+27 -2
View File
@@ -1,8 +1,33 @@
import { isDeployedEnv } from "@/config/env";
import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory";
import { RedisCoordinator } from "./redis";
let singleton: Coordinator | null = null;
let singletonKey: string | null = null;
/**
* Coordenador reutilizado pelo processo. Criar um `ioredis` por requisição
* vazava conexão: o cliente é lazy, abre no primeiro uso e nunca fecha.
*/
export function createCoordinator(): Coordinator {
if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL);
if (process.env.NODE_ENV === "production") return new UnavailableCoordinator();
const url = process.env.REDIS_URL ?? "";
const key = `${url}|${isDeployedEnv() ? "deployed" : "local"}`;
if (singleton && singletonKey === key) return singleton;
singletonKey = key;
singleton = buildCoordinator(url);
return singleton;
}
function buildCoordinator(url: string): Coordinator {
if (url) return new RedisCoordinator(url);
if (isDeployedEnv()) return new UnavailableCoordinator();
return new MemoryCoordinator();
}
/** Usado em testes para não vazar estado entre casos. */
export function resetCoordinator(): void {
singleton = null;
singletonKey = null;
}
export type { Coordinator };
+27
View File
@@ -2,6 +2,10 @@ import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
export interface Coordinator {
/** Verdadeiro quando a coordenação responde. Usado pelo health check. */
ping(): Promise<boolean>;
/** Contador por janela. Base do rate limiting. Retorna o total da janela. */
hit(key: string, windowSeconds: number): Promise<number>;
acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
ownsLock(key: string, owner: string): Promise<boolean>;
@@ -17,10 +21,25 @@ type LockRecord = { owner: string; expiresAt: number };
export class MemoryCoordinator implements Coordinator {
private readonly locks = new Map<string, LockRecord>();
private readonly progress = new Map<string, JobProgress>();
private readonly counters = new Map<string, { count: number; expiresAt: number }>();
private readonly queue: string[] = [];
constructor(private readonly now: () => number = () => Date.now()) {}
async ping(): Promise<boolean> {
return true;
}
async hit(key: string, windowSeconds: number): Promise<number> {
const current = this.counters.get(key);
if (!current || current.expiresAt <= this.now()) {
this.counters.set(key, { count: 1, expiresAt: this.now() + windowSeconds * 1000 });
return 1;
}
current.count += 1;
return current.count;
}
private live(key: string): LockRecord | null {
const record = this.locks.get(key);
if (!record) return null;
@@ -76,6 +95,14 @@ export class UnavailableCoordinator implements Coordinator {
throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
}
async ping(): Promise<boolean> {
return false;
}
hit(): Promise<number> {
this.fail();
}
acquireLock(): Promise<boolean> {
this.fail();
}
+34 -4
View File
@@ -1,10 +1,12 @@
import Redis from "ioredis";
import { resourceNamespace } from "@/config/env";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
import type { Coordinator } from "./memory";
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const RENEW = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`;
const HIT = `local n = redis.call("incr", KEYS[1]) if n == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return n`;
export class RedisCoordinator implements Coordinator {
private readonly redis: Redis;
@@ -15,6 +17,8 @@ export class RedisCoordinator implements Coordinator {
enableOfflineQueue: false,
connectTimeout: 2000,
lazyConnect: true,
// Separa este aplicativo dos demais do ecossistema no Redis compartilhado.
keyPrefix: "",
});
this.redis.on("error", () => undefined);
}
@@ -29,6 +33,19 @@ export class RedisCoordinator implements Coordinator {
}
}
async ping(): Promise<boolean> {
try {
if (this.redis.status === "wait") await this.redis.connect();
return (await this.redis.ping()) === "PONG";
} catch {
return false;
}
}
async hit(key: string, windowSeconds: number): Promise<number> {
return this.run(async () => Number(await this.redis.eval(HIT, 1, key, String(windowSeconds))));
}
async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK");
}
@@ -69,20 +86,33 @@ export class RedisCoordinator implements Coordinator {
async dequeue(): Promise<string | null> {
return this.run(async () => this.redis.rpop(queueKey()));
}
async close(): Promise<void> {
await this.redis.quit().catch(() => undefined);
}
}
/**
* Prefixo de todas as chaves. Deriva de `ADAPTER_NAMESPACE` e do ambiente
* lógico, não de `SENTRY_ENVIRONMENT`: observabilidade e isolamento de dados
* são decisões diferentes, e o servidor é compartilhado com outros aplicativos.
*/
export function envNamespace(): string {
return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local";
return resourceNamespace();
}
export function lockKey(userId: string): string {
return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`;
return `${envNamespace()}:lock:user:${userId}`;
}
export function progressKey(jobId: string): string {
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
return `${envNamespace()}:progress:${jobId}`;
}
export function queueKey(): string {
return `spreadsheet-adapter:${envNamespace()}:queue`;
return `${envNamespace()}:queue`;
}
export function rateLimitKey(bucket: string, subject: string, windowStart: number): string {
return `${envNamespace()}:ratelimit:${bucket}:${subject}:${windowStart}`;
}
+31 -4
View File
@@ -1,15 +1,42 @@
import path from "node:path";
import { isDeployedEnv } from "@/config/env";
import { LocalPrivateStorage } from "./local";
import { createSupabaseStorage } from "./supabase";
import type { ObjectStorage } from "./memory";
let singleton: ObjectStorage | null = null;
let singletonKey: string | null = null;
/**
* Armazenamento reutilizado pelo processo. O cliente Supabase mantém pool
* de conexões HTTP: criar um por requisição desperdiça soquete.
*/
export function createStorage(): ObjectStorage {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket);
if (process.env.NODE_ENV === "production") {
throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta.");
const key = `${url ?? ""}|${bucket ?? ""}|${serviceKey ? "svc" : "nosvc"}`;
if (singleton && singletonKey === key) return singleton;
if (url && serviceKey && bucket) {
singletonKey = key;
singleton = createSupabaseStorage(url, serviceKey, bucket);
return singleton;
}
return new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage"));
if (isDeployedEnv()) {
throw new Error(
"Armazenamento privado não configurado. Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.",
);
}
singletonKey = key;
singleton = new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage"));
return singleton;
}
/** Usado em testes para não vazar estado entre casos. */
export function resetStorage(): void {
singleton = null;
singletonKey = null;
}
export type { ObjectStorage };
+9
View File
@@ -61,4 +61,13 @@ export class LocalPrivateStorage implements ObjectStorage {
await walk(start);
return found.filter((key) => key.startsWith(prefix.replace(/\/$/, "")));
}
async healthcheck(): Promise<boolean> {
try {
await mkdir(this.root, { recursive: true });
return true;
} catch {
return false;
}
}
}
+7
View File
@@ -5,7 +5,10 @@ export interface ObjectStorage {
put(key: string, body: Buffer): Promise<void>;
get(key: string): Promise<Buffer>;
delete(key: string): Promise<void>;
/** Listagem completa do prefixo. Implementações remotas devem paginar. */
list(prefix: string): Promise<string[]>;
/** Verdadeiro quando o bucket responde. Usado pelo health check. */
healthcheck(): Promise<boolean>;
}
export class MemoryStorage implements ObjectStorage {
@@ -31,4 +34,8 @@ export class MemoryStorage implements ObjectStorage {
async list(prefix: string): Promise<string[]> {
return [...this.objects.keys()].filter((key) => key.startsWith(prefix));
}
async healthcheck(): Promise<boolean> {
return true;
}
}
+56 -3
View File
@@ -3,6 +3,11 @@ import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
import type { ObjectStorage } from "./memory";
/** Página da API de listagem do Supabase Storage. */
const LIST_PAGE = 1000;
/** Teto de segurança para não varrer indefinidamente um prefixo corrompido. */
const LIST_MAX_PAGES = 100;
export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage {
const client: SupabaseClient = createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
@@ -24,11 +29,59 @@ export function createSupabaseStorage(url: string, serviceKey: string, bucket: s
assertStorageKey(key);
await bucketApi().remove([key]);
},
/**
* Listagem paginada e recursiva. A API devolve no máximo 1000 entradas por
* chamada: sem paginar, a limpeza por TTL deixava de ver arquivos acima
* disso e temporários venciam sem nunca serem removidos.
*/
async list(prefix) {
const folder = prefix.replace(/\/$/, "");
const { data, error } = await bucketApi().list(folder, { limit: 1000 });
if (error || !data) return [];
return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`);
const found: string[] = [];
const pending: string[] = [folder];
let pages = 0;
while (pending.length > 0 && pages < LIST_MAX_PAGES) {
const current = pending.shift() as string;
let offset = 0;
for (;;) {
pages += 1;
if (pages > LIST_MAX_PAGES) break;
const { data, error } = await bucketApi().list(current, { limit: LIST_PAGE, offset });
if (error || !data) break;
for (const item of data) {
if (!item.name) continue;
const key = current ? `${current}/${item.name}` : item.name;
// Entradas sem metadado de objeto são pastas: entram na fila.
if (item.id === null || item.id === undefined) pending.push(key);
else found.push(key);
}
if (data.length < LIST_PAGE) break;
offset += data.length;
}
}
return found.filter((key) => key.startsWith(folder));
},
async healthcheck() {
try {
const { error } = await bucketApi().list("", { limit: 1 });
return !error;
} catch {
return false;
}
},
};
}
/**
* O bucket precisa ser privado. Um bucket público torna qualquer chave um
* link permanente e contorna a rota autenticada de download.
*/
export async function assertPrivateBucket(url: string, serviceKey: string, bucket: string): Promise<void> {
const client = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
const { data, error } = await client.storage.getBucket(bucket);
if (error || !data) {
throw new Error(`Bucket "${bucket}" não encontrado com a chave de serviço informada.`);
}
if (data.public) {
throw new Error(`Bucket "${bucket}" é público. O Adaptador exige bucket privado.`);
}
}
+37 -2
View File
@@ -1,8 +1,20 @@
import { assertProductionEnv } from "@/config/env";
import { adapterEnv, assertDeployedEnv } from "@/config/env";
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
/**
* Executa no start do servidor. Configuração inválida encerra o processo com
* código diferente de zero: um servidor que fica no ar respondendo 500 em todas
* as rotas engana o orquestrador e o health check.
*/
export async function register() {
assertProductionEnv(process.env);
if (process.env.NEXT_RUNTIME === "edge") {
// No Edge Runtime não há `process.exit`: reporta e deixa o runtime Node,
// que roda em paralelo, encerrar o processo.
assertDeployedEnv(process.env);
} else {
const { exitOnInvalidConfig } = await import("@/config/fail-fast");
exitOnInvalidConfig(process.env);
}
const options = sentryInitOptions(process.env.SENTRY_DSN);
if (!options) return;
const Sentry = await import("@sentry/nextjs");
@@ -13,3 +25,26 @@ export async function register() {
},
});
}
/**
* Erros de render e de route handler. Sem este export o Sentry do Next não
* recebe as falhas do servidor automaticamente. O payload passa pelo mesmo
* scrub: nada de conteúdo de célula.
*/
export async function onRequestError(
error: unknown,
request: { path: string; method: string; headers: Record<string, string | undefined> },
context: { routerKind: string; routePath: string; routeType: string },
) {
if (!process.env.SENTRY_DSN) return;
const Sentry = await import("@sentry/nextjs");
const { sentrySafeError } = await import("@/infra/observability/scrub");
Sentry.withScope((scope) => {
scope.setTag("module", "spreadsheet-adapter");
scope.setTag("environment_tier", adapterEnv());
scope.setTag("route_path", context.routePath.slice(0, 120));
scope.setTag("route_type", context.routeType);
scope.setTag("http_method", request.method);
Sentry.captureException(sentrySafeError(error));
});
}
+8 -2
View File
@@ -1,9 +1,14 @@
import { NextResponse, type NextRequest } from "next/server";
/** Caminhos que exigem alguma sessão. A autorização real é no servidor. */
const GUARDED = ["/adaptador", "/api/spreadsheet-adapter", "/selecionar-workspace"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const guarded = pathname.startsWith("/adaptador") || pathname.startsWith("/api/spreadsheet-adapter");
const guarded = GUARDED.some((prefix) => pathname.startsWith(prefix));
if (!guarded) return NextResponse.next();
// Presença de cookie é só um atalho para evitar render desnecessário.
// Nenhuma decisão de autorização acontece aqui.
const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
if (hasDev || hasSupabase) return NextResponse.next();
@@ -12,10 +17,11 @@ export function proxy(request: NextRequest) {
}
const login = request.nextUrl.clone();
login.pathname = "/login";
login.search = "";
login.searchParams.set("next", pathname);
return NextResponse.redirect(login);
}
export const config = {
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"],
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*", "/selecionar-workspace"],
};
+36 -32
View File
@@ -1,49 +1,45 @@
import "server-only";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
import { getDb } from "@/infra/db/client";
import { devAuthEnabled, verifyDevSession } from "./dev-session";
import { activeProviders, WORKSPACE_COOKIE, type Identity } from "./identity-provider";
import { listMemberships, resolveWorkspace, type Membership } from "./workspace";
export type AuthState =
| { kind: "anonymous" }
| { kind: "session_expired" }
| { kind: "no_workspace" }
| { kind: "not_member" }
| { kind: "workspace_choice"; memberships: Membership[] }
| { kind: "ok"; ctx: AuthenticatedContext };
export async function resolveAuth(): Promise<AuthState> {
export { WORKSPACE_COOKIE };
async function resolveIdentity(): Promise<{ identity: Identity | null; hadHint: boolean }> {
const jar = await cookies();
let userId: string | null = null;
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
const devToken = jar.get("adapter_dev_session")?.value;
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
if (devToken && devAuthEnabled()) {
const dev = verifyDevSession(devToken);
if (dev) {
userId = dev.userId;
workspaceId = dev.workspaceId;
}
const providers = activeProviders();
let hadHint = false;
for (const provider of providers) {
if (provider.hadSessionHint(jar)) hadHint = true;
const identity = await provider.resolve(jar);
if (identity) return { identity, hadHint };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!userId && supabaseUrl && supabaseKey) {
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) jar.set(item.name, item.value, item.options);
},
},
});
const { data } = await supabase.auth.getUser();
userId = data.user?.id ?? null;
}
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
if (!workspaceId) return { kind: "no_workspace" };
return { identity: null, hadHint };
}
export async function resolveAuth(): Promise<AuthState> {
const { identity, hadHint } = await resolveIdentity();
if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
const db = await getDb();
const rows = await db.withUser(userId, (query) =>
const memberships = await listMemberships(db, identity.userId);
const resolution = resolveWorkspace(memberships, identity.workspaceHint);
if (resolution.kind === "none") return { kind: "no_workspace" };
if (resolution.kind === "choice") return { kind: "workspace_choice", memberships: resolution.memberships };
const workspaceId = resolution.workspaceId;
const rows = await db.withUser(identity.userId, (query) =>
query.query<{
display_name: string;
is_superadmin: boolean;
@@ -61,13 +57,13 @@ export async function resolveAuth(): Promise<AuthState> {
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[userId, workspaceId],
[identity.userId, workspaceId],
),
);
const row = rows[0];
if (!row) return { kind: "not_member" };
const ctx = {
userId,
userId: identity.userId,
displayName: row.display_name,
isSuperadmin: row.is_superadmin,
workspaceId,
@@ -78,3 +74,11 @@ export async function resolveAuth(): Promise<AuthState> {
};
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
}
/** Lista as associações do usuário autenticado, para a tela de escolha. */
export async function currentMemberships(): Promise<{ userId: string; memberships: Membership[] } | null> {
const { identity } = await resolveIdentity();
if (!identity) return null;
const db = await getDb();
return { userId: identity.userId, memberships: await listMemberships(db, identity.userId) };
}
+15 -1
View File
@@ -1,9 +1,23 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { adapterEnv } from "@/config/env";
export type DevSession = { userId: string; workspaceId: string; exp: number };
/**
* Sessão local sem senha. Existe apenas em `ADAPTER_ENV=development`.
* Homologação pública usa Supabase Auth real — ver `stagingAuthEnabled`.
*
* A autoridade é `ADAPTER_ENV`, não `NODE_ENV`: a imagem roda sempre com
* `NODE_ENV=production` (build otimizado), inclusive no compose local. E o
* padrão protege o caso perigoso: sem `ADAPTER_ENV` explícito, um processo com
* `NODE_ENV=production` resolve para `production` e esta função devolve false.
*/
export function devAuthEnabled(): boolean {
return process.env.NODE_ENV !== "production" && process.env.ADAPTER_DEV_AUTH === "1" && Boolean(process.env.ADAPTER_DEV_AUTH_SECRET);
return (
adapterEnv() === "development" &&
process.env.ADAPTER_DEV_AUTH === "1" &&
Boolean(process.env.ADAPTER_DEV_AUTH_SECRET)
);
}
function secret(): string {
+104
View File
@@ -0,0 +1,104 @@
import "server-only";
import type { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { adapterEnv, stagingAuthEnabled } from "@/config/env";
import { devAuthEnabled, verifyDevSession } from "./dev-session";
export type CookieJar = Awaited<ReturnType<typeof cookies>>;
/** Identidade resolvida. O Adaptador nunca cria usuário, senha ou cadastro. */
export type Identity = {
userId: string;
/** Workspace sugerido pelo provedor. É dica, nunca autorização. */
workspaceHint: string | null;
source: "supabase" | "dev";
};
/**
* Interface de autenticação. Hoje há duas implementações sobre o mesmo
* Supabase Auth — o projeto próprio de homologação e o projeto do Elo Pro —
* e uma de desenvolvimento local. Quando o SSO do Elo Pro existir, basta uma
* nova implementação deste contrato: nada fora daqui conhece o mecanismo.
*/
export interface IdentityProvider {
readonly name: string;
/** Identidade da requisição, ou null quando não há sessão válida. */
resolve(jar: CookieJar): Promise<Identity | null>;
/** Indica se havia resquício de sessão, para distinguir expirada de anônima. */
hadSessionHint(jar: CookieJar): boolean;
}
export const SUPABASE_COOKIE_HINT = (name: string) => name.startsWith("sb-") && name.includes("auth");
export function supabaseServerClient(jar: CookieJar) {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !key) return null;
return createServerClient(url, key, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) {
try {
jar.set(item.name, item.value, item.options);
} catch {
// Server Components não deixam gravar cookie. O refresh acontece na próxima rota.
}
}
},
},
});
}
/** Supabase Auth. Serve o projeto próprio de homologação e o do Elo Pro. */
export const supabaseIdentityProvider: IdentityProvider = {
name: "supabase",
async resolve(jar) {
const supabase = supabaseServerClient(jar);
if (!supabase) return null;
const { data } = await supabase.auth.getUser();
const userId = data.user?.id ?? null;
if (!userId) return null;
return { userId, workspaceHint: jar.get(WORKSPACE_COOKIE)?.value ?? null, source: "supabase" };
},
hadSessionHint(jar) {
return jar.getAll().some((cookie) => SUPABASE_COOKIE_HINT(cookie.name));
},
};
/** Sessão local assinada, sem senha. Existe apenas em development. */
export const devIdentityProvider: IdentityProvider = {
name: "dev",
async resolve(jar) {
if (!devAuthEnabled()) return null;
const token = jar.get(DEV_COOKIE)?.value;
if (!token) return null;
const session = verifyDevSession(token);
if (!session) return null;
return { userId: session.userId, workspaceHint: session.workspaceId, source: "dev" };
},
hadSessionHint(jar) {
return devAuthEnabled() && Boolean(jar.get(DEV_COOKIE)?.value);
},
};
export const DEV_COOKIE = "adapter_dev_session";
export const WORKSPACE_COOKIE = "elo_pro_workspace_id";
/**
* Provedores na ordem de precedência do ambiente. Em staging e produção o
* provedor de desenvolvimento não entra na lista — não é questão de
* configuração, é ausência no caminho de código.
*/
export function activeProviders(): IdentityProvider[] {
if (adapterEnv() === "development") return [devIdentityProvider, supabaseIdentityProvider];
return [supabaseIdentityProvider];
}
/** Descreve como o ambiente atual autentica. Usado pela tela de login. */
export function authMode(): "dev" | "staging-supabase" | "elo-pro-sso" | "unconfigured" {
if (devAuthEnabled()) return "dev";
if (stagingAuthEnabled()) return "staging-supabase";
if (process.env.ELO_PRO_LOGIN_URL) return "elo-pro-sso";
return "unconfigured";
}
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
import type { Db } from "@/infra/db/types";
import type { Role } from "@/domain/spreadsheet-adapter/types";
export type Membership = {
workspaceId: string;
workspaceName: string;
role: Role;
moduleEnabled: boolean;
};
export type WorkspaceResolution =
| { kind: "none" }
| { kind: "resolved"; workspaceId: string }
| { kind: "choice"; memberships: Membership[] };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isWorkspaceId(value: string | null | undefined): value is string {
return typeof value === "string" && UUID_RE.test(value);
}
/**
* Associações reais do usuário, lidas com o papel `app_user` sob RLS.
* Esta é a única fonte de autorização de workspace.
*/
export async function listMemberships(db: Db, userId: string): Promise<Membership[]> {
const rows = await db.withUser(userId, (query) =>
query.query<{
workspace_id: string;
workspace_name: string;
role: Role;
module_enabled: boolean;
}>(
`select w.id as workspace_id, w.name as workspace_name, m.role,
coalesce(f.enabled, false) as module_enabled
from public.workspace_members m
join public.workspaces w on w.id = m.workspace_id
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
where m.user_id = $1
order by w.name asc`,
[userId],
),
);
return rows.map((row) => ({
workspaceId: row.workspace_id,
workspaceName: row.workspace_name,
role: row.role,
moduleEnabled: row.module_enabled,
}));
}
/**
* Resolve o workspace no servidor. O cookie da plataforma é apenas uma dica de
* preferência: se não corresponder a uma associação real ele é ignorado, nunca
* aceito como prova. Com uma única associação a escolha é automática; com mais
* de uma e sem dica válida, quem escolhe é a pessoa.
*/
export function resolveWorkspace(memberships: Membership[], hint: string | null | undefined): WorkspaceResolution {
if (memberships.length === 0) return { kind: "none" };
if (isWorkspaceId(hint)) {
const match = memberships.find((item) => item.workspaceId.toLowerCase() === hint.toLowerCase());
if (match) return { kind: "resolved", workspaceId: match.workspaceId };
}
if (memberships.length === 1) return { kind: "resolved", workspaceId: memberships[0].workspaceId };
return { kind: "choice", memberships };
}
/** Confirma que o usuário é membro antes de gravar a preferência no cookie. */
export async function assertMembership(db: Db, userId: string, workspaceId: string): Promise<Membership> {
if (!isWorkspaceId(workspaceId)) throw new Error("workspace inválido");
const memberships = await listMemberships(db, userId);
const match = memberships.find((item) => item.workspaceId.toLowerCase() === workspaceId.toLowerCase());
if (!match) throw new Error("associação não encontrada");
return match;
}
+81
View File
@@ -0,0 +1,81 @@
import "server-only";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import { createCoordinator } from "@/infra/redis";
import { rateLimitKey } from "@/infra/redis/redis";
import type { Coordinator } from "@/infra/redis/memory";
export type RateLimitBucket = "login" | "upload" | "process" | "download" | "mutation" | "read";
export type RateLimitRule = { limit: number; windowSeconds: number };
/**
* Tetos por janela. São limites de abuso, não de produto: o uso normal do
* piloto fica muito abaixo. `upload` e `process` são os caros — leem 10 MB
* e geram XLSX, CSV e PDF.
*/
export const RATE_LIMITS: Record<RateLimitBucket, RateLimitRule> = {
login: { limit: 10, windowSeconds: 300 },
upload: { limit: 30, windowSeconds: 300 },
process: { limit: 20, windowSeconds: 300 },
download: { limit: 120, windowSeconds: 300 },
mutation: { limit: 120, windowSeconds: 300 },
read: { limit: 600, windowSeconds: 300 },
};
export function windowStart(rule: RateLimitRule, now = Date.now()): number {
return Math.floor(now / (rule.windowSeconds * 1000));
}
/**
* Conta o acesso e devolve o estado da janela. Não lança: quem chama decide.
* Falha de coordenação não bloqueia leitura — o processamento já é recusado
* por conta própria quando o Redis está fora.
*/
export async function hitRateLimit(
bucket: RateLimitBucket,
subject: string,
options: { coordinator?: Coordinator; now?: number } = {},
): Promise<{ allowed: boolean; limit: number; remaining: number; retryAfterSeconds: number }> {
const rule = RATE_LIMITS[bucket];
const coordinator = options.coordinator ?? createCoordinator();
const now = options.now ?? Date.now();
const key = rateLimitKey(bucket, subject, windowStart(rule, now));
let count: number;
try {
count = await coordinator.hit(key, rule.windowSeconds);
} catch {
// Sem coordenação não há contagem confiável. Não inventa bloqueio.
return { allowed: true, limit: rule.limit, remaining: rule.limit, retryAfterSeconds: 0 };
}
const elapsed = now % (rule.windowSeconds * 1000);
const retryAfterSeconds = Math.max(1, Math.ceil((rule.windowSeconds * 1000 - elapsed) / 1000));
return {
allowed: count <= rule.limit,
limit: rule.limit,
remaining: Math.max(0, rule.limit - count),
retryAfterSeconds,
};
}
/** Versão que recusa a requisição. Usada pelas rotas do módulo. */
export async function enforceRateLimit(
bucket: RateLimitBucket,
subject: string,
options: { coordinator?: Coordinator; now?: number } = {},
): Promise<void> {
const result = await hitRateLimit(bucket, subject, options);
if (result.allowed) return;
throw new AdapterError(
"RATE_LIMITED",
`Muitas chamadas em sequência. Aguarde ${result.retryAfterSeconds} segundos e tente de novo.`,
429,
{ retryAfterSeconds: result.retryAfterSeconds, limit: result.limit },
);
}
/** Identificador do chamador anônimo, para a tela de login. */
export function requestSubject(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "desconhecido";
return ip.slice(0, 64);
}
@@ -7,7 +7,6 @@ import { canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissio
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types";
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
import { buildPdf } from "@/infra/export/pdf";
import type { Query } from "@/infra/db/types";
import { lockKey } from "@/infra/redis/redis";
import { sanitizeAuditMetadata } from "@/infra/observability/scrub";
@@ -282,6 +281,10 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
const grid = parseCsvText(csv);
const headers = grid[0] ?? [];
// Import tardio: o worker não gera PDF (o PDF sai sob demanda no download),
// e carregar @react-pdf/renderer fora do bundler do Next falha ao resolver
// o subpath de hifenização. Sem isto o processo do worker nem subia.
const { buildPdf } = await import("@/infra/export/pdf");
const body = await buildPdf(file.model_name, headers, grid.slice(1));
return { filename, contentType: CONTENT_TYPE.pdf, body: body.body };
}
@@ -0,0 +1,176 @@
import { asJson } from "./db-utils";
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "./process-job";
import type { AppDeps } from "./deps";
import type { JobProgress, RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
/**
* Loop do worker, fora do script de entrada para poder ser testado sem subir
* um processo. O script `scripts/worker.ts` só cuida de sinais e de bootstrap.
*
* Sem `import "server-only"` de propósito: este módulo roda também no processo
* do worker, que é Node puro. O pacote `server-only` lança quando é carregado
* fora do bundler do Next, e isso derrubava o worker no start.
*/
export type WorkerOptions = {
/** Máximo de ids retirados da fila do Redis por tick. */
batch?: number;
/** Caminho do arquivo de heartbeat lido pelo health check do contêiner. */
heartbeatPath?: string | null;
/** Injetável para teste. */
writeHeartbeat?: (path: string) => Promise<void>;
log?: (message: string) => void;
onError?: (scope: string, error: unknown) => void;
};
export type TickResult = {
/** Jobs que o worker tentou processar neste tick. */
processed: string[];
/** Temporários expirados removidos neste tick. */
cleaned: number;
recovered: boolean;
};
const DEFAULT_BATCH = 20;
async function defaultWriteHeartbeat(target: string): Promise<void> {
const { writeFile } = await import("node:fs/promises");
await writeFile(target, new Date().toISOString(), "utf8");
}
/**
* Contexto do dono do job, relido do banco a cada execução. Nunca vem do
* payload do job: papel, flag do módulo e configuração podem ter mudado entre
* o enfileiramento e o processamento.
*/
async function loadContext(deps: AppDeps, userId: string, workspaceId: string): Promise<RequestContext | null> {
const rows = await deps.db.withService((query) =>
query.query<{
display_name: string;
is_superadmin: boolean;
role: Role;
workspace_name: string;
module_enabled: boolean;
allow_upload: boolean;
}>(
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
coalesce(f.enabled, false) as module_enabled,
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
from public.profiles p
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
join public.workspaces w on w.id = $2
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[userId, workspaceId],
),
);
const found = rows[0];
if (!found) return null;
return {
userId,
displayName: found.display_name,
isSuperadmin: found.is_superadmin,
workspaceId,
workspaceName: found.workspace_name,
role: found.role,
moduleEnabled: found.module_enabled,
allowNonAdminModelUpload: found.allow_upload,
};
}
export function createWorker(deps: AppDeps, options: WorkerOptions = {}) {
const batch = options.batch ?? DEFAULT_BATCH;
const heartbeatPath = options.heartbeatPath ?? null;
const writeHeartbeat = options.writeHeartbeat ?? defaultWriteHeartbeat;
const onError = options.onError ?? (() => undefined);
let running = true;
let inTick = false;
async function tick(): Promise<TickResult> {
if (!running || inTick) return { processed: [], cleaned: 0, recovered: false };
inTick = true;
const processed: string[] = [];
let cleaned = 0;
let recovered = false;
try {
recovered = await recoverStaleJobs(deps)
.then(() => true)
.catch((error) => {
onError("recover", error);
return false;
});
cleaned = await cleanupExpired(deps).catch((error) => {
onError("cleanup", error);
return 0;
});
// Duas origens para o mesmo trabalho: a fila do Redis é o caminho rápido,
// a varredura do Postgres garante que nada se perde se o Redis reiniciar.
const queued = new Set(
await listQueuedJobIds(deps).catch((error) => {
onError("list", error);
return [] as string[];
}),
);
for (let index = 0; index < batch; index += 1) {
const jobId = await deps.coordinator.dequeue().catch(() => null);
if (!jobId) break;
queued.add(jobId);
}
for (const jobId of queued) {
if (!running) break;
const rows = await readJobForWorker(deps, jobId).catch((error) => {
onError("read", error);
return [];
});
const row = rows[0];
if (!row) continue;
const progress = asJson<JobProgress | null>(row.progress, null);
if (!progress?.sourceId) continue;
const ctx = await loadContext(deps, row.user_id, row.workspace_id).catch((error) => {
onError("context", error);
return null;
});
if (!ctx) continue;
await runProcess(deps, ctx, jobId, progress.sourceId)
.then(() => {
processed.push(jobId);
})
.catch((error) => {
onError("process", error);
});
}
if (heartbeatPath) {
await writeHeartbeat(heartbeatPath).catch((error) => onError("heartbeat", error));
}
} finally {
inTick = false;
}
return { processed, cleaned, recovered };
}
/**
* Para de aceitar trabalho novo e espera o tick corrente terminar, para que o
* `finally` do processamento libere o lock. Sem essa espera o job ficaria em
* `processing` até o lock vencer por TTL.
*/
async function shutdown(graceMs = 20_000, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise<boolean> {
running = false;
const deadline = Date.now() + graceMs;
while (inTick && Date.now() < deadline) {
await sleep(100);
}
return !inTick;
}
return {
tick,
shutdown,
isRunning: () => running,
isBusy: () => inTick,
};
}
+69
View File
@@ -0,0 +1,69 @@
import { vi } from "vitest";
import type { Db } from "@/infra/db/types";
import { MemoryCoordinator } from "@/infra/redis/memory";
import { MemoryStorage } from "@/infra/storage/memory";
import { signDevSession } from "@/server/auth/dev-session";
export type FakeCookie = { name: string; value: string };
/**
* Jar de cookies no formato que `next/headers` entrega. Permite exercitar as
* rotas reais, com o caminho real de autenticação, sem subir um servidor.
*/
export class FakeJar {
private readonly store = new Map<string, string>();
get(name: string): FakeCookie | undefined {
const value = this.store.get(name);
return value === undefined ? undefined : { name, value };
}
getAll(): FakeCookie[] {
return [...this.store.entries()].map(([name, value]) => ({ name, value }));
}
set(name: string, value: string): void {
this.store.set(name, value);
}
delete(name: string): void {
this.store.delete(name);
}
has(name: string): boolean {
return this.store.has(name);
}
}
export const jar = new FakeJar();
/** Sessão local assinada. Só existe porque os testes rodam em development. */
export function signIn(userId: string, workspaceId: string): void {
jar.set("adapter_dev_session", signDevSession(userId, workspaceId));
}
export function signOut(): void {
for (const cookie of jar.getAll()) jar.delete(cookie.name);
}
/** Dependências do módulo em memória, para não escrever em disco nos testes. */
export function makeRuntimeDeps(db: Db) {
const storage = new MemoryStorage();
const coordinator = new MemoryCoordinator();
return {
deps: { db, storage, coordinator, ttlSeconds: 3600 },
storage,
coordinator,
};
}
export function installNextHeadersMock(): void {
vi.mock("next/headers", () => ({
cookies: async () => jar,
headers: async () => new Headers(),
}));
}
export async function readJson<T = unknown>(response: Response): Promise<T> {
return (await response.json()) as T;
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { proxy, config } from "@/proxy";
function request(path: string, cookies: Record<string, string> = {}): NextRequest {
const next = new NextRequest(new URL(`http://localhost${path}`));
for (const [name, value] of Object.entries(cookies)) next.cookies.set(name, value);
return next;
}
describe("proxy", () => {
it("cobre as áreas protegidas e nada além", () => {
expect(config.matcher).toContain("/adaptador/:path*");
expect(config.matcher).toContain("/api/spreadsheet-adapter/:path*");
expect(config.matcher).toContain("/selecionar-workspace");
});
it("redireciona a página protegida ao login preservando o destino", () => {
const response = proxy(request("/adaptador/operacao"));
expect(response.status).toBe(307);
const location = new URL(response.headers.get("location") as string);
expect(location.pathname).toBe("/login");
expect(location.searchParams.get("next")).toBe("/adaptador/operacao");
});
it("não propaga query da requisição original para o login", () => {
const next = new NextRequest(new URL("http://localhost/adaptador?returnTo=https://evil.example"));
const response = proxy(next);
const location = new URL(response.headers.get("location") as string);
expect(location.searchParams.get("returnTo")).toBeNull();
expect(location.searchParams.get("next")).toBe("/adaptador");
});
it("responde 401 em JSON na API sem sessão", async () => {
const response = proxy(request("/api/spreadsheet-adapter/models"));
expect(response.status).toBe(401);
const body = (await response.json()) as { error: { code: string } };
expect(body.error.code).toBe("UNAUTHENTICATED");
});
it("deixa passar com cookie do Supabase e com a sessão local", () => {
expect(proxy(request("/adaptador", { "sb-projeto-auth-token": "abc" })).status).toBe(200);
expect(proxy(request("/adaptador", { adapter_dev_session: "abc" })).status).toBe(200);
});
it("não interfere em rota pública nem no health check", () => {
expect(proxy(request("/")).status).toBe(200);
expect(proxy(request("/api/health/live")).status).toBe(200);
expect(proxy(request("/login")).status).toBe(200);
});
it("protege a tela de escolha de workspace", () => {
expect(proxy(request("/selecionar-workspace")).status).toBe(307);
});
});
+192
View File
@@ -0,0 +1,192 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createReadyDb, IDS } from "../helpers/world";
import { FakeJar, jar, makeRuntimeDeps, signIn, signOut } from "./helpers";
import type { Db } from "@/infra/db/types";
// Autenticação local assinada: os testes rodam como development.
process.env.ADAPTER_ENV = "development";
process.env.ADAPTER_DEV_AUTH = "1";
process.env.ADAPTER_DEV_AUTH_SECRET = "segredo-de-teste";
let db: Db;
let runtime: ReturnType<typeof makeRuntimeDeps>;
vi.mock("next/headers", () => ({
cookies: async () => jar as unknown as FakeJar,
headers: async () => new Headers(),
}));
vi.mock("@/infra/db/client", () => ({
getDb: async () => db,
probeDb: async () => {
await db.withService((query) => query.query("select 1 as ok"));
},
}));
vi.mock("@/server/spreadsheet-adapter/runtime", () => ({
runtimeDeps: async () => runtime.deps,
}));
beforeAll(async () => {
db = await createReadyDb();
runtime = makeRuntimeDeps(db);
});
beforeEach(() => {
signOut();
});
describe("rotas de saúde", () => {
it("liveness responde sem tocar em dependência", async () => {
const { GET } = await import("@/app/api/health/live/route");
const response = GET();
expect(response.status).toBe(200);
const body = (await response.json()) as { status: string; environment: string };
expect(body.status).toBe("live");
expect(body.environment).toBe("development");
});
it("readiness reporta cada dependência e não devolve connection string", async () => {
const { GET } = await import("@/app/api/health/ready/route");
const response = await GET();
const body = (await response.json()) as {
status: string;
checks: Record<string, string>;
configIssues: string[];
};
expect(body.checks.database).toBe("ok");
expect(body.checks.migrations).toBe("ok");
expect(body.status).toBe("ready");
expect(JSON.stringify(body)).not.toContain("postgres://");
expect(body.configIssues).toEqual([]);
});
});
describe("autenticação das rotas do módulo", () => {
it("recusa sem sessão", async () => {
const { GET } = await import("@/app/api/spreadsheet-adapter/models/route");
const response = await GET();
expect(response.status).toBe(401);
const body = (await response.json()) as { error: { code: string } };
expect(body.error.code).toBe("UNAUTHENTICATED");
});
it("responde com sessão válida no workspace com o módulo ligado", async () => {
signIn(IDS.ana, IDS.alpha);
const { GET } = await import("@/app/api/spreadsheet-adapter/models/route");
const response = await GET();
expect(response.status).toBe(200);
const body = (await response.json()) as { models: unknown[] };
expect(Array.isArray(body.models)).toBe(true);
});
it("recusa a configuração para quem não é owner nem admin", async () => {
signIn(IDS.bruno, IDS.alpha);
const { PATCH } = await import("@/app/api/spreadsheet-adapter/settings/route");
const response = await PATCH(
new Request("http://localhost/api/spreadsheet-adapter/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowNonAdminModelUpload: true }),
}),
);
expect(response.status).toBeGreaterThanOrEqual(400);
const body = (await response.json()) as { error: { code: string } };
expect(["FORBIDDEN", "WORKSPACE_FORBIDDEN", "FEATURE_DISABLED"]).toContain(body.error.code);
});
it("bloqueia o módulo desligado no workspace", async () => {
signIn(IDS.diego, IDS.beta);
const { GET } = await import("@/app/api/spreadsheet-adapter/models/route");
const response = await GET();
expect(response.status).toBeGreaterThanOrEqual(400);
});
});
describe("seleção de workspace", () => {
it("lista só as associações reais do usuário", async () => {
signIn(IDS.eva, IDS.beta);
const { GET } = await import("@/app/api/auth/workspace/route");
const response = await GET();
expect(response.status).toBe(200);
const body = (await response.json()) as { memberships: { workspaceId: string }[] };
expect(body.memberships.map((item) => item.workspaceId)).toEqual([IDS.beta]);
});
it("recusa gravar workspace do qual o usuário não é membro", async () => {
signIn(IDS.diego, IDS.beta);
const { POST } = await import("@/app/api/auth/workspace/route");
const response = await POST(
new Request("http://localhost/api/auth/workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: IDS.alpha }),
}),
);
expect(response.status).toBe(403);
expect(jar.has("elo_pro_workspace_id")).toBe(false);
});
it("grava a preferência quando a associação existe", async () => {
signIn(IDS.ana, IDS.alpha);
const { POST } = await import("@/app/api/auth/workspace/route");
const response = await POST(
new Request("http://localhost/api/auth/workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: IDS.alpha }),
}),
);
expect(response.status).toBe(200);
expect(jar.get("elo_pro_workspace_id")?.value).toBe(IDS.alpha);
});
it("recusa sem sessão", async () => {
const { POST } = await import("@/app/api/auth/workspace/route");
const response = await POST(
new Request("http://localhost/api/auth/workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: IDS.alpha }),
}),
);
expect(response.status).toBe(401);
});
});
describe("login de homologação", () => {
it("não existe fora de staging", async () => {
const { POST } = await import("@/app/api/auth/staging/session/route");
const response = await POST(
new Request("http://localhost/api/auth/staging/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "a@b.c", password: "x" }),
}),
);
expect(response.status).toBe(404);
});
});
describe("rate limiting nas rotas", () => {
it("devolve 429 com Retry-After ao estourar a janela", async () => {
signIn(IDS.ana, IDS.alpha);
const { createCoordinator } = await import("@/infra/redis");
const { RATE_LIMITS, windowStart } = await import("@/server/rate-limit");
const { rateLimitKey } = await import("@/infra/redis/redis");
const rule = RATE_LIMITS.read;
const key = rateLimitKey("read", `${IDS.alpha}:${IDS.ana}`, windowStart(rule));
const coordinator = createCoordinator();
for (let index = 0; index < rule.limit; index += 1) {
await coordinator.hit(key, rule.windowSeconds);
}
const { GET } = await import("@/app/api/spreadsheet-adapter/models/route");
const response = await GET();
expect(response.status).toBe(429);
expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0);
const body = (await response.json()) as { error: { code: string } };
expect(body.error.code).toBe("RATE_LIMITED");
});
});
+220
View File
@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { addSource, createJob, createModel, saveMappings, startProcess } from "@/server/spreadsheet-adapter/service";
import { downloadOutput } from "@/server/spreadsheet-adapter/process-job";
import { createWorker } from "@/server/spreadsheet-adapter/worker-loop";
import { lockKey } from "@/infra/redis/redis";
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
/**
* Jornada assíncrona pelo loop real do worker: a web enfileira e devolve, e o
* processamento acontece depois, no tick. É o mesmo código que o processo do
* contêiner executa. O que estes casos não cobrem é a fronteira de processo e o
* Redis real — isso está em `tests/services/redis.test.ts` e no CI.
*/
const previous = { mode: process.env.ADAPTER_JOB_MODE };
beforeEach(() => {
// Modo worker: `startProcess` só enfileira e sai.
process.env.ADAPTER_JOB_MODE = "worker";
});
afterEach(() => {
if (previous.mode === undefined) delete process.env.ADAPTER_JOB_MODE;
else process.env.ADAPTER_JOB_MODE = previous.mode;
});
type World = Awaited<ReturnType<typeof prepare>>;
async function prepare() {
const db = await createReadyDb();
const deps = depsOf(db);
const ana = ctxOf("ana", "alpha", "owner", true);
const model = await createModel(deps, ana, {
name: "Clientes",
filename: "modelo.csv",
bytes: Buffer.from("Nome,Valor\n", "utf8"),
requiredColumns: ["Nome", "Valor"],
});
const modelBytes = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`);
const job = await createJob(deps, ana, model.id, "job-async");
const added = await addSource(deps, ana, job.id, {
filename: "fonte.csv",
bytes: Buffer.from("Nome,Valor\nAna,10\nBruno,20\n", "utf8"),
});
const sourceId = added.job.sources[0].id;
await saveMappings(deps, ana, job.id, sourceId, [
{ sourceColumn: "Nome", modelColumn: "Nome" },
{ sourceColumn: "Valor", modelColumn: "Valor" },
]);
return { db, deps, ana, model, modelBytes, job, sourceId };
}
async function statusOf(world: World, jobId: string): Promise<string> {
const rows = await world.db.withService((query) =>
query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [jobId]),
);
return rows[0]?.status ?? "ausente";
}
describe("jornada assíncrona pelo worker", () => {
it("enfileira na web, processa no tick e entrega os três formatos", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
const queued = await startProcess(deps, ana, job.id, sourceId, "proc-async");
expect(queued.status).toBe("queued");
// Nada foi processado ainda: a web só enfileirou.
await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "NOT_FOUND" });
const worker = createWorker(deps, { heartbeatPath: null });
const result = await worker.tick();
expect(result.processed).toEqual([job.id]);
expect(await statusOf(world, job.id)).toBe("completed");
const csv = await downloadOutput(deps, ana, job.id, sourceId, "csv");
expect(csv.body.toString("utf8")).toContain("Ana");
const xlsx = await downloadOutput(deps, ana, job.id, sourceId, "xlsx");
expect(xlsx.body.subarray(0, 2).toString()).toBe("PK");
const pdf = await downloadOutput(deps, ana, job.id, sourceId, "pdf");
expect(pdf.body.subarray(0, 4).toString()).toBe("%PDF");
const after = await deps.storage.get(`workspaces/${IDS.alpha}/models/${world.model.id}/original`);
expect(after.equals(world.modelBytes)).toBe(true);
});
it("libera o lock do usuário ao concluir, permitindo o próximo job", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
await startProcess(deps, ana, job.id, sourceId, "proc-lock");
await createWorker(deps, { heartbeatPath: null }).tick();
// Lock livre: outro processamento consegue adquirir.
expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "outro-job", 30)).toBe(true);
});
it("encontra o job pela varredura do banco quando a fila do Redis se perdeu", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
await startProcess(deps, ana, job.id, sourceId, "proc-sem-fila");
// Simula Redis reiniciado: a fila some, o estado durável no Postgres fica.
while (await deps.coordinator.dequeue()) {
/* drena */
}
expect(await deps.coordinator.dequeue()).toBeNull();
const result = await createWorker(deps, { heartbeatPath: null }).tick();
expect(result.processed).toEqual([job.id]);
expect(await statusOf(world, job.id)).toBe("completed");
});
it("relê o contexto do dono e não processa com o módulo desligado", async () => {
const world = await prepare();
const { db, deps, ana, job, sourceId } = world;
await startProcess(deps, ana, job.id, sourceId, "proc-flag");
// A flag cai entre o enfileiramento e o tick.
await db.withService((query) =>
query.query(
`update public.workspace_module_flags set enabled = false where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
[IDS.alpha],
),
);
const result = await createWorker(deps, { heartbeatPath: null }).tick();
expect(result.processed).toEqual([]);
expect(await statusOf(world, job.id)).not.toBe("completed");
await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toBeTruthy();
});
it("expira temporários no tick e preserva o modelo", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
await startProcess(deps, ana, job.id, sourceId, "proc-ttl");
await createWorker(deps, { heartbeatPath: null }).tick();
await downloadOutput(deps, ana, job.id, sourceId, "csv");
// Avança além do TTL e roda o tick de novo.
const expirado = { ...deps, now: () => new Date("2027-01-01T00:00:00.000Z") };
const result = await createWorker(expirado, { heartbeatPath: null }).tick();
expect(result.cleaned).toBeGreaterThan(0);
await expect(downloadOutput(expirado, ana, job.id, sourceId, "csv")).rejects.toBeTruthy();
const model = await deps.storage.get(`workspaces/${IDS.alpha}/models/${world.model.id}/original`);
expect(model.equals(world.modelBytes)).toBe(true);
});
it("grava heartbeat ao fim do tick", async () => {
const world = await prepare();
const escritas: string[] = [];
const worker = createWorker(world.deps, {
heartbeatPath: "/tmp/teste-heartbeat",
writeHeartbeat: async (target) => {
escritas.push(target);
},
});
await worker.tick();
expect(escritas).toEqual(["/tmp/teste-heartbeat"]);
});
});
describe("desligamento gracioso do worker", () => {
it("para de aceitar trabalho novo depois do shutdown", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
const worker = createWorker(deps, { heartbeatPath: null });
expect(worker.isRunning()).toBe(true);
expect(await worker.shutdown(1000)).toBe(true);
expect(worker.isRunning()).toBe(false);
await startProcess(deps, ana, job.id, sourceId, "proc-pos-shutdown");
const result = await worker.tick();
expect(result.processed).toEqual([]);
expect(await statusOf(world, job.id)).toBe("queued");
});
it("espera o tick em andamento e não perde o job que não começou", async () => {
const world = await prepare();
const { deps, ana, job, sourceId } = world;
await startProcess(deps, ana, job.id, sourceId, "proc-espera");
const worker = createWorker(deps, { heartbeatPath: null });
const inFlight = worker.tick();
expect(worker.isBusy()).toBe(true);
// O encerramento espera o tick corrente sair: é essa espera que dá tempo ao
// `finally` do processamento liberar o lock de um job já iniciado.
const clean = await worker.shutdown(10_000);
expect(clean).toBe(true);
expect(worker.isBusy()).toBe(false);
await inFlight;
// O job que ainda não havia começado continua `queued`: nada pela metade.
expect(await statusOf(world, job.id)).toBe("queued");
// O lock continua reservado para ESTE job, não vazou para outro dono. Em
// modo worker ele é adquirido no enfileiramento, com TTL de 120s, e
// `runProcess` o reaproveita no tick seguinte.
expect(await deps.coordinator.ownsLock(lockKey(ana.userId), job.id)).toBe(true);
expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "outro-job", 5)).toBe(false);
// O worker seguinte conclui o mesmo job, como aconteceria após um restart.
const result = await createWorker(deps, { heartbeatPath: null }).tick();
expect(result.processed).toEqual([job.id]);
expect(await statusOf(world, job.id)).toBe("completed");
// Concluído, o lock é liberado pelo `finally` do processamento.
expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "sonda", 5)).toBe(true);
});
it("reporta encerramento sujo quando a carência estoura", async () => {
const world = await prepare();
const worker = createWorker(world.deps, { heartbeatPath: null });
const inFlight = worker.tick();
expect(worker.isBusy()).toBe(true);
// Carência zero: não espera nada e reporta que o tick não terminou.
expect(await worker.shutdown(0)).toBe(false);
await inFlight;
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createPostgresDb } from "@/infra/db/postgres";
import { migrate, pendingMigrations } from "@/infra/db/migrate";
import type { Db } from "@/infra/db/types";
/**
* RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa
* PGlite, que é o mesmo motor mas não o mesmo servidor: papel `app_user`,
* `force row level security` e `set local role` precisam ser confirmados aqui.
*/
const url = process.env.ADAPTER_TEST_DATABASE_URL;
const enabled = Boolean(url);
const ALPHA = "11111111-1111-4111-8111-111111111111";
const BETA = "22222222-2222-4222-8222-222222222222";
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const DIEGO = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
let db: Db;
describe.skipIf(!enabled)("PostgreSQL real", () => {
beforeAll(async () => {
process.env.ADAPTER_APPLY_IDENTITY_SHIM = "1";
db = createPostgresDb(url as string);
await db.exec(`drop schema if exists public cascade; create schema public;`);
await migrate(db, { applyIdentityShim: true });
await db.withService(async (query) => {
await query.query(
`insert into public.profiles (id, display_name, is_superadmin) values ($1,'Ana',false), ($2,'Diego',false)
on conflict (id) do nothing`,
[ANA, DIEGO],
);
await query.query(
`insert into public.workspaces (id, name) values ($1,'Alpha'), ($2,'Beta') on conflict (id) do nothing`,
[ALPHA, BETA],
);
await query.query(
`insert into public.workspace_members (workspace_id, user_id, role) values ($1,$2,'owner'), ($3,$4,'admin')
on conflict (workspace_id, user_id) do nothing`,
[ALPHA, ANA, BETA, DIEGO],
);
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1,'spreadsheet_adapter',true), ($2,'spreadsheet_adapter',true)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled`,
[ALPHA, BETA],
);
});
});
afterAll(async () => {
if (db) await db.close();
});
it("aplica todas as migrations e não deixa pendência", async () => {
expect(await pendingMigrations(db, { applyIdentityShim: true })).toEqual([]);
});
it("cria o papel app_user sem bypass de RLS", async () => {
const rows = await db.withService((query) =>
query.query<{ rolbypassrls: boolean; rolsuper: boolean }>(
`select rolbypassrls, rolsuper from pg_roles where rolname = 'app_user'`,
),
);
expect(rows[0]).toBeDefined();
expect(rows[0].rolbypassrls).toBe(false);
expect(rows[0].rolsuper).toBe(false);
});
it("força RLS em todas as tabelas do módulo", async () => {
const rows = await db.withService((query) =>
query.query<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean }>(
`select relname, relrowsecurity, relforcerowsecurity from pg_class
where relname like 'spreadsheet%' or relname in ('profiles','workspaces','workspace_members','audit_log')`,
),
);
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
}
});
it("não deixa um usuário ver o workspace do outro", async () => {
const anaSees = await db.withUser(ANA, (query) =>
query.query<{ id: string }>(`select id from public.workspaces`),
);
expect(anaSees.map((row) => row.id.toLowerCase())).toEqual([ALPHA]);
const diegoSees = await db.withUser(DIEGO, (query) =>
query.query<{ id: string }>(`select id from public.workspaces`),
);
expect(diegoSees.map((row) => row.id.toLowerCase())).toEqual([BETA]);
});
it("não deixa um usuário ler modelo de outro workspace", async () => {
const modelId = "99999999-9999-4999-8999-999999999999";
await db.withService((query) =>
query.query(
`insert into public.spreadsheet_models
(id, workspace_id, name, original_filename, format, storage_key, content_sha256, sheet_name, header_row, columns_schema, created_by, status)
values ($1,$2,'Modelo Beta','b.xlsx','xlsx',$3,'sha','Plan1',1,'[]'::jsonb,$4,'active')
on conflict (id) do nothing`,
[modelId, BETA, `workspaces/${BETA}/models/${modelId}/original`, DIEGO],
),
);
const anaSees = await db.withUser(ANA, (query) =>
query.query(`select id from public.spreadsheet_models where id = $1`, [modelId]),
);
expect(anaSees).toHaveLength(0);
});
it("não deixa o papel da aplicação alterar auditoria", async () => {
await db.withService((query) =>
query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
values ($1,$2,'test.event','workspace',$1,'success')`,
[ALPHA, ANA],
),
);
await expect(
db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)),
).rejects.toThrow();
});
it("pula o shim quando a decisão é não aplicar", async () => {
const pending = await pendingMigrations(db, { applyIdentityShim: false });
expect(pending).not.toContain("0001_platform_shim.sql");
});
});
describe.skipIf(enabled)("PostgreSQL real", () => {
it("PENDENTE: defina ADAPTER_TEST_DATABASE_URL para executar", () => {
expect(enabled).toBe(false);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { afterAll, describe, expect, it } from "vitest";
import { RedisCoordinator, lockKey, progressKey, queueKey, rateLimitKey } from "@/infra/redis/redis";
/**
* Lock, fila, progresso e contador contra Redis real. A suíte padrão usa o
* coordenador em memória, que não atravessa processos — é justamente o que o
* lock precisa fazer em homologação com web e worker separados.
*/
const url = process.env.ADAPTER_TEST_REDIS_URL;
const enabled = Boolean(url);
const coordinators: RedisCoordinator[] = [];
function coordinator(): RedisCoordinator {
const instance = new RedisCoordinator(url as string);
coordinators.push(instance);
return instance;
}
afterAll(async () => {
for (const instance of coordinators) await instance.close().catch(() => undefined);
});
describe.skipIf(!enabled)("Redis real", () => {
it("responde ao ping", async () => {
expect(await coordinator().ping()).toBe(true);
});
it("dá o lock a um dono só, entre conexões diferentes", async () => {
const key = `${lockKey("teste-" + Date.now())}`;
const web = coordinator();
const worker = coordinator();
expect(await web.acquireLock(key, "web", 30)).toBe(true);
expect(await worker.acquireLock(key, "worker", 30)).toBe(false);
expect(await worker.ownsLock(key, "web")).toBe(true);
await web.releaseLock(key, "web");
expect(await worker.acquireLock(key, "worker", 30)).toBe(true);
await worker.releaseLock(key, "worker");
});
it("não deixa quem não é dono liberar o lock", async () => {
const key = `${lockKey("dono-" + Date.now())}`;
const web = coordinator();
expect(await web.acquireLock(key, "web", 30)).toBe(true);
await web.releaseLock(key, "intruso");
expect(await web.ownsLock(key, "web")).toBe(true);
await web.releaseLock(key, "web");
});
it("entrega o job enfileirado ao outro processo", async () => {
const jobId = `job-${Date.now()}`;
const web = coordinator();
const worker = coordinator();
await web.enqueue(jobId);
const dequeued = await worker.dequeue();
expect(dequeued).toBe(jobId);
});
it("compartilha progresso entre processos", async () => {
const jobId = `progresso-${Date.now()}`;
const web = coordinator();
const worker = coordinator();
await worker.setProgress(jobId, {
phase: "processing",
label: "Processando",
percent: 42,
sourceIndex: 0,
sourceCount: 1,
});
const progress = await web.getProgress(jobId);
expect(progress?.percent).toBe(42);
});
it("conta a janela de rate limit e expira a chave", async () => {
const key = rateLimitKey("process", `teste-${Date.now()}`, 1);
const instance = coordinator();
expect(await instance.hit(key, 60)).toBe(1);
expect(await instance.hit(key, 60)).toBe(2);
expect(await instance.hit(key, 60)).toBe(3);
});
it("separa as chaves por aplicativo e ambiente", () => {
const previous = process.env.ADAPTER_ENV;
try {
process.env.ADAPTER_ENV = "staging";
const stagingQueue = queueKey();
const stagingProgress = progressKey("x");
process.env.ADAPTER_ENV = "production";
expect(queueKey()).not.toBe(stagingQueue);
expect(progressKey("x")).not.toBe(stagingProgress);
expect(stagingQueue).toContain("adaptador-planilhas:staging");
} finally {
if (previous === undefined) delete process.env.ADAPTER_ENV;
else process.env.ADAPTER_ENV = previous;
}
});
it("recusa com erro de domínio quando o Redis está inacessível", async () => {
const broken = new RedisCoordinator("redis://127.0.0.1:1");
await expect(broken.enqueue("x")).rejects.toMatchObject({ code: "REDIS_UNAVAILABLE" });
expect(await broken.ping()).toBe(false);
await broken.close().catch(() => undefined);
});
});
describe.skipIf(enabled)("Redis real", () => {
it("PENDENTE: defina ADAPTER_TEST_REDIS_URL para executar", () => {
expect(enabled).toBe(false);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it } from "vitest";
import { adapterEnv, collectConfigIssues, stagingAuthEnabled } from "@/config/env";
import { devAuthEnabled } from "@/server/auth/dev-session";
/**
* O login local sem senha não pode existir em homologação pública, em nenhuma
* combinação de variáveis. Há três barreiras independentes e esta suíte cobre
* as três, na matriz inteira de ambientes.
*/
const original = {
ADAPTER_ENV: process.env.ADAPTER_ENV,
NODE_ENV: process.env.NODE_ENV,
ADAPTER_DEV_AUTH: process.env.ADAPTER_DEV_AUTH,
ADAPTER_DEV_AUTH_SECRET: process.env.ADAPTER_DEV_AUTH_SECRET,
};
function setEnv(values: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
afterEach(() => {
setEnv(original);
});
describe("barreira 1: devAuthEnabled", () => {
const casos: { nome: string; env: Record<string, string | undefined>; esperado: boolean }[] = [
{
nome: "development com flag e segredo",
env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: true,
},
{
nome: "development sem a flag",
env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "0", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "development sem segredo",
env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: undefined },
esperado: false,
},
{
nome: "staging com flag e segredo",
env: { ADAPTER_ENV: "staging", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "production com flag e segredo",
env: { ADAPTER_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "ADAPTER_ENV ausente com NODE_ENV=production",
env: { ADAPTER_ENV: undefined, NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "ADAPTER_ENV com valor inválido",
env: { ADAPTER_ENV: "homolog", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "ADAPTER_ENV com maiúsculas não é aceito como development",
env: { ADAPTER_ENV: "Development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
{
nome: "staging mesmo com NODE_ENV=development",
env: { ADAPTER_ENV: "staging", NODE_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" },
esperado: false,
},
];
for (const caso of casos) {
it(`${caso.nome} → ${caso.esperado ? "ligado" : "desligado"}`, () => {
setEnv({ ADAPTER_ENV: undefined, NODE_ENV: "test", ADAPTER_DEV_AUTH: undefined, ADAPTER_DEV_AUTH_SECRET: undefined });
setEnv(caso.env);
expect(devAuthEnabled()).toBe(caso.esperado);
});
}
it("um valor inesperado de ADAPTER_ENV nunca resolve para development", () => {
for (const valor of ["prod", "hml", "homolog", "homologacao", "stage", "staging ", "DEV", "Development", "dev"]) {
setEnv({ ADAPTER_ENV: valor, NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" });
expect(adapterEnv(), `valor ${JSON.stringify(valor)}`).not.toBe("development");
expect(devAuthEnabled(), `valor ${JSON.stringify(valor)}`).toBe(false);
}
});
it("espaço em volta de um valor válido é tolerado, de propósito", () => {
// Espaço sobrando em arquivo de env é erro de digitação comum, e o valor
// ainda é inequívoco. O que nunca acontece é um valor DESCONHECIDO virar
// development: o caso acima cobre isso, e `ADAPTER_ENV` vazio cai em
// NODE_ENV. Reconhecer " staging " como staging é o comportamento seguro.
setEnv({ ADAPTER_ENV: " staging ", NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" });
expect(adapterEnv()).toBe("staging");
expect(devAuthEnabled()).toBe(false);
setEnv({ ADAPTER_ENV: " development ", NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" });
expect(adapterEnv()).toBe("development");
expect(devAuthEnabled()).toBe(true);
});
});
describe("barreira 2: o start é recusado", () => {
const base = {
ADAPTER_ENV: "staging",
NEXT_PUBLIC_SUPABASE_URL: "https://p.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
SUPABASE_SERVICE_ROLE_KEY: "service",
SUPABASE_STORAGE_BUCKET: "bucket-hml",
DATABASE_URL: "postgres://host/db",
REDIS_URL: "redis://host:6379",
SPREADSHEET_TEMP_TTL_SECONDS: "86400",
ADAPTER_JOB_MODE: "worker",
ADAPTER_APPLY_IDENTITY_SHIM: "1",
ADAPTER_STAGING_AUTH: "1",
};
it("staging com ADAPTER_DEV_AUTH=1 não passa na validação", () => {
const issues = collectConfigIssues({ ...base, ADAPTER_DEV_AUTH: "1" });
expect(issues.map((issue) => issue.variable)).toContain("ADAPTER_DEV_AUTH");
});
it("production com ADAPTER_DEV_AUTH=1 não passa na validação", () => {
const issues = collectConfigIssues({
...base,
ADAPTER_ENV: "production",
ADAPTER_STAGING_AUTH: "0",
SENTRY_DSN: "https://k@sentry.io/1",
ELO_PRO_LOGIN_URL: "https://login.elo.example",
ADAPTER_DEV_AUTH: "1",
});
expect(issues.map((issue) => issue.variable)).toContain("ADAPTER_DEV_AUTH");
});
it("staging sem a flag de dev passa", () => {
expect(collectConfigIssues({ ...base, ADAPTER_DEV_AUTH: "0" })).toEqual([]);
});
});
describe("barreira 3: o provedor não entra no caminho de código", () => {
it("fora de development só o Supabase resolve identidade", async () => {
setEnv({ ADAPTER_ENV: "staging", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" });
const { activeProviders } = await import("@/server/auth/identity-provider");
expect(activeProviders().map((provider) => provider.name)).toEqual(["supabase"]);
});
it("em development o provedor local vem primeiro", async () => {
setEnv({ ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" });
const { activeProviders } = await import("@/server/auth/identity-provider");
expect(activeProviders().map((provider) => provider.name)).toEqual(["dev", "supabase"]);
});
it("o provedor local recusa o token mesmo se for chamado em staging", async () => {
setEnv({ ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "segredo" });
const { signDevSession } = await import("@/server/auth/dev-session");
const token = signDevSession("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "11111111-1111-4111-8111-111111111111");
setEnv({ ADAPTER_ENV: "staging" });
const { devIdentityProvider } = await import("@/server/auth/identity-provider");
const jar = {
get: (name: string) => (name === "adapter_dev_session" ? { name, value: token } : undefined),
getAll: () => [{ name: "adapter_dev_session", value: token }],
set: () => undefined,
};
expect(await devIdentityProvider.resolve(jar as never)).toBeNull();
expect(devIdentityProvider.hadSessionHint(jar as never)).toBe(false);
});
});
describe("login de homologação não vaza para produção", () => {
it("stagingAuthEnabled é falso em development e production", () => {
const base = {
ADAPTER_STAGING_AUTH: "1",
NEXT_PUBLIC_SUPABASE_URL: "https://p.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
};
expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "staging" })).toBe(true);
expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "production" })).toBe(false);
expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "development" })).toBe(false);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import {
adapterEnv,
assertDeployedEnv,
collectConfigIssues,
identityShimEnabled,
isDeployedEnv,
jobRunsInline,
requireDatabaseUrl,
resourceNamespace,
stagingAuthEnabled,
} from "@/config/env";
import { isShimMigration } from "@/infra/db/migrate";
import { resolveWorkspace, type Membership } from "@/server/auth/workspace";
const ALPHA = "11111111-1111-4111-8111-111111111111";
const BETA = "22222222-2222-4222-8222-222222222222";
function membership(workspaceId: string, name: string): Membership {
return { workspaceId, workspaceName: name, role: "owner", moduleEnabled: true };
}
const stagingBase = {
ADAPTER_ENV: "staging",
NODE_ENV: "production",
NEXT_PUBLIC_SUPABASE_URL: "https://projeto.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
SUPABASE_SERVICE_ROLE_KEY: "service",
SUPABASE_STORAGE_BUCKET: "adaptador-planilhas-hml",
DATABASE_URL: "postgres://host/db",
REDIS_URL: "redis://host:6379",
SPREADSHEET_TEMP_TTL_SECONDS: "86400",
ADAPTER_JOB_MODE: "worker",
ADAPTER_APPLY_IDENTITY_SHIM: "1",
ADAPTER_STAGING_AUTH: "1",
};
describe("ambiente lógico", () => {
it("separa development, staging e production", () => {
expect(adapterEnv({ ADAPTER_ENV: "staging", NODE_ENV: "production" })).toBe("staging");
expect(adapterEnv({ NODE_ENV: "production" })).toBe("production");
expect(adapterEnv({ NODE_ENV: "development" })).toBe("development");
expect(isDeployedEnv({ ADAPTER_ENV: "staging" })).toBe(true);
expect(isDeployedEnv({ ADAPTER_ENV: "development", NODE_ENV: "production" })).toBe(false);
});
it("trata ADAPTER_ENV inválido como ambiente implantado e aponta o erro", () => {
const env = { ADAPTER_ENV: "homolog", NODE_ENV: "production" };
expect(isDeployedEnv(env)).toBe(true);
expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
});
it("namespeia recursos por aplicativo e ambiente", () => {
expect(resourceNamespace({ ADAPTER_ENV: "staging" })).toBe("adaptador-planilhas:staging");
expect(resourceNamespace({ ADAPTER_ENV: "production" })).toBe("adaptador-planilhas:production");
expect(resourceNamespace({ ADAPTER_ENV: "staging", ADAPTER_NAMESPACE: "outro-app" })).toBe("outro-app:staging");
});
it("não deixa homologação e produção compartilharem namespace", () => {
expect(resourceNamespace({ ADAPTER_ENV: "staging" })).not.toBe(resourceNamespace({ ADAPTER_ENV: "production" }));
});
});
describe("configuração de homologação", () => {
it("aceita o conjunto completo", () => {
expect(collectConfigIssues(stagingBase)).toEqual([]);
expect(() => assertDeployedEnv(stagingBase)).not.toThrow();
});
it("recusa dev auth, inline e bucket público", () => {
const names = collectConfigIssues({
...stagingBase,
ADAPTER_DEV_AUTH: "1",
ADAPTER_JOB_MODE: "inline",
SUPABASE_STORAGE_BUCKET: "public",
}).map((issue) => issue.variable);
expect(names).toContain("ADAPTER_DEV_AUTH");
expect(names).toContain("ADAPTER_JOB_MODE");
expect(names).toContain("SUPABASE_STORAGE_BUCKET");
});
it("exige decisão explícita sobre o shim de identidade", () => {
const env = { ...stagingBase } as Record<string, string | undefined>;
delete env.ADAPTER_APPLY_IDENTITY_SHIM;
expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_APPLY_IDENTITY_SHIM");
});
it("exige um caminho de login em homologação", () => {
const env = { ...stagingBase, ADAPTER_STAGING_AUTH: "0" };
expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_STAGING_AUTH");
expect(collectConfigIssues({ ...env, ELO_PRO_LOGIN_URL: "https://login.elo.example" })).toEqual([]);
});
it("não deixa o login próprio de homologação ir a produção", () => {
const env = {
...stagingBase,
ADAPTER_ENV: "production",
SENTRY_DSN: "https://k@sentry.io/1",
ELO_PRO_LOGIN_URL: "https://login.elo.example",
};
expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_STAGING_AUTH");
});
it("exige Sentry e login central em produção, mas não bloqueia homologação por isso", () => {
const production = { ...stagingBase, ADAPTER_ENV: "production", ADAPTER_STAGING_AUTH: "0" };
const names = collectConfigIssues(production).map((issue) => issue.variable);
expect(names).toContain("SENTRY_DSN");
expect(names).toContain("ELO_PRO_LOGIN_URL");
expect(collectConfigIssues({ ...stagingBase, SENTRY_DSN: undefined })).toEqual([]);
});
it("não valida nada em development", () => {
expect(collectConfigIssues({ NODE_ENV: "development" })).toEqual([]);
expect(() => assertDeployedEnv({ NODE_ENV: "development" })).not.toThrow();
});
it("recusa staging sem nenhuma variável e lista o que falta", () => {
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging" })).toThrow(/DATABASE_URL/);
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging" })).toThrow(/REDIS_URL/);
});
it("login próprio de homologação só liga com Supabase configurado", () => {
expect(stagingAuthEnabled(stagingBase)).toBe(true);
expect(stagingAuthEnabled({ ...stagingBase, NEXT_PUBLIC_SUPABASE_URL: "" })).toBe(false);
expect(stagingAuthEnabled({ ...stagingBase, ADAPTER_ENV: "production" })).toBe(false);
expect(stagingAuthEnabled({ ...stagingBase, ADAPTER_ENV: "development" })).toBe(false);
});
});
describe("banco e migrations", () => {
it("nunca cai em PGlite fora de development", () => {
expect(() => requireDatabaseUrl({ ADAPTER_ENV: "staging" })).toThrow(/DATABASE_URL/);
expect(() => requireDatabaseUrl({ ADAPTER_ENV: "production" })).toThrow(/DATABASE_URL/);
expect(requireDatabaseUrl({ NODE_ENV: "development" })).toBeNull();
expect(requireDatabaseUrl({ ADAPTER_ENV: "staging", DATABASE_URL: "postgres://h/d" })).toBe("postgres://h/d");
});
it("o shim de identidade é decisão explícita em ambiente implantado", () => {
expect(identityShimEnabled({ NODE_ENV: "development" })).toBe(true);
expect(identityShimEnabled({ ADAPTER_ENV: "staging" })).toBe(false);
expect(identityShimEnabled({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toBe(true);
expect(identityShimEnabled({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "0" })).toBe(false);
expect(identityShimEnabled({ NODE_ENV: "development", ADAPTER_APPLY_IDENTITY_SHIM: "0" })).toBe(false);
});
it("reconhece qual migration é o shim", () => {
expect(isShimMigration("0001_platform_shim.sql")).toBe(true);
expect(isShimMigration("0002_spreadsheet_adapter.sql")).toBe(false);
expect(isShimMigration("0003_rls.sql")).toBe(false);
});
it("inline é recusado em ambiente implantado mesmo se a variável pedir", () => {
expect(jobRunsInline({ ADAPTER_ENV: "staging", ADAPTER_JOB_MODE: "inline" })).toBe(false);
expect(jobRunsInline({ ADAPTER_ENV: "production", ADAPTER_JOB_MODE: "inline" })).toBe(false);
expect(jobRunsInline({ NODE_ENV: "development", ADAPTER_JOB_MODE: "inline" })).toBe(true);
expect(jobRunsInline({ NODE_ENV: "development", ADAPTER_JOB_MODE: "worker" })).toBe(false);
});
});
describe("resolução de workspace no servidor", () => {
it("sem associação não há workspace", () => {
expect(resolveWorkspace([], ALPHA)).toEqual({ kind: "none" });
});
it("ignora a dica que não corresponde a associação real", () => {
const result = resolveWorkspace([membership(BETA, "Beta")], ALPHA);
expect(result).toEqual({ kind: "resolved", workspaceId: BETA });
});
it("com várias associações e dica inválida, quem escolhe é a pessoa", () => {
const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], ALPHA.replace(/1/g, "9"));
expect(result.kind).toBe("choice");
});
it("respeita a dica quando ela é uma associação real", () => {
const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], BETA);
expect(result).toEqual({ kind: "resolved", workspaceId: BETA });
});
it("não aceita valor fora de UUID como dica", () => {
const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], "' or 1=1 --");
expect(result.kind).toBe("choice");
});
});
+3
View File
@@ -5,6 +5,9 @@ export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
environment: "node",
// tests/services exige Postgres e Redis reais: roda em vitest.services.config.ts
// para que `npm test` passe em qualquer máquina sem reportar casos ignorados.
exclude: ["node_modules/**", "dist/**", ".next/**", "tests/services/**"],
setupFiles: ["./tests/setup.ts"],
testTimeout: 30000,
hookTimeout: 30000,
+26
View File
@@ -0,0 +1,26 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
/**
* Suíte que exige Postgres e Redis reais. Fica fora de `npm test` de propósito:
* a suíte padrão roda em qualquer máquina, esta roda no CI com serviços.
* Sem `ADAPTER_TEST_DATABASE_URL` e `ADAPTER_TEST_REDIS_URL` os casos são
* marcados como ignorados, nunca como aprovados.
*/
export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
environment: "node",
include: ["tests/services/**/*.test.ts"],
setupFiles: ["./tests/setup.ts"],
testTimeout: 120000,
hookTimeout: 120000,
fileParallelism: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"server-only": path.resolve(__dirname, "tests/helpers/server-only.ts"),
},
},
});