diff --git a/README.md b/README.md index bdb0ac9..89b059b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,26 @@ # Adaptador de Planilhas -Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria. +Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria: a identidade é a do ecossistema. + +| Ambiente | Host | `ADAPTER_ENV` | Autenticação | +| --- | --- | --- | --- | +| Desenvolvimento | `localhost:3000` | `development` | sessão local assinada, sem senha | +| Homologação | `hml-ada.elopro.app` | `staging` | Supabase Auth do projeto de homologação | +| Produção | `ada.elopro.app` | `production` | SSO do Elo Pro | + +`ADAPTER_ENV` é independente de `NODE_ENV`: homologação roda com `NODE_ENV=production` (build otimizado) e política própria. Ver [ADR 0007](docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md). ## Desenvolvimento local ```bash npm install -copy .env.example .env.local +cp .env.example .env.local ``` -No `.env.local`, para o piloto sem o host do Elo Pro: +No `.env.local`: ``` +ADAPTER_ENV=development ADAPTER_DEV_AUTH=1 ADAPTER_DEV_AUTH_SECRET=um-segredo-local ADAPTER_JOB_MODE=inline @@ -21,15 +30,58 @@ ADAPTER_JOB_MODE=inline npm run dev ``` -Abra `/login` e escolha uma identidade de desenvolvimento. Isso não pede senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`. +Abra `/login` e escolha uma identidade de desenvolvimento. Não há senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`. + +### Com Docker, igual ao artefato de homologação + +Mesma imagem, dois processos, Postgres e Redis de verdade: + +```bash +docker compose up --build +``` + +```bash +docker compose exec web node node_modules/tsx/dist/cli.mjs scripts/migrate.ts +``` + +Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`. + +## Homologação + +O procedimento completo — DNS, Nginx, Supabase, banco, bucket, containers, worker, observabilidade e rollback — está em **[docs/spreadsheet-adapter/20-homologacao-runbook.md](docs/spreadsheet-adapter/20-homologacao-runbook.md)**. O estado de validação está em [21-checklist-validacao.md](docs/spreadsheet-adapter/21-checklist-validacao.md). + +Resumo: + +```bash +cp .env.staging.example .env.staging # preencher; não versionar +npm run verify:config # recusa configuração incompleta +npm run check:bucket # o bucket precisa existir e ser privado +npm run migrate:check # mostra o que vai rodar +npm run migrate # aplica (passo com aprovação manual) +npm run seed:staging # liga identidades do Supabase a workspaces +docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --no-deps web worker +ADAPTER_SMOKE_URL=https://hml-ada.elopro.app npm run smoke:staging +npm run flag -- on # último passo, depois do smoke +``` + +`ADAPTER_APPLY_IDENTITY_SHIM` é obrigatória fora de development: `1` cria o shim local de identidade, `0` usa o schema existente do Elo Pro. Aplicar o shim sobre um schema real é destrutivo em potencial, por isso a escolha nunca é inferida. ## Produção -Obrigatórios: `DATABASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_STORAGE_BUCKET`, `REDIS_URL`, `SPREADSHEET_TEMP_TTL_SECONDS`, `ELO_PRO_LOGIN_URL`. +Além das variáveis de homologação, produção exige `SENTRY_DSN` e `ELO_PRO_LOGIN_URL`, e recusa `ADAPTER_STAGING_AUTH=1`. A feature flag `spreadsheet_adapter` nasce desligada e é ligada por workspace com `npm run flag`, usando a conexão de serviço, nunca pelo browser. -A feature flag `spreadsheet_adapter` nasce desligada. Habilite por workspace com `npm run flag -- on` usando a conexão de serviço, nunca pelo browser. +O processamento usa `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. `inline` é recusado fora de development. -O processamento de produção deve usar `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. O modo `inline` existe para desenvolvimento. +Produção não é liberada sem smoke real e ensaio de rollback registrados no checklist. + +## Saúde + +| Rota | O que responde | +| --- | --- | +| `GET /api/health/live` | processo vivo, sem tocar dependência | +| `GET /api/health/ready` | banco, migrations pendentes, Redis (PING), bucket, configuração e modo de job | + +Configuração inválida encerra o processo com código diferente de zero, em vez de deixar o servidor no ar respondendo erro. ## Verificação @@ -39,3 +91,13 @@ npm run typecheck npm test npm run build ``` + +Suíte que exige Postgres e Redis reais (roda no CI, e localmente com os dois serviços em pé): + +```bash +ADAPTER_TEST_DATABASE_URL=postgres://... ADAPTER_TEST_REDIS_URL=redis://... npm run test:services +``` + +Sem essas variáveis os casos são **ignorados**, nunca aprovados. + +O CI tem três jobs: `verify` (lint, typecheck, testes, build), `services` (Postgres e Redis reais: as quatro migrations, shim explícito, `FORCE RLS`, isolamento) e `image` (build da imagem Linux, conteúdo, fail-fast dos dois processos, compose completo, jornada assíncrona entre processos e desligamento gracioso do worker). diff --git a/docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md b/docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md new file mode 100644 index 0000000..1222fcd --- /dev/null +++ b/docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md @@ -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. diff --git a/docs/spreadsheet-adapter/07-deployment-runbook.md b/docs/spreadsheet-adapter/07-deployment-runbook.md index 3491480..6b2c8f0 100644 --- a/docs/spreadsheet-adapter/07-deployment-runbook.md +++ b/docs/spreadsheet-adapter/07-deployment-runbook.md @@ -1,5 +1,11 @@ # Deploy e operação +> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de +> homologação existir. Não siga este arquivo. O procedimento vigente está em +> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e +> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões. + + ## Ordem 1. Conferir envs sem colar segredo no repositório. diff --git a/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md b/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md index fbe73e9..74fa74d 100644 --- a/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md +++ b/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md @@ -1,5 +1,11 @@ # Runbook de implantação do piloto +> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de +> homologação existir. Não siga este arquivo. O procedimento vigente está em +> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e +> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões. + + Execute nesta ordem. Não apague auditoria, banco ou arquivos como parte do rollback. 1. Preparar o projeto Supabase da Elo Pro e anotar URL, anon key e service role. diff --git a/docs/spreadsheet-adapter/20-homologacao-runbook.md b/docs/spreadsheet-adapter/20-homologacao-runbook.md new file mode 100644 index 0000000..9fcf82d --- /dev/null +++ b/docs/spreadsheet-adapter/20-homologacao-runbook.md @@ -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 +``` + +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=`. 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 +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 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 -- 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//:staging-`. 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//' --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 off + +# 4. Trocar para a imagem anterior. Só web e worker; o Redis fica de pé. +export ADAPTER_IMAGE=ghcr.io//:staging- +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 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-`) 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 -- 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. diff --git a/docs/spreadsheet-adapter/21-checklist-validacao.md b/docs/spreadsheet-adapter/21-checklist-validacao.md new file mode 100644 index 0000000..ba34dad --- /dev/null +++ b/docs/spreadsheet-adapter/21-checklist-validacao.md @@ -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.