Compare commits
19
Commits
main
...
b410fa4559
No files matched your search
@@ -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
@@ -1,44 +1,77 @@
|
||||
# Identidade Elo Pro (Supabase Auth). Sem estes valores o módulo não autentica em produção.
|
||||
# Desenvolvimento local. Para homologação use .env.staging.example.
|
||||
# Confira a qualquer momento com: npm run verify:config
|
||||
|
||||
# --- Ambiente lógico -------------------------------------------------------
|
||||
# development | staging | production. Independente de NODE_ENV.
|
||||
ADAPTER_ENV=development
|
||||
# Prefixo das chaves externas (Redis). Separa este aplicativo e este ambiente.
|
||||
ADAPTER_NAMESPACE=adaptador-planilhas
|
||||
|
||||
# --- Sessão local sem senha (SOMENTE development) --------------------------
|
||||
# Em staging e produção o provedor de desenvolvimento não existe no caminho de
|
||||
# código, e ADAPTER_DEV_AUTH=1 faz o start ser recusado.
|
||||
ADAPTER_DEV_AUTH=1
|
||||
ADAPTER_DEV_AUTH_SECRET=troque-este-segredo-local
|
||||
|
||||
# --- Identidade Supabase (opcional em development) -------------------------
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
# Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_.
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
# Bucket privado. O valor literal "public" é recusado.
|
||||
SUPABASE_STORAGE_BUCKET=
|
||||
# Login próprio de homologação com Supabase Auth real. Só vale em staging.
|
||||
ADAPTER_STAGING_AUTH=0
|
||||
|
||||
# PostgreSQL do Elo Pro. Obrigatório em produção.
|
||||
# --- Banco ----------------------------------------------------------------
|
||||
# Ausente em development usa PGlite em .data/pglite.
|
||||
# Em staging e produção é obrigatória: não há fallback local.
|
||||
DATABASE_URL=
|
||||
# Decisão explícita, obrigatória fora de development.
|
||||
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
|
||||
# 0 = o schema do Elo Pro já existe; não recriar
|
||||
ADAPTER_APPLY_IDENTITY_SHIM=1
|
||||
|
||||
# Redis. Obrigatório em produção para lock. Sem REDIS_URL o processamento recusa executar.
|
||||
# --- Redis ----------------------------------------------------------------
|
||||
# Ausente em development usa coordenação em memória.
|
||||
# Em staging e produção é obrigatória: sem ela o processamento é recusado.
|
||||
REDIS_URL=
|
||||
|
||||
# Sentry. Sem DSN a instrumentação fica inerte.
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=development
|
||||
SENTRY_RELEASE=
|
||||
# --- Processamento --------------------------------------------------------
|
||||
# inline processa no mesmo processo (somente development).
|
||||
# worker enfileira no Redis e no Postgres. Obrigatório fora de development.
|
||||
ADAPTER_JOB_MODE=inline
|
||||
# TTL de fontes e saídas, em segundos (1 a 604800). Obrigatório fora de
|
||||
# development; ausente em development usa 3600.
|
||||
SPREADSHEET_TEMP_TTL_SECONDS=3600
|
||||
# Teto de um processamento, em milissegundos. Ausente usa 120000.
|
||||
SPREADSHEET_JOB_TIMEOUT_MS=
|
||||
ADAPTER_WORKER_TICK_MS=2000
|
||||
ADAPTER_WORKER_BATCH=20
|
||||
|
||||
# Login central do ecossistema. O Adaptador não tem formulário de senha.
|
||||
# --- Login central do ecossistema -----------------------------------------
|
||||
# O Adaptador não tem formulário de senha própria.
|
||||
ELO_PRO_LOGIN_URL=
|
||||
# URL pública deste aplicativo. Usada para montar returnTo no mesmo host.
|
||||
ELO_PRO_APP_URL=
|
||||
# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido.
|
||||
ELO_PRO_APP_URL=http://localhost:3000
|
||||
# Hosts adicionais aceitos no returnTo, separados por vírgula.
|
||||
AUTH_ALLOWED_RETURN_HOSTS=
|
||||
# Cookie de workspace ativo, definido pela plataforma: elo_pro_workspace_id.
|
||||
# É preferência, não autorização: o servidor revalida a associação sempre.
|
||||
|
||||
# Cookie de workspace ativo definido pela plataforma Elo Pro.
|
||||
# Nome lido pelo servidor: elo_pro_workspace_id
|
||||
# --- Observabilidade ------------------------------------------------------
|
||||
# Sem DSN a instrumentação fica inerte.
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Ausente cai em ADAPTER_ENV.
|
||||
SENTRY_ENVIRONMENT=
|
||||
SENTRY_RELEASE=
|
||||
# Upload de sourcemap só acontece com os três presentes no build.
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# TTL de fontes e saídas, em segundos. Obrigatório em produção. Inteiro de 1 a 604800.
|
||||
# Em dev/teste, a ausência usa 3600. O número de produto continua configurável.
|
||||
SPREADSHEET_TEMP_TTL_SECONDS=
|
||||
|
||||
# Tempo máximo de um processamento, em milissegundos. Ausente usa 120000.
|
||||
SPREADSHEET_JOB_TIMEOUT_MS=
|
||||
|
||||
# inline = processa no mesmo processo (somente dev/teste). worker = enfileira no Redis e no Postgres.
|
||||
# Em produção, inline é recusado.
|
||||
ADAPTER_JOB_MODE=worker
|
||||
|
||||
# Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro.
|
||||
# Produção recusa ADAPTER_DEV_AUTH=1.
|
||||
ADAPTER_DEV_AUTH=0
|
||||
ADAPTER_DEV_AUTH_SECRET=
|
||||
# --- Testes com serviços reais (npm run test:services) --------------------
|
||||
# Banco descartável: a suíte recria o schema public.
|
||||
ADAPTER_TEST_DATABASE_URL=
|
||||
ADAPTER_TEST_REDIS_URL=
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+100
@@ -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"
|
||||
Executable
+185
@@ -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
@@ -4,20 +4,317 @@ on:
|
||||
push:
|
||||
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:
|
||||
verify:
|
||||
name: lint, typecheck, testes e build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
ADAPTER_ENV: development
|
||||
ADAPTER_JOB_MODE: inline
|
||||
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
services:
|
||||
name: Postgres e Redis reais
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -5,6 +5,9 @@ dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
.env.staging
|
||||
.env.production
|
||||
*.tsbuildinfo
|
||||
_prompts_tmp
|
||||
_escopo_extraido.txt
|
||||
config/staging-seed.json
|
||||
+95
@@ -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"]
|
||||
@@ -1,17 +1,26 @@
|
||||
# Adaptador de Planilhas
|
||||
|
||||
Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria.
|
||||
Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria: a identidade é a do ecossistema.
|
||||
|
||||
| Ambiente | Host | `ADAPTER_ENV` | Autenticação |
|
||||
| --- | --- | --- | --- |
|
||||
| Desenvolvimento | `localhost:3000` | `development` | sessão local assinada, sem senha |
|
||||
| Homologação | `hml-ada.elopro.app` | `staging` | Supabase Auth do projeto de homologação |
|
||||
| Produção | `ada.elopro.app` | `production` | SSO do Elo Pro |
|
||||
|
||||
`ADAPTER_ENV` é independente de `NODE_ENV`: homologação roda com `NODE_ENV=production` (build otimizado) e política própria. Ver [ADR 0007](docs/decisions/0007-ambiente-logico-e-auth-de-homologacao.md).
|
||||
|
||||
## Desenvolvimento local
|
||||
|
||||
```bash
|
||||
npm install
|
||||
copy .env.example .env.local
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
No `.env.local`, para o piloto sem o host do Elo Pro:
|
||||
No `.env.local`:
|
||||
|
||||
```
|
||||
ADAPTER_ENV=development
|
||||
ADAPTER_DEV_AUTH=1
|
||||
ADAPTER_DEV_AUTH_SECRET=um-segredo-local
|
||||
ADAPTER_JOB_MODE=inline
|
||||
@@ -21,15 +30,60 @@ ADAPTER_JOB_MODE=inline
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Abra `/login` e escolha uma identidade de desenvolvimento. Isso não pede senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`.
|
||||
Abra `/login` e escolha uma identidade de desenvolvimento. Não há senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`.
|
||||
|
||||
### Com Docker, igual ao artefato de homologação
|
||||
|
||||
Mesma imagem, dois processos, Postgres e Redis de verdade:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec web node node_modules/tsx/dist/cli.mjs scripts/migrate.ts
|
||||
```
|
||||
|
||||
Portas só em `127.0.0.1`: web `3000`, Postgres `55432`, Redis `56379`.
|
||||
|
||||
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
|
||||
|
||||
Obrigatórios: `DATABASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_STORAGE_BUCKET`, `REDIS_URL`, `SPREADSHEET_TEMP_TTL_SECONDS`, `ELO_PRO_LOGIN_URL`.
|
||||
Além das variáveis de homologação, produção exige `SENTRY_DSN` e `ELO_PRO_LOGIN_URL`, e recusa `ADAPTER_STAGING_AUTH=1`. A feature flag `spreadsheet_adapter` nasce desligada e é ligada por workspace com `npm run flag`, usando a conexão de serviço, nunca pelo browser.
|
||||
|
||||
A feature flag `spreadsheet_adapter` nasce desligada. Habilite por workspace com `npm run flag -- <workspace_id> on` usando a conexão de serviço, nunca pelo browser.
|
||||
O processamento usa `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. `inline` é recusado fora de development.
|
||||
|
||||
O processamento de produção deve usar `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. O modo `inline` existe para desenvolvimento.
|
||||
Produção não é liberada sem smoke real e ensaio de rollback registrados no checklist.
|
||||
|
||||
## Saúde
|
||||
|
||||
| Rota | O que responde |
|
||||
| --- | --- |
|
||||
| `GET /api/health/live` | processo vivo, sem tocar dependência |
|
||||
| `GET /api/health/ready` | banco, migrations pendentes, Redis (PING), bucket, configuração e modo de job |
|
||||
|
||||
Configuração inválida encerra o processo com código diferente de zero, em vez de deixar o servidor no ar respondendo erro.
|
||||
|
||||
## Verificação
|
||||
|
||||
@@ -39,3 +93,13 @@ npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Suíte que exige Postgres e Redis reais (roda no CI, e localmente com os dois serviços em pé):
|
||||
|
||||
```bash
|
||||
ADAPTER_TEST_DATABASE_URL=postgres://... ADAPTER_TEST_REDIS_URL=redis://... npm run test:services
|
||||
```
|
||||
|
||||
Sem essas variáveis os casos são **ignorados**, nunca aprovados.
|
||||
|
||||
O CI tem três jobs: `verify` (lint, typecheck, testes, build), `services` (Postgres e Redis reais: as quatro migrations, shim explícito, `FORCE RLS`, isolamento) e `image` (build da imagem Linux, conteúdo, fail-fast dos dois processos, compose completo, jornada assíncrona entre processos e desligamento gracioso do worker).
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+125
@@ -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
|
||||
@@ -0,0 +1,23 @@
|
||||
// Healthcheck do contêiner do worker. O worker grava o heartbeat ao fim de cada
|
||||
// tick; se o arquivo envelhecer além da janela, o processo está vivo mas travado,
|
||||
// que é o caso que um healthcheck de PID não detecta.
|
||||
import { stat } from "node:fs/promises";
|
||||
|
||||
const file = process.env.ADAPTER_WORKER_HEARTBEAT ?? "/tmp/adaptador-worker-heartbeat";
|
||||
const tick = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
|
||||
// Tolera dez ticks ou 60s, o que for maior: um job grande ocupa o tick inteiro.
|
||||
const maxAgeMs = Math.max(tick * 10, 60_000);
|
||||
|
||||
try {
|
||||
const info = await stat(file);
|
||||
const age = Date.now() - info.mtimeMs;
|
||||
if (age > maxAgeMs) {
|
||||
console.error(`heartbeat com ${Math.round(age / 1000)}s, limite ${Math.round(maxAgeMs / 1000)}s`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`heartbeat com ${Math.round(age / 1000)}s`);
|
||||
process.exit(0);
|
||||
} catch {
|
||||
console.error(`heartbeat ausente em ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# 0007 — Ambiente lógico e autenticação de homologação
|
||||
|
||||
## Contexto
|
||||
|
||||
`next start` força `NODE_ENV=production`. Até aqui o módulo só conhecia dois estados: `NODE_ENV=production` ou não. Consequência prática: em homologação a autenticação local sem senha ficava desligada (correto) e o módulo exigia o login central do Elo Pro (que ainda não existe), sem terceira opção. Homologação ficava sem caminho de entrada.
|
||||
|
||||
Além disso, nada no repositório gravava o cookie `elo_pro_workspace_id`, lido como origem do workspace ativo. Mesmo com sessão válida, a resposta seria sempre "sem workspace".
|
||||
|
||||
## Decisão
|
||||
|
||||
1. **`ADAPTER_ENV` com três valores**: `development`, `staging`, `production`. É independente de `NODE_ENV`, que continua `production` em homologação para manter o build otimizado. Toda política passa a derivar de `ADAPTER_ENV`.
|
||||
|
||||
2. **Homologação autentica com Supabase Auth real**, em um projeto Supabase exclusivo de homologação, via `signInWithPassword` em `/api/auth/staging/session`. Há senha, emitida e validada pelo Supabase. O Adaptador continua sem tabela de usuário, senha, cadastro ou sessão própria.
|
||||
|
||||
3. **`ADAPTER_DEV_AUTH` só existe em `development`.** Não é configuração: em `staging` e `production` o provedor de desenvolvimento não entra na lista de provedores, e `ADAPTER_DEV_AUTH=1` faz o start ser recusado.
|
||||
|
||||
4. **Contrato de identidade explícito** em `src/server/auth/identity-provider.ts`. Trocar o Supabase de homologação pelo SSO do Elo Pro é adicionar uma implementação; nenhum outro arquivo conhece o mecanismo.
|
||||
|
||||
5. **Workspace é resolvido no servidor, por associação real.** O cookie é preferência, não prova: `resolveWorkspace` só o aceita quando corresponde a uma linha de `workspace_members` lida sob RLS. Sem associação, não há workspace. Com várias, a pessoa escolhe em `/selecionar-workspace`, e `POST /api/auth/workspace` grava o cookie apenas depois de confirmar a associação.
|
||||
|
||||
## Alternativas descartadas
|
||||
|
||||
- **Habilitar o dev auth em homologação.** Rejeitada: homologação é pública, e autenticação sem senha em host público é uma porta aberta.
|
||||
- **Aceitar o cookie de workspace como autorização.** Rejeitada: transformaria um valor editável pelo cliente em credencial. O custo de reler a associação por requisição é uma consulta indexada.
|
||||
- **Usar o projeto Supabase de produção com usuários de teste.** Rejeitada: compartilharia a service role entre ambientes.
|
||||
- **Inferir o ambiente de `SENTRY_ENVIRONMENT`.** Rejeitada: observabilidade e política de segurança são decisões diferentes; a mesma variável governando as duas foi o que fez o namespace do Redis colidir entre ambientes.
|
||||
|
||||
## Consequências
|
||||
|
||||
- `ADAPTER_ENV` passa a ser obrigatória nos ambientes implantados, e `ADAPTER_APPLY_IDENTITY_SHIM` passa a ser uma decisão explícita.
|
||||
- Homologação tem seu próprio projeto Supabase, bucket, banco, Redis e ambiente Sentry.
|
||||
- O `0002-sso-topology.md` continua valendo para produção. Esta decisão acrescenta o degrau de homologação, sem substituí-lo.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Deploy e operação
|
||||
|
||||
> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de
|
||||
> homologação existir. Não siga este arquivo. O procedimento vigente está em
|
||||
> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e
|
||||
> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões.
|
||||
|
||||
|
||||
## Ordem
|
||||
|
||||
1. Conferir envs sem colar segredo no repositório.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Runbook de implantação do piloto
|
||||
|
||||
> **Histórico.** Este runbook foi escrito para o piloto, antes do ambiente de
|
||||
> homologação existir. Não siga este arquivo. O procedimento vigente está em
|
||||
> [`20-homologacao-runbook.md`](20-homologacao-runbook.md), que consolida este e
|
||||
> o `16-pilot-deployment-runbook.md`. Mantido para registro das decisões.
|
||||
|
||||
|
||||
Execute nesta ordem. Não apague auditoria, banco ou arquivos como parte do rollback.
|
||||
|
||||
1. Preparar o projeto Supabase da Elo Pro e anotar URL, anon key e service role.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# Runbook de homologação — hml-ada.elopro.app
|
||||
|
||||
Runbook único do Adaptador de Planilhas em homologação. Substitui `07-deployment-runbook.md` e `16-pilot-deployment-runbook.md`, que ficam no repositório como histórico e não devem ser seguidos.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Homologação | `hml-ada.elopro.app` |
|
||||
| Produção futura | `ada.elopro.app` |
|
||||
| Servidor | compartilhado com os outros aplicativos do ecossistema |
|
||||
| Projeto do compose | `adaptador-planilhas-hml` |
|
||||
| Rede Docker | `adaptador-planilhas-hml` |
|
||||
| Volume | `adaptador-planilhas-hml-redis` |
|
||||
| Portas publicadas | só em `127.0.0.1`: web `3110`, Redis `56380` |
|
||||
| Namespace de chaves | `adaptador-planilhas:staging` |
|
||||
|
||||
## Princípios que o procedimento respeita
|
||||
|
||||
1. Nenhum serviço de outro aplicativo é criado, alterado ou reiniciado. O compose tem nome de projeto, rede e volume próprios, e o deploy usa `up -d --no-deps web worker`.
|
||||
2. DNS, serviços e banco de produção não são tocados. Homologação tem subdomínio, banco, bucket, Redis, projeto Supabase e projeto/ambiente Sentry próprios.
|
||||
3. Não há autenticação de desenvolvimento em homologação. `ADAPTER_DEV_AUTH=1` faz o processo recusar o start fora de `development`.
|
||||
4. Nenhuma credencial administrativa é compartilhada entre aplicações ou entre ambientes.
|
||||
5. RLS e as validações multi-tenant não são afrouxadas em nenhum passo.
|
||||
6. Migrations destrutivas não rodam automaticamente. `npm run migrate` é um job separado, com aprovação manual, precedido de `npm run migrate:check`.
|
||||
7. Produção não é liberada sem smoke real e ensaio de rollback registrados em `21-checklist-validacao.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. DNS
|
||||
|
||||
Registro novo, sem mexer em nada existente:
|
||||
|
||||
```
|
||||
hml-ada.elopro.app. A <IP do servidor compartilhado>
|
||||
```
|
||||
|
||||
Se o ecossistema usa CNAME para um host único, siga o mesmo padrão dos outros aplicativos. Não altere o registro de `ada.elopro.app` nem o apex `elopro.app`.
|
||||
|
||||
Certificado TLS: inclua apenas `hml-ada.elopro.app` na emissão. Com Certbot e Nginx:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d hml-ada.elopro.app
|
||||
```
|
||||
|
||||
Isso cria um bloco novo. Não use `--expand` em um certificado que atende aplicativos existentes: uma renovação com falha derrubaria o TLS deles.
|
||||
|
||||
## 2. Nginx
|
||||
|
||||
Arquivo novo em `/etc/nginx/sites-available/hml-ada.elopro.app`, sem editar nenhum `server` existente:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name hml-ada.elopro.app;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hml-ada.elopro.app/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hml-ada.elopro.app/privkey.pem;
|
||||
|
||||
# Upload de modelo e de fonte: o limite do produto é 10 MB por arquivo.
|
||||
# A margem cobre o envelope multipart.
|
||||
client_max_body_size 12m;
|
||||
|
||||
# Processamento assíncrono responde rápido, mas a geração de PDF e XLSX
|
||||
# do download é síncrona. O teto do job é SPREADSHEET_JOB_TIMEOUT_MS.
|
||||
proxy_read_timeout 180s;
|
||||
proxy_send_timeout 180s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3110;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Sondas internas: o monitoramento usa estas duas rotas.
|
||||
location = /api/health/live { proxy_pass http://127.0.0.1:3110; access_log off; }
|
||||
location = /api/health/ready { proxy_pass http://127.0.0.1:3110; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name hml-ada.elopro.app;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
`X-Forwarded-For` é obrigatório: o rate limiting da tela de login identifica o chamador por ele.
|
||||
|
||||
Os cabeçalhos de segurança (CSP, HSTS, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) vêm da aplicação, em `next.config.ts`. Não duplique no Nginx: `add_header` no servidor substituiria o conjunto da aplicação.
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/hml-ada.elopro.app /etc/nginx/sites-enabled/
|
||||
sudo nginx -t # precisa passar antes de recarregar
|
||||
sudo systemctl reload nginx # reload, nunca restart: não derruba os outros sites
|
||||
```
|
||||
|
||||
## 3. Identidade (Supabase Auth de homologação)
|
||||
|
||||
O Adaptador não tem tabela de usuário, senha, cadastro ou sessão própria. Em homologação a identidade é um **projeto Supabase separado**, só de homologação.
|
||||
|
||||
1. Crie o projeto Supabase de homologação. Anote URL, anon key e service role.
|
||||
2. A service role de homologação não é a de produção e não é compartilhada com nenhum outro aplicativo.
|
||||
3. Crie as contas de teste no painel do Supabase (Authentication → Users). Nenhuma senha passa pelo repositório, pelo seed ou por este runbook.
|
||||
4. Anote o UUID de cada conta criada: é o que liga a identidade ao workspace.
|
||||
5. Em `.env.staging`, deixe `ADAPTER_STAGING_AUTH=1`. Isso habilita `/api/auth/staging/session`, que faz `signInWithPassword` contra esse projeto. A sessão é emitida e validada pelo Supabase.
|
||||
6. Quando o SSO do Elo Pro existir, troque para `ADAPTER_STAGING_AUTH=0` e `ELO_PRO_LOGIN_URL=<login central>`. Nada mais muda: o contrato está em `src/server/auth/identity-provider.ts`.
|
||||
|
||||
### Workspace ativo
|
||||
|
||||
O workspace **não** vem de cookie como prova. O cookie `elo_pro_workspace_id` é preferência; toda requisição relê as associações reais em `workspace_members` sob RLS (`src/server/auth/workspace.ts`):
|
||||
|
||||
- nenhuma associação → a pessoa vê "nenhum workspace";
|
||||
- uma associação → escolha automática;
|
||||
- várias → tela `/selecionar-workspace`, e `POST /api/auth/workspace` só grava o cookie depois de confirmar a associação.
|
||||
|
||||
### Seed de homologação
|
||||
|
||||
```bash
|
||||
cp config/staging-seed.example.json config/staging-seed.json
|
||||
# preencha users[].id com os UUID do Supabase Auth e escolha os UUID de workspace
|
||||
npm run seed:staging
|
||||
```
|
||||
|
||||
Idempotente: rodar de novo produz o mesmo estado. Não cria conta nem senha, só liga identidades a workspaces e papéis. A feature flag nasce desligada mesmo aqui.
|
||||
|
||||
## 4. Banco de homologação
|
||||
|
||||
```bash
|
||||
# 1. Veja o que vai rodar, antes de rodar.
|
||||
ADAPTER_ENV=staging DATABASE_URL=... ADAPTER_APPLY_IDENTITY_SHIM=? npm run migrate:check
|
||||
```
|
||||
|
||||
A decisão do shim é **explícita e obrigatória**: sem `ADAPTER_APPLY_IDENTITY_SHIM` o start é recusado.
|
||||
|
||||
| Situação do banco | Valor | Efeito |
|
||||
| --- | --- | --- |
|
||||
| Banco novo, sem `profiles`/`workspaces`/`workspace_members` | `1` | `0001_platform_shim.sql` cria o shim e o papel `app_user` |
|
||||
| Banco já com o schema do Elo Pro | `0` | `0001` é pulado; só `0002`, `0003` e `0004` rodam |
|
||||
|
||||
Com `0`, antes de migrar: confirme que existem `public.profiles(id, display_name, is_superadmin)`, `public.workspaces(id, name)` e `public.workspace_members(workspace_id, user_id, role)`, e que o papel `app_user` existe sem `BYPASSRLS`. Se os nomes ou as colunas diferirem, adapte `src/server/auth/identity-port.ts` **antes** — é o único arquivo que precisa mudar.
|
||||
|
||||
```bash
|
||||
# 2. Aplicar (job com aprovação manual no workflow).
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Nenhum processo aplica migration sozinho: o worker recusa iniciar com migration pendente e a web não migra em ambiente implantado.
|
||||
|
||||
## 5. Bucket privado
|
||||
|
||||
```bash
|
||||
# Crie o bucket no painel do Supabase de homologação, com visibilidade privada.
|
||||
# Nome sugerido: adaptador-planilhas-hml
|
||||
npm run check:bucket
|
||||
```
|
||||
|
||||
`check:bucket` falha se o bucket não existir ou for público. A aplicação também recusa o valor literal `public` em `SUPABASE_STORAGE_BUCKET`. Nenhuma URL pública é gerada: o download passa por `/api/spreadsheet-adapter/jobs/[id]/download`, autenticado e sob RLS.
|
||||
|
||||
## 6. Containers
|
||||
|
||||
A imagem tem um entrypoint com subcomandos. Use sempre o subcomando, nunca `node` direto: ele garante `node --import tsx`, que é o que faz o SIGTERM do `docker stop` chegar ao processo do worker. O CLI do `tsx` cria um processo filho e o sinal pararia nele.
|
||||
|
||||
```bash
|
||||
cd <HML_DEPLOY_PATH>
|
||||
cp .env.staging.example .env.staging
|
||||
# preencha todos os campos e confira
|
||||
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
|
||||
$C run --rm web verify-config
|
||||
|
||||
$C up -d redis
|
||||
$C up -d --no-deps web worker
|
||||
$C ps
|
||||
```
|
||||
|
||||
| Subcomando | O que faz |
|
||||
| --- | --- |
|
||||
| `web` | servidor Next (saída `standalone`) |
|
||||
| `worker` | processo de fila |
|
||||
| `migrate [--dry-run]` | migrations |
|
||||
| `seed-staging` | associa identidades do Supabase a workspaces |
|
||||
| `flag <workspace_id> on off` | feature flag do módulo |
|
||||
| `cleanup` | expira temporários fora do ciclo do worker |
|
||||
| `verify-config` | confere a configuração sem tocar em recurso externo |
|
||||
| `check-bucket` | confere que o bucket existe e é privado |
|
||||
| `smoke` | smoke do ambiente |
|
||||
| `worker-health` | confere o heartbeat do worker |
|
||||
|
||||
`--no-deps` e o `--project-name` embutido (`adaptador-planilhas-hml`) garantem que nenhum stack de outro aplicativo é tocado. Nunca rode `docker compose down` sem `-f docker-compose.staging.yml`, e nunca `docker system prune` neste servidor.
|
||||
|
||||
Web e worker saem da **mesma imagem**: mesmo código, mesmas dependências. A diferença é só o subcomando. O `node_modules` de produção e o traçado pelo `standalone` vêm do mesmo lockfile e não divergem em nenhuma versão; a imagem sobrepõe as duas árvores nessa ordem, com a traçada por último, que é a que o `server.js` espera.
|
||||
|
||||
O Redis é um contêiner exclusivo deste aplicativo, com senha, volume e porta próprios. Mesmo assim as chaves são prefixadas com `adaptador-planilhas:staging`, então um Redis compartilhado por engano não misturaria homologação com produção.
|
||||
|
||||
## 7. Worker
|
||||
|
||||
```bash
|
||||
$C logs -f worker
|
||||
$C exec worker /app/docker/entrypoint.sh worker-health
|
||||
```
|
||||
|
||||
O start imprime `[worker] pronto. intervalo=…ms lote=… carencia=…ms alvo=postgres heartbeat=…`.
|
||||
|
||||
- `ADAPTER_JOB_MODE=worker` é obrigatório. `inline` faz o start ser recusado.
|
||||
- Migration pendente faz o worker sair com código 1, com a lista no log.
|
||||
- `SIGTERM` espera o tick corrente (até `ADAPTER_WORKER_GRACE_MS`, padrão 20 s, dentro do `stop_grace_period` de 30 s) para o `finally` liberar o lock. Sem isso o job ficaria em `processing` até o lock vencer por TTL. O log de uma parada limpa traz `SIGTERM recebido` e `[worker] encerrado`; se faltarem, o contêiner levou SIGKILL e o lock vai esperar o TTL de 120 s.
|
||||
- Um job que estava apenas na fila, e não começou, permanece `queued`: nada pela metade. O worker seguinte o conclui.
|
||||
- O worker grava um heartbeat ao fim de cada tick (`ADAPTER_WORKER_HEARTBEAT`, padrão `/tmp/adaptador-worker-heartbeat`). O healthcheck do contêiner usa esse arquivo, porque um processo vivo mas travado passa em qualquer verificação de PID.
|
||||
- Cada tick também expira temporários (`cleanupExpired`) e recupera jobs órfãos (`recoverStaleJobs`). Não há cron separado.
|
||||
- O log traz nome e código do erro. Nunca conteúdo de célula.
|
||||
|
||||
## 8. Observabilidade
|
||||
|
||||
| Item | Onde |
|
||||
| --- | --- |
|
||||
| Ambiente no Sentry | `SENTRY_ENVIRONMENT=staging`; sem a variável, cai em `ADAPTER_ENV` |
|
||||
| Release | `SENTRY_RELEASE` = SHA do commit, injetado como build-arg da imagem |
|
||||
| Sourcemap | `withSentryConfig` só atua com `SENTRY_ORG`, `SENTRY_PROJECT` e `SENTRY_AUTH_TOKEN` no build |
|
||||
| Erros de rota e render | `onRequestError` em `src/instrumentation.ts` |
|
||||
| Sanitização | `scrubSentryEvent` descarta texto livre, cookie, header de autorização e qualquer campo de célula |
|
||||
| Liveness | `GET /api/health/live` — não toca dependência |
|
||||
| Readiness | `GET /api/health/ready` — banco, migrations pendentes, Redis (PING), bucket (listagem), configuração e modo de job |
|
||||
| Auditoria | `public.audit_log`, append-only para `app_user`, sem conteúdo de célula |
|
||||
|
||||
Teste a sanitização provocando um erro controlado e confirmando no Sentry que o evento não traz célula, cookie nem nome de pessoa.
|
||||
|
||||
## 9. Liberação da feature flag
|
||||
|
||||
Último passo, depois do smoke:
|
||||
|
||||
```bash
|
||||
npm run flag -- <workspace_id> on
|
||||
```
|
||||
|
||||
O script recusa um workspace que não existe e registra o evento em `audit_log`. A flag nasce desligada, inclusive em homologação.
|
||||
|
||||
## 10. Rollback
|
||||
|
||||
### Aplicação: rollback de imagem (reversível, é o caminho normal)
|
||||
|
||||
O artefato é imutável e marcado com o SHA do commit: `ghcr.io/<org>/<repo>:staging-<sha>`. Voltar é apontar o compose para a tag anterior. Nada de banco, bucket ou auditoria é tocado.
|
||||
|
||||
```bash
|
||||
export C="docker compose -f docker-compose.staging.yml --env-file .env.staging"
|
||||
|
||||
# 1. Qual imagem está no ar agora. Anote: é o ponto de retorno se o rollback falhar.
|
||||
$C ps --format '{{.Service}} {{.Image}}'
|
||||
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
|
||||
|
||||
# 2. Quais tags existem para voltar (mais recentes primeiro).
|
||||
docker image ls 'ghcr.io/<org>/<repo>' --format '{{.Tag}} {{.CreatedAt}}' | sort -k2 -r | head
|
||||
|
||||
# 3. Desligar o módulo para os usuários. Efeito imediato, sem deploy.
|
||||
# Faça isto ANTES de trocar a imagem: encerra o incidente de produto em segundos.
|
||||
$C run --rm web flag <workspace_id> off
|
||||
|
||||
# 4. Trocar para a imagem anterior. Só web e worker; o Redis fica de pé.
|
||||
export ADAPTER_IMAGE=ghcr.io/<org>/<repo>:staging-<sha-anterior>
|
||||
docker pull "$ADAPTER_IMAGE"
|
||||
$C up -d --no-deps web worker
|
||||
|
||||
# 5. Conferir que voltou e está saudável.
|
||||
docker inspect "$($C ps -q web)" --format '{{.Config.Image}}'
|
||||
curl -fsS https://hml-ada.elopro.app/api/health/live
|
||||
curl -fsS https://hml-ada.elopro.app/api/health/ready
|
||||
$C exec worker /app/docker/entrypoint.sh worker-health
|
||||
|
||||
# 6. Religar a flag só depois de confirmar o ambiente.
|
||||
$C run --rm web flag <workspace_id> on
|
||||
```
|
||||
|
||||
Para parar apenas o processamento, sem derrubar a interface:
|
||||
|
||||
```bash
|
||||
$C stop -t 30 worker # respeita a carência: o lock é liberado
|
||||
```
|
||||
|
||||
Jobs `queued` param de avançar e são retomados quando o worker volta. Jobs `processing` sem lock viram `INTERRUPTED` na próxima varredura e podem ser repetidos pelo usuário. Nenhuma saída já gerada é apagada: ela vence pelo TTL.
|
||||
|
||||
Fixe a tag no `.env.staging` (`ADAPTER_IMAGE=...:staging-<sha>`) para que um `up -d` acidental não traga de volta a imagem com defeito.
|
||||
|
||||
**Migrations não voltam no rollback de imagem.** As migrations do Adaptador são aditivas: a imagem anterior convive com o schema novo. Se a imagem anterior não conviver, o problema é de compatibilidade de schema e vira operação planejada, não rollback às pressas.
|
||||
|
||||
### Schema (não automatizado, de propósito)
|
||||
|
||||
Não existe rollback automático de schema e não deve existir.
|
||||
|
||||
| Migration | Caminho de volta |
|
||||
| --- | --- |
|
||||
| `0002_spreadsheet_adapter.sql` | `migrations/down/0002_spreadsheet_adapter_down.sql`, aplicado à mão, com backup antes |
|
||||
| `0003_rls.sql` | sem down. Remover policy é afrouxar isolamento: não se desfaz, corrige-se para frente |
|
||||
| `0004_audit_append_only.sql` | sem down. A trava de auditoria não se remove em rollback |
|
||||
| `0001_platform_shim.sql` | sem down. O shim só existe em banco que não tem o schema do Elo Pro |
|
||||
|
||||
Regra: desligar a flag resolve o incidente de produto. Reverter imagem resolve o incidente de código. Mexer em schema é operação planejada com backup, nunca parte de um rollback às pressas.
|
||||
|
||||
**Migrations de segurança são preservadas em qualquer rollback.** `0003_rls.sql` (RLS e `FORCE RLS`) e `0004_audit_append_only.sql` (auditoria append-only) não têm e não terão caminho de volta: desfazê-las afrouxa isolamento entre workspaces e abre a auditoria para alteração pelo papel da aplicação. Um rollback que precise disso está errado por definição.
|
||||
|
||||
**Não apague `audit_log`, modelos, banco ou arquivos em nenhum rollback.**
|
||||
|
||||
## 11. Diagnóstico
|
||||
|
||||
| Sintoma | Causa provável | Verificação |
|
||||
| --- | --- | --- |
|
||||
| Container sai no start com código 1 | configuração recusada | `docker compose logs web` traz a lista de variáveis |
|
||||
| `/api/health/live` não responde | processo caiu | configuração é a primeira hipótese: veja o log do start |
|
||||
| `ready` com `config: missing` | variável ausente ou inválida | `configIssues` na resposta nomeia cada uma |
|
||||
| `ready` com `redis: down` | senha, host ou contêiner | `docker compose ps redis` e o PING |
|
||||
| `ready` com `storage: down` | bucket, nome ou service role | `npm run check:bucket` |
|
||||
| `ready` com `migrations: missing` | migration pendente | `npm run migrate:check` |
|
||||
| `ready` com `job_mode: missing` | `ADAPTER_JOB_MODE=inline` | corrija e suba de novo |
|
||||
| Login cai em branco | `ADAPTER_STAGING_AUTH` desligado e sem `ELO_PRO_LOGIN_URL` | `/login` explica qual falta |
|
||||
| Fica pedindo workspace | identidade sem associação | `npm run seed:staging` ou incluir em `workspace_members` |
|
||||
| Job preso em `processing` | worker parado ou lock vencido | log do worker; a varredura marca `INTERRUPTED` |
|
||||
| 429 nas chamadas | rate limiting | `Retry-After` na resposta; tetos em `src/server/rate-limit.ts` |
|
||||
|
||||
## 12. Ordem completa, do zero
|
||||
|
||||
1. DNS de `hml-ada.elopro.app`.
|
||||
2. Certificado TLS só desse host.
|
||||
3. `server` novo no Nginx, `nginx -t`, `systemctl reload nginx`.
|
||||
4. Projeto Supabase de homologação; contas de teste no painel.
|
||||
5. Bucket privado; `npm run check:bucket`.
|
||||
6. `.env.staging` preenchido; `npm run verify:config`.
|
||||
7. `npm run migrate:check` e depois `npm run migrate` (aprovação manual).
|
||||
8. `config/staging-seed.json` e `npm run seed:staging`.
|
||||
9. `up -d redis`, depois `up -d --no-deps web worker`.
|
||||
10. `/api/health/live` responde `live`; `/api/health/ready` responde `ready`.
|
||||
11. Smoke, nas duas fases (ver seção 13).
|
||||
12. Roteiro manual de `21-checklist-validacao.md`: login real, isolamento, processamento assíncrono, download XLSX/CSV/PDF, erro no Sentry.
|
||||
13. `npm run flag -- <workspace_id> on`.
|
||||
14. Ensaio de rollback, com evidência.
|
||||
15. Só então discutir produção.
|
||||
|
||||
## 13. Smoke de homologação
|
||||
|
||||
Fase 1 roda sem nenhuma credencial: saúde, recusa sem sessão, cabeçalhos de segurança e ausência de dev auth.
|
||||
|
||||
```bash
|
||||
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app npm run smoke:staging
|
||||
```
|
||||
|
||||
Fase 2 só roda quando a operação fornece uma conta de teste **criada no painel do Supabase de homologação**. Nenhuma credencial fica no repositório, e o script não inventa conta: sem estas variáveis a fase 2 é reportada como PENDENTE, nunca como aprovada.
|
||||
|
||||
| Variável | Obrigatória | O que é |
|
||||
| --- | --- | --- |
|
||||
| `ADAPTER_SMOKE_URL` | sim | `https://hml-ada.elopro.app` |
|
||||
| `ADAPTER_SMOKE_EMAIL` | fase 2 | e-mail de uma conta de teste do Supabase de homologação |
|
||||
| `ADAPTER_SMOKE_PASSWORD` | fase 2 | senha dessa conta |
|
||||
| `ADAPTER_SMOKE_WORKSPACE` | opcional | UUID do workspace que deve aparecer associado |
|
||||
|
||||
```bash
|
||||
ADAPTER_SMOKE_URL=https://hml-ada.elopro.app ADAPTER_SMOKE_EMAIL=... ADAPTER_SMOKE_PASSWORD=... ADAPTER_SMOKE_WORKSPACE=... npm run smoke:staging
|
||||
```
|
||||
|
||||
A fase 2 confere: login real emitindo sessão, associações de workspace resolvidas no servidor, seleção de workspace aceita e leitura autenticada do módulo. Com a feature flag ainda desligada, `FEATURE_DISABLED` é resposta correta e passa.
|
||||
|
||||
Continua fora do smoke, no roteiro manual, porque exige arquivo e flag ligada:
|
||||
|
||||
- envio de modelo e de fonte pela interface;
|
||||
- processamento assíncrono com o worker e download XLSX, CSV e PDF;
|
||||
- troca de workspace e sessão expirada no navegador;
|
||||
- erro controlado no Sentry, confirmado sem conteúdo de célula;
|
||||
- ensaio de rollback de imagem.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
-- 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_sources;
|
||||
drop table if exists public.spreadsheet_jobs;
|
||||
|
||||
+82
-7
@@ -1,11 +1,66 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function hostOf(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-Security-Policy. `unsafe-inline` em script-src é necessário para o
|
||||
* bootstrap do App Router sem nonce; o restante é fechado. `connect-src` só
|
||||
* aceita o próprio host, o Supabase do ambiente e o Sentry.
|
||||
*/
|
||||
function contentSecurityPolicy(): string {
|
||||
const supabase = hostOf(process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
const connect = ["'self'", supabase, supabase?.replace("https://", "wss://"), "https://*.sentry.io"].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
`connect-src ${connect.join(" ")}`,
|
||||
"worker-src 'self' blob:",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Content-Security-Policy", value: contentSecurityPolicy() },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
|
||||
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
||||
{ key: "X-DNS-Prefetch-Control", value: "off" },
|
||||
// HSTS só faz sentido atrás de TLS. O Nginx do ecossistema termina o TLS.
|
||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: { root },
|
||||
// Saída autocontida para a imagem de runtime.
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: root,
|
||||
// `migrations/` é lido em runtime pelo migrate e pelo health check.
|
||||
outputFileTracingIncludes: {
|
||||
"/api/health/ready": ["./migrations/**/*.sql"],
|
||||
},
|
||||
serverExternalPackages: [
|
||||
"exceljs",
|
||||
"@react-pdf/renderer",
|
||||
@@ -16,16 +71,36 @@ const nextConfig: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
{ source: "/:path*", headers: securityHeaders },
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
],
|
||||
source: "/api/:path*",
|
||||
headers: [...securityHeaders, { key: "Cache-Control", value: "no-store" }],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
/**
|
||||
* Upload de sourcemap e release só acontece quando a organização, o projeto e o
|
||||
* token existem no ambiente de build. Sem isto o build local e o CI continuam
|
||||
* funcionando, e o stack trace do servidor chega legível ao Sentry quando o
|
||||
* pipeline fornece os segredos.
|
||||
*/
|
||||
function withSentryIfConfigured(config: NextConfig): NextConfig {
|
||||
const org = process.env.SENTRY_ORG;
|
||||
const project = process.env.SENTRY_PROJECT;
|
||||
if (!org || !project || !process.env.SENTRY_AUTH_TOKEN) return config;
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const { withSentryConfig } = nodeRequire("@sentry/nextjs") as typeof import("@sentry/nextjs");
|
||||
return withSentryConfig(config, {
|
||||
org,
|
||||
project,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
silent: true,
|
||||
widenClientFileUpload: true,
|
||||
disableLogger: true,
|
||||
sourcemaps: { deleteSourcemapsAfterUpload: true },
|
||||
});
|
||||
}
|
||||
|
||||
export default withSentryIfConfigured(nextConfig);
|
||||
+20
-7
@@ -2,19 +2,32 @@
|
||||
"name": "adaptador-planilhas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12 <25"
|
||||
},
|
||||
"packageManager": "npm@11.6.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start:standalone": "node .next/standalone/server.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"seed": "tsx scripts/seed-dev.ts",
|
||||
"worker": "tsx scripts/worker.ts",
|
||||
"cleanup": "tsx scripts/cleanup-temp.ts",
|
||||
"flag": "tsx scripts/set-flag.ts"
|
||||
"test:services": "vitest run --config vitest.services.config.ts",
|
||||
"migrate": "node --import=tsx scripts/migrate.ts",
|
||||
"migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
|
||||
"identity:init": "node --import=tsx scripts/init-project-identity.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": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -31,7 +44,8 @@
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"server-only": "^0.0.1"
|
||||
"server-only": "^0.0.1",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
@@ -46,7 +60,6 @@
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { MemoryCoordinator } from "../src/infra/redis/memory";
|
||||
import { createCoordinator } from "../src/infra/redis";
|
||||
import { createStorage } from "../src/infra/storage";
|
||||
import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job";
|
||||
import { bootstrapScript, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() });
|
||||
console.log(`temporários expirados: ${removed}`);
|
||||
await db.close();
|
||||
bootstrapScript("cleanup-temp");
|
||||
|
||||
runWithDb("cleanup-temp", "worker", async (db) => {
|
||||
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
|
||||
console.log(`temporários expirados: ${removed}`);
|
||||
});
|
||||
@@ -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
@@ -1,10 +1,28 @@
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import path from "node:path";
|
||||
import { identityShimEnabled } from "../src/config/env";
|
||||
import { migrate, pendingMigrations } from "../src/infra/db/migrate";
|
||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
const ran = await migrate(db);
|
||||
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente");
|
||||
await db.close();
|
||||
/**
|
||||
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline
|
||||
* antes do passo manual de migração, para que ninguém descubra o que vai rodar
|
||||
* só depois de ter rodado.
|
||||
*/
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
bootstrapScript("migrate");
|
||||
|
||||
runWithDb("migrate", "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");
|
||||
});
|
||||
@@ -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
@@ -1,12 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { adapterEnv } from "../src/config/env";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import { ensureDevFixtures } from "../src/dev/fixtures";
|
||||
import { bootstrapScript, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
await ensureDevFixtures(db);
|
||||
console.log("fixtures de desenvolvimento conferidas");
|
||||
await db.close();
|
||||
bootstrapScript("seed-dev");
|
||||
if (adapterEnv() !== "development") {
|
||||
console.error("seed-dev só roda em development. Para homologação use `npm run seed:staging`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
runWithDb("seed-dev", "local", async (db) => {
|
||||
await migrate(db);
|
||||
await ensureDevFixtures(db);
|
||||
console.log("fixtures de desenvolvimento conferidas");
|
||||
});
|
||||
@@ -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
@@ -1,30 +1,37 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
||||
|
||||
const workspaceId = process.argv[2];
|
||||
const enabled = process.argv[3] !== "off";
|
||||
if (!workspaceId) {
|
||||
const action = process.argv[3];
|
||||
if (!workspaceId || !action || !["on", "off"].includes(action)) {
|
||||
console.error("uso: npm run flag -- <workspace_id> on|off");
|
||||
process.exit(1);
|
||||
}
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await db.withService(async (query) => {
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||
values ($1, 'spreadsheet_adapter', $2)
|
||||
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
|
||||
[workspaceId, enabled],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
|
||||
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
|
||||
from public.workspace_members m
|
||||
where m.workspace_id = $1 and m.role = 'owner'
|
||||
limit 1`,
|
||||
[workspaceId, enabled],
|
||||
const enabled = action === "on";
|
||||
|
||||
bootstrapScript("set-flag");
|
||||
|
||||
runWithDb("set-flag", "admin", async (db) => {
|
||||
const existing = await db.withService((query) =>
|
||||
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
|
||||
);
|
||||
if (!existing[0]) {
|
||||
throw new Error(`workspace ${workspaceId} não existe em ${describeTarget()}. Nada foi alterado.`);
|
||||
}
|
||||
await db.withService(async (query) => {
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||
values ($1, 'spreadsheet_adapter', $2)
|
||||
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
|
||||
[workspaceId, enabled],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
|
||||
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
|
||||
from public.workspace_members m
|
||||
where m.workspace_id = $1 and m.role = 'owner'
|
||||
limit 1`,
|
||||
[workspaceId, enabled],
|
||||
);
|
||||
});
|
||||
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`);
|
||||
});
|
||||
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
|
||||
await db.close();
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
@@ -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$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -1,76 +1,73 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { jobRunsInline } from "../src/config/env";
|
||||
import { pendingMigrations } from "../src/infra/db/migration-manifest";
|
||||
import { createCoordinator } from "../src/infra/redis";
|
||||
import { createStorage } from "../src/infra/storage";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job";
|
||||
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
|
||||
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
|
||||
import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop";
|
||||
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
|
||||
|
||||
import { assertProductionEnv } from "../src/config/env";
|
||||
/**
|
||||
* Processo do worker. Só bootstrap, sinais e cadência: a lógica do tick está em
|
||||
* `src/server/spreadsheet-adapter/worker-loop.ts`, que é testável sem subir
|
||||
* processo.
|
||||
*
|
||||
* Precisa ser executado com `node --import tsx scripts/worker.ts`, não com o
|
||||
* CLI do `tsx`: o CLI cria um processo filho e o SIGTERM do contêiner pararia
|
||||
* no pai, sem nunca chegar ao handler que espera o lock ser liberado.
|
||||
*/
|
||||
|
||||
assertProductionEnv(process.env);
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl && process.env.NODE_ENV === "production") {
|
||||
throw new Error("DATABASE_URL é obrigatório para o worker.");
|
||||
}
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
|
||||
const TICK_MS = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
|
||||
const BATCH = Number(process.env.ADAPTER_WORKER_BATCH ?? 20);
|
||||
const GRACE_MS = Number(process.env.ADAPTER_WORKER_GRACE_MS ?? 20000);
|
||||
const HEARTBEAT = process.env.ADAPTER_WORKER_HEARTBEAT ?? path.join("/tmp", "adaptador-worker-heartbeat");
|
||||
|
||||
async function tick() {
|
||||
await recoverStaleJobs(deps).catch((error) => {
|
||||
console.error("worker", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
await cleanupExpired(deps).catch((error) => {
|
||||
console.error("worker", "cleanup", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
const queued = new Set(await listQueuedJobIds(deps));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const jobId = await deps.coordinator.dequeue().catch(() => null);
|
||||
if (!jobId) break;
|
||||
queued.add(jobId);
|
||||
}
|
||||
for (const jobId of queued) {
|
||||
const rows = await readJobForWorker(deps, jobId);
|
||||
const row = rows[0];
|
||||
if (!row) continue;
|
||||
const progress = asJson<JobProgress | null>(row.progress, null);
|
||||
if (!progress?.sourceId) continue;
|
||||
const profile = await db.withService((query) =>
|
||||
query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>(
|
||||
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
|
||||
coalesce(f.enabled, false) as module_enabled,
|
||||
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
|
||||
from public.profiles p
|
||||
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
|
||||
join public.workspaces w on w.id = $2
|
||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||
where p.id = $1`,
|
||||
[row.user_id, row.workspace_id],
|
||||
),
|
||||
);
|
||||
const found = profile[0];
|
||||
if (!found) continue;
|
||||
const ctx: RequestContext = {
|
||||
userId: row.user_id,
|
||||
displayName: found.display_name,
|
||||
isSuperadmin: found.is_superadmin,
|
||||
workspaceId: row.workspace_id,
|
||||
workspaceName: found.workspace_name,
|
||||
role: found.role,
|
||||
moduleEnabled: found.module_enabled,
|
||||
allowNonAdminModelUpload: found.allow_upload,
|
||||
};
|
||||
await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => {
|
||||
console.error("worker", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
}
|
||||
bootstrapScript("worker");
|
||||
if (jobRunsInline()) {
|
||||
console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
|
||||
}
|
||||
|
||||
await tick();
|
||||
setInterval(() => {
|
||||
void tick();
|
||||
}, 2000);
|
||||
runScript("worker", async () => {
|
||||
const db = await openOperationalDb("worker");
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
|
||||
export default async function SettingsPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Fconfiguracoes");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
if (!canManageSpreadsheetModels(auth.ctx)) {
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
export default async function OperationPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Foperacao");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
|
||||
@@ -10,10 +10,17 @@ export const dynamic = "force-dynamic";
|
||||
export default async function AdapterHome() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador");
|
||||
if (auth.kind !== "ok") {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
|
||||
<ErrorState
|
||||
message={
|
||||
auth.kind === "no_workspace"
|
||||
? "Sua identidade não está associada a nenhum workspace."
|
||||
: "Você não é membro deste workspace."
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { supabaseServerClient, DEV_COOKIE, WORKSPACE_COOKIE } from "@/server/auth/identity-provider";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
const jar = await cookies();
|
||||
jar.delete("adapter_dev_session");
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (url && key) {
|
||||
const supabase = createServerClient(url, key, {
|
||||
cookies: {
|
||||
getAll: () => jar.getAll(),
|
||||
setAll: (items) => {
|
||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
await supabase.auth.signOut();
|
||||
}
|
||||
jar.delete(DEV_COOKIE);
|
||||
jar.delete(WORKSPACE_COOKIE);
|
||||
const supabase = supabaseServerClient(jar);
|
||||
if (supabase) await supabase.auth.signOut();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { adapterEnv } from "@/config/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Liveness: não toca em nenhuma dependência. Se o processo responde aqui, ele
|
||||
* está vivo. Configuração inválida derruba o processo no start (ver
|
||||
* `exitOnInvalidConfig`), então esta rota nunca mente sobre o estado.
|
||||
*/
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: "live", module: "spreadsheet-adapter" });
|
||||
return NextResponse.json(
|
||||
{ status: "live", module: "spreadsheet-adapter", environment: adapterEnv() },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { collectProductionIssues } from "@/config/env";
|
||||
import { adapterEnv, collectConfigIssues, isDeployedEnv, jobRunsInline } from "@/config/env";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { pendingMigrations } from "@/infra/db/migration-manifest";
|
||||
import { createCoordinator } from "@/infra/redis";
|
||||
import { createStorage } from "@/infra/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CheckState = "ok" | "missing" | "down";
|
||||
|
||||
/**
|
||||
* Prontidão real: cada dependência é tocada de verdade. Não basta a variável
|
||||
* existir — Redis responde PING, o bucket aceita listagem e o banco responde
|
||||
* uma consulta. Nenhuma connection string aparece na resposta.
|
||||
*/
|
||||
export async function GET() {
|
||||
const checks: Record<string, "ok" | "missing" | "down"> = {
|
||||
const checks: Record<string, CheckState> = {
|
||||
config: "ok",
|
||||
database: "down",
|
||||
redis: process.env.REDIS_URL ? "ok" : "missing",
|
||||
storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing",
|
||||
config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing",
|
||||
migrations: "ok",
|
||||
redis: "down",
|
||||
storage: "down",
|
||||
job_mode: "ok",
|
||||
};
|
||||
const issues = collectConfigIssues(process.env);
|
||||
if (issues.length > 0) {
|
||||
checks.config = "missing";
|
||||
}
|
||||
|
||||
// Só o nome do erro e, para migrations, os ids pendentes. Nunca connection
|
||||
// string, credencial ou mensagem com host.
|
||||
const reasons: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.withService((query) => query.query("select 1 as ok"));
|
||||
checks.database = "ok";
|
||||
} catch {
|
||||
const pending = await pendingMigrations(db);
|
||||
checks.migrations = pending.length === 0 ? "ok" : "missing";
|
||||
if (pending.length > 0) reasons.migrations = pending.join(", ");
|
||||
} catch (error) {
|
||||
checks.database = "down";
|
||||
checks.migrations = "down";
|
||||
reasons.database = error instanceof Error ? error.name : "erro";
|
||||
}
|
||||
const ready = checks.database === "ok" && (process.env.NODE_ENV !== "production" || (checks.redis === "ok" && checks.storage === "ok" && checks.config === "ok"));
|
||||
return NextResponse.json({ status: ready ? "ready" : "not_ready", checks }, { status: ready ? 200 : 503 });
|
||||
|
||||
try {
|
||||
checks.redis = (await createCoordinator().ping()) ? "ok" : "down";
|
||||
} catch (error) {
|
||||
checks.redis = "down";
|
||||
reasons.redis = error instanceof Error ? error.name : "erro";
|
||||
}
|
||||
|
||||
try {
|
||||
checks.storage = (await createStorage().healthcheck()) ? "ok" : "down";
|
||||
} catch (error) {
|
||||
checks.storage = "down";
|
||||
reasons.storage = error instanceof Error ? error.name : "erro";
|
||||
}
|
||||
|
||||
if (isDeployedEnv() && jobRunsInline()) checks.job_mode = "missing";
|
||||
|
||||
const required = isDeployedEnv()
|
||||
? (["config", "database", "migrations", "redis", "storage", "job_mode"] as const)
|
||||
: (["database"] as const);
|
||||
const ready = required.every((key) => checks[key] === "ok");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: ready ? "ready" : "not_ready",
|
||||
environment: adapterEnv(),
|
||||
checks,
|
||||
reasons,
|
||||
configIssues: issues.map((issue) => `${issue.variable}: ${issue.message}`),
|
||||
},
|
||||
{ status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
+14
-10
@@ -1,20 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { adapterEnv } from "@/config/env";
|
||||
import { probeDb } from "@/infra/db/client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Atalho histórico, mantido para quem já aponta monitoramento aqui.
|
||||
* O detalhe está em /api/health/ready; a vida do processo, em /api/health/live.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.withService((query) => query.query("select 1 as ok"));
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
module: "spreadsheet-adapter",
|
||||
redis: process.env.REDIS_URL ? "configured" : "not_configured",
|
||||
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured",
|
||||
});
|
||||
await probeDb();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 503 });
|
||||
return NextResponse.json({ ok: false, environment: adapterEnv() }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
module: "spreadsheet-adapter",
|
||||
environment: adapterEnv(),
|
||||
detail: "/api/health/ready",
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { presentError } from "@/domain/spreadsheet-adapter/error-catalog";
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
import { enforceRateLimit, type RateLimitBucket } from "@/server/rate-limit";
|
||||
import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -11,9 +12,12 @@ export const dynamic = "force-dynamic";
|
||||
export function errorResponse(error: unknown): NextResponse {
|
||||
if (isAdapterError(error)) {
|
||||
const view = presentError(error.code);
|
||||
const headers: Record<string, string> = {};
|
||||
const retryAfter = error.details?.retryAfterSeconds;
|
||||
if (typeof retryAfter === "number") headers["Retry-After"] = String(retryAfter);
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, details: error.details ?? null } },
|
||||
{ status: error.status },
|
||||
{ status: error.status, headers },
|
||||
);
|
||||
}
|
||||
console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error");
|
||||
@@ -24,12 +28,24 @@ export function errorResponse(error: unknown): NextResponse {
|
||||
);
|
||||
}
|
||||
|
||||
export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> {
|
||||
/**
|
||||
* Sessão, workspace e dependências da requisição. O `bucket` aplica o teto de
|
||||
* abuso por usuário: upload e processamento custam muito mais que leitura.
|
||||
*/
|
||||
export async function requireReady(bucket: RateLimitBucket = "read"): Promise<{ ctx: RequestContext; deps: AppDeps }> {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") {
|
||||
throw new AdapterError("UNAUTHENTICATED", auth.kind === "session_expired" ? "Sessão expirada. Entre novamente." : "Sessão Elo Pro necessária.", 401);
|
||||
}
|
||||
if (auth.kind === "workspace_choice" || auth.kind === "no_workspace") {
|
||||
throw new AdapterError(
|
||||
"WORKSPACE_REQUIRED",
|
||||
auth.kind === "no_workspace" ? "Sua identidade não está em nenhum workspace." : "Selecione o workspace ativo.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403);
|
||||
await enforceRateLimit(bucket, `${auth.ctx.workspaceId}:${auth.ctx.userId}`);
|
||||
const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
|
||||
return { ctx: auth.ctx, deps: await runtimeDeps() };
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("download");
|
||||
const { id } = await context.params;
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get("format") as OutputFormat | null;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("process");
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("read");
|
||||
const { id } = await context.params;
|
||||
return NextResponse.json({ job: await getJob(deps, ctx, id) });
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const { id, sourceId } = await context.params;
|
||||
const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
@@ -20,7 +20,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s
|
||||
|
||||
export async function DELETE(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const { id, sourceId } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const body = (await request.json()) as { modelId?: string; idempotencyKey?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (!body.modelId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Selecione um modelo." } }, { status: 400 });
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const { id } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("read");
|
||||
return NextResponse.json({ models: await listModels(deps, ctx) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -16,7 +16,7 @@ export async function GET() {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("upload");
|
||||
const form = await request.formData();
|
||||
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
|
||||
const file = form.get("file");
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("read");
|
||||
return NextResponse.json(await getSettings(deps, ctx));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -16,7 +16,7 @@ export async function GET() {
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("mutation");
|
||||
const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (typeof body.allowNonAdminModelUpload !== "boolean") {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { ctx, deps } = await requireReady("upload");
|
||||
const form = await request.formData();
|
||||
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
|
||||
const file = form.get("file");
|
||||
|
||||
+23
-4
@@ -1,9 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { adapterEnv } from "@/config/env";
|
||||
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
import { devAuthEnabled } from "@/server/auth/dev-session";
|
||||
import { authMode } from "@/server/auth/identity-provider";
|
||||
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||
import { DevLogin } from "@/components/dev-login";
|
||||
import { StagingLogin } from "@/components/staging-login";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,10 +14,14 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
||||
const auth = await resolveAuth();
|
||||
const nextPath = safeReturnPath(params.next);
|
||||
if (auth.kind === "ok") redirect(nextPath);
|
||||
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
|
||||
if (auth.kind === "workspace_choice") redirect(`/selecionar-workspace?next=${encodeURIComponent(nextPath)}`);
|
||||
|
||||
const mode = authMode();
|
||||
if (mode === "elo-pro-sso" && process.env.ELO_PRO_LOGIN_URL) {
|
||||
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
|
||||
}
|
||||
const expired = auth.kind === "session_expired" || params.reason === "expired";
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-xl p-8">
|
||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||
@@ -23,8 +29,21 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
||||
<p className="mt-3 text-sm text-ink/80">
|
||||
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
||||
</p>
|
||||
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
|
||||
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
|
||||
{adapterEnv() === "staging" ? (
|
||||
<p className="mt-3 inline-flex rounded-full bg-warning/15 px-3 py-1 text-xs font-semibold">
|
||||
Ambiente de homologação
|
||||
</p>
|
||||
) : null}
|
||||
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente.</p> : null}
|
||||
|
||||
{mode === "dev" ? <DevLogin identities={DEV_IDENTITIES} /> : null}
|
||||
{mode === "staging-supabase" ? <StagingLogin next={nextPath} /> : null}
|
||||
{mode === "unconfigured" ? (
|
||||
<p className="mt-6 text-sm">
|
||||
Autenticação não configurada. Em produção defina <code>ELO_PRO_LOGIN_URL</code>; em homologação defina{" "}
|
||||
<code>ADAPTER_STAGING_AUTH=1</code> com o projeto Supabase de homologação.
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -2,31 +2,110 @@ export type EnvMap = Record<string, string | undefined>;
|
||||
|
||||
export type EnvIssue = { variable: string; message: string };
|
||||
|
||||
const REQUIRED_IN_PRODUCTION = [
|
||||
/**
|
||||
* Ambiente lógico do Adaptador. É independente de NODE_ENV porque homologação
|
||||
* roda com NODE_ENV=production (build otimizado) mas com política própria.
|
||||
*/
|
||||
export const ADAPTER_ENVS = ["development", "staging", "production"] as const;
|
||||
export type AdapterEnv = (typeof ADAPTER_ENVS)[number];
|
||||
|
||||
const DEFAULT_NAMESPACE = "adaptador-planilhas";
|
||||
|
||||
/** Variáveis exigidas em qualquer ambiente implantado (staging e produção). */
|
||||
const REQUIRED_WHEN_DEPLOYED = [
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"SUPABASE_STORAGE_BUCKET",
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"SENTRY_DSN",
|
||||
"ELO_PRO_LOGIN_URL",
|
||||
"SPREADSHEET_TEMP_TTL_SECONDS",
|
||||
] as const;
|
||||
|
||||
export function collectProductionIssues(env: EnvMap): EnvIssue[] {
|
||||
if (env.NODE_ENV !== "production") return [];
|
||||
/** Variáveis exigidas somente em produção. */
|
||||
const REQUIRED_IN_PRODUCTION_ONLY = ["SENTRY_DSN", "ELO_PRO_LOGIN_URL"] as const;
|
||||
|
||||
export function adapterEnv(env: EnvMap = process.env): AdapterEnv {
|
||||
const raw = env.ADAPTER_ENV?.trim();
|
||||
if (raw && (ADAPTER_ENVS as readonly string[]).includes(raw)) return raw as AdapterEnv;
|
||||
if (raw) return "production";
|
||||
return env.NODE_ENV === "production" ? "production" : "development";
|
||||
}
|
||||
|
||||
/** Ambiente servido para outras pessoas. Exige configuração completa. */
|
||||
export function isDeployedEnv(env: EnvMap = process.env): boolean {
|
||||
return adapterEnv(env) !== "development";
|
||||
}
|
||||
|
||||
export function isProductionEnv(env: EnvMap = process.env): boolean {
|
||||
return adapterEnv(env) === "production";
|
||||
}
|
||||
|
||||
export function isStagingEnv(env: EnvMap = process.env): boolean {
|
||||
return adapterEnv(env) === "staging";
|
||||
}
|
||||
|
||||
/** Namespace de chaves externas. Separa aplicativo e ambiente no servidor compartilhado. */
|
||||
export function resourceNamespace(env: EnvMap = process.env): string {
|
||||
const app = env.ADAPTER_NAMESPACE?.trim() || DEFAULT_NAMESPACE;
|
||||
return `${app}:${adapterEnv(env)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisão explícita sobre o shim de identidade (`profiles`, `workspaces`,
|
||||
* `workspace_members`). Em ambiente implantado nunca é implícita: aplicar o shim
|
||||
* sobre o schema real do Elo Pro é destrutivo em potencial.
|
||||
*/
|
||||
export function identityShimEnabled(env: EnvMap = process.env): boolean {
|
||||
const raw = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim();
|
||||
if (raw === "1" || raw === "true") return true;
|
||||
if (raw === "0" || raw === "false") return false;
|
||||
return !isDeployedEnv(env);
|
||||
}
|
||||
|
||||
/** Login próprio de homologação, com Supabase Auth real. Nunca em produção. */
|
||||
export function stagingAuthEnabled(env: EnvMap = process.env): boolean {
|
||||
return (
|
||||
isStagingEnv(env) &&
|
||||
env.ADAPTER_STAGING_AUTH === "1" &&
|
||||
Boolean(env.NEXT_PUBLIC_SUPABASE_URL) &&
|
||||
Boolean(env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectConfigIssues(env: EnvMap = process.env): EnvIssue[] {
|
||||
const target = adapterEnv(env);
|
||||
const raw = env.ADAPTER_ENV?.trim();
|
||||
const issues: EnvIssue[] = [];
|
||||
for (const variable of REQUIRED_IN_PRODUCTION) {
|
||||
|
||||
if (raw && !(ADAPTER_ENVS as readonly string[]).includes(raw)) {
|
||||
issues.push({ variable: "ADAPTER_ENV", message: `valor inválido. Use ${ADAPTER_ENVS.join(", ")}` });
|
||||
}
|
||||
if (target === "development") return issues;
|
||||
|
||||
for (const variable of REQUIRED_WHEN_DEPLOYED) {
|
||||
if (!env[variable] || env[variable]?.trim() === "") {
|
||||
issues.push({ variable, message: "obrigatória em produção" });
|
||||
issues.push({ variable, message: `obrigatória em ${target}` });
|
||||
}
|
||||
}
|
||||
if (target === "production") {
|
||||
for (const variable of REQUIRED_IN_PRODUCTION_ONLY) {
|
||||
if (!env[variable] || env[variable]?.trim() === "") {
|
||||
issues.push({ variable, message: "obrigatória em produção" });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (env.ADAPTER_DEV_AUTH === "1") {
|
||||
issues.push({ variable: "ADAPTER_DEV_AUTH", message: "não pode estar ligado em produção" });
|
||||
issues.push({ variable: "ADAPTER_DEV_AUTH", message: `só existe em development, nunca em ${target}` });
|
||||
}
|
||||
if (env.ADAPTER_JOB_MODE === "inline") {
|
||||
issues.push({ variable: "ADAPTER_JOB_MODE", message: "inline não é permitido em produção" });
|
||||
issues.push({ variable: "ADAPTER_JOB_MODE", message: `inline não é permitido em ${target}` });
|
||||
}
|
||||
if (env.ADAPTER_APPLY_IDENTITY_SHIM === undefined || env.ADAPTER_APPLY_IDENTITY_SHIM.trim() === "") {
|
||||
issues.push({
|
||||
variable: "ADAPTER_APPLY_IDENTITY_SHIM",
|
||||
message: "decisão explícita obrigatória. 1 cria o shim de identidade, 0 usa o schema existente",
|
||||
});
|
||||
}
|
||||
const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
if (ttl && ttl.trim() !== "") {
|
||||
@@ -38,22 +117,68 @@ export function collectProductionIssues(env: EnvMap): EnvIssue[] {
|
||||
if (env.SUPABASE_STORAGE_BUCKET === "public") {
|
||||
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" });
|
||||
}
|
||||
if (target === "production" && env.ADAPTER_STAGING_AUTH === "1") {
|
||||
issues.push({ variable: "ADAPTER_STAGING_AUTH", message: "login próprio de homologação não vai a produção" });
|
||||
}
|
||||
if (target === "staging" && env.ADAPTER_STAGING_AUTH !== "1" && !env.ELO_PRO_LOGIN_URL) {
|
||||
issues.push({
|
||||
variable: "ADAPTER_STAGING_AUTH",
|
||||
message: "homologação precisa de ADAPTER_STAGING_AUTH=1 ou de ELO_PRO_LOGIN_URL",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function assertProductionEnv(env: EnvMap = process.env): void {
|
||||
if (env.NODE_ENV !== "production") return;
|
||||
if (env.NEXT_PHASE === "phase-production-build") return;
|
||||
if (env.npm_lifecycle_event === "build") return;
|
||||
const issues = collectProductionIssues(env);
|
||||
/** Mantido para compatibilidade com chamadas e testes existentes. */
|
||||
export function collectProductionIssues(env: EnvMap = process.env): EnvIssue[] {
|
||||
return collectConfigIssues(env);
|
||||
}
|
||||
|
||||
export function formatConfigIssues(issues: EnvIssue[]): string {
|
||||
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||
}
|
||||
|
||||
function skipAssertion(env: EnvMap): boolean {
|
||||
if (env.NEXT_PHASE === "phase-production-build") return true;
|
||||
if (env.npm_lifecycle_event === "build") return true;
|
||||
return env.VITEST === "true" || env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recusa a configuração inválida. Lança: quem chama decide entre encerrar o
|
||||
* processo (servidor e worker) ou apenas reportar (health check).
|
||||
*/
|
||||
export function assertDeployedEnv(env: EnvMap = process.env): void {
|
||||
if (!isDeployedEnv(env)) return;
|
||||
if (skipAssertion(env)) return;
|
||||
const issues = collectConfigIssues(env);
|
||||
if (issues.length === 0) return;
|
||||
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||
throw new Error(`Configuração de produção recusada. ${summary}`);
|
||||
throw new Error(`Configuração de ${adapterEnv(env)} recusada. ${formatConfigIssues(issues)}`);
|
||||
}
|
||||
|
||||
/** Mantido para compatibilidade. */
|
||||
export function assertProductionEnv(env: EnvMap = process.env): void {
|
||||
assertDeployedEnv(env);
|
||||
}
|
||||
|
||||
export function jobRunsInline(env: EnvMap = process.env): boolean {
|
||||
if (env.NODE_ENV === "production" && env.ADAPTER_JOB_MODE === "inline") return false;
|
||||
if (isDeployedEnv(env)) return false;
|
||||
if (env.ADAPTER_JOB_MODE === "worker") return false;
|
||||
if (env.ADAPTER_JOB_MODE === "inline") return true;
|
||||
return env.NODE_ENV !== "production";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection string obrigatória fora de development. Sem isto um segredo
|
||||
* ausente viraria uma migração silenciosa num arquivo PGlite local.
|
||||
*/
|
||||
export function requireDatabaseUrl(env: EnvMap = process.env): string | null {
|
||||
const databaseUrl = env.DATABASE_URL?.trim();
|
||||
if (databaseUrl) return databaseUrl;
|
||||
if (isDeployedEnv(env)) {
|
||||
throw new Error(
|
||||
`DATABASE_URL é obrigatória em ${adapterEnv(env)}. O banco local PGlite só existe em development.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { adapterEnv, isDeployedEnv } from "./env";
|
||||
|
||||
/** TTL de temporários. O número de produto ainda é decisão aberta. */
|
||||
export function tempTtlSeconds(): number {
|
||||
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
@@ -8,9 +10,9 @@ export function tempTtlSeconds(): number {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (isDeployedEnv() || process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em produção. A retenção de temporários continua decisão aberta.",
|
||||
`SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em ${adapterEnv()}. A retenção de temporários continua decisão aberta.`,
|
||||
);
|
||||
}
|
||||
return 60 * 60;
|
||||
|
||||
@@ -8,7 +8,8 @@ export type ErrorCategory =
|
||||
| "processing"
|
||||
| "timeout"
|
||||
| "storage"
|
||||
| "worker";
|
||||
| "worker"
|
||||
| "rate_limit";
|
||||
|
||||
export type PublicErrorView = {
|
||||
category: ErrorCategory;
|
||||
@@ -38,6 +39,8 @@ const VIEWS: Record<string, PublicErrorView> = {
|
||||
STORAGE_FAILURE: { category: "storage", action: "Não foi possível guardar o arquivo. Tente novamente." },
|
||||
INVALID_SETTING: { category: "invalid_configuration", action: "Revise a configuração com um administrador." },
|
||||
PROCESS_FAILED: { category: "processing", action: "Tente processar novamente." },
|
||||
RATE_LIMITED: { category: "rate_limit", action: "Aguarde o tempo indicado e tente de novo." },
|
||||
WORKSPACE_REQUIRED: { category: "authorization", action: "Selecione o workspace para continuar." },
|
||||
INTERNAL: { category: "temporary", action: "Tente novamente. Se continuar, informe a referência ao suporte." },
|
||||
};
|
||||
|
||||
|
||||
+37
-7
@@ -1,8 +1,11 @@
|
||||
import "server-only";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
|
||||
import { buildPostgresOptions } from "./connection-policy";
|
||||
import { createFileDb } from "./pglite";
|
||||
import { createPostgresDb } from "./postgres";
|
||||
import { migrate } from "./migrate";
|
||||
import { assertDatabaseGuard } from "./target-guard";
|
||||
import type { Db } from "./types";
|
||||
|
||||
let singleton: Promise<Db> | null = null;
|
||||
@@ -10,15 +13,42 @@ let singleton: Promise<Db> | null = null;
|
||||
export async function getDb(): Promise<Db> {
|
||||
if (!singleton) {
|
||||
singleton = (async () => {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("DATABASE_URL é obrigatório em produção.");
|
||||
// Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite.
|
||||
const databaseUrl = requireDatabaseUrl();
|
||||
if (databaseUrl) {
|
||||
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);
|
||||
return db;
|
||||
})();
|
||||
})().catch((error) => {
|
||||
singleton = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** Usado pelos health checks. Não cria o banco local nem aplica migrations. */
|
||||
export async function probeDb(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.withService((query) => query.query("select 1 as ok"));
|
||||
}
|
||||
@@ -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
@@ -1,28 +1,77 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { identityShimEnabled } from "@/config/env";
|
||||
import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest";
|
||||
import type { Db } from "./types";
|
||||
|
||||
/**
|
||||
* Executor de migrations. Lê o diretório `migrations/`, por isso só é usado
|
||||
* pelos scripts operacionais e pelo banco local de development — nunca por uma
|
||||
* rota. Quem precisa saber o estado do schema em runtime usa
|
||||
* `migration-manifest.ts`, que só consulta o banco.
|
||||
*
|
||||
* O caminho é estaticamente limitado a `<cwd>/migrations`: montar o caminho a
|
||||
* partir de uma variável fazia o Next tracear o projeto inteiro para dentro da
|
||||
* saída `standalone`, levando docs, testes e fontes para a imagem.
|
||||
*/
|
||||
|
||||
const MIGRATIONS_FOLDER = "migrations";
|
||||
|
||||
export type MigrateOptions = {
|
||||
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
|
||||
applyIdentityShim?: boolean;
|
||||
};
|
||||
|
||||
export function migrationsDir(): string {
|
||||
return path.join(process.cwd(), "migrations");
|
||||
return path.join(process.cwd(), MIGRATIONS_FOLDER);
|
||||
}
|
||||
|
||||
export async function migrate(db: Db, directory = migrationsDir()): Promise<string[]> {
|
||||
export async function listMigrationFiles(): Promise<string[]> {
|
||||
const entries = await readdir(path.join(process.cwd(), MIGRATIONS_FOLDER));
|
||||
return entries.filter((file) => file.endsWith(".sql")).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* O manifesto estático e o diretório precisam contar a mesma história. Um
|
||||
* arquivo novo sem entrada no manifesto passaria em branco pelo health check.
|
||||
*/
|
||||
export function assertManifestMatches(files: string[]): void {
|
||||
const expected: string[] = [...EXPECTED_MIGRATIONS];
|
||||
const extra = files.filter((file) => !expected.includes(file));
|
||||
const missing = expected.filter((file) => !files.includes(file));
|
||||
if (extra.length === 0 && missing.length === 0) return;
|
||||
const parts = [
|
||||
extra.length ? `fora do manifesto: ${extra.join(", ")}` : "",
|
||||
missing.length ? `ausentes no diretório: ${missing.join(", ")}` : "",
|
||||
].filter(Boolean);
|
||||
throw new Error(
|
||||
`migrations/ e EXPECTED_MIGRATIONS divergiram (${parts.join("; ")}). Atualize src/infra/db/migration-manifest.ts.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
|
||||
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
||||
|
||||
await db.exec(`
|
||||
create table if not exists public.schema_migrations (
|
||||
id text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
const files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort();
|
||||
const files = await listMigrationFiles();
|
||||
assertManifestMatches(files);
|
||||
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
|
||||
const applied = new Set(appliedRows.map((row) => row.id));
|
||||
const ran: string[] = [];
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = await readFile(path.join(directory, file), "utf8");
|
||||
if (isShimMigration(file) && !applyShim) continue;
|
||||
const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8");
|
||||
await db.exec(sql);
|
||||
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
|
||||
ran.push(file);
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
|
||||
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import postgres from "postgres";
|
||||
import type { PostgresClientOptions } from "./connection-policy";
|
||||
import { assertUuid, type Db, type Query, type Row } from "./types";
|
||||
|
||||
export function createPostgresDb(connectionString: string): Db {
|
||||
const sql = postgres(connectionString, { max: 8, prepare: false });
|
||||
export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
|
||||
const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
|
||||
const serviceQuery: Query = {
|
||||
async query<T = Row>(text: string, params: unknown[] = []) {
|
||||
return sql.unsafe(text, params as never[]) as unknown as T[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { adapterEnv } from "@/config/env";
|
||||
import { scrubSentryEvent, sentrySafeError } from "./scrub";
|
||||
|
||||
export { scrubSentryEvent, sentrySafeError };
|
||||
@@ -16,6 +17,7 @@ export function captureAdapterException(error: unknown, tags: Record<string, str
|
||||
void import("@sentry/nextjs").then((Sentry) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("module", "spreadsheet-adapter");
|
||||
scope.setTag("environment_tier", adapterEnv());
|
||||
for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
|
||||
Sentry.captureException(sentrySafeError(error));
|
||||
});
|
||||
@@ -26,8 +28,10 @@ export function sentryInitOptions(dsn: string | undefined) {
|
||||
if (!dsn) return null;
|
||||
return {
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
// Ambiente lógico do Adaptador: homologação e produção nunca se misturam
|
||||
// no mesmo grupo de eventos, mesmo com NODE_ENV=production nos dois.
|
||||
environment: process.env.SENTRY_ENVIRONMENT || adapterEnv(),
|
||||
release: process.env.SENTRY_RELEASE || process.env.NEXT_PUBLIC_SENTRY_RELEASE,
|
||||
sendDefaultPii: false,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,33 @@
|
||||
import { isDeployedEnv } from "@/config/env";
|
||||
import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory";
|
||||
import { RedisCoordinator } from "./redis";
|
||||
|
||||
let singleton: Coordinator | null = null;
|
||||
let singletonKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Coordenador reutilizado pelo processo. Criar um `ioredis` por requisição
|
||||
* vazava conexão: o cliente é lazy, abre no primeiro uso e nunca fecha.
|
||||
*/
|
||||
export function createCoordinator(): Coordinator {
|
||||
if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL);
|
||||
if (process.env.NODE_ENV === "production") return new UnavailableCoordinator();
|
||||
const url = process.env.REDIS_URL ?? "";
|
||||
const key = `${url}|${isDeployedEnv() ? "deployed" : "local"}`;
|
||||
if (singleton && singletonKey === key) return singleton;
|
||||
singletonKey = key;
|
||||
singleton = buildCoordinator(url);
|
||||
return singleton;
|
||||
}
|
||||
|
||||
function buildCoordinator(url: string): Coordinator {
|
||||
if (url) return new RedisCoordinator(url);
|
||||
if (isDeployedEnv()) return new UnavailableCoordinator();
|
||||
return new MemoryCoordinator();
|
||||
}
|
||||
|
||||
/** Usado em testes para não vazar estado entre casos. */
|
||||
export function resetCoordinator(): void {
|
||||
singleton = null;
|
||||
singletonKey = null;
|
||||
}
|
||||
|
||||
export type { Coordinator };
|
||||
@@ -2,6 +2,10 @@ import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
|
||||
export interface Coordinator {
|
||||
/** Verdadeiro quando a coordenação responde. Usado pelo health check. */
|
||||
ping(): Promise<boolean>;
|
||||
/** Contador por janela. Base do rate limiting. Retorna o total da janela. */
|
||||
hit(key: string, windowSeconds: number): Promise<number>;
|
||||
acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
|
||||
renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
|
||||
ownsLock(key: string, owner: string): Promise<boolean>;
|
||||
@@ -17,10 +21,25 @@ type LockRecord = { owner: string; expiresAt: number };
|
||||
export class MemoryCoordinator implements Coordinator {
|
||||
private readonly locks = new Map<string, LockRecord>();
|
||||
private readonly progress = new Map<string, JobProgress>();
|
||||
private readonly counters = new Map<string, { count: number; expiresAt: number }>();
|
||||
private readonly queue: string[] = [];
|
||||
|
||||
constructor(private readonly now: () => number = () => Date.now()) {}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async hit(key: string, windowSeconds: number): Promise<number> {
|
||||
const current = this.counters.get(key);
|
||||
if (!current || current.expiresAt <= this.now()) {
|
||||
this.counters.set(key, { count: 1, expiresAt: this.now() + windowSeconds * 1000 });
|
||||
return 1;
|
||||
}
|
||||
current.count += 1;
|
||||
return current.count;
|
||||
}
|
||||
|
||||
private live(key: string): LockRecord | null {
|
||||
const record = this.locks.get(key);
|
||||
if (!record) return null;
|
||||
@@ -76,6 +95,14 @@ export class UnavailableCoordinator implements Coordinator {
|
||||
throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
hit(): Promise<number> {
|
||||
this.fail();
|
||||
}
|
||||
|
||||
acquireLock(): Promise<boolean> {
|
||||
this.fail();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Redis from "ioredis";
|
||||
import { resourceNamespace } from "@/config/env";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
|
||||
import type { Coordinator } from "./memory";
|
||||
|
||||
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
||||
const RENEW = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`;
|
||||
const HIT = `local n = redis.call("incr", KEYS[1]) if n == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return n`;
|
||||
|
||||
export class RedisCoordinator implements Coordinator {
|
||||
private readonly redis: Redis;
|
||||
@@ -15,6 +17,8 @@ export class RedisCoordinator implements Coordinator {
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 2000,
|
||||
lazyConnect: true,
|
||||
// Separa este aplicativo dos demais do ecossistema no Redis compartilhado.
|
||||
keyPrefix: "",
|
||||
});
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
@@ -29,6 +33,19 @@ export class RedisCoordinator implements Coordinator {
|
||||
}
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
if (this.redis.status === "wait") await this.redis.connect();
|
||||
return (await this.redis.ping()) === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hit(key: string, windowSeconds: number): Promise<number> {
|
||||
return this.run(async () => Number(await this.redis.eval(HIT, 1, key, String(windowSeconds))));
|
||||
}
|
||||
|
||||
async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
|
||||
return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK");
|
||||
}
|
||||
@@ -69,20 +86,33 @@ export class RedisCoordinator implements Coordinator {
|
||||
async dequeue(): Promise<string | null> {
|
||||
return this.run(async () => this.redis.rpop(queueKey()));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixo de todas as chaves. Deriva de `ADAPTER_NAMESPACE` e do ambiente
|
||||
* lógico, não de `SENTRY_ENVIRONMENT`: observabilidade e isolamento de dados
|
||||
* são decisões diferentes, e o servidor é compartilhado com outros aplicativos.
|
||||
*/
|
||||
export function envNamespace(): string {
|
||||
return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local";
|
||||
return resourceNamespace();
|
||||
}
|
||||
|
||||
export function lockKey(userId: string): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`;
|
||||
return `${envNamespace()}:lock:user:${userId}`;
|
||||
}
|
||||
|
||||
export function progressKey(jobId: string): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
|
||||
return `${envNamespace()}:progress:${jobId}`;
|
||||
}
|
||||
|
||||
export function queueKey(): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:queue`;
|
||||
return `${envNamespace()}:queue`;
|
||||
}
|
||||
|
||||
export function rateLimitKey(bucket: string, subject: string, windowStart: number): string {
|
||||
return `${envNamespace()}:ratelimit:${bucket}:${subject}:${windowStart}`;
|
||||
}
|
||||
@@ -1,15 +1,42 @@
|
||||
import path from "node:path";
|
||||
import { isDeployedEnv } from "@/config/env";
|
||||
import { LocalPrivateStorage } from "./local";
|
||||
import { createSupabaseStorage } from "./supabase";
|
||||
import type { ObjectStorage } from "./memory";
|
||||
|
||||
let singleton: ObjectStorage | null = null;
|
||||
let singletonKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Armazenamento reutilizado pelo processo. O cliente Supabase mantém pool
|
||||
* de conexões HTTP: criar um por requisição desperdiça soquete.
|
||||
*/
|
||||
export function createStorage(): ObjectStorage {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
|
||||
if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta.");
|
||||
const key = `${url ?? ""}|${bucket ?? ""}|${serviceKey ? "svc" : "nosvc"}`;
|
||||
if (singleton && singletonKey === key) return singleton;
|
||||
|
||||
if (url && serviceKey && bucket) {
|
||||
singletonKey = key;
|
||||
singleton = createSupabaseStorage(url, serviceKey, bucket);
|
||||
return singleton;
|
||||
}
|
||||
return new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage"));
|
||||
if (isDeployedEnv()) {
|
||||
throw new Error(
|
||||
"Armazenamento privado não configurado. Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.",
|
||||
);
|
||||
}
|
||||
singletonKey = key;
|
||||
singleton = new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage"));
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** Usado em testes para não vazar estado entre casos. */
|
||||
export function resetStorage(): void {
|
||||
singleton = null;
|
||||
singletonKey = null;
|
||||
}
|
||||
|
||||
export type { ObjectStorage };
|
||||
@@ -61,4 +61,13 @@ export class LocalPrivateStorage implements ObjectStorage {
|
||||
await walk(start);
|
||||
return found.filter((key) => key.startsWith(prefix.replace(/\/$/, "")));
|
||||
}
|
||||
|
||||
async healthcheck(): Promise<boolean> {
|
||||
try {
|
||||
await mkdir(this.root, { recursive: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ export interface ObjectStorage {
|
||||
put(key: string, body: Buffer): Promise<void>;
|
||||
get(key: string): Promise<Buffer>;
|
||||
delete(key: string): Promise<void>;
|
||||
/** Listagem completa do prefixo. Implementações remotas devem paginar. */
|
||||
list(prefix: string): Promise<string[]>;
|
||||
/** Verdadeiro quando o bucket responde. Usado pelo health check. */
|
||||
healthcheck(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class MemoryStorage implements ObjectStorage {
|
||||
@@ -31,4 +34,8 @@ export class MemoryStorage implements ObjectStorage {
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
return [...this.objects.keys()].filter((key) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
async healthcheck(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import type { ObjectStorage } from "./memory";
|
||||
|
||||
/** Página da API de listagem do Supabase Storage. */
|
||||
const LIST_PAGE = 1000;
|
||||
/** Teto de segurança para não varrer indefinidamente um prefixo corrompido. */
|
||||
const LIST_MAX_PAGES = 100;
|
||||
|
||||
export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage {
|
||||
const client: SupabaseClient = createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
@@ -24,11 +29,59 @@ export function createSupabaseStorage(url: string, serviceKey: string, bucket: s
|
||||
assertStorageKey(key);
|
||||
await bucketApi().remove([key]);
|
||||
},
|
||||
/**
|
||||
* Listagem paginada e recursiva. A API devolve no máximo 1000 entradas por
|
||||
* chamada: sem paginar, a limpeza por TTL deixava de ver arquivos acima
|
||||
* disso e temporários venciam sem nunca serem removidos.
|
||||
*/
|
||||
async list(prefix) {
|
||||
const folder = prefix.replace(/\/$/, "");
|
||||
const { data, error } = await bucketApi().list(folder, { limit: 1000 });
|
||||
if (error || !data) return [];
|
||||
return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`);
|
||||
const found: string[] = [];
|
||||
const pending: string[] = [folder];
|
||||
let pages = 0;
|
||||
while (pending.length > 0 && pages < LIST_MAX_PAGES) {
|
||||
const current = pending.shift() as string;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
pages += 1;
|
||||
if (pages > LIST_MAX_PAGES) break;
|
||||
const { data, error } = await bucketApi().list(current, { limit: LIST_PAGE, offset });
|
||||
if (error || !data) break;
|
||||
for (const item of data) {
|
||||
if (!item.name) continue;
|
||||
const key = current ? `${current}/${item.name}` : item.name;
|
||||
// Entradas sem metadado de objeto são pastas: entram na fila.
|
||||
if (item.id === null || item.id === undefined) pending.push(key);
|
||||
else found.push(key);
|
||||
}
|
||||
if (data.length < LIST_PAGE) break;
|
||||
offset += data.length;
|
||||
}
|
||||
}
|
||||
return found.filter((key) => key.startsWith(folder));
|
||||
},
|
||||
async healthcheck() {
|
||||
try {
|
||||
const { error } = await bucketApi().list("", { limit: 1 });
|
||||
return !error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* O bucket precisa ser privado. Um bucket público torna qualquer chave um
|
||||
* link permanente e contorna a rota autenticada de download.
|
||||
*/
|
||||
export async function assertPrivateBucket(url: string, serviceKey: string, bucket: string): Promise<void> {
|
||||
const client = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||
const { data, error } = await client.storage.getBucket(bucket);
|
||||
if (error || !data) {
|
||||
throw new Error(`Bucket "${bucket}" não encontrado com a chave de serviço informada.`);
|
||||
}
|
||||
if (data.public) {
|
||||
throw new Error(`Bucket "${bucket}" é público. O Adaptador exige bucket privado.`);
|
||||
}
|
||||
}
|
||||
+37
-2
@@ -1,8 +1,20 @@
|
||||
import { assertProductionEnv } from "@/config/env";
|
||||
import { adapterEnv, assertDeployedEnv } from "@/config/env";
|
||||
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
||||
|
||||
/**
|
||||
* Executa no start do servidor. Configuração inválida encerra o processo com
|
||||
* código diferente de zero: um servidor que fica no ar respondendo 500 em todas
|
||||
* as rotas engana o orquestrador e o health check.
|
||||
*/
|
||||
export async function register() {
|
||||
assertProductionEnv(process.env);
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
// No Edge Runtime não há `process.exit`: reporta e deixa o runtime Node,
|
||||
// que roda em paralelo, encerrar o processo.
|
||||
assertDeployedEnv(process.env);
|
||||
} else {
|
||||
const { exitOnInvalidConfig } = await import("@/config/fail-fast");
|
||||
exitOnInvalidConfig(process.env);
|
||||
}
|
||||
const options = sentryInitOptions(process.env.SENTRY_DSN);
|
||||
if (!options) return;
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
@@ -13,3 +25,26 @@ export async function register() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erros de render e de route handler. Sem este export o Sentry do Next não
|
||||
* recebe as falhas do servidor automaticamente. O payload passa pelo mesmo
|
||||
* scrub: nada de conteúdo de célula.
|
||||
*/
|
||||
export async function onRequestError(
|
||||
error: unknown,
|
||||
request: { path: string; method: string; headers: Record<string, string | undefined> },
|
||||
context: { routerKind: string; routePath: string; routeType: string },
|
||||
) {
|
||||
if (!process.env.SENTRY_DSN) return;
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
const { sentrySafeError } = await import("@/infra/observability/scrub");
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("module", "spreadsheet-adapter");
|
||||
scope.setTag("environment_tier", adapterEnv());
|
||||
scope.setTag("route_path", context.routePath.slice(0, 120));
|
||||
scope.setTag("route_type", context.routeType);
|
||||
scope.setTag("http_method", request.method);
|
||||
Sentry.captureException(sentrySafeError(error));
|
||||
});
|
||||
}
|
||||
+8
-2
@@ -1,9 +1,14 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
/** Caminhos que exigem alguma sessão. A autorização real é no servidor. */
|
||||
const GUARDED = ["/adaptador", "/api/spreadsheet-adapter", "/selecionar-workspace"];
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const guarded = pathname.startsWith("/adaptador") || pathname.startsWith("/api/spreadsheet-adapter");
|
||||
const guarded = GUARDED.some((prefix) => pathname.startsWith(prefix));
|
||||
if (!guarded) return NextResponse.next();
|
||||
// Presença de cookie é só um atalho para evitar render desnecessário.
|
||||
// Nenhuma decisão de autorização acontece aqui.
|
||||
const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
|
||||
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||
if (hasDev || hasSupabase) return NextResponse.next();
|
||||
@@ -12,10 +17,11 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
const login = request.nextUrl.clone();
|
||||
login.pathname = "/login";
|
||||
login.search = "";
|
||||
login.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(login);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"],
|
||||
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*", "/selecionar-workspace"],
|
||||
};
|
||||
+36
-32
@@ -1,49 +1,45 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
|
||||
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
||||
import { activeProviders, WORKSPACE_COOKIE, type Identity } from "./identity-provider";
|
||||
import { listMemberships, resolveWorkspace, type Membership } from "./workspace";
|
||||
|
||||
export type AuthState =
|
||||
| { kind: "anonymous" }
|
||||
| { kind: "session_expired" }
|
||||
| { kind: "no_workspace" }
|
||||
| { kind: "not_member" }
|
||||
| { kind: "workspace_choice"; memberships: Membership[] }
|
||||
| { kind: "ok"; ctx: AuthenticatedContext };
|
||||
|
||||
export async function resolveAuth(): Promise<AuthState> {
|
||||
export { WORKSPACE_COOKIE };
|
||||
|
||||
async function resolveIdentity(): Promise<{ identity: Identity | null; hadHint: boolean }> {
|
||||
const jar = await cookies();
|
||||
let userId: string | null = null;
|
||||
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
|
||||
const devToken = jar.get("adapter_dev_session")?.value;
|
||||
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||
if (devToken && devAuthEnabled()) {
|
||||
const dev = verifyDevSession(devToken);
|
||||
if (dev) {
|
||||
userId = dev.userId;
|
||||
workspaceId = dev.workspaceId;
|
||||
}
|
||||
const providers = activeProviders();
|
||||
let hadHint = false;
|
||||
for (const provider of providers) {
|
||||
if (provider.hadSessionHint(jar)) hadHint = true;
|
||||
const identity = await provider.resolve(jar);
|
||||
if (identity) return { identity, hadHint };
|
||||
}
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!userId && supabaseUrl && supabaseKey) {
|
||||
const supabase = createServerClient(supabaseUrl, supabaseKey, {
|
||||
cookies: {
|
||||
getAll: () => jar.getAll(),
|
||||
setAll: (items) => {
|
||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { data } = await supabase.auth.getUser();
|
||||
userId = data.user?.id ?? null;
|
||||
}
|
||||
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
|
||||
if (!workspaceId) return { kind: "no_workspace" };
|
||||
return { identity: null, hadHint };
|
||||
}
|
||||
|
||||
export async function resolveAuth(): Promise<AuthState> {
|
||||
const { identity, hadHint } = await resolveIdentity();
|
||||
if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.withUser(userId, (query) =>
|
||||
const memberships = await listMemberships(db, identity.userId);
|
||||
const resolution = resolveWorkspace(memberships, identity.workspaceHint);
|
||||
if (resolution.kind === "none") return { kind: "no_workspace" };
|
||||
if (resolution.kind === "choice") return { kind: "workspace_choice", memberships: resolution.memberships };
|
||||
|
||||
const workspaceId = resolution.workspaceId;
|
||||
const rows = await db.withUser(identity.userId, (query) =>
|
||||
query.query<{
|
||||
display_name: string;
|
||||
is_superadmin: boolean;
|
||||
@@ -61,13 +57,13 @@ export async function resolveAuth(): Promise<AuthState> {
|
||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||
where p.id = $1`,
|
||||
[userId, workspaceId],
|
||||
[identity.userId, workspaceId],
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return { kind: "not_member" };
|
||||
const ctx = {
|
||||
userId,
|
||||
userId: identity.userId,
|
||||
displayName: row.display_name,
|
||||
isSuperadmin: row.is_superadmin,
|
||||
workspaceId,
|
||||
@@ -78,3 +74,11 @@ export async function resolveAuth(): Promise<AuthState> {
|
||||
};
|
||||
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
|
||||
}
|
||||
|
||||
/** Lista as associações do usuário autenticado, para a tela de escolha. */
|
||||
export async function currentMemberships(): Promise<{ userId: string; memberships: Membership[] } | null> {
|
||||
const { identity } = await resolveIdentity();
|
||||
if (!identity) return null;
|
||||
const db = await getDb();
|
||||
return { userId: identity.userId, memberships: await listMemberships(db, identity.userId) };
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { adapterEnv } from "@/config/env";
|
||||
|
||||
export type DevSession = { userId: string; workspaceId: string; exp: number };
|
||||
|
||||
/**
|
||||
* Sessão local sem senha. Existe apenas em `ADAPTER_ENV=development`.
|
||||
* Homologação pública usa Supabase Auth real — ver `stagingAuthEnabled`.
|
||||
*
|
||||
* A autoridade é `ADAPTER_ENV`, não `NODE_ENV`: a imagem roda sempre com
|
||||
* `NODE_ENV=production` (build otimizado), inclusive no compose local. E o
|
||||
* padrão protege o caso perigoso: sem `ADAPTER_ENV` explícito, um processo com
|
||||
* `NODE_ENV=production` resolve para `production` e esta função devolve false.
|
||||
*/
|
||||
export function devAuthEnabled(): boolean {
|
||||
return process.env.NODE_ENV !== "production" && process.env.ADAPTER_DEV_AUTH === "1" && Boolean(process.env.ADAPTER_DEV_AUTH_SECRET);
|
||||
return (
|
||||
adapterEnv() === "development" &&
|
||||
process.env.ADAPTER_DEV_AUTH === "1" &&
|
||||
Boolean(process.env.ADAPTER_DEV_AUTH_SECRET)
|
||||
);
|
||||
}
|
||||
|
||||
function secret(): string {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "server-only";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { createCoordinator } from "@/infra/redis";
|
||||
import { rateLimitKey } from "@/infra/redis/redis";
|
||||
import type { Coordinator } from "@/infra/redis/memory";
|
||||
|
||||
export type RateLimitBucket = "login" | "upload" | "process" | "download" | "mutation" | "read";
|
||||
|
||||
export type RateLimitRule = { limit: number; windowSeconds: number };
|
||||
|
||||
/**
|
||||
* Tetos por janela. São limites de abuso, não de produto: o uso normal do
|
||||
* piloto fica muito abaixo. `upload` e `process` são os caros — leem 10 MB
|
||||
* e geram XLSX, CSV e PDF.
|
||||
*/
|
||||
export const RATE_LIMITS: Record<RateLimitBucket, RateLimitRule> = {
|
||||
login: { limit: 10, windowSeconds: 300 },
|
||||
upload: { limit: 30, windowSeconds: 300 },
|
||||
process: { limit: 20, windowSeconds: 300 },
|
||||
download: { limit: 120, windowSeconds: 300 },
|
||||
mutation: { limit: 120, windowSeconds: 300 },
|
||||
read: { limit: 600, windowSeconds: 300 },
|
||||
};
|
||||
|
||||
export function windowStart(rule: RateLimitRule, now = Date.now()): number {
|
||||
return Math.floor(now / (rule.windowSeconds * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta o acesso e devolve o estado da janela. Não lança: quem chama decide.
|
||||
* Falha de coordenação não bloqueia leitura — o processamento já é recusado
|
||||
* por conta própria quando o Redis está fora.
|
||||
*/
|
||||
export async function hitRateLimit(
|
||||
bucket: RateLimitBucket,
|
||||
subject: string,
|
||||
options: { coordinator?: Coordinator; now?: number } = {},
|
||||
): Promise<{ allowed: boolean; limit: number; remaining: number; retryAfterSeconds: number }> {
|
||||
const rule = RATE_LIMITS[bucket];
|
||||
const coordinator = options.coordinator ?? createCoordinator();
|
||||
const now = options.now ?? Date.now();
|
||||
const key = rateLimitKey(bucket, subject, windowStart(rule, now));
|
||||
let count: number;
|
||||
try {
|
||||
count = await coordinator.hit(key, rule.windowSeconds);
|
||||
} catch {
|
||||
// Sem coordenação não há contagem confiável. Não inventa bloqueio.
|
||||
return { allowed: true, limit: rule.limit, remaining: rule.limit, retryAfterSeconds: 0 };
|
||||
}
|
||||
const elapsed = now % (rule.windowSeconds * 1000);
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((rule.windowSeconds * 1000 - elapsed) / 1000));
|
||||
return {
|
||||
allowed: count <= rule.limit,
|
||||
limit: rule.limit,
|
||||
remaining: Math.max(0, rule.limit - count),
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Versão que recusa a requisição. Usada pelas rotas do módulo. */
|
||||
export async function enforceRateLimit(
|
||||
bucket: RateLimitBucket,
|
||||
subject: string,
|
||||
options: { coordinator?: Coordinator; now?: number } = {},
|
||||
): Promise<void> {
|
||||
const result = await hitRateLimit(bucket, subject, options);
|
||||
if (result.allowed) return;
|
||||
throw new AdapterError(
|
||||
"RATE_LIMITED",
|
||||
`Muitas chamadas em sequência. Aguarde ${result.retryAfterSeconds} segundos e tente de novo.`,
|
||||
429,
|
||||
{ retryAfterSeconds: result.retryAfterSeconds, limit: result.limit },
|
||||
);
|
||||
}
|
||||
|
||||
/** Identificador do chamador anônimo, para a tela de login. */
|
||||
export function requestSubject(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip = forwarded?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "desconhecido";
|
||||
return ip.slice(0, 64);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissio
|
||||
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
|
||||
import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
|
||||
import { buildPdf } from "@/infra/export/pdf";
|
||||
import type { Query } from "@/infra/db/types";
|
||||
import { lockKey } from "@/infra/redis/redis";
|
||||
import { sanitizeAuditMetadata } from "@/infra/observability/scrub";
|
||||
@@ -282,6 +281,10 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
||||
const grid = parseCsvText(csv);
|
||||
const headers = grid[0] ?? [];
|
||||
// Import tardio: o worker não gera PDF (o PDF sai sob demanda no download),
|
||||
// e carregar @react-pdf/renderer fora do bundler do Next falha ao resolver
|
||||
// o subpath de hifenização. Sem isto o processo do worker nem subia.
|
||||
const { buildPdf } = await import("@/infra/export/pdf");
|
||||
const body = await buildPdf(file.model_name, headers, grid.slice(1));
|
||||
return { filename, contentType: CONTENT_TYPE.pdf, body: body.body };
|
||||
}
|
||||
@@ -294,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||
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 }>(
|
||||
`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 < $1::timestamptz and s.status <> 'expired'`,
|
||||
`select id, workspace_id, temp_storage_key, output_csv_key, output_xlsx_key
|
||||
from public.adapter_list_expired_sources($1::timestamptz)`,
|
||||
[(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" });
|
||||
});
|
||||
}
|
||||
await deps.db.withService((query) =>
|
||||
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
|
||||
);
|
||||
await deps.db.withService((query) => query.query(`select public.adapter_mark_source_expired($1::uuid)`, [row.id]));
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
@@ -321,19 +320,14 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||
const rows = await deps.db.withService((query) =>
|
||||
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;
|
||||
for (const row of rows) {
|
||||
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
|
||||
if (owns) continue;
|
||||
await deps.db.withService((query) =>
|
||||
query.query(
|
||||
`update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`,
|
||||
[row.id],
|
||||
),
|
||||
);
|
||||
await deps.db.withService((query) => query.query(`select public.adapter_mark_job_interrupted($1::uuid)`, [row.id]));
|
||||
recovered += 1;
|
||||
}
|
||||
return recovered;
|
||||
@@ -342,7 +336,7 @@ export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||
export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
||||
return deps.db.withService((query) =>
|
||||
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],
|
||||
),
|
||||
);
|
||||
@@ -350,7 +344,7 @@ export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
||||
|
||||
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user