Compare commits

...
19 Commits
Author SHA1 Message Date
Ferraz Fagundes b410fa4559 fix(db): permite inicializacao da identidade sem roles operacionais
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
2026-10-20 00:25:22 -03:00
Ferraz Fagundes a4f0a2f649 fix(db): implementa identidade persistente segura para homologacao
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
2026-10-19 23:39:05 -03:00
Ferraz Fagundes 2c8c5d9507 fix(db): compatibiliza barreira com Supabase gerenciado
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
2026-10-19 17:14:04 -03:00
Ferraz Fagundes 8c22597420 fix(db): protege conexoes e migrations de homologacao
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
2026-10-19 16:32:03 -03:00
Ferraz Fagundes 4779d6b2a3 test(rollback): documenta ensaio isolado de recuperacao
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
2026-10-17 01:04:30 -03:00
Ferraz Fagundes bd0ecabf41 test(smoke): adiciona validacao completa do ambiente local
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
2026-10-17 00:37:09 -03:00
Ferraz Fagundes 2be97ea969 fix(docker): inicializar permissoes do storage com seguranca
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
2026-10-16 23:47:38 -03:00
Ferraz FagundesandClaude Opus 5.5 e142a44c01 Torna os scripts .sh executáveis e fixa LF para o contêiner
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
docker/entrypoint.sh estava versionado com modo 100644. Num build Linux o
ENTRYPOINT em forma exec falharia com permission denied. O mesmo vale para
.github/scripts/async-journey.sh, usado pelo job image do CI.

Sem .gitattributes, um checkout com core.autocrlf=true grava os .sh em CRLF,
e uma imagem construída a partir dessa cópia quebra o #!/bin/sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:56 -03:00
camilafagundesandClaude Opus 5.5 84ad5c4757 Corrige os testes de RLS e auditoria contra PostgreSQL real
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
A verificação de FORCE RLS lia pg_class sem filtrar relkind e pegava
índices como spreadsheet_adapter_settings_pkey, que nunca têm RLS. Agora
lê só tabelas (r/p) do schema public e compara com a lista exata das dez
tabelas protegidas pela 0003_rls.sql.

O teste de auditoria reutilizava $1 como uuid (workspace_id) e text
(resource_id), e o Postgres recusava antes de testar a permissão. Cada
coluna tem seu parâmetro e o teste exige o código 42501 em update e
delete, confirmando que o registro continua intacto.

Regressão do catálogo de RLS também roda na suíte padrão (PGlite).
Nenhuma migration, política ou permissão foi alterada.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:06:47 -03:00
camilafagundesandClaude Opus 5 b8586ba0aa Registra os três runs em espera e que concurrency não funciona no Gitea
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
Depois do push, o Gitea criou `ci.yml #3` e `runner-check.yml #4`, ambos em
Waiting com a mesma mensagem de runner ausente. Com o `ci.yml #2` anterior, são
três runs enfileirados esperando o runner — nenhum precisa ser republicado.

Duas correções de expectativa que valem registro:

O `concurrency` que eu declarei para evitar fila não tem efeito nesta versão: o
run 667 deveria ter cancelado o 666, do mesmo grupo, e não cancelou. A fila vai
crescer a cada push até existir runner. Fica documentado para ninguém contar
com um cancelamento que não acontece.

A barra lateral de Workflows mostra apenas `ci.yml` porque lista o que existe
na branch padrão, onde `runner-check.yml` ainda não está. Isso não impediu o
run de ser criado a partir de `homologacao`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:18:23 -03:00
camilafagundesandClaude Opus 5 c5790ab301 Protege o CI em repositório público e prepara a ativação do runner
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
runner-check / o runner responde (push) Has been cancelled
O push para o Gitea criou o run, mas ele ficou em Waiting: a mensagem da
instância é "No matching online runner with label: ubuntu-latest". A falha é na
atribuição do job, não no enfileiramento — o gatilho funcionou e a branch está
contemplada. O endpoint /actions/tasks devolvia lista vazia porque lista tasks
aceitas por runner, não runs criados, e foi isso que confundiu a leitura.

Com o diagnóstico fechado, duas coisas importam antes de um runner existir.

Primeira: este repositório é público e o CI dispara em pull_request. Sem
guarda, um fork traria código de terceiro para rodar na máquina do runner, com
Docker à disposição. Os três jobs do ci.yml e o runner-check passam a exigir
que o PR venha da própria origem; push continua rodando normalmente. Também
entram `permissions: contents: read` e `concurrency` por ref, para não
acumular fila de runs esperando runner.

Segunda: `runner-check` é o primeiro workflow a rodar quando o runner subir.
Não faz checkout, não instala dependência, não usa segredo e não toca rede
externa. Só confirma a ligação e inventaria node, npm, docker, psql e python3
— o job `services` precisa de psql e a jornada assíncrona precisa de python3,
e é melhor descobrir isso num workflow de dez segundos.

