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 5207 additions and 281 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_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_. # Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_.
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
# Bucket privado. O valor literal "public" é recusado.
SUPABASE_STORAGE_BUCKET= 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= 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= REDIS_URL=
# Sentry. Sem DSN a instrumentação fica inerte. # --- Processamento --------------------------------------------------------
SENTRY_DSN= # inline processa no mesmo processo (somente development).
NEXT_PUBLIC_SENTRY_DSN= # worker enfileira no Redis e no Postgres. Obrigatório fora de development.
SENTRY_ENVIRONMENT=development ADAPTER_JOB_MODE=inline
SENTRY_RELEASE= # 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= ELO_PRO_LOGIN_URL=
# URL pública deste aplicativo. Usada para montar returnTo no mesmo host. # URL pública deste aplicativo. Usada para montar returnTo no mesmo host.
ELO_PRO_APP_URL= ELO_PRO_APP_URL=http://localhost:3000
# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido. # Hosts adicionais aceitos no returnTo, separados por vírgula.
AUTH_ALLOWED_RETURN_HOSTS= 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. # --- Observabilidade ------------------------------------------------------
# Nome lido pelo servidor: elo_pro_workspace_id # 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. # --- Testes com serviços reais (npm run test:services) --------------------
# Em dev/teste, a ausência usa 3600. O número de produto continua configurável. # Banco descartável: a suíte recria o schema public.
SPREADSHEET_TEMP_TTL_SECONDS= ADAPTER_TEST_DATABASE_URL=
ADAPTER_TEST_REDIS_URL=
# 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=
+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: jobs:
verify: verify:
name: lint, typecheck, testes e build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
ADAPTER_ENV: development
ADAPTER_JOB_MODE: inline ADAPTER_JOB_MODE: inline
SPREADSHEET_TEMP_TTL_SECONDS: "3600" SPREADSHEET_TEMP_TTL_SECONDS: "3600"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version-file: .nvmrc
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npm run typecheck - run: npm run typecheck
- run: npm test - run: npm test
- run: npm run build - 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 coverage
.env .env
.env.local .env.local
.env.staging
.env.production
*.tsbuildinfo *.tsbuildinfo
_prompts_tmp _prompts_tmp
_escopo_extraido.txt _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 # 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 ## Desenvolvimento local
```bash ```bash
npm install 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=1
ADAPTER_DEV_AUTH_SECRET=um-segredo-local ADAPTER_DEV_AUTH_SECRET=um-segredo-local
ADAPTER_JOB_MODE=inline ADAPTER_JOB_MODE=inline
@@ -21,15 +30,58 @@ ADAPTER_JOB_MODE=inline
npm run dev 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 ## 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 ## Verificação
@@ -39,3 +91,13 @@ npm run typecheck
npm test npm test
npm run build 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 # 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 ## Ordem
1. Conferir envs sem colar segredo no repositório. 1. Conferir envs sem colar segredo no repositório.
@@ -1,5 +1,11 @@
# Runbook de implantação do piloto # 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. 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. 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 type { NextConfig } from "next";
import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.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 = { const nextConfig: NextConfig = {
turbopack: { root }, 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: [ serverExternalPackages: [
"exceljs", "exceljs",
"@react-pdf/renderer", "@react-pdf/renderer",
@@ -16,16 +71,36 @@ const nextConfig: NextConfig = {
poweredByHeader: false, poweredByHeader: false,
async headers() { async headers() {
return [ return [
{ source: "/:path*", headers: securityHeaders },
{ {
source: "/:path*", source: "/api/:path*",
headers: [ headers: [...securityHeaders, { key: "Cache-Control", value: "no-store" }],
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
],
}, },
]; ];
}, },
}; };
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", "name": "adaptador-planilhas",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": ">=22.12 <25"
},
"packageManager": "npm@11.6.2",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"start:standalone": "node .next/standalone/server.js",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"migrate": "tsx scripts/migrate.ts", "test:services": "vitest run --config vitest.services.config.ts",
"seed": "tsx scripts/seed-dev.ts", "migrate": "node --import=tsx scripts/migrate.ts",
"worker": "tsx scripts/worker.ts", "migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
"cleanup": "tsx scripts/cleanup-temp.ts", "seed": "node --import=tsx scripts/seed-dev.ts",
"flag": "tsx scripts/set-flag.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": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -31,7 +42,8 @@
"postgres": "^3.4.7", "postgres": "^3.4.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"server-only": "^0.0.1" "server-only": "^0.0.1",
"tsx": "^4.20.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "^4.1.14",
@@ -46,7 +58,6 @@
"eslint-config-next": "^16.0.0", "eslint-config-next": "^16.0.0",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.20.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.4" "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`);
});
+7 -8
View File
@@ -1,12 +1,11 @@
import path from "node:path"; import { createCoordinator } from "../src/infra/redis";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { MemoryCoordinator } from "../src/infra/redis/memory";
import { createStorage } from "../src/infra/storage"; import { createStorage } from "../src/infra/storage";
import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job"; import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job";
import { bootstrapScript, runWithDb } from "./_bootstrap";
const databaseUrl = process.env.DATABASE_URL; bootstrapScript("cleanup-temp");
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() }); runWithDb("cleanup-temp", async (db) => {
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
console.log(`temporários expirados: ${removed}`); console.log(`temporários expirados: ${removed}`);
await db.close(); });
+26 -8
View File
@@ -1,10 +1,28 @@
import { createFileDb } from "../src/infra/db/pglite"; import { identityShimEnabled } from "../src/config/env";
import { createPostgresDb } from "../src/infra/db/postgres"; import { migrate, pendingMigrations } from "../src/infra/db/migrate";
import { migrate } from "../src/infra/db/migrate"; import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
import path from "node:path";
const databaseUrl = process.env.DATABASE_URL; /**
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); * 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); const ran = await migrate(db);
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente"); console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
await db.close(); });
+10 -6
View File
@@ -1,12 +1,16 @@
import path from "node:path"; import { adapterEnv } from "../src/config/env";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { migrate } from "../src/infra/db/migrate"; import { migrate } from "../src/infra/db/migrate";
import { ensureDevFixtures } from "../src/dev/fixtures"; import { ensureDevFixtures } from "../src/dev/fixtures";
import { bootstrapScript, runWithDb } from "./_bootstrap";
const databaseUrl = process.env.DATABASE_URL; bootstrapScript("seed-dev");
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); 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 migrate(db);
await ensureDevFixtures(db); await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas"); console.log("fixtures de desenvolvimento conferidas");
await db.close(); });
+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);
}
});
+16 -9
View File
@@ -1,15 +1,22 @@
import path from "node:path"; import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
const workspaceId = process.argv[2]; const workspaceId = process.argv[2];
const enabled = process.argv[3] !== "off"; const action = process.argv[3];
if (!workspaceId) { if (!workspaceId || !action || !["on", "off"].includes(action)) {
console.error("uso: npm run flag -- <workspace_id> on|off"); console.error("uso: npm run flag -- <workspace_id> on|off");
process.exit(1); process.exit(1);
} }
const databaseUrl = process.env.DATABASE_URL; const enabled = action === "on";
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
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 db.withService(async (query) => {
await query.query( await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) `insert into public.workspace_module_flags (workspace_id, module_key, enabled)
@@ -26,5 +33,5 @@ await db.withService(async (query) => {
[workspaceId, enabled], [workspaceId, enabled],
); );
}); });
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`); console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`);
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);
+62 -65
View File
@@ -1,76 +1,73 @@
import path from "node:path"; import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite"; import { jobRunsInline } from "../src/config/env";
import { createPostgresDb } from "../src/infra/db/postgres"; import { pendingMigrations } from "../src/infra/db/migration-manifest";
import { createCoordinator } from "../src/infra/redis"; import { createCoordinator } from "../src/infra/redis";
import { createStorage } from "../src/infra/storage"; import { createStorage } from "../src/infra/storage";
import { migrate } from "../src/infra/db/migrate"; import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop";
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job"; import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
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 TICK_MS = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
const databaseUrl = process.env.DATABASE_URL; const BATCH = Number(process.env.ADAPTER_WORKER_BATCH ?? 20);
if (!databaseUrl && process.env.NODE_ENV === "production") { const GRACE_MS = Number(process.env.ADAPTER_WORKER_GRACE_MS ?? 20000);
throw new Error("DATABASE_URL é obrigatório para o worker."); const HEARTBEAT = process.env.ADAPTER_WORKER_HEARTBEAT ?? path.join("/tmp", "adaptador-worker-heartbeat");
bootstrapScript("worker");
if (jobRunsInline()) {
console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
} }
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await migrate(db); 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 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");
},
});
async function tick() { const timer = setInterval(() => {
await recoverStaleJobs(deps).catch((error) => { void worker.tick();
console.error("worker", error instanceof Error ? error.name : "error"); }, TICK_MS);
});
await cleanupExpired(deps).catch((error) => { let stopping = false;
console.error("worker", "cleanup", error instanceof Error ? error.name : "error"); async function shutdown(signal: string) {
}); if (stopping) return;
const queued = new Set(await listQueuedJobIds(deps)); stopping = true;
for (let index = 0; index < 20; index += 1) { console.log(`[worker] ${signal} recebido. Encerrando.`);
const jobId = await deps.coordinator.dequeue().catch(() => null); clearInterval(timer);
if (!jobId) break; const clean = await worker.shutdown(GRACE_MS);
queued.add(jobId); 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);
} }
for (const jobId of queued) {
const rows = await readJobForWorker(deps, jobId); process.on("SIGTERM", () => void shutdown("SIGTERM"));
const row = rows[0]; process.on("SIGINT", () => void shutdown("SIGINT"));
if (!row) continue;
const progress = asJson<JobProgress | null>(row.progress, null); console.log(
if (!progress?.sourceId) continue; `[worker] pronto. intervalo=${TICK_MS}ms lote=${BATCH} carencia=${GRACE_MS}ms alvo=${describeTarget()} heartbeat=${HEARTBEAT}`,
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]; await worker.tick();
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");
}); });
}
}
await tick();
setInterval(() => {
void tick();
}, 2000);
+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export default async function SettingsPage() { export default async function SettingsPage() {
const auth = await resolveAuth(); const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); 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 (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
if (!canManageSpreadsheetModels(auth.ctx)) { if (!canManageSpreadsheetModels(auth.ctx)) {
return ( return (
+1
View File
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
export default async function OperationPage() { export default async function OperationPage() {
const auth = await resolveAuth(); const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); 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"); if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
return ( return (
<AppShell ctx={auth.ctx}> <AppShell ctx={auth.ctx}>
+8 -1
View File
@@ -10,10 +10,17 @@ export const dynamic = "force-dynamic";
export default async function AdapterHome() { export default async function AdapterHome() {
const auth = await resolveAuth(); const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); 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") { if (auth.kind !== "ok") {
return ( return (
<main className="p-8"> <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> </main>
); );
} }
+6 -15
View File
@@ -1,24 +1,15 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST() { export async function POST() {
const jar = await cookies(); const jar = await cookies();
jar.delete("adapter_dev_session"); jar.delete(DEV_COOKIE);
const url = process.env.NEXT_PUBLIC_SUPABASE_URL; jar.delete(WORKSPACE_COOKIE);
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabase = supabaseServerClient(jar);
if (url && key) { if (supabase) await supabase.auth.signOut();
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();
}
return NextResponse.json({ ok: true }); 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 { NextResponse } from "next/server";
import { adapterEnv } from "@/config/env";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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() { 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 { NextResponse } from "next/server";
import { collectProductionIssues } from "@/config/env"; import { adapterEnv, collectConfigIssues, isDeployedEnv, jobRunsInline } from "@/config/env";
import { getDb } from "@/infra/db/client"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; 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() { export async function GET() {
const checks: Record<string, "ok" | "missing" | "down"> = { const checks: Record<string, CheckState> = {
config: "ok",
database: "down", database: "down",
redis: process.env.REDIS_URL ? "ok" : "missing", migrations: "ok",
storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing", redis: "down",
config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing", 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 { try {
const db = await getDb(); const db = await getDb();
await db.withService((query) => query.query("select 1 as ok")); await db.withService((query) => query.query("select 1 as ok"));
checks.database = "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.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" } },
);
} }
+12 -8
View File
@@ -1,20 +1,24 @@
import { NextResponse } from "next/server"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; 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() { export async function GET() {
try { try {
const db = await getDb(); await probeDb();
await db.withService((query) => query.query("select 1 as ok")); } catch {
return NextResponse.json({ ok: false, environment: adapterEnv() }, { status: 503 });
}
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
module: "spreadsheet-adapter", module: "spreadsheet-adapter",
redis: process.env.REDIS_URL ? "configured" : "not_configured", environment: adapterEnv(),
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured", detail: "/api/health/ready",
}); });
} catch {
return NextResponse.json({ ok: false }, { status: 503 });
}
} }
+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 { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types"; import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { resolveAuth } from "@/server/auth/context"; import { resolveAuth } from "@/server/auth/context";
import { enforceRateLimit, type RateLimitBucket } from "@/server/rate-limit";
import type { AppDeps } from "@/server/spreadsheet-adapter/deps"; import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,9 +12,12 @@ export const dynamic = "force-dynamic";
export function errorResponse(error: unknown): NextResponse { export function errorResponse(error: unknown): NextResponse {
if (isAdapterError(error)) { if (isAdapterError(error)) {
const view = presentError(error.code); 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( return NextResponse.json(
{ error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, details: error.details ?? null } }, { 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"); 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(); const auth = await resolveAuth();
if (auth.kind === "anonymous" || auth.kind === "session_expired") { 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); 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); 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"); const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
return { ctx: auth.ctx, deps: await runtimeDeps() }; 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 }> }) { export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("download");
const { id } = await context.params; const { id } = await context.params;
const url = new URL(request.url); const url = new URL(request.url);
const format = url.searchParams.get("format") as OutputFormat | null; 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 }> }) { export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params; const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string }; const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); 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 }> }) { export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params; const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; workspace_id?: string }; const body = (await request.json()) as { sourceId?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); 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 }> }) { export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("process");
const { id } = await context.params; const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string }; const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); 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 }> }) { export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("read");
const { id } = await context.params; const { id } = await context.params;
return NextResponse.json({ job: await getJob(deps, ctx, id) }); return NextResponse.json({ job: await getJob(deps, ctx, id) });
} catch (error) { } catch (error) {
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) { export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const { id, sourceId } = await context.params; const { id, sourceId } = await context.params;
const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string }; const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); 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 }> }) { export async function DELETE(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const { id, sourceId } = await context.params; const { id, sourceId } = await context.params;
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id); 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) { export async function POST(request: Request) {
try { 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 }; const body = (await request.json()) as { modelId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.modelId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Selecione um modelo." } }, { status: 400 }); 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 }> }) { export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const { id } = await context.params; const { id } = await context.params;
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id); await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("read");
return NextResponse.json({ models: await listModels(deps, ctx) }); return NextResponse.json({ models: await listModels(deps, ctx) });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -16,7 +16,7 @@ export async function GET() {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("upload");
const form = await request.formData(); const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id")); await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file"); const file = form.get("file");
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("read");
return NextResponse.json(await getSettings(deps, ctx)); return NextResponse.json(await getSettings(deps, ctx));
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -16,7 +16,7 @@ export async function GET() {
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("mutation");
const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string }; const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id); await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (typeof body.allowNonAdminModelUpload !== "boolean") { if (typeof body.allowNonAdminModelUpload !== "boolean") {
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { ctx, deps } = await requireReady(); const { ctx, deps } = await requireReady("upload");
const form = await request.formData(); const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id")); await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file"); const file = form.get("file");
+23 -4
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { adapterEnv } from "@/config/env";
import { DEV_IDENTITIES } from "@/dev/fixtures"; import { DEV_IDENTITIES } from "@/dev/fixtures";
import { resolveAuth } from "@/server/auth/context"; 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 { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
import { DevLogin } from "@/components/dev-login"; import { DevLogin } from "@/components/dev-login";
import { StagingLogin } from "@/components/staging-login";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,10 +14,14 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
const auth = await resolveAuth(); const auth = await resolveAuth();
const nextPath = safeReturnPath(params.next); const nextPath = safeReturnPath(params.next);
if (auth.kind === "ok") redirect(nextPath); 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)); redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
} }
const expired = auth.kind === "session_expired" || params.reason === "expired"; const expired = auth.kind === "session_expired" || params.reason === "expired";
return ( return (
<main className="mx-auto max-w-xl p-8"> <main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p> <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"> <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. O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
</p> </p>
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null} {adapterEnv() === "staging" ? (
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>} <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> </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>
);
}
+142 -17
View File
@@ -2,31 +2,110 @@ export type EnvMap = Record<string, string | undefined>;
export type EnvIssue = { variable: string; message: string }; 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_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_STORAGE_BUCKET", "SUPABASE_STORAGE_BUCKET",
"DATABASE_URL", "DATABASE_URL",
"REDIS_URL", "REDIS_URL",
"SENTRY_DSN",
"ELO_PRO_LOGIN_URL",
"SPREADSHEET_TEMP_TTL_SECONDS", "SPREADSHEET_TEMP_TTL_SECONDS",
] as const; ] as const;
export function collectProductionIssues(env: EnvMap): EnvIssue[] { /** Variáveis exigidas somente em produção. */
if (env.NODE_ENV !== "production") return []; 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[] = []; 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 ${target}` });
}
}
if (target === "production") {
for (const variable of REQUIRED_IN_PRODUCTION_ONLY) {
if (!env[variable] || env[variable]?.trim() === "") { if (!env[variable] || env[variable]?.trim() === "") {
issues.push({ variable, message: "obrigatória em produção" }); issues.push({ variable, message: "obrigatória em produção" });
} }
} }
}
if (env.ADAPTER_DEV_AUTH === "1") { 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") { 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; const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
if (ttl && ttl.trim() !== "") { if (ttl && ttl.trim() !== "") {
@@ -38,22 +117,68 @@ export function collectProductionIssues(env: EnvMap): EnvIssue[] {
if (env.SUPABASE_STORAGE_BUCKET === "public") { if (env.SUPABASE_STORAGE_BUCKET === "public") {
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" }); 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; return issues;
} }
export function assertProductionEnv(env: EnvMap = process.env): void { /** Mantido para compatibilidade com chamadas e testes existentes. */
if (env.NODE_ENV !== "production") return; export function collectProductionIssues(env: EnvMap = process.env): EnvIssue[] {
if (env.NEXT_PHASE === "phase-production-build") return; return collectConfigIssues(env);
if (env.npm_lifecycle_event === "build") return; }
const issues = collectProductionIssues(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; if (issues.length === 0) return;
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; "); throw new Error(`Configuração de ${adapterEnv(env)} recusada. ${formatConfigIssues(issues)}`);
throw new Error(`Configuração de produção recusada. ${summary}`); }
/** Mantido para compatibilidade. */
export function assertProductionEnv(env: EnvMap = process.env): void {
assertDeployedEnv(env);
} }
export function jobRunsInline(env: EnvMap = process.env): boolean { 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 === "worker") return false;
if (env.ADAPTER_JOB_MODE === "inline") return true; 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. */ /** TTL de temporários. O número de produto ainda é decisão aberta. */
export function tempTtlSeconds(): number { export function tempTtlSeconds(): number {
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS; const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
@@ -8,9 +10,9 @@ export function tempTtlSeconds(): number {
} }
return value; return value;
} }
if (process.env.NODE_ENV === "production") { if (isDeployedEnv() || process.env.NODE_ENV === "production") {
throw new Error( 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; return 60 * 60;
@@ -8,7 +8,8 @@ export type ErrorCategory =
| "processing" | "processing"
| "timeout" | "timeout"
| "storage" | "storage"
| "worker"; | "worker"
| "rate_limit";
export type PublicErrorView = { export type PublicErrorView = {
category: ErrorCategory; 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." }, 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." }, INVALID_SETTING: { category: "invalid_configuration", action: "Revise a configuração com um administrador." },
PROCESS_FAILED: { category: "processing", action: "Tente processar novamente." }, 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." }, 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 "server-only";
import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
import { createFileDb } from "./pglite"; import { createFileDb } from "./pglite";
import { createPostgresDb } from "./postgres"; import { createPostgresDb } from "./postgres";
import { migrate } from "./migrate";
import type { Db } from "./types"; import type { Db } from "./types";
let singleton: Promise<Db> | null = null; let singleton: Promise<Db> | null = null;
@@ -10,15 +11,33 @@ let singleton: Promise<Db> | null = null;
export async function getDb(): Promise<Db> { export async function getDb(): Promise<Db> {
if (!singleton) { if (!singleton) {
singleton = (async () => { 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 (databaseUrl) return createPostgresDb(databaseUrl);
if (process.env.NODE_ENV === "production") { if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatório em produção."); 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); await migrate(db);
return db; return db;
})(); })().catch((error) => {
singleton = null;
throw error;
});
} }
return singleton; 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 { readFile, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { identityShimEnabled } from "@/config/env";
import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest";
import type { Db } from "./types"; 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 { 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(` await db.exec(`
create table if not exists public.schema_migrations ( create table if not exists public.schema_migrations (
id text primary key, id text primary key,
applied_at timestamptz not null default now() 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 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 applied = new Set(appliedRows.map((row) => row.id));
const ran: string[] = []; const ran: string[] = [];
for (const file of files) { for (const file of files) {
if (applied.has(file)) continue; 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.exec(sql);
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file])); await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
ran.push(file); ran.push(file);
} }
return ran; 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"; import { scrubSentryEvent, sentrySafeError } from "./scrub";
export { scrubSentryEvent, sentrySafeError }; export { scrubSentryEvent, sentrySafeError };
@@ -16,6 +17,7 @@ export function captureAdapterException(error: unknown, tags: Record<string, str
void import("@sentry/nextjs").then((Sentry) => { void import("@sentry/nextjs").then((Sentry) => {
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setTag("module", "spreadsheet-adapter"); 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)); for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
Sentry.captureException(sentrySafeError(error)); Sentry.captureException(sentrySafeError(error));
}); });
@@ -26,8 +28,10 @@ export function sentryInitOptions(dsn: string | undefined) {
if (!dsn) return null; if (!dsn) return null;
return { return {
dsn, dsn,
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, // Ambiente lógico do Adaptador: homologação e produção nunca se misturam
release: process.env.SENTRY_RELEASE, // 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, sendDefaultPii: false,
}; };
} }
+27 -2
View File
@@ -1,8 +1,33 @@
import { isDeployedEnv } from "@/config/env";
import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory"; import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory";
import { RedisCoordinator } from "./redis"; 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 { export function createCoordinator(): Coordinator {
if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL); const url = process.env.REDIS_URL ?? "";
if (process.env.NODE_ENV === "production") return new UnavailableCoordinator(); 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(); 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"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
export interface Coordinator { 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>; acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>; renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
ownsLock(key: string, owner: string): Promise<boolean>; ownsLock(key: string, owner: string): Promise<boolean>;
@@ -17,10 +21,25 @@ type LockRecord = { owner: string; expiresAt: number };
export class MemoryCoordinator implements Coordinator { export class MemoryCoordinator implements Coordinator {
private readonly locks = new Map<string, LockRecord>(); private readonly locks = new Map<string, LockRecord>();
private readonly progress = new Map<string, JobProgress>(); private readonly progress = new Map<string, JobProgress>();
private readonly counters = new Map<string, { count: number; expiresAt: number }>();
private readonly queue: string[] = []; private readonly queue: string[] = [];
constructor(private readonly now: () => number = () => Date.now()) {} 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 { private live(key: string): LockRecord | null {
const record = this.locks.get(key); const record = this.locks.get(key);
if (!record) return null; 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); 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> { acquireLock(): Promise<boolean> {
this.fail(); this.fail();
} }
+34 -4
View File
@@ -1,10 +1,12 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { resourceNamespace } from "@/config/env";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { JobProgress } from "@/domain/spreadsheet-adapter/types"; import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
import type { Coordinator } from "./memory"; 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 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 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 { export class RedisCoordinator implements Coordinator {
private readonly redis: Redis; private readonly redis: Redis;
@@ -15,6 +17,8 @@ export class RedisCoordinator implements Coordinator {
enableOfflineQueue: false, enableOfflineQueue: false,
connectTimeout: 2000, connectTimeout: 2000,
lazyConnect: true, lazyConnect: true,
// Separa este aplicativo dos demais do ecossistema no Redis compartilhado.
keyPrefix: "",
}); });
this.redis.on("error", () => undefined); 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> { async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK"); 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> { async dequeue(): Promise<string | null> {
return this.run(async () => this.redis.rpop(queueKey())); 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 { export function envNamespace(): string {
return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local"; return resourceNamespace();
} }
export function lockKey(userId: string): string { export function lockKey(userId: string): string {
return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`; return `${envNamespace()}:lock:user:${userId}`;
} }
export function progressKey(jobId: string): string { export function progressKey(jobId: string): string {
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`; return `${envNamespace()}:progress:${jobId}`;
} }
export function queueKey(): string { 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 path from "node:path";
import { isDeployedEnv } from "@/config/env";
import { LocalPrivateStorage } from "./local"; import { LocalPrivateStorage } from "./local";
import { createSupabaseStorage } from "./supabase"; import { createSupabaseStorage } from "./supabase";
import type { ObjectStorage } from "./memory"; 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 { export function createStorage(): ObjectStorage {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const bucket = process.env.SUPABASE_STORAGE_BUCKET; const bucket = process.env.SUPABASE_STORAGE_BUCKET;
if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket); const key = `${url ?? ""}|${bucket ?? ""}|${serviceKey ? "svc" : "nosvc"}`;
if (process.env.NODE_ENV === "production") { if (singleton && singletonKey === key) return singleton;
throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta.");
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); await walk(start);
return found.filter((key) => key.startsWith(prefix.replace(/\/$/, ""))); 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>; put(key: string, body: Buffer): Promise<void>;
get(key: string): Promise<Buffer>; get(key: string): Promise<Buffer>;
delete(key: string): Promise<void>; delete(key: string): Promise<void>;
/** Listagem completa do prefixo. Implementações remotas devem paginar. */
list(prefix: string): Promise<string[]>; list(prefix: string): Promise<string[]>;
/** Verdadeiro quando o bucket responde. Usado pelo health check. */
healthcheck(): Promise<boolean>;
} }
export class MemoryStorage implements ObjectStorage { export class MemoryStorage implements ObjectStorage {
@@ -31,4 +34,8 @@ export class MemoryStorage implements ObjectStorage {
async list(prefix: string): Promise<string[]> { async list(prefix: string): Promise<string[]> {
return [...this.objects.keys()].filter((key) => key.startsWith(prefix)); 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 { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
import type { ObjectStorage } from "./memory"; 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 { export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage {
const client: SupabaseClient = createClient(url, serviceKey, { const client: SupabaseClient = createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
@@ -24,11 +29,59 @@ export function createSupabaseStorage(url: string, serviceKey: string, bucket: s
assertStorageKey(key); assertStorageKey(key);
await bucketApi().remove([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) { async list(prefix) {
const folder = prefix.replace(/\/$/, ""); const folder = prefix.replace(/\/$/, "");
const { data, error } = await bucketApi().list(folder, { limit: 1000 }); const found: string[] = [];
if (error || !data) return []; const pending: string[] = [folder];
return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`); 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"; 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() { 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); const options = sentryInitOptions(process.env.SENTRY_DSN);
if (!options) return; if (!options) return;
const Sentry = await import("@sentry/nextjs"); 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"; 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) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; 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(); 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 hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth")); const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
if (hasDev || hasSupabase) return NextResponse.next(); if (hasDev || hasSupabase) return NextResponse.next();
@@ -12,10 +17,11 @@ export function proxy(request: NextRequest) {
} }
const login = request.nextUrl.clone(); const login = request.nextUrl.clone();
login.pathname = "/login"; login.pathname = "/login";
login.search = "";
login.searchParams.set("next", pathname); login.searchParams.set("next", pathname);
return NextResponse.redirect(login); return NextResponse.redirect(login);
} }
export const config = { export const config = {
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"], matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*", "/selecionar-workspace"],
}; };
+35 -31
View File
@@ -1,49 +1,45 @@
import "server-only"; import "server-only";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions"; import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types"; import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
import { getDb } from "@/infra/db/client"; 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 = export type AuthState =
| { kind: "anonymous" } | { kind: "anonymous" }
| { kind: "session_expired" } | { kind: "session_expired" }
| { kind: "no_workspace" } | { kind: "no_workspace" }
| { kind: "not_member" } | { kind: "not_member" }
| { kind: "workspace_choice"; memberships: Membership[] }
| { kind: "ok"; ctx: AuthenticatedContext }; | { 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(); const jar = await cookies();
let userId: string | null = null; const providers = activeProviders();
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null; let hadHint = false;
const devToken = jar.get("adapter_dev_session")?.value; for (const provider of providers) {
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth")); if (provider.hadSessionHint(jar)) hadHint = true;
if (devToken && devAuthEnabled()) { const identity = await provider.resolve(jar);
const dev = verifyDevSession(devToken); if (identity) return { identity, hadHint };
if (dev) {
userId = dev.userId;
workspaceId = dev.workspaceId;
} }
return { identity: null, hadHint };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; export async function resolveAuth(): Promise<AuthState> {
if (!userId && supabaseUrl && supabaseKey) { const { identity, hadHint } = await resolveIdentity();
const supabase = createServerClient(supabaseUrl, supabaseKey, { if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
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" };
const db = await getDb(); 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<{ query.query<{
display_name: string; display_name: string;
is_superadmin: boolean; 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.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 left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`, where p.id = $1`,
[userId, workspaceId], [identity.userId, workspaceId],
), ),
); );
const row = rows[0]; const row = rows[0];
if (!row) return { kind: "not_member" }; if (!row) return { kind: "not_member" };
const ctx = { const ctx = {
userId, userId: identity.userId,
displayName: row.display_name, displayName: row.display_name,
isSuperadmin: row.is_superadmin, isSuperadmin: row.is_superadmin,
workspaceId, workspaceId,
@@ -78,3 +74,11 @@ export async function resolveAuth(): Promise<AuthState> {
}; };
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } }; 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 { createHmac, timingSafeEqual } from "node:crypto";
import { adapterEnv } from "@/config/env";
export type DevSession = { userId: string; workspaceId: string; exp: number }; 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 { 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 { 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 { transformRows } from "@/domain/spreadsheet-adapter/transform";
import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types"; import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types";
import { buildCsv, buildXlsx } from "@/infra/export/tabular"; import { buildCsv, buildXlsx } from "@/infra/export/tabular";
import { buildPdf } from "@/infra/export/pdf";
import type { Query } from "@/infra/db/types"; import type { Query } from "@/infra/db/types";
import { lockKey } from "@/infra/redis/redis"; import { lockKey } from "@/infra/redis/redis";
import { sanitizeAuditMetadata } from "@/infra/observability/scrub"; 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 csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
const grid = parseCsvText(csv); const grid = parseCsvText(csv);
const headers = grid[0] ?? []; 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)); const body = await buildPdf(file.model_name, headers, grid.slice(1));
return { filename, contentType: CONTENT_TYPE.pdf, body: body.body }; 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" }, esbuild: { jsx: "automatic" },
test: { test: {
environment: "node", 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"], setupFiles: ["./tests/setup.ts"],
testTimeout: 30000, testTimeout: 30000,
hookTimeout: 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"),
},
},
});