A documentação registra a evidência (runs 666 em Waiting e 584 cancelado),
recomenda act_runner 0.6.1-dind-rootless com escopo de repositório, e explica
por que Docker-in-Docker em vez do socket do host: num repositório público,
montar o socket entregaria o Docker da máquina ao workflow. Fica registrado
também que os ambientes de aprovação manual do deploy são conceito do GitHub e
não têm equivalente no Gitea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:17:12 -03:00
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
117 changed files with 9458 additions and 326 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=
+102
View File
@@ -0,0 +1,102 @@
# 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 ----------------------------------------------------------------
# Três credenciais. Nenhuma é a do Supabase self-hosted.
# Web: papel adaptador_web, sem SUPERUSER e sem BYPASSRLS.
# sslmode=verify-full é obrigatório. Não use require, prefer ou disable.
DATABASE_URL=
# Worker: papel adaptador_worker. Não reutilize a URL da web nem a administrativa.
ADAPTER_WORKER_DATABASE_URL=
# Migrations, seed:staging e flag. Papel postgres deste projeto, só na execução aprovada.
ADAPTER_MIGRATION_DATABASE_URL=
# Reference ID do Dashboard: 20 letras ou dígitos minúsculos. Sozinho não autoriza migration.
ADAPTER_DB_PROJECT_REF=
# Gravação explícita de adaptador_guard. Vazio por omissão. O valor 1 só no comando identity:init.
# migrate, migrate:check, seed e deploy não criam essa tabela.
ADAPTER_INIT_PROJECT_IDENTITY=
# Host do Session Pooler copiado do Dashboard. O índice aws-N não se deduz da região.
# O Dashboard deste projeto mostra host iniciado por aws-1-us-west-2.pooler.supabase.com.
# Só é aceito aws-<digitos>-us-west-2.pooler.supabase.com.
ADAPTER_DB_POOLER_HOST=
# Teto do pool por processo. Vazio usa 2 na web, 2 no worker e 1 na migration. Máximo 4.
ADAPTER_DB_POOL_MAX=
# 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.
+3
View File
@@ -0,0 +1,3 @@
# Scripts executados dentro do contêiner Linux precisam de LF, independente do
# core.autocrlf da máquina que faz o checkout. CRLF quebra o `#!/bin/sh`.
*.sh text eol=lf
+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"
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Permissões do armazenamento local (volume montado em /app/.data).
#
# Cada caso cria o próprio volume descartável com prefixo único e remove só os
# volumes que criou. Nunca toca nos volumes do compose
# (adaptador-planilhas-local-postgres, adaptador-planilhas-local-storage).
#
# ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
set -euo pipefail
# Git Bash no Windows converteria /app/... em caminho do Windows.
export MSYS_NO_PATHCONV=1
IMAGE="${ADAPTER_IMAGE:-adaptador-planilhas:local}"
PREFIX="adaptador-storage-test-$$-$RANDOM"
CREATED=()
CONTAINERS=()
cleanup() {
for c in "${CONTAINERS[@]:-}"; do [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true; done
for v in "${CREATED[@]:-}"; do [ -n "$v" ] && docker volume rm "$v" >/dev/null 2>&1 || true; done
}
trap cleanup EXIT
# Define VOL no shell atual: dentro de $(...) o registro em CREATED se perderia.
new_volume() {
VOL="$PREFIX-$1"
docker volume create "$VOL" >/dev/null
CREATED+=("$VOL")
}
# Mesmas restrições do serviço storage-init do docker-compose.yml.
prepare() {
docker run --rm --network none --user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
--cap-add DAC_READ_SEARCH --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
-v "$1:/app/.data" "$IMAGE" prepare-storage "${@:2}"
}
as_app() { local v="$1"; shift; docker run --rm --network none -v "$v:/app/.data" "$IMAGE" exec "$@"; }
as_root() { local v="$1"; shift; docker run --rm --network none --user 0:0 -v "$v:/app/.data" "$IMAGE" exec "$@"; }
# Estado do volume como root: dono, grupo, modo, tamanho e caminho, mais o hash dos arquivos.
snapshot() {
as_root "$1" sh -c 'cd /app/.data && find . -printf "%U:%G %m %s %p\n" | sort && find . -type f -exec sha256sum {} + | sort -k2'
}
expect_owner_mode() {
local v="$1" path="$2" want="$3"
local got
got=$(as_root "$v" stat -c '%u:%g %a' "$path")
[ "$got" = "$want" ] || { echo "ERRO: $path = '$got', esperado '$want'"; exit 1; }
}
fail() { echo "ERRO: $*"; exit 1; }
echo "== imagem: $IMAGE =="
docker image inspect "$IMAGE" >/dev/null
echo "== A. volume novo =="
new_volume a; VA="$VOL"
# A1: sem storage-init, o volume vazio herda dono e modo da imagem.
expect_owner_mode "$VA" /app/.data "1001:1001 750"
expect_owner_mode "$VA" /app/.data/storage "1001:1001 750"
as_app "$VA" sh -c ': > /app/.data/storage/ok && rm /app/.data/storage/ok' || fail "uid 1001 não escreve no volume novo"
# A2: com storage-init, o mesmo resultado e a prova de escrita.
prepare "$VA" | tee /tmp/storage-a.log
grep -q "STORAGE_WRITABLE" /tmp/storage-a.log || fail "storage-init não confirmou escrita"
echo "ok: volume novo 1001:1001 750, escrita pelo uid 1001"
echo "== B. volume existente, criado antes da correção (raiz root:root 755) =="
new_volume b; VB="$VOL"
as_root "$VB" sh -c '
chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage
mkdir -p /app/.data/storage/ws-alpha/modelos
printf "conteudo do modelo alpha\n" > /app/.data/storage/ws-alpha/modelos/modelo.xlsx
chown -R 1001:1001 /app/.data/storage/ws-alpha
printf "arquivo legado de root\n" > /app/.data/storage/legado.bin
chmod 640 /app/.data/storage/legado.bin'
# Antes da preparação o uid 1001 não escreve na raiz: é o defeito original.
if as_app "$VB" sh -c ': > /app/.data/storage/x' 2>/dev/null; then fail "o cenário B deveria começar sem escrita"; fi
prepare "$VB" 2>&1 | tee /tmp/storage-b.log
grep -q "STORAGE_WRITABLE" /tmp/storage-b.log || fail "storage-init não confirmou escrita"
grep -q "AVISO: 1 item" /tmp/storage-b.log || fail "o arquivo de root deveria ser só reportado"
expect_owner_mode "$VB" /app/.data "1001:1001 750"
expect_owner_mode "$VB" /app/.data/storage "1001:1001 750"
# Conteúdo e dono dos arquivos existentes intactos; só os dois diretórios mudaram.
expect_owner_mode "$VB" /app/.data/storage/legado.bin "0:0 640"
expect_owner_mode "$VB" /app/.data/storage/ws-alpha/modelos/modelo.xlsx "1001:1001 644"
test "$(as_root "$VB" cat /app/.data/storage/ws-alpha/modelos/modelo.xlsx)" = "conteudo do modelo alpha" \
|| fail "conteúdo do modelo alterado"
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo legado alterado"
as_app "$VB" sh -c ': > /app/.data/storage/novo && rm /app/.data/storage/novo' || fail "uid 1001 não escreve após preparar"
echo "ok: volume existente preservado e liberado para o uid 1001"
echo "== C. idempotência =="
s1=$(snapshot "$VB")
prepare "$VB" > /tmp/storage-c1.log 2>&1
prepare "$VB" > /tmp/storage-c2.log 2>&1
s2=$(snapshot "$VB")
[ "$s1" = "$s2" ] || { diff <(echo "$s1") <(echo "$s2") || true; fail "a preparação repetida alterou o volume"; }
if grep -q -- "->" /tmp/storage-c2.log; then fail "a segunda execução não deveria alterar dono nem modo"; fi
echo "ok: duas execuções extras, nenhum byte, dono ou modo alterado"
echo "== C2. --fix-existing é limitado ao volume e preserva conteúdo =="
prepare "$VB" --fix-existing > /tmp/storage-c3.log 2>&1
expect_owner_mode "$VB" /app/.data/storage/legado.bin "1001:1001 640"
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo alterado"
if grep -q "AVISO" /tmp/storage-c3.log; then fail "ainda há item com outro dono"; fi
echo "ok: dono ajustado só quando pedido, conteúdo intacto"
echo "== D. web e worker compartilham o volume =="
new_volume d; VD="$VOL"
prepare "$VD" >/dev/null
# Um contêiner grava como a web grava, outro lê e apaga como o worker faz na limpeza.
as_app "$VD" sh -c 'mkdir -p /app/.data/storage/ws/jobs && printf saida > /app/.data/storage/ws/jobs/out.csv'
test "$(as_app "$VD" cat /app/.data/storage/ws/jobs/out.csv)" = "saida" || fail "outro contêiner não lê o arquivo"
as_app "$VD" rm /app/.data/storage/ws/jobs/out.csv || fail "outro contêiner não apaga o arquivo"
# O comando web real passa da verificação e responde liveness.
WEB="$PREFIX-web"
CONTAINERS+=("$WEB")
docker run -d --name "$WEB" --network none -e ADAPTER_ENV=development -v "$VD:/app/.data" "$IMAGE" web >/dev/null
ok=""
for _ in $(seq 1 30); do
if docker exec "$WEB" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
sleep 1
done
[ -n "$ok" ] || { docker logs "$WEB"; fail "a web não subiu com o volume preparado"; }
docker logs "$WEB" 2>&1 | grep -q STORAGE_NOT_WRITABLE && fail "a web reportou storage sem escrita"
# O comando worker real passa da verificação de storage (falha depois, por falta de banco/Redis, o que não é deste teste).
WORKER="$PREFIX-worker"
CONTAINERS+=("$WORKER")
docker run -d --name "$WORKER" --network none -e ADAPTER_ENV=development -e ADAPTER_JOB_MODE=worker \
-v "$VD:/app/.data" "$IMAGE" worker >/dev/null
sleep 6
wlog=$(docker logs "$WORKER" 2>&1)
docker rm -f "$WORKER" >/dev/null
echo "$wlog" | grep -q STORAGE_NOT_WRITABLE && { echo "$wlog"; fail "o worker reportou storage sem escrita"; }
echo "$wlog" | grep -q '\[worker\]\|DATABASE\|Redis\|ECONNREFUSED\|pglite\|migration' \
|| { echo "$wlog"; fail "o worker não chegou ao código da aplicação"; }
echo "ok: escrita, leitura e remoção entre contêineres; web e worker passam da verificação"
echo "== E. usuário não privilegiado =="
test "$(docker exec "$WEB" id -u)" = "1001" || fail "a web não roda como 1001"
test "$(docker exec "$WEB" sh -c 'grep CapEff /proc/1/status | cut -f2')" = "0000000000000000" || fail "a web tem capacidades"
test "$(docker run --rm --network none "$IMAGE" exec id -u)" = "1001" || fail "o usuário padrão da imagem não é 1001"
# prepare-storage recusa rodar sem root, então não serve de atalho para escalar.
if docker run --rm --network none -v "$VD:/app/.data" "$IMAGE" prepare-storage >/dev/null 2>&1; then
fail "prepare-storage aceitou rodar como 1001"
fi
docker rm -f "$WEB" >/dev/null
test "$(docker image inspect "$IMAGE" --format "{{.Config.User}}")" = "adaptador" || fail "a imagem não declara USER adaptador"
echo "ok: web e worker como 1001 sem capacidades; preparação só como root"
echo "== F. falha segura com armazenamento sem escrita =="
new_volume f; VF="$VOL"
as_root "$VF" sh -c 'chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage'
for proc in web worker; do
set +e
out=$(docker run --rm --network none -e ADAPTER_ENV=development -v "$VF:/app/.data" "$IMAGE" "$proc" 2>&1)
code=$?
set -e
[ "$code" -ne 0 ] || fail "$proc subiu com storage sem escrita"
echo "$out" | grep -q STORAGE_NOT_WRITABLE || { echo "$out"; fail "$proc não explicou a falha"; }
echo " $proc: saiu com código $code e STORAGE_NOT_WRITABLE"
done
# Com Supabase configurado o volume local não é usado e a verificação não bloqueia.
SUPA="$PREFIX-supabase"
CONTAINERS+=("$SUPA")
docker run -d --name "$SUPA" --network none -e ADAPTER_ENV=development \
-e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.invalid -e SUPABASE_SERVICE_ROLE_KEY=x -e SUPABASE_STORAGE_BUCKET=x \
-v "$VF:/app/.data" "$IMAGE" web >/dev/null
ok=""
for _ in $(seq 1 30); do
if docker exec "$SUPA" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
sleep 1
done
slog=$(docker logs "$SUPA" 2>&1)
docker rm -f "$SUPA" >/dev/null
[ -n "$ok" ] || { echo "$slog"; fail "a web não subiu com bucket configurado"; }
echo "$slog" | grep -q STORAGE_NOT_WRITABLE && { echo "$slog"; fail "a verificação bloqueou com bucket configurado"; }
echo "ok: falha explícita sem storage; sem bloqueio quando o bucket é o Supabase"
echo ""
echo "PERMISSÕES DO ARMAZENAMENTO: OK (A, B, C, D, E, F)"
+298 -1
View File
@@ -4,20 +4,317 @@ on:
push: push:
pull_request: pull_request:
# Mínimo necessário. Nenhum job deste workflow escreve no repositório.
permissions:
contents: read
# O repositório é público e o runner é uma máquina real. Um pull request de
# fork traria código de terceiro para rodar nele, com Docker à disposição.
# Enquanto não houver aprovação manual de workflow configurada na instância,
# só PR da própria origem executa. Push continua rodando normalmente.
# Evita fila de runs esperando runner: um push novo cancela o anterior do
# mesmo ref. O que já estava em execução não é interrompido no meio.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
verify: verify:
name: lint, typecheck, testes e build
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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
- name: Permissões do armazenamento local (volume novo, existente, idempotência, falha segura)
run: bash .github/scripts/storage-permissions.sh
# ------------------------------------------------------------------
# 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
+78
View File
@@ -0,0 +1,78 @@
name: runner-check
# Primeiro workflow a rodar quando um runner entrar online. Não instala
# dependência, não constrói nada, não toca em rede externa, não usa segredo e
# não faz checkout do código: só confirma que a ligação Gitea → runner funciona
# e mostra o que o runner tem disponível.
#
# Dispare pela interface (Actions → runner-check → Run workflow) ou editando
# este arquivo.
on:
workflow_dispatch:
push:
paths:
- ".github/workflows/runner-check.yml"
permissions:
contents: read
concurrency:
group: runner-check-${{ github.ref }}
cancel-in-progress: true
jobs:
ligacao:
name: o runner responde
runs-on: ubuntu-latest
# Público: PR de fork não executa enquanto não houver aprovação manual.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Identidade do runner
run: |
echo "runner.os = ${RUNNER_OS:-?}"
echo "runner.arch = ${RUNNER_ARCH:-?}"
echo "ref = ${GITHUB_REF:-?}"
echo "sha = ${GITHUB_SHA:-?}"
echo "evento = ${GITHUB_EVENT_NAME:-?}"
echo "---"
uname -a
echo "usuario: $(id -un) (uid $(id -u))"
- name: Ferramentas que os outros jobs exigem
run: |
faltando=""
for cmd in node npm docker curl git psql python3; do
if command -v "$cmd" >/dev/null 2>&1; then
printf ' ok %-8s %s\n' "$cmd" "$($cmd --version 2>&1 | head -1)"
else
printf ' AUSENTE %-8s\n' "$cmd"
faltando="$faltando $cmd"
fi
done
echo ""
if [ -n "$faltando" ]; then
echo "Faltam no runner:$faltando"
echo "node e npm são exigidos pelo job verify."
echo "docker é exigido pelo job image (precisa de Docker-in-Docker)."
echo "psql e python3 são exigidos pelo job services e pela jornada assíncrona."
echo ""
echo "Isto não falha o workflow de propósito: o objetivo é inventariar."
else
echo "todas as ferramentas presentes"
fi
- name: Docker funciona de verdade
continue-on-error: true
run: |
if ! command -v docker >/dev/null 2>&1; then
echo "docker ausente: o job image não vai rodar neste runner"
exit 0
fi
docker version
docker run --rm hello-world >/dev/null 2>&1 \
&& echo "ok: o runner executa contêiner" \
|| echo "docker presente mas não executa contêiner: confira o Docker-in-Docker"
- name: Resultado
run: echo "ligacao Gitea -> runner confirmada"
+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
+95
View File
@@ -0,0 +1,95 @@
# 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
# 4) Ponto de montagem do armazenamento local já com o dono certo. Um volume
# nomeado VAZIO herda dono e modo deste diretório na primeira montagem. Sem
# ele, o Docker cria /app/.data como root:root 755 e o uid 1001 não escreve
# (STORAGE_NOT_WRITABLE). Volumes que já existem não herdam nada: para eles
# há o serviço storage-init do compose.
RUN mkdir -p /app/.data/storage \
&& chown adaptador:adaptador /app/.data /app/.data/storage \
&& chmod 750 /app/.data /app/.data/storage
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"]
+71 -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,60 @@ 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`.
O serviço `storage-init` prepara o volume do armazenamento e termina antes de web e worker subirem; os dois continuam como uid 1001. Ver [23-storage-permissions.md](docs/spreadsheet-adapter/23-storage-permissions.md).
## 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 +93,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"
}
]
}
+111
View File
@@ -0,0 +1,111 @@
# Ensaio descartável de rollback de imagem.
#
# Projeto, rede e volumes são exclusivos deste ensaio. Não há `build:` de
# propósito: a imagem vem só de ADAPTER_IMAGE, com tag imutável do commit.
# Nunca use a tag `adaptador-planilhas:local` aqui — ela alimenta o compose
# principal. Postgres e Redis não publicam porta.
name: adaptador-rollback-ensaio
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: adaptador
POSTGRES_PASSWORD: rollback_ensaio_local
POSTGRES_DB: adaptador_rollback
volumes:
- ensaio-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U adaptador -d adaptador_rollback"]
interval: 5s
timeout: 3s
retries: 20
networks: [ensaio]
redis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
networks: [ensaio]
storage-init:
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
command: ["prepare-storage"]
user: "0:0"
network_mode: none
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID]
security_opt: ["no-new-privileges:true"]
healthcheck:
disable: true
restart: "no"
volumes:
- ensaio-storage:/app/.data
web:
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
command: ["web"]
environment: &app-env
ADAPTER_ENV: development
ADAPTER_NAMESPACE: adaptador-rollback-ensaio
ADAPTER_JOB_MODE: worker
ADAPTER_APPLY_IDENTITY_SHIM: "1"
ADAPTER_DEV_AUTH: "1"
ADAPTER_DEV_AUTH_SECRET: segredo-apenas-ensaio
DATABASE_URL: postgres://adaptador:rollback_ensaio_local@postgres:5432/adaptador_rollback
REDIS_URL: redis://redis:6379
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
ELO_PRO_APP_URL: http://127.0.0.1:3310
PORT: "3000"
HOSTNAME: 0.0.0.0
ports:
- "127.0.0.1:3310:3000"
volumes:
- ensaio-storage:/app/.data
depends_on:
storage-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [ensaio]
worker:
image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit}
command: ["worker"]
environment: *app-env
volumes:
- ensaio-storage:/app/.data
healthcheck:
test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
stop_grace_period: 30s
depends_on:
storage-init:
condition: service_completed_successfully
web:
condition: service_started
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [ensaio]
networks:
ensaio:
name: adaptador-rollback-ensaio
volumes:
ensaio-postgres:
name: adaptador-rollback-ensaio-postgres
ensaio-storage:
name: adaptador-rollback-ensaio-storage
+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
+135
View File
@@ -0,0 +1,135 @@
# 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]
# Prepara o volume do armazenamento antes de web e worker e termina. É o único
# passo como root: sem rede, só com as capacidades de ajustar dono e trocar de
# usuário para provar a escrita como uid 1001. Idempotente e sem recursão.
storage-init:
image: ${ADAPTER_IMAGE:-adaptador-planilhas:local}
build:
context: .
target: runner
command: ["prepare-storage"]
user: "0:0"
network_mode: none
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID]
security_opt: ["no-new-privileges:true"]
healthcheck:
disable: true
restart: "no"
volumes:
- app-storage:/app/.data
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:
storage-init:
condition: service_completed_successfully
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:
storage-init:
condition: service_completed_successfully
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
+125
View File
@@ -0,0 +1,125 @@
#!/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"
# Armazenamento local (development): o volume `app-storage` montado em /app/.data.
# Homologação e produção usam o bucket do Supabase e não montam este volume.
DATA_DIR=/app/.data
STORAGE_DIR="$DATA_DIR/storage"
# Mesma regra de src/infra/storage/index.ts: sem as três variáveis do Supabase,
# o processo grava no disco local.
local_storage_in_use() {
[ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ] || [ -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ] || [ -z "${SUPABASE_STORAGE_BUCKET:-}" ]
}
# Web e worker recusam subir com armazenamento local que não aceita escrita.
# Sem isto o processo fica no ar e o primeiro upload responde "Erro interno".
assert_storage_writable() {
local_storage_in_use || return 0
probe="$STORAGE_DIR/.write-probe-$$"
if mkdir -p "$STORAGE_DIR" 2>/dev/null && touch "$probe" 2>/dev/null; then
rm -f "$probe"
return 0
fi
echo "STORAGE_NOT_WRITABLE: $STORAGE_DIR não aceita escrita pelo uid $(id -u)." >&2
echo "Rode o serviço storage-init do compose (docs/spreadsheet-adapter/23-storage-permissions.md)." >&2
exit 1
}
# Roda como root, uma vez, antes de web e worker (serviço storage-init).
# Ajusta só os dois diretórios do armazenamento, sem recursão: arquivos que já
# existem não são tocados, a não ser com --fix-existing, que é passo manual.
prepare_storage() {
if [ "$(id -u)" != "0" ]; then
echo "prepare-storage precisa rodar como root (user: \"0:0\" no serviço storage-init)." >&2
exit 1
fi
app_uid=$(id -u adaptador)
app_gid=$(id -g adaptador)
for dir in "$DATA_DIR" "$STORAGE_DIR"; do
if [ -L "$dir" ]; then
echo "[storage-init] recusado: $dir é link simbólico." >&2
exit 1
fi
mkdir -p "$dir"
owner=$(stat -c '%u:%g' "$dir")
if [ "$owner" != "$app_uid:$app_gid" ]; then
chown "$app_uid:$app_gid" "$dir"
echo "[storage-init] $dir: dono $owner -> $app_uid:$app_gid"
fi
mode=$(stat -c '%a' "$dir")
if [ "$mode" != "750" ]; then
chmod 750 "$dir"
echo "[storage-init] $dir: modo $mode -> 750"
fi
done
if [ "${1:-}" = "--fix-existing" ]; then
# -xdev não atravessa outro sistema de arquivos; -h não segue link simbólico.
find "$DATA_DIR" -xdev ! -user "$app_uid" -exec chown -h "$app_uid:$app_gid" {} +
echo "[storage-init] dono de itens existentes ajustado para $app_uid:$app_gid"
fi
foreign=$(find "$DATA_DIR" -xdev ! -user "$app_uid" | wc -l)
if [ "$foreign" -gt 0 ]; then
echo "[storage-init] AVISO: $foreign item(ns) em $DATA_DIR com outro dono; nada foi alterado neles." >&2
echo "[storage-init] Para ajustar, rode com --fix-existing (procedimento de recuperação no doc 23)." >&2
fi
# Prova com o usuário da aplicação, não com root.
if ! setpriv --reuid="$app_uid" --regid="$app_gid" --clear-groups \
sh -c 'p="$1/.write-probe-$$"; touch "$p" && rm -f "$p"' _ "$STORAGE_DIR"; then
echo "STORAGE_NOT_WRITABLE: o uid $app_uid não escreve em $STORAGE_DIR mesmo após a preparação." >&2
exit 1
fi
echo "[storage-init] STORAGE_WRITABLE: $STORAGE_DIR, dono $app_uid:$app_gid, modo 750"
}
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)
prepare-storage [--fix-existing]
prepara /app/.data como root (serviço storage-init)
exec <cmd...> qualquer outro comando
EOF
}
cmd="${1:-web}"
[ $# -gt 0 ] && shift || true
case "$cmd" in
web) assert_storage_writable; exec node server.js ;;
worker) assert_storage_writable; exec node $TSX scripts/worker.ts ;;
prepare-storage) prepare_storage "$@" ;;
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,165 @@
# 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 |
| D19 | Storage local gravável pelo uid 1001 em volume novo e existente (local) | PASS | 16 out 2026, Docker Desktop/WSL2: `.github/scripts/storage-permissions.sh` casos A–F em volumes descartáveis. Corrige `STORAGE_NOT_WRITABLE` no upload. Ver [23-storage-permissions.md](23-storage-permissions.md). Não se aplica a homologação (bucket Supabase, D7) |
| D20 | Web e worker recusam subir com storage local sem escrita | PASS | mesmo script, caso F: saída com código 1 e `STORAGE_NOT_WRITABLE` |
| 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 | 56 |
| 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.
@@ -0,0 +1,129 @@
# Gitea Actions — diagnóstico e ativação do runner
Repositório `cferraz/adaptador-planilhas` em `gitea.fatorxtech.com`, Gitea **1.26.2**.
## Causa confirmada da ausência de execuções
**Os runs existem. O que não existe é runner para executá-los.** A falha está na **atribuição**, não no enfileiramento.
| Evidência | Resultado |
| --- | --- |
| `ci.yml` detectado por Gitea | sim, listado em Workflows |
| Gatilho de `ci.yml` | `on: push` sem filtro de branch — contempla `homologacao` |
| Actions no repositório | habilitado (`has_actions: true`) |
| Actions na instância | habilitado (a página `/actions` responde e lista workflows) |
| Runs criados pelos pushes | **sim**, três |
| Estado de todos | **Waiting**, duração `0s` |
| Mensagem do Gitea em cada um | **`No matching online runner with label: ubuntu-latest`** |
| Run | Workflow | Commit | Branch | Estado |
| --- | --- | --- | --- | --- |
| [668](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/668) | `runner-check.yml #4` | `c5790ab301` | `homologacao` | **Waiting** |
| [667](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/667) | `ci.yml #3` | `c5790ab301` | `homologacao` | **Waiting** |
| [666](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/666) | `ci.yml #2` | `5b11496cd9` | `homologacao` | **Waiting** |
| [584](https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions/runs/584) | `ci.yml #1` | `419325db12` | `main` | Canceled |
Listagem: https://gitea.fatorxtech.com/cferraz/adaptador-planilhas/actions
Dois detalhes observados na prática:
- a barra lateral de Workflows mostra só `ci.yml`, porque lista os workflows da branch padrão (`main`), onde `runner-check.yml` ainda não existe. Isso não impediu o run `668` de ser criado a partir de `homologacao`;
- **`concurrency` não tem efeito no Gitea 1.26.2.** O run `667` deveria ter cancelado o `666`, que está no mesmo grupo, e não cancelou. A fila de runs em `Waiting` vai crescer a cada push enquanto não houver runner. Não é problema: quando o runner subir, cancele os antigos pela interface ou deixe-os rodar em sequência.
### Por que a API parecia dizer "zero execuções"
`GET /api/v1/repos/cferraz/adaptador-planilhas/actions/tasks` devolve `{"workflow_runs":[],"total_count":0}`. Esse endpoint lista **tasks**, isto é, jobs que um runner aceitou. Nenhum runner aceitou nada, então a lista é vazia — mas os runs estão criados e esperando. `GET /actions/runs` responde `token is required`, e foi por isso que a primeira leitura passou a impressão errada.
**Consequência prática: nada precisa ser republicado.** O run `666` está em `Waiting` e começa sozinho no momento em que um runner com a label compatível ficar online.
## Runners encontrados e labels
| | |
| --- | --- |
| Runners com label `ubuntu-latest` **online** | **nenhum** (é o que o Gitea reporta) |
| Outros runners na instância | **não determinado** |
Listar runners exige token de owner ou admin: `GET /api/v1/repos/{owner}/{repo}/actions/runners`, ou a interface em **Settings → Actions → Runners** (nível repositório, organização ou admin). Os números de run (`584`, `666`) sugerem que a instância já teve atividade de Actions em outros repositórios, então **é possível que existam runners com outra label**. Confira antes de instalar um novo:
- se já houver runner Linux com Docker e outra label (`linux_amd64`, `ubuntu-22.04`, `docker`), **não instale nada**: basta ajustar o `runs-on` dos workflows. Diga qual é a label e a troca é de uma linha em três arquivos;
- se não houver, siga a instalação abaixo.
## Configuração recomendada do runner
Máquina Linux **dedicada a homologação**, sem nenhuma relação com o Docker de produção do Elo Pro.
### Por que Docker-in-Docker, e não o socket do host
O repositório é **público**. Montar `/var/run/docker.sock` do host dá ao workflow controle do Docker da máquina — e, num repositório público, código de terceiro poderia chegar lá por pull request de fork. A imagem `-dind-rootless` sobe um Docker próprio dentro do contêiner do runner: o job `image` constrói e executa contêineres sem alcançar o Docker do host.
### Instalação
Versão fixada, compatível com o Gitea 1.26.2 (`0.6.1` é a mais recente publicada; as alternativas semver são `0.1.4` a `0.6.1`):
```bash
# 1. Token de registro: Settings → Actions → Runners → Create new runner,
# no NÍVEL DO REPOSITÓRIO (escopo restrito, não use token de admin).
# Grave o token num arquivo com permissão 600 — nunca em commit, nunca em log.
umask 077
printf '%s' 'COLE_O_TOKEN_AQUI' > /etc/gitea-runner.token
# 2. Suba o runner com Docker-in-Docker rootless.
docker run -d \
--name gitea-runner-hml \
--restart always \
--privileged \
-e GITEA_INSTANCE_URL=https://gitea.fatorxtech.com \
-e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/token \
-e GITEA_RUNNER_NAME=hml-adaptador \
-e GITEA_RUNNER_LABELS='ubuntu-latest:docker://node:24-bookworm' \
-v /etc/gitea-runner.token:/run/secrets/token:ro \
-v gitea-runner-hml-data:/data \
docker.io/gitea/act_runner:0.6.1-dind-rootless
```
Notas que importam:
- `GITEA_RUNNER_REGISTRATION_TOKEN_FILE` em vez da variável com o valor: o token não aparece em `docker inspect` nem no histórico do shell.
- `--privileged` é exigência do Docker-in-Docker. É justamente por isso que a máquina precisa ser dedicada.
- A label **tem de ser `ubuntu-latest`**, que é o `runs-on` dos três jobs. O mapeamento `docker://node:24-bookworm` já entrega `node` e `npm` na versão certa.
- `psql` e `python3` não vêm nessa imagem. O job `services` usa `psql` e a jornada assíncrona usa `python3`. Duas saídas: trocar o mapeamento para uma imagem que os tenha, ou deixar que o `runner-check` reporte a ausência e então instalá-los num passo do workflow. Rode o `runner-check` primeiro: ele faz esse inventário.
- Rede de saída necessária: `gitea.fatorxtech.com` (fila e logs), `github.com` (o Gitea resolve `actions/checkout@v4`, `actions/setup-node@v4` e `docker/*` via proxy), `registry.npmjs.org` e `docker.io`.
### Conferir que subiu
```bash
docker logs -f gitea-runner-hml # espere "runner registered successfully"
```
Depois, em **Settings → Actions → Runners**, o runner aparece online com a label `ubuntu-latest`. O run `666`, que está em `Waiting`, começa sozinho.
## Ordem de validação
Nenhum destes foi executado ainda. Não marque nada como aprovado sem o log.
1. **`runner-check`** — dispare manualmente. Não faz checkout, não instala nada, não usa segredo. Confirma a ligação e inventaria `node`, `npm`, `docker`, `psql`, `python3`.
2. **`verify`** — lint, typecheck, 102 testes, build.
3. **`services`** — Postgres e Redis efêmeros: as quatro migrations, idempotência, `--dry-run` sem efeito, shim recusado com `ADAPTER_APPLY_IDENTITY_SHIM=0`, `app_user` sem `BYPASSRLS`, `FORCE RLS`, isolamento entre workspaces, auditoria não alterável.
4. **`image`** — build Linux, conteúdo da imagem, usuário não-root, ausência de `.env`/docs/testes, os oito scripts operacionais transformando e executando, fail-fast com código diferente de zero na web e no worker, liveness com cabeçalhos, compose até `ready`, heartbeat do worker, jornada assíncrona entre processos e desligamento gracioso exigindo `SIGTERM recebido` em menos de 25 s.
## Segurança revisada
| Risco | Estado |
| --- | --- |
| Repositório **público** com Actions | `ci.yml`, `runner-check` e os três jobs têm guarda: PR de fork **não executa** (`head.repo.full_name == github.repository`). Push continua rodando |
| Código não confiável com acesso privilegiado | O runner usa Docker-in-Docker rootless, sem o socket do host. A máquina é dedicada e não alcança o Docker de produção |
| Deploy automático | `deploy-staging.yml` tem gatilho único `workflow_dispatch`. Nenhum push, tag ou merge dispara deploy |
| Aprovação manual de deploy | Os ambientes `hml-migracao`, `hml-deploy` e `hml-flag` são conceito do GitHub. **O Gitea não os implementa da mesma forma**: se o deploy for rodar aqui, as aprovações deixam de existir e isso precisa ser resolvido antes de apontar o workflow ao servidor |
| Permissão do token de Actions | `permissions: contents: read` nos dois workflows |
| Fila de runs esperando runner | `concurrency` por ref declarado, **sem efeito no Gitea 1.26.2** (verificado: o run 667 não cancelou o 666). Cancele os antigos pela interface quando o runner subir |
| SSH de produção | **não configurado**. Nenhum segredo de produção foi adicionado |
| Branch `main` | **preservada** em `419325d` |
| Token de registro do runner | por arquivo com permissão 600, fora de commit e de log |
Vale decidir também se este repositório deve seguir público. Auditei os commits e não há segredo algum — só modelos com campos vazios — mas um repositório privado remove de uma vez o risco de PR de fork.
## Pendências
**Para o CI rodar:** um runner Linux online com a label `ubuntu-latest`, Docker-in-Docker e saída de rede. Nada além disso.
**Para homologação, depois do CI verde:** projeto Supabase de homologação com contas de teste e bucket privado; `DATABASE_URL` e a decisão do `ADAPTER_APPLY_IDENTITY_SHIM`; Redis com senha; DSN do Sentry; DNS, TLS e Nginx de `hml-ada.elopro.app`; smoke real e ensaio de rollback. O procedimento está em [`20-homologacao-runbook.md`](20-homologacao-runbook.md) e o estado item a item em [`21-checklist-validacao.md`](21-checklist-validacao.md).
@@ -0,0 +1,115 @@
# Permissões do armazenamento local em Docker
Escopo: `docker-compose.yml` (desenvolvimento local). Homologação e produção usam o bucket privado do Supabase e não montam volume em `/app/.data` — `docker-compose.staging.yml` não foi alterado.
## Problema
Cadastro de `modelo_imoveis_teste.xlsx` no compose local respondeu `Não foi possível continuar — Erro interno.`
```
uid=1001(adaptador) gid=1001(adaptador)
drwxr-xr-x 2 root root /app/.data
STORAGE_NOT_WRITABLE
```
Contornado à mão no volume local com `chown 1001:1001` e `chmod 750` em `/app/.data`, depois `STORAGE_WRITABLE`. Esta correção elimina o passo manual.
## Causa raiz
1. A imagem não continha `/app/.data`. `LocalPrivateStorage` grava em `/app/.data/storage` e só cria o diretório em runtime.
2. Quando um volume nomeado é montado num caminho que não existe na imagem, o Docker cria o ponto de montagem como `root:root 755`. O volume vazio não tem de onde herdar dono.
3. Web e worker rodam como `adaptador` (1001) sem capacidades. Não escrevem num diretório de root.
4. O health check do storage local só fazia `mkdir -p`, que passa quando o diretório já existe. O processo subia saudável e o erro só aparecia no primeiro upload, como 500 genérico.
Reproduzido num volume descartável com a imagem anterior: `drwxr-xr-x 0 0 /app/.data` e `touch: Permission denied`.
## Correção
Duas camadas, porque `RUN chown` no Dockerfile sozinho não alcança volume que já existe.
| Camada | Arquivo | O que faz | Alcança |
| --- | --- | --- | --- |
| Imagem | `Dockerfile` | Cria `/app/.data/storage` com dono `adaptador` e modo `750`. Um volume nomeado **vazio** herda dono e modo na primeira montagem | Volumes novos |
| Inicialização | `docker-compose.yml` (`storage-init`) + `docker/entrypoint.sh prepare-storage` | Serviço de uma execução, antes de web e worker, que ajusta **só** `/app/.data` e `/app/.data/storage` e prova a escrita como uid 1001 | Volumes existentes, inclusive os criados antes desta correção |
| Falha segura | `docker/entrypoint.sh` (`web`, `worker`) | Antes de iniciar, grava e apaga um arquivo de prova em `/app/.data/storage`. Sem escrita, sai com código 1 e `STORAGE_NOT_WRITABLE` | Qualquer ambiente que use o storage local |
Alternativa A (preparar dentro do entrypoint de web e worker) foi descartada: exigiria iniciar os processos como root e rebaixar o usuário depois. A alternativa B mantém web e worker exatamente como antes — `USER adaptador`, sem capacidades — e concentra o único passo privilegiado num contêiner que termina.
## Fluxo de inicialização
```
postgres (healthy) ─┐
redis (healthy) ────┤
storage-init ───────┼─ completed_successfully ─► web ─► worker
(root, sem rede, │
termina) │
```
`storage-init`:
- `user: "0:0"`, `network_mode: none`, `cap_drop: [ALL]` e só `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `DAC_READ_SEARCH`, `SETUID`, `SETGID`; `no-new-privileges`.
- Recusa link simbólico em `/app/.data` ou `/app/.data/storage`.
- Muda dono/modo somente quando diferem de `1001:1001 750`, e registra cada mudança.
- **Não é recursivo.** Itens com outro dono dentro do volume são contados e reportados, não alterados.
- Prova a escrita com `setpriv --reuid 1001` e falha com `STORAGE_NOT_WRITABLE` se não conseguir. Falha do `storage-init` impede web e worker de subir.
- UID/GID vêm de `id -u adaptador` na própria imagem, a mesma fonte do `USER` do Dockerfile.
## Permissões esperadas
| Caminho | Dono | Modo |
| --- | --- | --- |
| `/app/.data` | `1001:1001` | `750` |
| `/app/.data/storage` | `1001:1001` | `750` |
| Subpastas e arquivos criados pela aplicação | `1001:1001` | padrão do processo (`755`/`644`), protegidos pelo `750` acima |
| Processo | Usuário | Capacidades efetivas |
| --- | --- | --- |
| web | `adaptador` (1001) | nenhuma |
| worker | `adaptador` (1001) | nenhuma |
| storage-init | root, só durante a preparação | as seis listadas acima |
Web e worker montam o mesmo volume `adaptador-planilhas-local-storage` em `/app/.data`. O volume não é servido por HTTP: o download continua passando pela rota autenticada.
## Volume novo × existente
- **Novo:** herda `1001:1001 750` da imagem; `storage-init` só confirma e não altera nada.
- **Existente com raiz de root:** `storage-init` ajusta os dois diretórios. Conteúdo, dono e modo dos arquivos existentes ficam como estavam.
- **Existente já corrigido à mão:** nenhuma mudança de dono; o modo de `/app/.data/storage` passa de `755` para `750` na primeira execução.
## Validação
```bash
docker compose --parallel 1 build web
ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
```
O script usa apenas volumes e contêineres descartáveis com prefixo `adaptador-storage-test-` e remove só o que criou. Casos:
| Caso | Verifica |
| --- | --- |
| A | Volume novo: `1001:1001 750` sem `storage-init`, escrita pelo uid 1001, `STORAGE_WRITABLE` |
| B | Volume com raiz `root:root 755` e arquivos: diretórios corrigidos, conteúdo e dono dos arquivos intactos, item de root apenas reportado |
| C | Duas execuções extras: snapshot de dono, modo, tamanho e SHA-256 idêntico; nenhuma mudança registrada |
| C2 | `--fix-existing` ajusta o dono só quando pedido e preserva o conteúdo |
| D | Um contêiner grava, outro lê e apaga; `web` real sobe e responde liveness; `worker` real passa da verificação |
| E | web como 1001 com `CapEff` zero; imagem com `USER adaptador`; `prepare-storage` recusa rodar sem root |
| F | Volume sem escrita: `web` e `worker` saem com código 1 e `STORAGE_NOT_WRITABLE`; com Supabase configurado a verificação não bloqueia |
O job `image` do CI executa o mesmo script.
## Recuperação
Se `storage-init` reportar `AVISO: N item(ns) ... com outro dono`, os arquivos continuam íntegros, mas a aplicação pode não conseguir sobrescrevê-los ou apagá-los. Ajuste controlado, limitado ao volume, sem seguir link simbólico e sem atravessar outro sistema de arquivos — **execute só com autorização**:
```bash
docker compose run --rm storage-init prepare-storage --fix-existing
```
Se `storage-init` falhar com `STORAGE_NOT_WRITABLE`: confira `docker compose logs storage-init`. Causas prováveis são volume montado somente leitura ou driver de volume que não aceita `chown`. Não use `chmod 777` nem rode web/worker como root.
## Limitações
- Bind mount (`./pasta:/app/.data`) não herda dono da imagem. No Docker Desktop/WSL2 sobre o disco do Windows o `chown` pode não ter efeito; a falha segura impede o processo de subir em silêncio. O compose usa volume nomeado.
- `--fix-existing` é recursivo dentro do volume e por isso é manual.
- O ajuste do volume local real `adaptador-planilhas-local-storage` acontece quando os contêineres forem recriados com o compose novo. Esta validação não recriou os contêineres em execução.
- Não valida homologação: o storage de homologação é o bucket privado do Supabase (item D7 do checklist continua PENDENTE).
@@ -0,0 +1,73 @@
# Smoke local do compose de desenvolvimento
Smoke do ambiente Docker local. Confere o Adaptador depois de atualizar código, reinstalar dependências ou recriar os contêineres, sem Supabase de homologação.
Não altera `scripts/smoke-staging.ts` nem o comando `npm run smoke:staging`.
## Separação
| | Smoke local | Smoke de homologação |
| --- | --- | --- |
| Comando | `npm run smoke:local` | `npm run smoke:staging` |
| Script | `scripts/smoke-local.ts` | `scripts/smoke-staging.ts` |
| Alvo | `http://127.0.0.1:3000` (`ADAPTER_SMOKE_LOCAL_URL` só se for HTTP em loopback) | `ADAPTER_SMOKE_URL` público |
| Identidade | `POST /api/dev/session` com as fixtures de desenvolvimento | Supabase Auth de homologação |
| Banco | Postgres 16 do `docker-compose.yml`, publicado em loopback | Banco de homologação |
| Jornada | modelo XLSX, upload, mapeamento, prévia, worker, XLSX, CSV e PDF | fase 1 anônima; fase 2 só leitura autenticada. Upload e download continuam no runbook |
| Resultado | evidência do compose local | evidência de `hml-ada.elopro.app` |
Um PASS local não preenche nem aprova os gates de homologação externa do [checklist](21-checklist-validacao.md): deploy real (F7), smoke de homologação (F10) e ensaio de rollback (F11) continuam pendentes até rodarem no ambiente externo.
## Quando rodar
Com o compose local no ar:
```bash
docker compose up --build
docker compose exec web /app/docker/entrypoint.sh migrate
npm run smoke:local
```
A migration é um passo anterior e manual. O smoke não executa `migrate`, `seed`, `seed:staging` nem `flag`.
## O que verifica
1. `GET /api/health/ready` com HTTP 200 e `status=ready`.
2. `checks.database=ok`.
3. `checks.migrations=ok`, sem ids pendentes. Se houver migration pendente, a jornada não grava.
4. `checks.redis=ok`.
5. Leitura e escrita no volume privado: o contêiner `web` grava, relê e apaga um arquivo em `/app/.data/storage/_smoke-local`. O health de storage só cria diretório; esta prova é separada.
6. `ADAPTER_JOB_MODE=worker` nos contêineres `web` e `worker`.
7. Sessão local emitida e `POST /api/auth/staging/session` respondendo 404.
8. Ana enxerga só o workspace Alpha.
9. `GET /api/spreadsheet-adapter/models` autenticado.
10. Cadastro de um XLSX fictício, upload, mapeamento, prévia, fila do worker e download XLSX, CSV e PDF pelos contratos reais em `src/app/api/spreadsheet-adapter/`.
11. Conteúdo da sentinela fictícia nos três formatos e hash do modelo original inalterado. No PDF a sentinela é lida nos operadores de texto do arquivo gerado: a fonte padrão grava os caracteres em hexadecimal, não como texto solto.
12. Anônimo recebe 401; Diego, no workspace Beta com o módulo desligado, recebe 403 `FEATURE_DISABLED`; Ana não seleciona Beta; Diego não lê, baixa nem exclui o recurso de Alpha; corpo com `workspace_id` de Beta recebe 403 `WORKSPACE_FORBIDDEN`.
13. Logs de `web` e `worker` durante a execução, mais respostas HTTP 5xx.
Os dados são colunas `Nome` e `Codigo` e um valor `SMOKELOCAL` mais um sufixo da execução. Não há planilha real.
## Segurança da gravação
Antes de cadastrar o modelo, o script:
- recusa `ADAPTER_ENV=staging` ou `production` no processo;
- recusa URL que não seja HTTP em loopback;
- interrompe se `/api/health/ready` reportar outro ambiente;
- lê `DATABASE_URL` só dentro do contêiner `web` e só aceita host `postgres` e database `adaptador`;
- conecta no host pela porta publicada por `docker compose port`, e só se o bind for loopback;
- exige role com `rolsuper` ou `rolbypassrls`, porque a RLS força política e não há `DELETE` de modelo ou de job para `app_user`;
- fotografa modelos, jobs, feature flags e configuração de upload.
A limpeza, na mesma transação, apaga somente o modelo e as operações criados nesta execução (nome, ids e autor conferidos, ids anteriores excluídos da cláusula). Auditoria removida é só a da Ana, posterior ao início da gravação, ligada a esses ids ou à tentativa de workspace forjado. Se a contagem de auditoria alheia mudar, a transação é desfeita.
Os arquivos apagados no volume são só as chaves `original`, `source`, `out.csv` e `out.xlsx` desse modelo e dessa operação. O script não roda `docker compose down` nem remove volume.
A feature flag não é ligada, desligada nem restaurada. Se ela mudar durante a execução, o item correspondente falha.
Se a limpeza não puder ser garantida, os itens de gravação ficam **PENDENTE** e não entram na conta de aprovados. Código de saída diferente de zero ocorre quando uma verificação executada falha. Pendência sozinha não reprova o processo.
## Relatório
Ao terminar, o script imprime executados, aprovados, reprovados, pendentes, tempo, o estado de XLSX, CSV e PDF, as falhas e as pendências.
@@ -0,0 +1,136 @@
# Ensaio local de rollback de imagem
Ensaio descartável feito em 17 de outubro de 2026, nesta máquina, no projeto Docker `adaptador-rollback-ensaio`. Não é o rollback da homologação nem o da produção da Elo Pro.
O gate F11 do [checklist](21-checklist-validacao.md) continua **pendente**. Ele exige duas imagens publicadas e o ambiente `hml-ada.elopro.app`. O procedimento de lá está na seção 10 de [20-homologacao-runbook.md](20-homologacao-runbook.md). Este documento não o aprova.
## O que foi comparado
| | Atual | Anterior |
| --- | --- | --- |
| Commit | `bd0ecabf41330d8e48dbe070cb090b25fd6eb64c` | `2be97ea9694eb3fe44e125fbd2ab3e13d8cc488a` |
| Tag da imagem | `adaptador-planilhas:bd0ecab` | `adaptador-planilhas:2be97ea` |
| Id da imagem | `sha256:bc06e875c96b2a1f28b406fd0512e40b37399550355f204d32d77ae766db9eed` | `sha256:edc36b8532b172ea940ed6dd252e2f965a91694366adb5828e87f7c72dc520d9` |
`git diff 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml` não tem diferença. O commit atual só acrescenta o smoke local, o `package.json`, o `tsconfig.json` e a documentação. As quatro migrations (`0001` a `0004`) são as mesmas. Por isso a imagem anterior convive com o schema aplicado pela imagem atual. Não houve downgrade de migration.
A tag `adaptador-planilhas:local` do compose principal não foi reescrita. Antes e depois do ensaio ela continuou `sha256:1efe0dfab0a80518563bd8ac2905466eca5692c05a26818a397551fffc05f1fa`, com web, worker, Postgres e Redis principais saudáveis. O banco `adaptador` seguiu com 1 modelo e as flags `spreadsheet_adapter` Alpha ligada e Beta desligada. O comando `flag` não foi executado.
## Isolamento
Arquivo: `docker-compose.rollback-local.yml`.
| Recurso | Nome | Publicado no host |
| --- | --- | --- |
| Projeto | `adaptador-rollback-ensaio` | não |
| Rede | `adaptador-rollback-ensaio` | não |
| Volume Postgres | `adaptador-rollback-ensaio-postgres` | não |
| Volume storage | `adaptador-rollback-ensaio-storage` | não |
| Banco | `adaptador_rollback` | não |
| Redis | interno | não |
| Web | `127.0.0.1:3310` | só loopback |
Não há `build:` nesse compose. Sem `ADAPTER_IMAGE` ele recusa subir, para não cair na tag `:local`. Postgres e Redis não têm porta no host. A sonda `scripts/rollback-local-probe.ts` só aceita `http://127.0.0.1:3310`.
Os volumes `adaptador-planilhas-local-postgres` e `adaptador-planilhas-local-storage` não entraram no ensaio. Não houve `docker compose down -v` no projeto principal, nem `git reset`.
## Comandos executados
Conferência de compatibilidade, sem trocar de branch:
```bash
git diff --stat 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml
git worktree add --detach "$TEMP/adaptador-2be97ea-src" 2be97ea
```
Imagens, sem mover a tag `:local`:
```bash
docker build -t adaptador-planilhas:bd0ecab --build-arg SENTRY_RELEASE=bd0ecabf41330d8e48dbe070cb090b25fd6eb64c --target runner .
docker build -t adaptador-planilhas:2be97ea --build-arg SENTRY_RELEASE=2be97eaf --target runner "$TEMP/adaptador-2be97ea-src"
```
Subida do ensaio e migration única, com a imagem atual:
```bash
export ADAPTER_IMAGE=adaptador-planilhas:bd0ecab
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d postgres redis storage-init
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml run --rm --no-deps web migrate
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d web worker
```
A migration só rodou depois do Postgres saudável. Aplicou `0001_platform_shim.sql`, `0002_spreadsheet_adapter.sql`, `0003_rls.sql` e `0004_audit_append_only.sql`. Não existe passo de down. `0003` e `0004` continuam sem caminho de volta, como no runbook.
Jornada fictícia:
```bash
node --import=tsx scripts/rollback-local-probe.ts seed
```
Troca só de web e worker, volumes intactos:
```bash
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml stop -t 30 web worker
export ADAPTER_IMAGE=adaptador-planilhas:2be97ea
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d --no-deps --force-recreate web worker
```
Volta para a imagem atual, do mesmo jeito, com `ADAPTER_IMAGE=adaptador-planilhas:bd0ecab`.
Depois das evidências, só o projeto do ensaio foi removido:
```bash
docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml down -v
git worktree remove --force "$TEMP/adaptador-2be97ea-src"
```
Antes do `down -v`, os nomes listados eram apenas `adaptador-rollback-ensaio`, `adaptador-rollback-ensaio-postgres` e `adaptador-rollback-ensaio-storage`. As imagens `:bd0ecab` e `:2be97ea` foram mantidas. A tag `:local` não.
## Resultado
Prontidão da imagem atual, antes da troca: HTTP 200, `status=ready`, `environment=development`, e `config`, `database`, `migrations`, `redis`, `storage` e `job_mode` em `ok`.
Dados fictícios gravados:
| Campo | Valor |
| --- | --- |
| Modelo | `52add4ba-88b5-4611-b8d7-4ffe33273d3e` |
| Operação | `ee5fb92f-57ba-4414-a7ab-b9635e243aea` |
| Fonte | `eabec970-e86d-4b39-8c76-c2b5d10b4674` |
| SHA-256 do XLSX original | `74a4130f7712f94727c6bbf8708b9e77fd34e61c7504da8b84be309cbd6d9559` |
| SHA-256 do XLSX exportado | `89abbca277ea2569bf63ac5cffcd581181b5b6e97d22a285cf21786e175608dc` |
| SHA-256 do CSV exportado | `199e83c30d6fbae7174a4ecd563d32e9ece19a677209e79e88bb56116a6b8977` |
O mesmo SHA-256 do original foi lido no volume pela imagem atual e, depois da troca, pela imagem `2be97ea`. O `content_sha256` no banco isolado coincidiu. `schema_migrations` permaneceu nas quatro migrations. As flags do banco isolado nasceram do login local de desenvolvimento (Alpha ligada, Beta desligada) e não mudaram na troca de imagem.
Na imagem anterior e de novo na atual:
- `GET /api/health/ready` voltou `ready`.
- O modelo foi encontrado pelo id e pelo nome.
- A operação permaneceu `completed`.
- Os bytes armazenados de XLSX e CSV repetiram o SHA-256.
- O PDF voltou HTTP 200. O SHA-256 do PDF não se repete: ele é gerado no download, não fica gravado. Isso ocorreu nas duas imagens, não é incompatibilidade de rollback.
- O worker da imagem anterior registrou `[worker] pronto`. Depois da restauração, o healthcheck do worker ficou `healthy`.
Tempo observado da troca, do `stop` até o `ready`, no ensaio local: cerca de 3 s na ida para `2be97ea` e 2,8 s na volta para `bd0ecab`. Esse número não descreve pull de registry, Nginx nem a flag do piloto.
Nenhuma incompatibilidade de schema apareceu. O rollback não foi interrompido.
## Como repetir a recuperação
1. Anote a imagem que está no ar. No ensaio, o retorno é `adaptador-planilhas:bd0ecab`.
2. Pare só `web` e `worker` do projeto isolado, com `stop -t 30`.
3. Aponte `ADAPTER_IMAGE` para a tag do commit desejado.
4. Suba de novo com `up -d --no-deps --force-recreate web worker`.
5. Não rode migration de down. Se `migrations` não ficar `ok`, ou se o processo não subir, interrompa e volte para a tag atual pelo mesmo caminho. Schema incompatível é operação planejada, com backup, não rollback às pressas.
6. Confira `/api/health/ready`, o modelo, o SHA-256 do original no volume e o download do XLSX e do CSV.
Na homologação real o equivalente está no runbook: tag `staging-<sha>`, `up -d --no-deps web worker`, e a flag do módulo é outro passo, feito com `flag`, antes da troca e só religada depois da saúde. Este ensaio não fez isso e não deve ser copiado para o servidor compartilhado.
## Limitações
- As duas imagens executam o mesmo código de aplicação. O ensaio prova a troca de imagem com volume e schema preservados. Não prova um rollback atravessando uma migration nova.
- Não usa Supabase, SSO, Sentry nem bucket da Elo Pro.
- A flag do piloto não foi ligada nem desligada.
- O PDF não serve como hash de integridade entre downloads.
- O tempo de 3 s é só o recreate local das duas imagens já presentes no Docker.
+152
View File
@@ -0,0 +1,152 @@
-- Papéis e funções de manutenção da homologação.
-- Não altera os schemas auth e storage do Supabase.
-- Não concede BYPASSRLS nem SUPERUSER.
-- Os papéis nascem NOLOGIN. A senha e o LOGIN ficam para um passo autorizado no projeto de homologação.
-- As funções são SECURITY DEFINER, com search_path travado, e EXECUTE só para adaptador_worker.
-- O dono é quem aplica a migration. No Supabase gerenciado esse dono é o postgres,
-- com rolsuper = false e rolbypassrls = true. O BYPASSRLS do dono faz a função
-- atravessar FORCE RLS; SUPERUSER não é necessário para isso. BYPASSRLS não identifica o projeto.
-- adaptador_web e adaptador_worker são NOINHERIT: sem SET ROLE app_user não herdam os grants do papel da aplicação.
do $$ begin
create role adaptador_web nologin nosuperuser nobypassrls noinherit;
exception when duplicate_object then null;
end $$;
do $$ begin
create role adaptador_worker nologin nosuperuser nobypassrls noinherit;
exception when duplicate_object then null;
end $$;
grant app_user to adaptador_web;
grant app_user to adaptador_worker;
grant usage on schema public to adaptador_web, adaptador_worker;
grant select on table public.schema_migrations to adaptador_web, adaptador_worker;
create or replace function public.adapter_list_queued_job_ids()
returns table (id uuid)
language sql
stable
security definer
set search_path = pg_catalog
as $$
select j.id
from public.spreadsheet_jobs j
where j.status = 'queued'
order by j.created_at asc
limit 5
$$;
create or replace function public.adapter_read_queued_job(p_job uuid)
returns table (user_id uuid, workspace_id uuid, progress jsonb)
language sql
stable
security definer
set search_path = pg_catalog
as $$
select j.user_id, j.workspace_id, j.progress
from public.spreadsheet_jobs j
where j.id = p_job and j.status = 'queued'
$$;
create or replace function public.adapter_worker_context(p_user uuid, p_workspace uuid)
returns table (
display_name text,
is_superadmin boolean,
role text,
workspace_name text,
module_enabled boolean,
allow_upload boolean
)
language sql
stable
security definer
set search_path = pg_catalog
as $$
select p.display_name,
p.is_superadmin,
m.role,
w.name,
coalesce(f.enabled, false),
coalesce(s.allow_non_admin_model_upload, false)
from public.profiles p
join public.workspace_members m on m.user_id = p.id and m.workspace_id = p_workspace
join public.workspaces w on w.id = p_workspace
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 = p_user
$$;
create or replace function public.adapter_list_expired_sources(p_now timestamptz)
returns table (
id uuid,
workspace_id uuid,
temp_storage_key text,
output_csv_key text,
output_xlsx_key text
)
language sql
stable
security definer
set search_path = pg_catalog
as $$
select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where s.expires_at < p_now and s.status <> 'expired'
$$;
create or replace function public.adapter_mark_source_expired(p_source uuid)
returns void
language plpgsql
security definer
set search_path = pg_catalog
as $$
begin
update public.spreadsheet_job_sources
set status = 'expired', output_csv_key = null, output_xlsx_key = null
where id = p_source and status <> 'expired';
end;
$$;
create or replace function public.adapter_list_processing_jobs()
returns table (id uuid, user_id uuid, workspace_id uuid)
language sql
stable
security definer
set search_path = pg_catalog
as $$
select j.id, j.user_id, j.workspace_id
from public.spreadsheet_jobs j
where j.status = 'processing'
$$;
create or replace function public.adapter_mark_job_interrupted(p_job uuid)
returns void
language plpgsql
security definer
set search_path = pg_catalog
as $$
begin
update public.spreadsheet_jobs
set status = 'failed', error_code = 'INTERRUPTED', updated_at = now()
where id = p_job and status = 'processing';
end;
$$;
revoke all on function public.adapter_list_queued_job_ids() from public, app_user, adaptador_web;
revoke all on function public.adapter_read_queued_job(uuid) from public, app_user, adaptador_web;
revoke all on function public.adapter_worker_context(uuid, uuid) from public, app_user, adaptador_web;
revoke all on function public.adapter_list_expired_sources(timestamptz) from public, app_user, adaptador_web;
revoke all on function public.adapter_mark_source_expired(uuid) from public, app_user, adaptador_web;
revoke all on function public.adapter_list_processing_jobs() from public, app_user, adaptador_web;
revoke all on function public.adapter_mark_job_interrupted(uuid) from public, app_user, adaptador_web;
grant execute on function public.adapter_list_queued_job_ids() to adaptador_worker;
grant execute on function public.adapter_read_queued_job(uuid) to adaptador_worker;
grant execute on function public.adapter_worker_context(uuid, uuid) to adaptador_worker;
grant execute on function public.adapter_list_expired_sources(timestamptz) to adaptador_worker;
grant execute on function public.adapter_mark_source_expired(uuid) to adaptador_worker;
grant execute on function public.adapter_list_processing_jobs() to adaptador_worker;
grant execute on function public.adapter_mark_job_interrupted(uuid) to adaptador_worker;
@@ -2,6 +2,14 @@
-- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro. -- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro.
-- Funções de autorização usadas também pelo audit_log permanecem. -- Funções de autorização usadas também pelo audit_log permanecem.
drop function if exists public.adapter_mark_job_interrupted(uuid);
drop function if exists public.adapter_list_processing_jobs();
drop function if exists public.adapter_mark_source_expired(uuid);
drop function if exists public.adapter_list_expired_sources(timestamptz);
drop function if exists public.adapter_worker_context(uuid, uuid);
drop function if exists public.adapter_read_queued_job(uuid);
drop function if exists public.adapter_list_queued_job_ids();
drop table if exists public.spreadsheet_job_mappings; drop table if exists public.spreadsheet_job_mappings;
drop table if exists public.spreadsheet_job_sources; drop table if exists public.spreadsheet_job_sources;
drop table if exists public.spreadsheet_jobs; drop table if exists public.spreadsheet_jobs;
+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);
+20 -7
View File
@@ -2,19 +2,32 @@
"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", "identity:init": "node --import=tsx scripts/init-project-identity.ts",
"flag": "tsx scripts/set-flag.ts" "seed": "node --import=tsx scripts/seed-dev.ts",
"seed:staging": "node --import=tsx scripts/seed-staging.ts",
"worker": "node --import=tsx scripts/worker.ts",
"cleanup": "node --import=tsx scripts/cleanup-temp.ts",
"flag": "node --import=tsx scripts/set-flag.ts",
"verify:config": "node --import=tsx scripts/verify-config.ts",
"check:bucket": "node --import=tsx scripts/check-bucket.ts",
"smoke:staging": "node --import=tsx scripts/smoke-staging.ts",
"smoke:local": "node --import=tsx scripts/smoke-local.ts"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -31,7 +44,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 +60,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"
} }
+77
View File
@@ -0,0 +1,77 @@
import path from "node:path";
import { adapterEnv, identityShimEnabled, isDeployedEnv } from "../src/config/env";
import { exitOnInvalidConfig } from "../src/config/fail-fast";
import { buildPostgresOptions, databaseUrlFor, scrubDbError, type DbPurpose } from "../src/infra/db/connection-policy";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { assertDatabaseGuard } from "../src/infra/db/target-guard";
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(purpose: DbPurpose): Promise<Db> {
const databaseUrl = databaseUrlFor(purpose);
if (databaseUrl) {
const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, purpose));
try {
await assertDatabaseGuard(db, purpose, databaseUrl);
return db;
} catch (error) {
await db.close().catch(() => undefined);
throw error;
}
}
if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatória fora do PGlite 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) => {
console.error(`[${name}] ${scrubDbError(error)}`);
process.exit(1);
},
);
}
/** Abre o banco, executa e fecha, mesmo em caso de falha. */
export function runWithDb(name: string, purpose: DbPurpose, body: (db: Db) => Promise<void>): void {
runScript(name, async () => {
const db = await openOperationalDb(purpose);
try {
await body(db);
} finally {
await db.close().catch(() => undefined);
}
});
}
+16
View File
@@ -0,0 +1,16 @@
import { assertPrivateBucket } from "../src/infra/storage/supabase";
import { runScript } from "./_bootstrap";
/** Confere que o bucket existe e é privado. Não cria bucket. */
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
if (!url || !key || !bucket) {
console.error("Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.");
process.exit(1);
}
runScript("check-bucket", async () => {
await assertPrivateBucket(url, key, bucket);
console.log(`bucket "${bucket}" existe e é privado`);
});
+8 -9
View File
@@ -1,12 +1,11 @@
import path from "node:path"; import { 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", "worker", async (db) => {
console.log(`temporários expirados: ${removed}`); const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
await db.close(); console.log(`temporários expirados: ${removed}`);
});
+37
View File
@@ -0,0 +1,37 @@
import { adapterEnv } from "../src/config/env";
import { buildPostgresOptions, databaseUrlFor, DatabaseGuardError } from "../src/infra/db/connection-policy";
import { createPostgresDb } from "../src/infra/db/postgres";
import { assertInitAuthorized, installProjectIdentity } from "../src/infra/db/project-identity";
import { assertAdminTarget } from "../src/infra/db/target-guard";
import { runScript } from "./_bootstrap";
/**
* Grava `adaptador_guard.project_identity` uma única vez.
* Exige ADAPTER_INIT_PROJECT_IDENTITY=1 e o destino administrativo já conferido.
* Não chama o fail-fast da aplicação: nesta etapa os papéis web e worker ainda
* não existem, e Redis, seed e a feature flag ficam fora desta operação.
* Migrate, seed e deploy não chamam este script.
*/
try {
assertInitAuthorized(process.env);
} catch (error) {
const code = error instanceof DatabaseGuardError ? error.code : "init_recusada";
console.error(`[init-project-identity] ${code}`);
process.exit(1);
}
console.log(`[init-project-identity] ambiente=${adapterEnv()}`);
runScript("init-project-identity", async () => {
const databaseUrl = databaseUrlFor("admin");
if (!databaseUrl) throw new DatabaseGuardError("url_ausente");
const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "admin"));
try {
await assertAdminTarget(db);
const ref = process.env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
const result = await installProjectIdentity(db, ref);
console.log(`identidade: ${result}`);
} finally {
await db.close().catch(() => undefined);
}
});
+27 -9
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
const ran = await migrate(db); * antes do passo manual de migração, para que ninguém descubra o que vai rodar
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente"); * só depois de ter rodado.
await db.close(); */
const dryRun = process.argv.includes("--dry-run");
bootstrapScript("migrate");
runWithDb("migrate", "admin", async (db) => {
const pending = await pendingMigrations(db);
console.log(`alvo: ${describeTarget()}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
if (pending.length === 0) {
console.log("migrations: nada pendente");
return;
}
if (dryRun) {
console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`);
return;
}
const ran = await migrate(db);
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
});
+199
View File
@@ -0,0 +1,199 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import ExcelJS from "exceljs";
/**
* Jornada fictícia contra o ensaio de rollback, só em 127.0.0.1:3310.
* Recusa a porta 3000 do compose principal. Não abre banco nem Docker.
*/
const ALPHA = "11111111-1111-4111-8111-111111111111";
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const base = process.env.ROLLBACK_PROBE_URL ?? "http://127.0.0.1:3310";
const mode = process.argv[2];
const expectedRaw = process.argv[3];
function assertTarget(): void {
const url = new URL(base);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port !== "3310") {
throw new Error("alvo recusado: o ensaio só fala com http://127.0.0.1:3310");
}
}
function sha256(body: Buffer): string {
return createHash("sha256").update(body).digest("hex");
}
async function xlsx(rows: string[][]): Promise<Buffer> {
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet("Dados");
for (const row of rows) sheet.addRow(row);
return Buffer.from(await book.xlsx.writeBuffer()) as Buffer;
}
async function login(): Promise<string> {
const response = await fetch(`${base}/api/dev/session`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ userId: ANA, workspaceId: ALPHA }),
signal: AbortSignal.timeout(30_000),
});
if (response.status !== 200) throw new Error(`sessão local HTTP ${response.status}`);
const lines = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
const pair = lines.map((line) => line.split(";")[0]).find((line) => line.startsWith("adapter_dev_session="));
if (!pair) throw new Error("cookie de sessão ausente");
return pair;
}
async function request(path: string, cookie: string, init?: RequestInit): Promise<{ status: number; body: unknown; bytes: Buffer; headers: Headers }> {
const headers = new Headers(init?.headers);
headers.set("cookie", cookie);
headers.set("accept", headers.get("accept") ?? "application/json");
const response = await fetch(`${base}${path}`, { ...init, headers, signal: AbortSignal.timeout(60_000) });
const bytes = Buffer.from(await response.arrayBuffer());
let body: unknown = bytes.toString("utf8");
try {
body = JSON.parse(bytes.toString("utf8"));
} catch {
/* arquivo binário */
}
return { status: response.status, body, bytes, headers: response.headers };
}
async function waitJob(cookie: string, jobId: string): Promise<string> {
let status = "";
for (let attempt = 0; attempt < 45; attempt += 1) {
const polled = await request(`/api/spreadsheet-adapter/jobs/${jobId}`, cookie);
status = (polled.body as { job?: { status?: string } }).job?.status ?? "";
if (status === "completed" || status === "failed") return status;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
return status || "timeout";
}
async function downloads(cookie: string, jobId: string, sourceId: string) {
const files: Record<string, { status: number; sha256: string; size: number }> = {};
for (const format of ["xlsx", "csv", "pdf"]) {
const file = await request(`/api/spreadsheet-adapter/jobs/${jobId}/download?format=${format}&sourceId=${sourceId}`, cookie, {
headers: { accept: "*/*" },
});
files[format] = { status: file.status, sha256: sha256(file.bytes), size: file.bytes.length };
}
return files;
}
async function seed(): Promise<void> {
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
const modelName = `Rollback local ${stamp}-${suffix}`;
const sentinel = `ROLLBACKLOCAL${suffix.toUpperCase()}`;
const cookie = await login();
const modelBytes = await xlsx([["Nome", "Codigo"]]);
const sourceBytes = await xlsx([
["Nome", "Codigo"],
["Registro ficticio", sentinel],
]);
const modelForm = new FormData();
modelForm.append("name", modelName);
modelForm.append("requiredColumns", JSON.stringify(["Nome", "Codigo"]));
modelForm.append("file", new File([new Uint8Array(modelBytes)], "modelo-ficticio.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
const created = await request("/api/spreadsheet-adapter/models", cookie, { method: "POST", body: modelForm, headers: { accept: "application/json" } });
const modelId = (created.body as { model?: { id?: string } }).model?.id ?? "";
if (created.status !== 201 || !modelId) throw new Error(`modelo HTTP ${created.status}`);
const job = await request("/api/spreadsheet-adapter/jobs", cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ modelId, idempotencyKey: `rollback-job-${suffix}` }),
});
const jobId = (job.body as { job?: { id?: string } }).job?.id ?? "";
if (job.status !== 201 || !jobId) throw new Error(`operação HTTP ${job.status}`);
const sourceForm = new FormData();
sourceForm.append("jobId", jobId);
sourceForm.append("file", new File([new Uint8Array(sourceBytes)], "fonte-ficticia.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
const uploaded = await request("/api/spreadsheet-adapter/sources/validate", cookie, { method: "POST", body: sourceForm, headers: { accept: "application/json" } });
const sourceId = (uploaded.body as { job?: { sources?: { id?: string }[] } }).job?.sources?.[0]?.id ?? "";
if (uploaded.status !== 200 || !sourceId) throw new Error(`fonte HTTP ${uploaded.status}`);
const mapped = await request(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, cookie, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sourceId,
mappings: [
{ sourceColumn: "Nome", modelColumn: "Nome" },
{ sourceColumn: "Codigo", modelColumn: "Codigo" },
],
}),
});
if (mapped.status !== 200) throw new Error(`mapeamento HTTP ${mapped.status}`);
const preview = await request(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId }),
});
const previewRows = (preview.body as { rows?: string[][] }).rows ?? [];
if (preview.status !== 200 || !previewRows.some((row) => row.includes(sentinel))) throw new Error(`prévia HTTP ${preview.status}`);
const queued = await request(`/api/spreadsheet-adapter/jobs/${jobId}/process`, cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId, idempotencyKey: `rollback-proc-${suffix}` }),
});
const immediate = (queued.body as { job?: { status?: string } }).job?.status ?? "";
if (queued.status !== 200 || (immediate !== "queued" && immediate !== "processing")) throw new Error(`processo HTTP ${queued.status} status=${immediate}`);
const finalStatus = await waitJob(cookie, jobId);
if (finalStatus !== "completed") throw new Error(`worker status=${finalStatus}`);
const files = await downloads(cookie, jobId, sourceId);
if (Object.values(files).some((file) => file.status !== 200 || file.size < 1)) throw new Error("download incompleto");
console.log(JSON.stringify({ modelName, modelId, jobId, sourceId, modelSha256: sha256(modelBytes), sentinel, files }));
}
async function verify(): Promise<void> {
if (!expectedRaw) throw new Error("informe o JSON gravado na subida");
const expected = JSON.parse(expectedRaw.startsWith("{") ? expectedRaw : readFileSync(expectedRaw, "utf8")) as {
modelName: string;
modelId: string;
jobId: string;
sourceId: string;
modelSha256: string;
files: Record<string, { sha256: string }>;
};
const cookie = await login();
const ready = await request("/api/health/ready", cookie);
const models = await request("/api/spreadsheet-adapter/models", cookie);
const list = (models.body as { models?: { id?: string; name?: string }[] }).models ?? [];
const found = list.some((model) => model.id === expected.modelId && model.name === expected.modelName);
const job = await request(`/api/spreadsheet-adapter/jobs/${expected.jobId}`, cookie);
const status = (job.body as { job?: { status?: string } }).job?.status ?? "";
const files = await downloads(cookie, expected.jobId, expected.sourceId);
const stored = (["xlsx", "csv"] as const).every((format) => files[format].sha256 === expected.files[format].sha256 && files[format].status === 200);
const pdf = files.pdf.status === 200 && files.pdf.size > 4;
const report = {
ready: (ready.body as { status?: string }).status ?? ready.status,
modelFound: found,
jobStatus: status,
storedFilesMatch: stored,
pdfRegenerated: pdf,
sha256: {
xlsx: files.xlsx.sha256 === expected.files.xlsx.sha256,
csv: files.csv.sha256 === expected.files.csv.sha256,
pdf: files.pdf.sha256 === expected.files.pdf.sha256,
},
};
console.log(JSON.stringify(report));
if (report.ready !== "ready" || !found || status !== "completed" || !stored || !pdf) process.exitCode = 1;
}
assertTarget();
if (mode === "seed") {
seed().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "erro");
process.exit(1);
});
} else if (mode === "verify") {
verify().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "erro");
process.exit(1);
});
} else {
console.error("uso: rollback-local-probe.ts seed|verify [json]");
process.exit(1);
}
+13 -9
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") {
await migrate(db); console.error("seed-dev só roda em development. Para homologação use `npm run seed:staging`.");
await ensureDevFixtures(db); process.exit(1);
console.log("fixtures de desenvolvimento conferidas"); }
await db.close();
runWithDb("seed-dev", "local", async (db) => {
await migrate(db);
await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas");
});
+134
View File
@@ -0,0 +1,134 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { adapterEnv, isProductionEnv } from "../src/config/env";
import { migrate } from "../src/infra/db/migrate";
import type { Db } from "../src/infra/db/types";
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
/**
* Seed idempotente de homologação.
*
* Não cria conta, senha ou sessão: as identidades vivem no Supabase Auth do
* projeto de homologação e são criadas pela operação no painel do Supabase.
* Este script apenas liga aqueles `user_id` já existentes a workspaces e
* papéis. Rodar duas vezes produz o mesmo estado.
*
* Arquivo: `ADAPTER_STAGING_SEED_FILE` (padrão `config/staging-seed.json`).
* Modelo sem segredo em `config/staging-seed.example.json`.
*/
type SeedWorkspace = { id: string; name: string; moduleEnabled: boolean; allowNonAdminModelUpload?: boolean };
type SeedUser = { id: string; displayName: string; isSuperadmin?: boolean };
type SeedMember = { workspaceId: string; userId: string; role: "owner" | "admin" | "gestor" | "agent" };
type SeedFile = { workspaces: SeedWorkspace[]; users: SeedUser[]; members: SeedMember[] };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ROLES = new Set(["owner", "admin", "gestor", "agent"]);
function assertSeed(seed: SeedFile): void {
const workspaceIds = new Set<string>();
for (const workspace of seed.workspaces) {
if (!UUID_RE.test(workspace.id)) throw new Error(`workspace.id não é UUID: ${workspace.id}`);
if (!workspace.name?.trim()) throw new Error(`workspace ${workspace.id} sem nome`);
workspaceIds.add(workspace.id.toLowerCase());
}
const userIds = new Set<string>();
for (const user of seed.users) {
if (!UUID_RE.test(user.id)) {
throw new Error(`users[].id deve ser o UUID do usuário no Supabase Auth: ${user.id}`);
}
if (!user.displayName?.trim()) throw new Error(`usuário ${user.id} sem displayName`);
userIds.add(user.id.toLowerCase());
}
for (const member of seed.members) {
if (!ROLES.has(member.role)) throw new Error(`papel inválido: ${member.role}`);
if (!workspaceIds.has(member.workspaceId.toLowerCase())) {
throw new Error(`membro aponta workspace fora do seed: ${member.workspaceId}`);
}
if (!userIds.has(member.userId.toLowerCase())) {
throw new Error(`membro aponta usuário fora do seed: ${member.userId}`);
}
}
}
async function applySeed(db: Db, seed: SeedFile): Promise<void> {
await db.withService(async (query) => {
for (const workspace of seed.workspaces) {
await query.query(
`insert into public.workspaces (id, name) values ($1, $2)
on conflict (id) do update set name = excluded.name`,
[workspace.id, workspace.name],
);
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspace.id, workspace.moduleEnabled],
);
if (workspace.allowNonAdminModelUpload !== undefined) {
await query.query(
`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload)
values ($1, $2)
on conflict (workspace_id) do update set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload`,
[workspace.id, workspace.allowNonAdminModelUpload],
);
}
}
for (const user of seed.users) {
await query.query(
`insert into public.profiles (id, display_name, is_superadmin) values ($1, $2, $3)
on conflict (id) do update set display_name = excluded.display_name, is_superadmin = excluded.is_superadmin`,
[user.id, user.displayName, user.isSuperadmin ?? false],
);
}
for (const member of seed.members) {
await query.query(
`insert into public.workspace_members (workspace_id, user_id, role) values ($1, $2, $3)
on conflict (workspace_id, user_id) do update set role = excluded.role`,
[member.workspaceId, member.userId, member.role],
);
}
});
}
bootstrapScript("seed-staging");
if (isProductionEnv()) {
console.error("seed-staging não roda em produção. As associações de produção são da plataforma Elo Pro.");
process.exit(1);
}
if (adapterEnv() === "development" && process.env.ADAPTER_SEED_FORCE !== "1") {
console.error("Em development use `npm run seed`. Para forçar este seed, defina ADAPTER_SEED_FORCE=1.");
process.exit(1);
}
const file = process.env.ADAPTER_STAGING_SEED_FILE ?? path.join("config", "staging-seed.json");
runScript("seed-staging", async () => {
let seed: SeedFile;
try {
seed = JSON.parse(await readFile(path.resolve(process.cwd(), file), "utf8")) as SeedFile;
} catch (error) {
console.error(
`Não foi possível ler o seed em "${file}". Copie config/staging-seed.example.json e preencha com os UUID do Supabase Auth.`,
);
console.error(error instanceof Error ? error.name : "erro");
process.exit(1);
}
seed.workspaces ??= [];
seed.users ??= [];
seed.members ??= [];
assertSeed(seed);
const db = await openOperationalDb("admin");
try {
const ran = await migrate(db);
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
await applySeed(db, seed);
console.log(
`seed de homologação aplicado em ${describeTarget()}: ${seed.workspaces.length} workspace(s), ${seed.users.length} identidade(s), ${seed.members.length} associação(ões)`,
);
} finally {
await db.close().catch(() => undefined);
}
});
+30 -23
View File
@@ -1,30 +1,37 @@
import path from "node:path"; import { 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"));
await db.withService(async (query) => { bootstrapScript("set-flag");
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) runWithDb("set-flag", "admin", async (db) => {
values ($1, 'spreadsheet_adapter', $2) const existing = await db.withService((query) =>
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`, query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
[workspaceId, enabled],
);
await query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
from public.workspace_members m
where m.workspace_id = $1 and m.role = 'owner'
limit 1`,
[workspaceId, enabled],
); );
if (!existing[0]) {
throw new Error(`workspace ${workspaceId} não existe em ${describeTarget()}. Nada foi alterado.`);
}
await db.withService(async (query) => {
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspaceId, enabled],
);
await query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
from public.workspace_members m
where m.workspace_id = $1 and m.role = 'owner'
limit 1`,
[workspaceId, enabled],
);
});
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`);
}); });
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
await db.close();
File diff suppressed because it is too large. Load diff
+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);
});
+105
View File
@@ -0,0 +1,105 @@
-- Inicialização explícita da identidade de adaptador-planilha-hml.
-- Não faz parte de migrations/. Migrate, seed e deploy não executam este arquivo.
-- Substitua <<PROJECT_REF>> pelo Reference ID do Dashboard: 20 letras ou dígitos minúsculos.
-- Rode o bloco inteiro de uma vez. Qualquer falha desfaz o schema inteiro.
-- Não altera app.settings.jwt_exp nem os schemas auth e storage.
-- Se o schema já existir, não use este arquivo. O comando identity:init recusa linha divergente e objeto desconhecido.
do $identity$
begin
create schema adaptador_guard;
revoke all on schema adaptador_guard from public;
create table adaptador_guard.project_identity (
singleton boolean primary key default true check (singleton),
project_name text not null,
project_ref text not null,
constraint project_identity_name check (project_name = 'adaptador-planilha-hml'),
constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$')
);
revoke all on table adaptador_guard.project_identity from public;
alter table adaptador_guard.project_identity enable row level security;
alter table adaptador_guard.project_identity force row level security;
comment on table adaptador_guard.project_identity is
'Identidade persistente do projeto de homologacao. Nao e prova criptografica.';
create function adaptador_guard.reject_identity_change()
returns trigger
language plpgsql
security invoker
set search_path = pg_catalog
as $body$
begin
if tg_op <> 'INSERT' then
raise exception 'identidade imutavel' using errcode = '42501';
end if;
if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then
raise exception 'identidade protegida' using errcode = '42501';
end if;
return new;
end;
$body$;
create trigger project_identity_guard
before insert or update or delete on adaptador_guard.project_identity
for each row execute function adaptador_guard.reject_identity_change();
create function adaptador_guard.adapter_project_identity()
returns table (project_name text, project_ref text)
language sql
stable
security definer
set search_path = pg_catalog
as $body$
select i.project_name, i.project_ref
from adaptador_guard.project_identity i
$body$;
revoke all on function adaptador_guard.adapter_project_identity() from public;
revoke all on function adaptador_guard.reject_identity_change() from public;
insert into adaptador_guard.project_identity (project_name, project_ref)
values ('adaptador-planilha-hml', '<<PROJECT_REF>>');
revoke all on schema adaptador_guard from public;
revoke all on table adaptador_guard.project_identity from public;
revoke all on all functions in schema adaptador_guard from public;
alter default privileges in schema adaptador_guard revoke all on tables from public;
alter default privileges in schema adaptador_guard revoke execute on functions from public;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then
revoke all on schema adaptador_guard from adaptador_web;
revoke all on table adaptador_guard.project_identity from adaptador_web;
revoke all on all functions in schema adaptador_guard from adaptador_web;
grant usage on schema adaptador_guard to adaptador_web;
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then
revoke all on schema adaptador_guard from adaptador_worker;
revoke all on table adaptador_guard.project_identity from adaptador_worker;
revoke all on all functions in schema adaptador_guard from adaptador_worker;
grant usage on schema adaptador_guard to adaptador_worker;
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then
revoke all on schema adaptador_guard from anon;
revoke all on table adaptador_guard.project_identity from anon;
revoke all on all functions in schema adaptador_guard from anon;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then
revoke all on schema adaptador_guard from authenticated;
revoke all on table adaptador_guard.project_identity from authenticated;
revoke all on all functions in schema adaptador_guard from authenticated;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then
revoke all on schema adaptador_guard from service_role;
revoke all on table adaptador_guard.project_identity from service_role;
revoke all on all functions in schema adaptador_guard from service_role;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then
revoke all on schema adaptador_guard from app_user;
revoke all on table adaptador_guard.project_identity from app_user;
revoke all on all functions in schema adaptador_guard from app_user;
end if;
end
$identity$;
+113
View File
@@ -0,0 +1,113 @@
-- Remove somente os objetos conhecidos de adaptador_guard.
-- Recusa se faltar peça, se houver objeto estranho ou se outro schema depender da identidade.
-- Não apaga auth, storage, public nem app.settings.jwt_exp.
-- O bloco e os DROP seguem juntos: se a conferência falhar, nada é removido.
do $revert$
begin
if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then
raise exception 'marcador_ausente' using errcode = 'P0002';
end if;
if exists (
select kind, name
from (
select 'missing'::text as kind, 'project_identity'::text as name
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r'
)
union all
select 'missing', 'adapter_project_identity'
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity'
)
union all
select 'missing', 'reject_identity_change'
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change'
)
union all
select 'unknown_relation', c.relname
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'adaptador_guard'
and not (
(c.relname = 'project_identity' and c.relkind = 'r')
or (
c.relkind = 'i'
and exists (
select 1
from pg_catalog.pg_index i
join pg_catalog.pg_class t on t.oid = i.indrelid
where i.indexrelid = c.oid
and t.relname = 'project_identity'
and t.relnamespace = n.oid
)
)
)
union all
select 'unknown_function', p.proname
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard'
and p.proname not in ('adapter_project_identity', 'reject_identity_change')
union all
select 'unknown_type', t.typname
from pg_catalog.pg_type t
join pg_catalog.pg_namespace n on n.oid = t.typnamespace
where n.nspname = 'adaptador_guard'
and not exists (
select 1
from pg_catalog.pg_class c
where c.oid = t.typrelid
and c.relname = 'project_identity'
and c.relnamespace = n.oid
)
and not exists (
select 1
from pg_catalog.pg_type elem
join pg_catalog.pg_class c on c.oid = elem.typrelid
where elem.oid = t.typelem
and c.relname = 'project_identity'
and c.relnamespace = n.oid
)
union all
select 'external', vn.nspname || '.' || v.relname
from pg_catalog.pg_depend d
join pg_catalog.pg_rewrite rw on rw.oid = d.objid
join pg_catalog.pg_class v on v.oid = rw.ev_class
join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace
join pg_catalog.pg_class t on t.oid = d.refobjid
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
where tn.nspname = 'adaptador_guard'
and vn.nspname <> 'adaptador_guard'
union all
select 'external', n.nspname || '.' || p.proname
from pg_catalog.pg_depend d
join pg_catalog.pg_proc p on p.oid = d.objid
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
join pg_catalog.pg_class t on t.oid = d.refobjid
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
where tn.nspname = 'adaptador_guard'
and n.nspname <> 'adaptador_guard'
) findings
) then
raise exception 'reversao_recusada' using errcode = '55000';
end if;
end
$revert$;
drop function adaptador_guard.adapter_project_identity();
drop table adaptador_guard.project_identity;
drop function adaptador_guard.reject_identity_change();
drop schema adaptador_guard restrict;
+39
View File
@@ -0,0 +1,39 @@
-- Leitura da identidade e dos privilégios. Não grava.
select project_name, project_ref
from adaptador_guard.adapter_project_identity();
select p.proname, p.prosecdef, p.proconfig
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard'
order by p.proname;
select c.relname, c.relrowsecurity, c.relforcerowsecurity
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'adaptador_guard'
and c.relkind = 'r'
order by c.relname;
select 'public' as papel,
has_schema_privilege('public', 'adaptador_guard', 'usage') as uso,
has_table_privilege('public', 'adaptador_guard.project_identity', 'select') as leitura,
has_table_privilege('public', 'adaptador_guard.project_identity', 'update') as alteracao,
has_function_privilege('public', 'adaptador_guard.adapter_project_identity()', 'execute') as executar
union all
select r.rolname,
has_schema_privilege(r.oid, 'adaptador_guard', 'usage'),
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'select'),
has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'update'),
has_function_privilege(r.oid, 'adaptador_guard.adapter_project_identity()', 'execute')
from pg_catalog.pg_roles r
where r.rolname in ('anon', 'authenticated', 'service_role', 'app_user', 'adaptador_web', 'adaptador_worker')
order by papel;
select item
from pg_catalog.pg_db_role_setting s
cross join lateral unnest(s.setconfig) as item
where s.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database())
and s.setrole = 0
order by item;
+18
View File
@@ -0,0 +1,18 @@
import { adapterEnv, collectConfigIssues, identityShimEnabled, jobRunsInline, stagingAuthEnabled } from "../src/config/env";
/**
* Confere a configuração sem tocar em recurso externo. Usado no pipeline antes
* do deploy e dentro do container no primeiro start.
*/
const issues = collectConfigIssues(process.env);
console.log(`ambiente: ${adapterEnv()}`);
console.log(`modo de job: ${jobRunsInline() ? "inline" : "worker"}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "aplicar" : "não aplicar"}`);
console.log(`login próprio de homologação: ${stagingAuthEnabled() ? "ligado" : "desligado"}`);
if (issues.length === 0) {
console.log("configuração aceita");
process.exit(0);
}
console.error("configuração recusada:");
for (const issue of issues) console.error(` - ${issue.variable}: ${issue.message}`);
process.exit(1);
+65 -68
View File
@@ -1,76 +1,73 @@
import path from "node:path"; import 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");
}
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await migrate(db);
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
async function tick() { bootstrapScript("worker");
await recoverStaleJobs(deps).catch((error) => { if (jobRunsInline()) {
console.error("worker", error instanceof Error ? error.name : "error"); console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
});
await cleanupExpired(deps).catch((error) => {
console.error("worker", "cleanup", error instanceof Error ? error.name : "error");
});
const queued = new Set(await listQueuedJobIds(deps));
for (let index = 0; index < 20; index += 1) {
const jobId = await deps.coordinator.dequeue().catch(() => null);
if (!jobId) break;
queued.add(jobId);
}
for (const jobId of queued) {
const rows = await readJobForWorker(deps, jobId);
const row = rows[0];
if (!row) continue;
const progress = asJson<JobProgress | null>(row.progress, null);
if (!progress?.sourceId) continue;
const profile = await db.withService((query) =>
query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>(
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
coalesce(f.enabled, false) as module_enabled,
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
from public.profiles p
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
join public.workspaces w on w.id = $2
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[row.user_id, row.workspace_id],
),
);
const found = profile[0];
if (!found) continue;
const ctx: RequestContext = {
userId: row.user_id,
displayName: found.display_name,
isSuperadmin: found.is_superadmin,
workspaceId: row.workspace_id,
workspaceName: found.workspace_name,
role: found.role,
moduleEnabled: found.module_enabled,
allowNonAdminModelUpload: found.allow_upload,
};
await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => {
console.error("worker", error instanceof Error ? error.name : "error");
});
}
} }
await tick(); runScript("worker", async () => {
setInterval(() => { const db = await openOperationalDb("worker");
void tick();
}, 2000); // O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
const pending = await pendingMigrations(db);
if (pending.length > 0) {
await db.close().catch(() => undefined);
throw new Error(`migrations pendentes em ${describeTarget()}: ${pending.join(", ")}. Rode npm run migrate antes.`);
}
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
const worker = createWorker(deps, {
batch: BATCH,
heartbeatPath: HEARTBEAT,
onError: (scope, error) => {
// Nome e escopo do erro. Nunca conteúdo de célula.
console.error("worker", scope, error instanceof Error ? error.name : "error");
},
});
const timer = setInterval(() => {
void worker.tick();
}, TICK_MS);
let stopping = false;
async function shutdown(signal: string) {
if (stopping) return;
stopping = true;
console.log(`[worker] ${signal} recebido. Encerrando.`);
clearInterval(timer);
const clean = await worker.shutdown(GRACE_MS);
if (!clean) console.warn(`[worker] tick não terminou em ${GRACE_MS}ms. Encerrando de todo modo.`);
await db.close().catch(() => undefined);
console.log("[worker] encerrado.");
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
console.log(
`[worker] pronto. intervalo=${TICK_MS}ms lote=${BATCH} carencia=${GRACE_MS}ms alvo=${describeTarget()} heartbeat=${HEARTBEAT}`,
);
await worker.tick();
});
+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export default async function SettingsPage() { 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" } },
);
} }
+14 -10
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"));
return NextResponse.json({
ok: true,
module: "spreadsheet-adapter",
redis: process.env.REDIS_URL ? "configured" : "not_configured",
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured",
});
} catch { } catch {
return NextResponse.json({ ok: false }, { status: 503 }); return NextResponse.json({ ok: false, environment: adapterEnv() }, { status: 503 });
} }
return NextResponse.json({
ok: true,
module: "spreadsheet-adapter",
environment: adapterEnv(),
detail: "/api/health/ready",
});
} }
+18 -2
View File
@@ -3,6 +3,7 @@ import { presentError } from "@/domain/spreadsheet-adapter/error-catalog";
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors"; import { 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>
);
}
+143 -18
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() === "") { if (!env[variable] || env[variable]?.trim() === "") {
issues.push({ variable, message: "obrigatória em produção" }); issues.push({ variable, message: `obrigatória em ${target}` });
}
}
if (target === "production") {
for (const variable of REQUIRED_IN_PRODUCTION_ONLY) {
if (!env[variable] || env[variable]?.trim() === "") {
issues.push({ variable, message: "obrigatória em produção" });
}
} }
} }
if (env.ADAPTER_DEV_AUTH === "1") { 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;
} }
+23
View File
@@ -0,0 +1,23 @@
import { buildPostgresOptions } from "@/infra/db/connection-policy";
import { assertDeployedEnv, isDeployedEnv, 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);
if (isDeployedEnv(env) && env.DATABASE_URL?.trim()) {
buildPostgresOptions(env.DATABASE_URL, "web", 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." },
}; };
+37 -7
View File
@@ -1,8 +1,11 @@
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 { buildPostgresOptions } from "./connection-policy";
import { createFileDb } from "./pglite"; import { createFileDb } from "./pglite";
import { createPostgresDb } from "./postgres"; import { createPostgresDb } from "./postgres";
import { migrate } from "./migrate"; import { assertDatabaseGuard } from "./target-guard";
import type { Db } from "./types"; import type { Db } from "./types";
let singleton: Promise<Db> | null = null; let singleton: Promise<Db> | null = null;
@@ -10,15 +13,42 @@ 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.
if (databaseUrl) return createPostgresDb(databaseUrl); const databaseUrl = requireDatabaseUrl();
if (process.env.NODE_ENV === "production") { if (databaseUrl) {
throw new Error("DATABASE_URL é obrigatório em produção."); const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "web"));
try {
await assertDatabaseGuard(db, "web", databaseUrl);
return db;
} catch (error) {
await db.close().catch(() => undefined);
throw error;
}
} }
const db = await createFileDb(path.join(process.cwd(), ".data", "pglite")); if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatória fora de development.");
}
// 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"));
}
+214
View File
@@ -0,0 +1,214 @@
import type { EnvMap } from "@/config/env";
import { isDeployedEnv } from "@/config/env";
/** Projeto autorizado a receber migrations. Não é segredo. */
export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml";
/**
* Forma do Session Pooler IPv4 em us-west-2.
*
* O índice `aws-0` ou `aws-1` não se deduz da região. O valor `aws-0` chegou a
* ser usado como constante, mas era uma suposição, não uma leitura do Dashboard
* de `adaptador-planilha-hml`. O Dashboard desse projeto mostra um host que
* começa por `aws-1-us-west-2.pooler.supabase.com`. O host esperado entra em
* `ADAPTER_DB_POOLER_HOST` e precisa caber neste formato. Qualquer outro
* destino, inclusive `*.elopro.app` e o host direto `db.*.supabase.co`, é recusado.
* O hostname sozinho não identifica o projeto.
*/
export const POOLER_HOST_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/;
export const LOCAL_DATABASE_HOSTS = new Set([
"localhost",
"127.0.0.1",
"::1",
"postgres",
"db",
"host.docker.internal",
]);
export type DbPurpose = "web" | "worker" | "admin" | "local";
export type ParsedDatabaseUrl = {
username: string;
hostname: string;
port: number;
database: string;
sslmode: string | null;
hasPassword: boolean;
sessionOption: string | null;
};
export class DatabaseGuardError extends Error {
readonly code: string;
constructor(code: string) {
super(
`Banco recusado (${code}). A identidade do destino não foi comprovada. A operação foi interrompida e exige aprovação manual. Nenhum schema foi alterado.`,
);
this.name = "DatabaseGuardError";
this.code = code;
}
}
/**
* Reference ID do Supabase: 20 caracteres minúsculos, letras ou dígitos.
* O valor real sai do Dashboard. Sozinho, não prova qual banco atendeu a sessão.
*/
export const PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/;
export function scrubDbError(error: unknown): string {
const raw = error instanceof Error ? `${error.name}: ${error.message}` : "erro";
return raw
.replace(/postgres(?:ql)?:\/\/\S+/gi, "postgres://***")
.replace(/\b[\w.+-]+:[^@\s/]+@/g, "***@")
.replace(/password=\S+/gi, "password=***");
}
/** Lê a URL sem devolver senha, usuário completo ou connection string. */
export function parseDatabaseUrl(connectionString: string): ParsedDatabaseUrl {
let url: URL;
try {
url = new URL(connectionString);
} catch {
throw new DatabaseGuardError("url_invalida");
}
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
throw new DatabaseGuardError("url_invalida");
}
const database = decodeURIComponent(url.pathname.replace(/^\//, ""));
const port = url.port ? Number(url.port) : 5432;
if (!url.hostname || !database || !Number.isInteger(port)) {
throw new DatabaseGuardError("url_invalida");
}
return {
username: decodeURIComponent(url.username),
hostname: url.hostname.toLowerCase(),
port,
database,
sslmode: url.searchParams.get("sslmode")?.toLowerCase() ?? null,
hasPassword: url.password.length > 0,
sessionOption: url.searchParams.get("options"),
};
}
export function isLocalDatabaseHost(hostname: string): boolean {
return LOCAL_DATABASE_HOSTS.has(hostname.toLowerCase());
}
export function assertLocalDatabaseUrl(connectionString: string): void {
const parsed = parseDatabaseUrl(connectionString);
if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) {
throw new DatabaseGuardError("host_elopro_preservado");
}
if (parsed.hostname.endsWith(".supabase.com") || parsed.hostname.endsWith(".supabase.co")) {
throw new DatabaseGuardError("development_nao_acessa_supabase");
}
if (!isLocalDatabaseHost(parsed.hostname)) {
throw new DatabaseGuardError("development_somente_local");
}
}
/** Host declarado para a homologação. Precisa ser o pooler de us-west-2, nada além disso. */
export function expectedPoolerHost(env: EnvMap = process.env): string {
const host = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? "";
if (!host) throw new DatabaseGuardError("pooler_host_ausente");
if (!POOLER_HOST_PATTERN.test(host)) throw new DatabaseGuardError("pooler_host_recusado");
return host;
}
/**
* Confere a URL antes de conectar. Não autentica o projeto: isso só o servidor
* faz, pela leitura do catálogo. Rejeita TLS fraco e parâmetro de sessão.
*/
export function assertDeployedUrl(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): ParsedDatabaseUrl {
const parsed = parseDatabaseUrl(connectionString);
if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) {
throw new DatabaseGuardError("host_elopro_preservado");
}
if (parsed.sessionOption?.includes("hml_project")) {
throw new DatabaseGuardError("parametro_de_sessao_recusado");
}
if (parsed.sslmode !== "verify-full") {
throw new DatabaseGuardError("tls_inseguro");
}
if (!parsed.hasPassword) {
throw new DatabaseGuardError("credencial_ausente");
}
const expectedHost = expectedPoolerHost(env);
if (parsed.hostname !== expectedHost || parsed.port !== 5432 || parsed.database !== "postgres") {
throw new DatabaseGuardError("destino_incompativel");
}
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
if (!PROJECT_REF_PATTERN.test(ref)) {
throw new DatabaseGuardError("ref_ausente");
}
const expectedUser = purpose === "admin" ? "postgres" : purpose === "worker" ? "adaptador_worker" : "adaptador_web";
if (parsed.username !== `${expectedUser}.${ref}`) {
throw new DatabaseGuardError("usuario_url_diverge");
}
return parsed;
}
export function resolvePoolMax(purpose: DbPurpose, env: EnvMap = process.env): number {
const deployed = isDeployedEnv(env);
const defaults: Record<DbPurpose, number> = deployed
? { web: 2, worker: 2, admin: 1, local: 1 }
: { web: 8, worker: 8, admin: 2, local: 2 };
const cap = deployed ? 4 : 8;
const raw = env.ADAPTER_DB_POOL_MAX?.trim();
const value = raw ? Number(raw) : defaults[purpose];
if (!Number.isInteger(value) || value < 1 || value > cap) {
throw new DatabaseGuardError("pool_recusado");
}
return value;
}
export type PostgresClientOptions = {
max: number;
prepare: false;
connect_timeout: number;
idle_timeout: number;
max_lifetime: number;
ssl?: { rejectUnauthorized: true };
connection?: { family: 4 };
};
/** Opções do driver. Em ambiente implantado o certificado é verificado sempre. */
export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions {
const deployed = isDeployedEnv(env);
if (deployed) assertDeployedUrl(connectionString, purpose, env);
else if (connectionString) assertLocalDatabaseUrl(connectionString);
const options: PostgresClientOptions = {
max: resolvePoolMax(purpose, env),
prepare: false,
connect_timeout: 10,
idle_timeout: 20,
max_lifetime: 60 * 30,
};
if (deployed) {
options.ssl = { rejectUnauthorized: true };
options.connection = { family: 4 };
}
return options;
}
export function databaseUrlFor(purpose: DbPurpose, env: EnvMap = process.env): string | null {
if (purpose === "local") {
if (isDeployedEnv(env)) throw new DatabaseGuardError("seed_dev_fora_de_development");
return env.DATABASE_URL?.trim() || null;
}
if (!isDeployedEnv(env)) return env.DATABASE_URL?.trim() || null;
if (purpose === "admin") {
const url = env.ADAPTER_MIGRATION_DATABASE_URL?.trim();
if (!url) throw new DatabaseGuardError("migration_url_ausente");
return url;
}
if (purpose === "worker") {
const url = env.ADAPTER_WORKER_DATABASE_URL?.trim();
if (!url) throw new DatabaseGuardError("worker_url_ausente");
return url;
}
const url = env.DATABASE_URL?.trim();
if (!url) throw new DatabaseGuardError("web_url_ausente");
return url;
}
+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";
+40
View File
@@ -0,0 +1,40 @@
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",
"0005_maintenance_roles.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)));
}
+3 -2
View File
@@ -1,8 +1,9 @@
import postgres from "postgres"; import postgres from "postgres";
import type { PostgresClientOptions } from "./connection-policy";
import { assertUuid, type Db, type Query, type Row } from "./types"; import { assertUuid, type Db, type Query, type Row } from "./types";
export function createPostgresDb(connectionString: string): Db { export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
const sql = postgres(connectionString, { max: 8, prepare: false }); const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
const serviceQuery: Query = { const serviceQuery: Query = {
async query<T = Row>(text: string, params: unknown[] = []) { async query<T = Row>(text: string, params: unknown[] = []) {
return sql.unsafe(text, params as never[]) as unknown as T[]; return sql.unsafe(text, params as never[]) as unknown as T[];
+318
View File
@@ -0,0 +1,318 @@
import { isDeployedEnv, type EnvMap } from "@/config/env";
import { AUTHORIZED_HML_PROJECT, PROJECT_REF_PATTERN, DatabaseGuardError } from "./connection-policy";
import type { Db } from "./types";
export type IdentityInstallResult = "criada" | "inalterada";
/**
* Autorização explícita da gravação. Migrate, migrate:check, seed e deploy
* não definem esta variável e não importam este módulo.
*/
export function assertInitAuthorized(env: EnvMap = process.env): void {
if (env.ADAPTER_INIT_PROJECT_IDENTITY !== "1") throw new DatabaseGuardError("init_nao_autorizada");
if (!isDeployedEnv(env)) throw new DatabaseGuardError("init_fora_de_homologacao");
}
type StoredIdentity =
| { kind: "ausente" }
| { kind: "presente"; projectName: string; projectRef: string }
| { kind: "inconsistente" };
/**
* Fecha privilégios operacionais. Web e worker ficam só com USAGE e EXECUTE
* da função de leitura. Os demais papéis ficam sem acesso.
*/
const LOCK_BODY = `
revoke all on schema adaptador_guard from public;
revoke all on table adaptador_guard.project_identity from public;
revoke all on all functions in schema adaptador_guard from public;
alter default privileges in schema adaptador_guard revoke all on tables from public;
alter default privileges in schema adaptador_guard revoke execute on functions from public;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then
revoke all on schema adaptador_guard from adaptador_web;
revoke all on table adaptador_guard.project_identity from adaptador_web;
revoke all on all functions in schema adaptador_guard from adaptador_web;
grant usage on schema adaptador_guard to adaptador_web;
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then
revoke all on schema adaptador_guard from adaptador_worker;
revoke all on table adaptador_guard.project_identity from adaptador_worker;
revoke all on all functions in schema adaptador_guard from adaptador_worker;
grant usage on schema adaptador_guard to adaptador_worker;
grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then
revoke all on schema adaptador_guard from anon;
revoke all on table adaptador_guard.project_identity from anon;
revoke all on all functions in schema adaptador_guard from anon;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then
revoke all on schema adaptador_guard from authenticated;
revoke all on table adaptador_guard.project_identity from authenticated;
revoke all on all functions in schema adaptador_guard from authenticated;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then
revoke all on schema adaptador_guard from service_role;
revoke all on table adaptador_guard.project_identity from service_role;
revoke all on all functions in schema adaptador_guard from service_role;
end if;
if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then
revoke all on schema adaptador_guard from app_user;
revoke all on table adaptador_guard.project_identity from app_user;
revoke all on all functions in schema adaptador_guard from app_user;
end if;
`;
/** SQL da primeira gravação. Um único bloco: se falhar, o schema inteiro é desfeito. */
export function projectIdentityDefinitionSql(projectRef: string): string {
if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente");
return `
do $identity$
begin
create schema adaptador_guard;
revoke all on schema adaptador_guard from public;
create table adaptador_guard.project_identity (
singleton boolean primary key default true check (singleton),
project_name text not null,
project_ref text not null,
constraint project_identity_name check (project_name = '${AUTHORIZED_HML_PROJECT}'),
constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$')
);
revoke all on table adaptador_guard.project_identity from public;
alter table adaptador_guard.project_identity enable row level security;
alter table adaptador_guard.project_identity force row level security;
comment on table adaptador_guard.project_identity is
'Identidade persistente do projeto de homologacao. Nao e prova criptografica.';
create function adaptador_guard.reject_identity_change()
returns trigger
language plpgsql
security invoker
set search_path = pg_catalog
as $body$
begin
if tg_op <> 'INSERT' then
raise exception 'identidade imutavel' using errcode = '42501';
end if;
if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then
raise exception 'identidade protegida' using errcode = '42501';
end if;
return new;
end;
$body$;
create trigger project_identity_guard
before insert or update or delete on adaptador_guard.project_identity
for each row execute function adaptador_guard.reject_identity_change();
create function adaptador_guard.adapter_project_identity()
returns table (project_name text, project_ref text)
language sql
stable
security definer
set search_path = pg_catalog
as $body$
select i.project_name, i.project_ref
from adaptador_guard.project_identity i
$body$;
revoke all on function adaptador_guard.adapter_project_identity() from public;
revoke all on function adaptador_guard.reject_identity_change() from public;
insert into adaptador_guard.project_identity (project_name, project_ref)
values ('${AUTHORIZED_HML_PROJECT}', '${projectRef}');
${LOCK_BODY}
end
$identity$;
`;
}
const GRANT_SQL = `
do $grant$
begin
${LOCK_BODY}
end
$grant$;
`;
const SHAPE_SQL = `
select kind, name
from (
select 'missing'::text as kind, 'project_identity'::text as name
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r'
)
union all
select 'missing', 'adapter_project_identity'
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity'
)
union all
select 'missing', 'reject_identity_change'
where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard')
and not exists (
select 1
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change'
)
union all
select 'unknown_relation', c.relname
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'adaptador_guard'
and not (
(c.relname = 'project_identity' and c.relkind = 'r')
or (
c.relkind = 'i'
and exists (
select 1
from pg_catalog.pg_index i
join pg_catalog.pg_class t on t.oid = i.indrelid
where i.indexrelid = c.oid
and t.relname = 'project_identity'
and t.relnamespace = n.oid
)
)
)
union all
select 'unknown_function', p.proname
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'adaptador_guard'
and p.proname not in ('adapter_project_identity', 'reject_identity_change')
union all
select 'unknown_type', t.typname
from pg_catalog.pg_type t
join pg_catalog.pg_namespace n on n.oid = t.typnamespace
where n.nspname = 'adaptador_guard'
and not exists (
select 1
from pg_catalog.pg_class c
where c.oid = t.typrelid
and c.relname = 'project_identity'
and c.relnamespace = n.oid
)
and not exists (
select 1
from pg_catalog.pg_type elem
join pg_catalog.pg_class c on c.oid = elem.typrelid
where elem.oid = t.typelem
and c.relname = 'project_identity'
and c.relnamespace = n.oid
)
union all
select 'external', vn.nspname || '.' || v.relname
from pg_catalog.pg_depend d
join pg_catalog.pg_rewrite rw on rw.oid = d.objid
join pg_catalog.pg_class v on v.oid = rw.ev_class
join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace
join pg_catalog.pg_class t on t.oid = d.refobjid
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
where tn.nspname = 'adaptador_guard'
and vn.nspname <> 'adaptador_guard'
union all
select 'external', n.nspname || '.' || p.proname
from pg_catalog.pg_depend d
join pg_catalog.pg_proc p on p.oid = d.objid
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
join pg_catalog.pg_class t on t.oid = d.refobjid
join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace
where tn.nspname = 'adaptador_guard'
and n.nspname <> 'adaptador_guard'
) findings
`;
/** Remove só os objetos conhecidos. Recusa se houver objeto ou dependência fora da lista. */
export function projectIdentityRevertSql(): string {
return `
do $revert$
begin
if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then
raise exception 'marcador_ausente' using errcode = 'P0002';
end if;
if exists (${SHAPE_SQL}) then
raise exception 'reversao_recusada' using errcode = '55000';
end if;
end
$revert$;
drop function adaptador_guard.adapter_project_identity();
drop table adaptador_guard.project_identity;
drop function adaptador_guard.reject_identity_change();
drop schema adaptador_guard restrict;
`;
}
async function readStoredIdentity(db: Db): Promise<StoredIdentity> {
let schemaExists = false;
let findings: { kind: string; name: string }[] = [];
try {
const presence = await db.withService((query) =>
query.query<{ exists: boolean }>(
`select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`,
),
);
schemaExists = Boolean(presence[0]?.exists);
if (schemaExists) {
findings = await db.withService((query) => query.query<{ kind: string; name: string }>(SHAPE_SQL));
}
} catch {
throw new DatabaseGuardError("marcador_indisponivel");
}
if (!schemaExists) return { kind: "ausente" };
if (findings.length > 0) return { kind: "inconsistente" };
try {
const rows = await db.withService((query) =>
query.query<{ project_name: string; project_ref: string }>(
`select project_name, project_ref from adaptador_guard.project_identity`,
),
);
if (rows.length !== 1) return { kind: "inconsistente" };
return { kind: "presente", projectName: rows[0]?.project_name ?? "", projectRef: rows[0]?.project_ref ?? "" };
} catch {
throw new DatabaseGuardError("marcador_indisponivel");
}
}
/**
* Grava a identidade uma vez, ou só reaplica os privilégios se a linha já concorda.
* Recusa schema incompleto, objeto desconhecido e Reference ID diferente.
* Não é chamada por migrate, seed nem deploy.
*/
export async function installProjectIdentity(db: Db, projectRef: string): Promise<IdentityInstallResult> {
if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente");
const current = await readStoredIdentity(db);
if (current.kind === "inconsistente") throw new DatabaseGuardError("marcador_indisponivel");
if (current.kind === "presente") {
if (current.projectName !== AUTHORIZED_HML_PROJECT || current.projectRef !== projectRef) {
throw new DatabaseGuardError("marcador_diverge");
}
await db.exec(GRANT_SQL);
return "inalterada";
}
await db.exec(projectIdentityDefinitionSql(projectRef));
return "criada";
}
/** Desfaz a identidade sem CASCADE. Não toca em outro schema. */
export async function revertProjectIdentity(db: Db): Promise<void> {
try {
await db.exec(projectIdentityRevertSql());
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("marcador_ausente")) throw new DatabaseGuardError("marcador_ausente");
if (message.includes("reversao_recusada")) throw new DatabaseGuardError("reversao_recusada");
throw error;
}
}
+217
View File
@@ -0,0 +1,217 @@
import type { EnvMap } from "@/config/env";
import { isDeployedEnv } from "@/config/env";
import {
AUTHORIZED_HML_PROJECT,
PROJECT_REF_PATTERN,
type DbPurpose,
DatabaseGuardError,
assertLocalDatabaseUrl,
} from "./connection-policy";
import type { Db } from "./types";
export type RoleFacts = {
sessionUser: string;
rolsuper: boolean | null;
rolbypassrls: boolean | null;
};
export type ProjectIdentity = {
projectName: string | null;
projectRef: string | null;
};
export type AdminAttestation = RoleFacts & {
databaseName: string;
hasAuth: boolean;
hasStorage: boolean;
projectName: string | null;
projectRef: string | null;
};
/**
* Prova administrativa do Supabase gerenciado. O hostname, o nome `postgres`,
* um GUC de sessão, a variável de ambiente e o atributo BYPASSRLS, cada um
* sozinho, não passam.
*
* O PostgreSQL 17.11 de `adaptador-planilha-hml` recusou
* `ALTER DATABASE ... SET "app.hml_project"` com 42501. Parâmetro personalizado
* não é gravável por esse papel, e `app.settings.jwt_exp` permanece intocado.
* A identidade persistente fica na tabela `adaptador_guard.project_identity`,
* lida pela função `adaptador_guard.adapter_project_identity()`. Um SET de
* sessão ou `options=-c` na URL não escreve nessa tabela.
*
* A função de leitura é SECURITY DEFINER, com `search_path = pg_catalog` e nome
* qualificado. Só `adaptador_web` e `adaptador_worker` recebem EXECUTE. A linha
* não é prova criptográfica do projeto. Quem tem a credencial administrativa
* pode apagar o schema e criar outra linha igual em outro banco. A cópia só
* passa se a URL também trouxer o pooler declarado, o usuário `postgres.<ref>`
* e o mesmo ref. Sem a linha, ou com divergência, a operação para.
*
* O Session Pooler pode devolver `session_user` só como `postgres`. O ref
* autenticado fica no usuário da URL e o vínculo com este projeto fica na
* tabela. Os dois têm de ser o mesmo ref. O nome do banco `postgres` e o host
* da região se repetem em outros projetos.
*/
export function evaluateProjectIdentity(identity: ProjectIdentity, env: EnvMap = process.env): void {
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
if (!PROJECT_REF_PATTERN.test(ref)) throw new DatabaseGuardError("ref_ausente");
if (!identity.projectName || !identity.projectRef) throw new DatabaseGuardError("marcador_ausente");
if (identity.projectName !== AUTHORIZED_HML_PROJECT || identity.projectRef !== ref) {
throw new DatabaseGuardError("marcador_diverge");
}
}
export function evaluateAdminConnection(
attestation: Omit<AdminAttestation, "projectName" | "projectRef">,
env: EnvMap = process.env,
): void {
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
if (!PROJECT_REF_PATTERN.test(ref)) throw new DatabaseGuardError("ref_ausente");
if (attestation.databaseName !== "postgres") throw new DatabaseGuardError("banco_nome");
if (!attestation.hasAuth || !attestation.hasStorage) throw new DatabaseGuardError("schema_supabase_ausente");
const session = attestation.sessionUser;
if (session !== "postgres" && session !== `postgres.${ref}`) {
throw new DatabaseGuardError("usuario_admin_nao_comprovado");
}
if (attestation.rolsuper === null || attestation.rolbypassrls === null) {
throw new DatabaseGuardError("papel_nao_comprovado");
}
}
export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void {
evaluateAdminConnection(attestation, env);
evaluateProjectIdentity({ projectName: attestation.projectName, projectRef: attestation.projectRef }, env);
}
export function evaluateRuntimeRole(facts: RoleFacts, purpose: "web" | "worker", env: EnvMap = process.env): void {
if (facts.rolsuper === null || facts.rolbypassrls === null) {
throw new DatabaseGuardError("papel_nao_comprovado");
}
if (facts.rolsuper || facts.rolbypassrls) {
throw new DatabaseGuardError(purpose === "web" ? "web_com_credencial_admin" : "worker_privilegiado");
}
const expected = purpose === "web" ? "adaptador_web" : "adaptador_worker";
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
const allowed = new Set([expected]);
if (PROJECT_REF_PATTERN.test(ref)) allowed.add(`${expected}.${ref}`);
if (!allowed.has(facts.sessionUser)) throw new DatabaseGuardError("papel_recusado");
}
type FactRow = {
session_user: string;
database_name: string;
rolsuper: boolean | null;
rolbypassrls: boolean | null;
has_auth: boolean;
has_storage: boolean;
};
function postgresErrorCode(error: unknown): string | null {
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") {
return error.code;
}
return null;
}
async function readRole(db: Db): Promise<FactRow> {
try {
const rows = await db.withService((query) =>
query.query<FactRow>(
`select session_user::text as session_user,
current_database()::text as database_name,
r.rolsuper,
r.rolbypassrls,
exists(select 1 from information_schema.schemata where schema_name = 'auth') as has_auth,
exists(select 1 from information_schema.schemata where schema_name = 'storage') as has_storage
from pg_catalog.pg_roles r
where r.rolname = session_user::text`,
),
);
const facts = rows[0];
if (!facts) throw new DatabaseGuardError("papel_nao_comprovado");
return facts;
} catch (error) {
if (error instanceof DatabaseGuardError) throw error;
throw new DatabaseGuardError("identidade_indisponivel");
}
}
/** Lê a linha gravada no banco. GUC de sessão e variável de ambiente não entram aqui. */
export async function readProjectIdentity(db: Db): Promise<ProjectIdentity> {
try {
const rows = await db.withService((query) =>
query.query<{ project_name: string; project_ref: string }>(
`select project_name, project_ref from adaptador_guard.adapter_project_identity()`,
),
);
if (rows.length !== 1) throw new DatabaseGuardError(rows.length === 0 ? "marcador_ausente" : "marcador_diverge");
return { projectName: rows[0]?.project_name ?? null, projectRef: rows[0]?.project_ref ?? null };
} catch (error) {
if (error instanceof DatabaseGuardError) throw error;
const code = postgresErrorCode(error);
if (code === "42P01" || code === "3F000") throw new DatabaseGuardError("marcador_ausente");
throw new DatabaseGuardError("marcador_indisponivel");
}
}
/**
* Confere o destino administrativo antes da gravação da identidade.
* Não cria a tabela e não aceita a ausência dela como sucesso de migration.
*/
export async function assertAdminTarget(db: Db, env: EnvMap = process.env): Promise<void> {
const facts = await readRole(db);
evaluateAdminConnection(
{
sessionUser: facts.session_user,
databaseName: facts.database_name,
rolsuper: facts.rolsuper,
rolbypassrls: facts.rolbypassrls,
hasAuth: facts.has_auth,
hasStorage: facts.has_storage,
},
env,
);
}
/**
* Barreira de destino. Em development só aceita host local. Em staging e
* produção a web e o worker recusam SUPERUSER e BYPASSRLS. Migrate, seed,
* flag, web e worker só seguem quando a tabela de identidade concorda com o
* ref da URL. Nenhum desses comandos grava essa tabela.
*/
export async function assertDatabaseGuard(
db: Db,
purpose: DbPurpose,
connectionString: string | null,
env: EnvMap = process.env,
): Promise<void> {
if (!isDeployedEnv(env)) {
if (connectionString) assertLocalDatabaseUrl(connectionString);
return;
}
if (!connectionString) throw new DatabaseGuardError("url_ausente");
if (purpose === "local") throw new DatabaseGuardError("seed_dev_fora_de_development");
const facts = await readRole(db);
if (purpose === "admin") {
evaluateAdminConnection(
{
sessionUser: facts.session_user,
databaseName: facts.database_name,
rolsuper: facts.rolsuper,
rolbypassrls: facts.rolbypassrls,
hasAuth: facts.has_auth,
hasStorage: facts.has_storage,
},
env,
);
} else {
evaluateRuntimeRole(
{ sessionUser: facts.session_user, rolsuper: facts.rolsuper, rolbypassrls: facts.rolbypassrls },
purpose,
env,
);
}
const identity = await readProjectIdentity(db);
evaluateProjectIdentity(identity, env);
}
+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"],
}; };
+36 -32
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;
}
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; return { identity: null, hadHint };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; }
if (!userId && supabaseUrl && supabaseKey) {
const supabase = createServerClient(supabaseUrl, supabaseKey, { export async function resolveAuth(): Promise<AuthState> {
cookies: { const { identity, hadHint } = await resolveIdentity();
getAll: () => jar.getAll(), if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
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);
}
+11 -17
View File
@@ -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 };
} }
@@ -294,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
export async function cleanupExpired(deps: AppDeps): Promise<number> { export async function cleanupExpired(deps: AppDeps): Promise<number> {
const rows = await deps.db.withService((query) => const rows = await deps.db.withService((query) =>
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>( query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
`select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key `select id, workspace_id, temp_storage_key, output_csv_key, output_xlsx_key
from public.spreadsheet_job_sources s from public.adapter_list_expired_sources($1::timestamptz)`,
join public.spreadsheet_jobs j on j.id = s.job_id
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
[(deps.now ?? (() => new Date()))().toISOString()], [(deps.now ?? (() => new Date()))().toISOString()],
), ),
); );
@@ -310,9 +311,7 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" }); deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
}); });
} }
await deps.db.withService((query) => await deps.db.withService((query) => query.query(`select public.adapter_mark_source_expired($1::uuid)`, [row.id]));
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
);
removed += 1; removed += 1;
} }
return removed; return removed;
@@ -321,19 +320,14 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
export async function recoverStaleJobs(deps: AppDeps): Promise<number> { export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
const rows = await deps.db.withService((query) => const rows = await deps.db.withService((query) =>
query.query<{ id: string; user_id: string; workspace_id: string }>( query.query<{ id: string; user_id: string; workspace_id: string }>(
`select id, user_id, workspace_id from public.spreadsheet_jobs where status = 'processing'`, `select id, user_id, workspace_id from public.adapter_list_processing_jobs()`,
), ),
); );
let recovered = 0; let recovered = 0;
for (const row of rows) { for (const row of rows) {
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false); const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
if (owns) continue; if (owns) continue;
await deps.db.withService((query) => await deps.db.withService((query) => query.query(`select public.adapter_mark_job_interrupted($1::uuid)`, [row.id]));
query.query(
`update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`,
[row.id],
),
);
recovered += 1; recovered += 1;
} }
return recovered; return recovered;
@@ -342,7 +336,7 @@ export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
export async function readJobForWorker(deps: AppDeps, jobId: string) { export async function readJobForWorker(deps: AppDeps, jobId: string) {
return deps.db.withService((query) => return deps.db.withService((query) =>
query.query<{ user_id: string; workspace_id: string; progress: unknown }>( query.query<{ user_id: string; workspace_id: string; progress: unknown }>(
`select user_id, workspace_id, progress from public.spreadsheet_jobs where id = $1 and status = 'queued'`, `select user_id, workspace_id, progress from public.adapter_read_queued_job($1::uuid)`,
[jobId], [jobId],
), ),
); );
@@ -350,7 +344,7 @@ export async function readJobForWorker(deps: AppDeps, jobId: string) {
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> { export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
const rows = await deps.db.withService((query) => const rows = await deps.db.withService((query) =>
query.query<{ id: string }>(`select id from public.spreadsheet_jobs where status = 'queued' order by created_at asc limit 5`), query.query<{ id: string }>(`select id from public.adapter_list_queued_job_ids()`),
); );
return rows.map((row) => row.id); return rows.map((row) => row.id);
} }
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more