Projeto Profissional — base template for new repos
Pedro's canonical starting point for any new web project. Instead of scaffolding
from scratch (and re-deciding security every time), copy the vetted template and
adapt it.
Template location: /home/pedro/Repositorios/templates/projeto-profissional
(git-initialised, 44 passing tests, boot-verified, load-tested with real k6 numbers).
Origin: distilled from /home/pedro/Repositorios/academicos/sistema-academico,
keeping that project's layered architecture but stripping the academic domain.
When to use
- "cria um projeto novo / um repositório novo / o primeiro commit"
- "quero um modelo base pro meu GitHub"
- "preciso de login com admin nesse app"
- Any new Node web app for Pedro — start here, don't hand-roll auth.
Stack (do not swap without being asked)
Node 20 + Express · MongoDB/Mongoose · EJS SSR + vanilla JS (no bundler, no
React, no build step) · Zod · JWT HS256 · Jest + Supertest +
mongodb-memory-server · Docker Compose.
Procedure
- Copy, don't regenerate.
cp -r /home/pedro/Repositorios/templates/projeto-profissional <destino>
then rm -rf <destino>/.git <destino>/app/node_modules and git init.
- Rename in
app/package.json (name, description), .env.example and
docker-compose.yml (DB name, APP_NAME), and the brand in
app/views/partials/header.ejs + app/views/landing.ejs.
- Adjust roles if needed — the
role enum lives in BOTH
src/models/user.model.js and src/schemas/admin.schemas.js. Change together.
- Add the domain following the chain: model → Zod schema → service →
controller → routes → register in
routes/index.js → test in tests/.
For a domain with several entities, write the layers in that order for ALL
entities before touching views — the suite can go green before any UI
exists, which is the cheapest place to find modelling mistakes. Money,
monthly competence, weighted average cost and CSP-safe SVG charts:
references/money-and-charts.md.
When Pedro names an app category ("controle financeiro", "gestão de X"),
look up the established open-source players first and port their model
(Firefly III, Actual Budget, Ghostfolio for finance) instead of inventing
entities — then say which ones you drew from.
Ship a scripts/seed-demo.js with realistic data so the screens can be
inspected for real; guard it like seed-carga.js (pitfall 26). Quando o
domínio pedido é "bastante simples" (ex.: task manager board + calendário),
use o padrão copy-paste em references/task-manager-simples.md — sem
drag-and-drop nem redesenho do shell. If the app has more than one domain, treat them as optional modules from
the start (MODULE_<NOME> flags, guard per request, dashboard desacoplado):
references/optional-modules.md. Retrofitting modularity later means
rewriting the dashboard.
- Backup do banco de produção: copie
templates/backup.sh para
<repo>/scripts/backup.sh (mongodump dentro do container → zip com
timestamp + MANIFEST de restauração, chmod 600, retenção configurável).
Copie também templates/reset-senha.js para <repo>/app/scripts/ — sem ele,
perder a senha do admin só se resolve escrevendo hash na mão no banco.
Acesso às duas stacks, criação de usuário e o 429 do limiter:
references/operacao-e-acesso.md. Todo projeto derivado ganha um
docs/operacao.md com esse conteúdo.
- Verify for real before reporting done:
cd app && npm install && npm test (must be green), then boot-smoke it.
See scripts/smoke-boot.js — copy it into app/ and run it there
(it needs mongodb-memory-server from the app's own node_modules; running
it from /tmp fails with MODULE_NOT_FOUND).
For the structural invariants Jest can't reach (stack isolation, k6
scenario, CSP, workflows) run scripts/verify-stacks.sh <destino>, and for
live brute-force behaviour (which Jest cannot cover at all) run
scripts/verify-bruteforce.sh <destino> 3 30 — it boots both stacks, asserts
the exact 401 401 401 429 sequence, and tears them down via trap.
- Commit and confirm
git ls-files shows no .env and no node_modules.
Non-negotiable design decisions
Carry these into every derived project; they are the point of the template.
- No public self-registration. Admin creates accounts
(
POST /api/admin/users); server generates a temp password shown exactly
once, account is born with mustChangePassword.
- Strict layers: Route → Controller → Service → Model. Services never
receive
req; they take validated data + userId explicitly.
- Zod on every POST/PUT/PATCH via
validate(schema).
AppError(msg, status) for expected errors; errorHandler is the only
place that formats an error response.
- CSP without
unsafe-inline ⇒ zero inline <script> in views; all JS in
public/js/, wired through the pageScript footer variable.
- Secrets only via
.env read by config/env.js; .env.example holds
empty keys, never real values.
Security baseline already wired
bcrypt cost 12 + select:false on the hash · 12-char password policy ·
account lockout 3 attempts/30min · timing-safe dummy-hash compare (anti-enumeration) ·
identical /forgot-password response either way · tokenValidAfter for global
session revocation · csrfGuard (Origin/Referer) · sanitizeInput (NoSQL
injection) · Helmet+CSP · rate limits · CORS allowlist (never *) · 100kB body
cap · AuditLog with 180-day TTL · non-root read-only container.
Full rationale: references/security-baseline.md.
Brute-force protection is TWO layers — tune them together
Anti-brute-force lives in two independent places, and changing one without the
other silently disables it:
| Layer |
Where |
Protects |
Env vars |
| IP rate limit |
middleware/rateLimiters.js |
the service (one noisy IP) |
RATE_LIMIT_AUTH_MAX, RATE_LIMIT_AUTH_WINDOW_MIN |
| Account lockout |
services/authService.js → lockedUntil |
the account (distributed credential stuffing) |
MAX_FAILED_ATTEMPTS, LOCKOUT_MIN |
Current default: 3 attempts / 30 min on both. When Pedro asks for "3
tentativas, bloqueio de 30 min", he means the observable behaviour — set both
layers. Leaving the account lockout at a higher count than the IP limit means
the lockout can never fire (the IP limiter answers 429 first), so the layer
that defends against distributed attacks is dead code.
Both are read from config/env.js — no magic numbers in authService.js.
Defaults are pinned by tests/config.test.js.
Verify end-to-end, not just by reading config: the limiters are disabled
under NODE_ENV=test, so Jest structurally cannot prove the limit. Only a
running container can:
for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' \
-X POST localhost:4447/api/auth/login -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"errada123456"}'; done
# esperado exatamente: 401 401 401 429
curl -sD- -o /dev/null ... | grep -i ratelimit-policy # RateLimit-Policy: 3;w=1800
Assert the exact sequence ("401 401 401 429 "), not "contains 429" — the
loose version passes even if it locks on the first attempt. Then confirm the
correct password still returns 429 (proves real blocking, not error
counting), and that lockedUntil is ~30 min ahead in Mongo.
Uma instância, teste de carga via NODE_ENV
O template sobe como uma instância na porta 4450 (docker-compose.yml
apenas). Para teste de carga, suba com NODE_ENV=staging (desativa o rate
limit) — não há mais um segundo compose de teste. A demo é sempre acessível
pela landing. Veja docs/load-testing.md.
|
Instância única |
| File |
docker-compose.yml |
| DB |
app_db, app_test_db, app_demo_db (mesma instância Mongo) |
| Port |
${BIND_ADDR:-127.0.0.1}:4450 |
NODE_ENV |
production (ou staging para carga, sem rate limit) |
| Rate limiting |
ativa em production, desativada em staging |
NODE_ENV=staging JWT_SECRET=$(openssl rand -base64 48) docker compose up -d --build
Load testing (k6)
Full methodology, measured numbers and capacity planning:
references/load-testing.md. Scenario lives at loadtest/carga.js with five
profiles (smoke, carga, estresse, pico, auth) and k6 thresholds that
fail CI when crossed.
Reference measurements (i5-9400F 6 cores, single instance): 100 VUs → 0%
errors, 214 req/s; 200 VUs → still 0% errors but latency over target. The
bottleneck is always login (bcrypt cost 12 is CPU-bound by design,
~4.5 logins/s per instance); reads and SSR cost ~3-6 ms. The app is stateless,
so the answer is horizontal scaling — never quietly lower the bcrypt cost to
win a benchmark.
Pitfalls (all hit for real)
JWT iat is in SECONDS, truncated. Comparing payload.iat * 1000 < tokenValidAfter.getTime() rejects legitimate tokens minted in the same
second. Normalise both sides to seconds. Covered by a test — keep it.
passwordHash has select:false. Any query that must compare a
password needs .select('+passwordHash'), or bcrypt.compare silently
receives undefined.
COOKIE_SECURE=true over plain HTTP ⇒ browser discards the cookie:
login returns 200 and the session still doesn't stick. Classic LAN/VPN trap.
upgradeInsecureRequests must stay off while serving HTTP, or CSS/JS
fail to load and the page renders unstyled.
Express 5 makes req.query a getter — sanitizeInput mutates in place;
do not "simplify" it to req.query = {...}.
Never build a RegExp from user input unescaped (user search) — escape
metacharacters or it's a ReDoS.
npm ci in CI needs package-lock.json committed.
Load-test scripts must reuse the session token. Logging in on every
k6 iteration is a login-storm, not realistic traffic: with bcrypt 12 it
drove 200 VUs to 60s latency and 6% errors and made the app look broken.
Log in once per VU, reuse the token, and isolate the worst case in its own
auth profile.
grep -q 'app_db' also matches app_test_db. Substring asserts cannot
tell the two stacks apart — compare the full resolved URI for equality.
Also export a dummy JWT_SECRET before docker compose config, or
interpolation fails and the command returns empty (a silent false pass).
The container serves the image, not your edited file. After changing a
view/CSS, docker restart keeps the stale build-time copy — rebuild
(up -d --build) before believing a visual fix didn't work.
dotenv loads the project .env and overrides env vars you pass when
testing config guards. Run such probes with cwd outside the project
(e.g. /tmp) or the guard appears not to fire.
The k6 container runs as its own UID and cannot traverse a 0700 home
directory to reach a bind mount ("permission denied" reading the script).
Set user: "0:0" on that ephemeral test container instead of loosening
permissions on the host.
A port change is a repo-wide edit, not a compose edit. The host port
appears in both compose files, .env.example, the PORT default in
config/env.js, the k6 BASE_URL default, the readiness curl in
carga.yml, the nginx proxy_pass in docs/deployment.md, and the dev
URL in README/AGENTS/CLAUDE/CONTRIBUTING. Grep for the old number and
confirm zero hits before committing. Only the host side changes — the
container keeps listening on 5000 (4447:5000).
Tightening a limit invalidates existing tests. The old
"bloqueia após 5 tentativas" loop still passes with a limit of 3 (it just
over-shoots), so it silently stops testing anything. When a threshold
changes, rewrite the assertion to pin the exact boundary: two failures
must still return 401, the third must arm the lockout.
127.0.0.1 in the compose port binding means "this machine only".
The page simply won't open from another host and the app logs look
perfectly healthy — nothing errors, so it reads like a firewall problem.
Publish via ${BIND_ADDR:-127.0.0.1} and set BIND_ADDR to the reachable
interface (Pedro's Tailscale IP is 100.120.54.126; 0.0.0.0 only behind
a firewall). Update APP_BASE_URL to match, and keep COOKIE_SECURE=false
over plain HTTP or the session won't stick (see pitfall 3).
A missing JWT_SECRET fails at compose interpolation, not at boot.
docker compose up aborts with "required variable JWT_SECRET is missing"
and no container is created — easy to misread as an app crash. Create
.env (chmod 600, git-ignored) with
echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env first.
Once a real .env exists, it poisons default-value checks. docker compose config merges it, so a probe asserting the template's built-in
fallback (e.g. BIND_ADDR defaulting to 127.0.0.1) reads Pedro's local
value and reports a false failure. Isolate with
docker compose --env-file /dev/null ... when verifying defaults. Same
family as pitfall 11.
Inserting prose into a markdown table splits it in two. Patching a
README by anchoring on a | row | and appending a paragraph lands the text
between rows and silently breaks the table. Anchor on the sentence
after the table instead, then re-read the rendered section to confirm.
A new <%= var %> in a view is a runtime 500, not a lint error. Routes
call bare res.render('landing'), so an EJS variable nobody passes only
explodes when the page is requested. Register shared values once with
app.locals.appName = env.appName in app.js instead of threading them
through every route, and verify by compiling the template directly:
node -e "require('ejs').render(fs.readFileSync('views/landing.ejs','utf8'),{appName:'X'})".
An in-page anchor needs its target to exist. href="#topo" silently
does nothing unless some element carries id="topo" — put it on <body>
in the header partial. Prefer the pure-anchor + scroll-behavior: smooth
combo over JS: the CSP forbids inline scripts anyway.
The template's .field/.card rules defeat the hidden attribute.
hidden only sets display:none through the UA stylesheet, so any class
rule with display: block wins and a field you "hid" from JS stays on
screen — the JS looks broken while el.hidden === true. Add
[hidden] { display: none !important; } to main.css once per derived
project. Diagnose this class of bug by comparing el.hidden against
getComputedStyle(el).display, not by re-reading the JS.
A new derived project collides with the template's own ports/project
name. The template stack is usually still up on 4447/4446 under compose
project pp. Before the first up -d, pick fresh host ports AND a fresh
project name (name: key + -p), then do pitfall 13's repo-wide sweep in
one pass over the known file list:
perl -pi -e 's/4447/4451/g; s/4446/4450/g; s/-p pp\b/-p fa/g; s/app_db/<novo>_db/g' <files>
Rename the DB too — two projects sharing app_db in your head is how a
demo seed lands in the wrong volume. Then grep for the old numbers/names
and confirm zero hits, INCLUDING inside tests/config.test.js, whose seed
guard asserts a literal DB name.
Point a booted stack at a scratch DB with an override file, not by
editing the committed compose. Write three lines to
/tmp/<proj>-demo.override.yml setting MONGO_URI to a *_demo database
and boot with -f docker-compose.yml -f /tmp/....yml. Keeps the repo clean
and the demo reproducible.
docker compose must run from the repo root, not from app/. From the
Node subdirectory it fails with open .../app/docker-compose.yml: no such file or directory, which reads like a missing file rather than a wrong cwd.
After a rebuild the browser still serves the old CSS/JS. Pitfall 10
covers the container; the other half is client cache, and it produces the
exact same symptom ("my fix did nothing"). Confirm the served bytes with
curl -s host/css/main.css | tail, and in the page use
fetch(url,{cache:'reload'}) before concluding the fix failed.
Any destructive seed needs a DB-name guard and a test for it. A demo
seeder that deleteMany({}) on every collection is total data loss if
pointed at production. Copy the seed-carga.js pattern — refuse unless the
URI matches /test|demo/i — and pin it in tests/config.test.js alongside
the existing guard.
Página vs JSON de mapeamento moram em prefixes de montagem diferentes.
routes/index.js é montado sob /api no app.js; pages.routes.js é
montado sob /. Uma rota router.get('/status') dentro de um arquivo sob
/api vira /api/status, não /status. Por isso o mapeamento HTTP se
divide: o JSON GET /api/status/:code fica em status.routes.js (definido
como router.get('/:code') e montado em routes/index.js sob /status →
resultado /api/status/:code); a página GET /status fica em
pages.routes.js. Misturar os dois num só arquivo faz a página ou o JSON
sumirem (vieram 404 até acertar esse split).
Catálogos separados: importe de onde cada um vive. HTTP_CATALOG está
em status.routes.js; ERROR_CATALOG/catalogFor estão em
errorHandler.js. Um teste que faz
require('../src/routes/status.routes').ERROR_CATALOG recebe undefined e
quebra com Cannot read properties of undefined. Importe cada um do seu
módulo.
Testar variante de erro exige mini-app com handler DEPOIS da rota de
teste. Para renderizar cada status 4xx/5xx, monte um express() isolado,
registre app.get('/_e_:code', (req,res,next)=>next(new AppError('x',Number(req.params.code))))
e SÓ DEPOIS app.use(notFoundHandler); app.use(errorHandler). Se o
notFoundHandler vier antes, ele responde 404 para a rota de teste e o
status forçado nunca chega ao errorHandler.
Botão "Voltar" não pode usar javascript: — a CSP bloqueia. href= "javascript:history.back()" é script inline disfarçado e a CSP
(script-src 'self') o impede; o clique não faz nada. Passe backUrl do
servidor usando o Referer same-origin (cai em / se ausente/externo).
Mesma regra do "Voltar ao topo", que é âncora pura #topo.
Script de verificação ad-hoc precisa rodar de dentro de app/. Rodar de
/tmp falha com MODULE_NOT_FOUND (mongodb-memory-server, supertest etc.
estão em app/node_modules). Copie o script para app/ com nome
hermes-verify-*.js ou use cwd em app/. Vale para smoke-boot.js e
qualquer probe que exija os deps do app — não confunda com o pitfall 11, que
manda rodar probe de config de /tmp justamente para o .env não
sobrescrever o ambiente.
README não é só 'como rodar' — Pedro quer que ensine a DESENVOLVER,
RODAR e DOCUMENTAR. Ao tocar no README do template (ou de projeto
derivado), inclua: (a) seção Desenvolvimento — fluxo de branch
feat/*/fix/*, .env, loop dev→test→verify, lint DESIGN.md e
npm audit, Conventional Commits e arquitetura em camadas; (b) seção
Documentação — tabela de responsabilidade dos arquivos de doc
(README/AGENTS/CLAUDE/CONTRIBUTING/SECURITY/CHANGELOG/DESIGN/docs) e regras
práticas (métrica só real, CSP na view, tokens no DESIGN.md, CHANGELOG
humano). Não entregue README que só lista comandos de instalação.
O status de validação deste template é 422, não 400. Testes novos
escritos por instinto (expect(res.status).toBe(400)) falham em massa
contra Zod/validate(). Confirme com uma chamada real antes de escrever a
bateria inteira. Idem para idParamSchema: id malformado dá 422.
Fixture curta demais vira "Cannot read properties of undefined".
Schemas usam .min(2) em description/nickname, então um helper de
teste que manda 'X'/'A' recebe 422 e res.body.<entidade> fica
undefined — o erro estoura três linhas depois, ao ler ._id, e parece
bug do service. Ao ver undefined lendo o corpo de um POST no teste,
cheque o status da resposta ANTES de investigar o código.
A suíte precisa de --forceExit. npx jest sozinho pendura após o
último teste (handle aberto do mongodb-memory-server/app) e estoura o
timeout do comando — parece suíte travada ou falhando quando na verdade
passou. Rode npx jest --forceExit e leia a linha Tests:.
jest.resetModules() para recarregar flags mata a conexão do
mongoose. Remontar o app por teste (para reler env) cria uma instância
nova do mongoose que não enxerga a conexão do setupDb; cada teste morre
em timeout de 5s sem mensagem útil. A saída não é remontar — é fazer a
config ser lida por getter e o guard rodar por request (ver
"Módulos opcionais"), aí um único app cobre todas as combinações só
mexendo em process.env.
Rota transversal não pode morar sob o prefixo de um módulo.
GET /api/financas/dashboard some junto quando finanças é desligado,
mesmo que ele agregue os outros módulos. Monte-a na raiz da API
(GET /api/dashboard, em routes/index.js) e atualize o fetch da view.
PUT vs POST: confira a rota real antes de escrever o inventário.
Orçamento é upsert de envelope, logo PUT /api/financas/orcamentos. Um
teste de inventário que assume POST recebe 404 e parece rota faltando.
O container é read-only: script novo entra pela IMAGEM, não por cp.
O baseline roda o container com rootfs read-only, então
docker compose cp arquivo app:/app/scripts/ falha com "container rootfs
is marked read-only" — parece permissão do host, mas é o hardening
funcionando. Commite o arquivo e rode up -d --build app.
Guard que responde antes do roteamento cega o teste de 404. Em
/api/admin/* o requireRole devolve 403 antes de o Express concluir que
a rota não existe, então um caminho ERRADO (/usuarios em vez de /users)
passa no not.toBe(404) e no toContain(403). O inventário fica verde
testando rota inexistente, e o defeito só aparece num curl real. Copie os
caminhos de admin do admin.routes.js; nunca os digite de memória.
AppError expõe statusCode, não status. Um probe que assere
(e) => e.status === 409 recebe undefined, o assert.rejects reprova
com "validation function is expected to return true" e parece que o
service não lançou o erro — quando lançou o certo. Use
e.statusCode. (Nos testes de rota isso não aparece, porque lá se lê
res.status do Supertest — daí o instinto errado.)
Ampliar um módulo para o domínio genérico é renomeação em ~8 arquivos.
moto → veiculos (carro + moto) toca service/controller/rotas/schemas,
mas também routes/index.js, config/env.js (getter e toJSON),
pages.routes.js, o bloco do relatorioService.dashboard() (a chave do
payload muda e quebra a view que lia d.moto), .env.example e a doc.
Receita completa, incluindo o discriminador type, o filtro por tipo em
coleções filhas e o consumo por combustível de carro flex:
references/optional-modules.md, seção "Renomear ou ampliar um módulo".
"Melhore o X" pode se referir a algo que NÃO EXISTE. Pedro pediu
"o pdf de extrato tem que ficar melhor" — não havia exportação nenhuma no
projeto, era item de roadmap (issue #2). Sair "melhorando" vira construir
do zero uma feature com escopo adivinhado. Antes de aceitar um verbo de
melhoria, grep pela feature; se não existir, diga isso e pergunte o
escopo em vez de assumir. (Ele não respondeu no tempo do clarify, e aí
seguir com a opção mais completa foi o certo — mas o aviso explícito de
que era construção, não ajuste, é obrigatório no relatório.)
Agregado sobre coleção heterogênea precisa de teste com DOIS donos.
$group: {_id: null, min/max} de odômetro somando carro + moto inventou
"34.410 km rodados, 237 km/l" e passou em toda a suíte, porque todo teste
existente tinha um veículo só. Sempre que um número derivar de min/max/
amplitude sobre linhas de entidades diferentes, escreva o caso com dois
registros distintos — é a única fixture que reprova a fórmula errada.
Detalhe e o snippet corrigido em references/optional-modules.md (item 6).
pageScript recebe o NOME da página, não o caminho. O footer monta
<script src="/js/<%= pageScript %>.js">. Passar '/js/veiculos.js' gera
/js//js/veiculos.js.js → 404 do script. Sintoma cruel: a página renderiza
inteira (é SSR), a API responde 200 em todo endpoint, o console não
registra erro nenhum — só as tabelas ficam vazias, o que parece bug de
service ou de banco. Confira com
[...document.querySelectorAll('script')].map(s=>s.src) antes de
investigar o backend. Convenção: { pageScript: 'veiculos' }; página sem
JS chama include('partials/footer') sem argumento (passar null também
quebra).
O front recebe vehicleId populado como OBJETO nas listagens. O
service faz .populate('vehicleId', 'nickname type'), então
veiculos.find(v => v._id === String(item.vehicleId)) nunca casa e a
coluna inteira vira —. Escreva um helper que aceite os dois formatos:
const nome = (ref) => !ref ? '—' : (typeof ref === 'object' ? ref.nickname : lista.find(v => v._id === String(ref))?.nickname ?? '—').
new Date(iso).toLocaleDateString('pt-BR') volta um dia. A data é
gravada como 2026-07-05T00:00:00.000Z; no fuso do Brasil (UTC-3) o
browser renderiza 04/07. A tabela inteira fica um dia atrás do que a
pessoa digitou. Formate sempre em UTC:
new Date(iso).toLocaleDateString('pt-BR', { timeZone: 'UTC' }). Vale
para toda view que exibe data vinda do Mongo.
Item ativo da navbar: injete currentPath uma vez, não rota a rota.
app.use((req, res, next) => { res.locals.currentPath = req.path; next(); })
no app.js (logo após app.locals.modules) evita repetir a variável em
cada res.render — e um res.render esquecido é um item de menu que nunca
acende.
doc.text() no rodapé do pdfkit CRIA página nova — e são DUAS causas.
Escrever o rodapé avança o cursor, então um extrato de 1 página sai com 3
(uma para o conteúdo, uma para "Página N de M", uma para "Gerado em").
Corrigir só metade não resolve, e o sintoma é idêntico:
(a) passe { lineBreak: false, width: larguraUtil } em toda escrita
posicionada de rodapé; (b) o rodapeY tem de cair DENTRO da área útil
— pageAltura - margins.bottom + 14 fica abaixo dela e pagina mesmo com
lineBreak:false; use - 12. Arrume as duas de uma vez e reconte.
Conte com extrator de verdade (pymupdf: d.page_count), não com
grep -c '/Type /Page' no binário, que erra por causa dos streams.
Demais armadilhas de diagramação (cabeçalho truncado em 1 letra, data ISO
quebrando a coluna, acento que é dado e não fonte) estão na skill
document-exports, com script scripts/verify-pdf-export.sh.
Validar seed/guard ANTES de qualquer I/O. Um script cuja trava roda
depois do mongoose.connect falha com ECONNREFUSED quando apontado para
um host inacessível — mensagem que esconde o motivo real e derruba o teste
que casa com /Recusado/. A checagem de nome de banco é a primeira linha
executável do arquivo, antes de conectar. (Complementa o pitfall 26.)
Extrair lógica de um script para um módulo quebra o teste do script.
tests/config.test.js executa scripts/seed-*.js como subprocesso e casa
com a mensagem de erro. Ao mover a lógica para src/seeds/*.seed.js,
re-rode aquele arquivo de teste especificamente — a suíte de domínio passa
e esconde a regressão do guard de segurança.
Para testar flags de boot, monte vários apps — nunca
jest.resetModules(). Quando a flag é lida em createApp() (e não por
getter), cada cenário precisa da sua instância. jest.resetModules()
recarrega o mongoose num registro novo, perde a conexão do setupDb e
todo teste morre em timeout de 5s sem mensagem útil — parece deadlock.
A saída é require do createApp UMA vez no topo e uma fábrica que só
troca process.env ao redor da chamada:
const { createApp } = require('../src/app');
function appCom(flags = {}) {
const antes = { FLAG_A: process.env.FLAG_A }; // salve só as chaves usadas
Object.assign(process.env, flags);
const app = createApp();
Object.assign(process.env, antes); // nunca `process.env = antes`
return app;
}
Complementa o pitfall 36: lá a solução foi getter + guard por request;
aqui, quando a leitura é mesmo no boot, é esta fábrica.
Middleware de autenticação montado em / engole a landing. Um
autologin registrado como app.use('/', mw) autentica o visitante ANTES
de a rota / rodar; ela então vê req.user e redireciona para /app —
a página que explica o produto fica inalcançável justamente na
instância de demonstração. Cada rota passa isoladamente no teste, então
só aparece navegando. Exclua a raiz: app.use(/^(?!\/$).*/, mw). Regra
geral: middleware que muda o estado de autenticação precisa declarar de
quais rotas ele fica de fora, e a landing é sempre uma delas.
Ao "melhorar" texto de UI, cheque se o dado também precisa mudar.
Um modelo de visão acusou "acentos errados" num PDF já corrigido — os
acentos que faltavam estavam nos dados do seed (Salario,
Condominio), não no gerador. Renderização e conteúdo são camadas
distintas: grep nas strings do serviço E no seed antes de concluir.
- 94. Para subir o app NO HOST (fora do Docker) e testar na 4450, o Mongo fica INATINGÍVEL em
localhost:27017. O container projeto-profissional-mongo-1 NÃO publica a porta 27017 no host — só expõe para a rede interna do compose (docker inspect mostra "27017/tcp": null). Então npm start do app/ falha com ECONNREFUSED 127.0.0.1:27017. O host ALCANÇA o Mongo pelo IP do container (docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' → ex.: 192.168.112.3, confirme a cada máquina). Receita de subida local (sem Docker, apontando pro Mongo do container):cd /home/pedro/Repositorios/templates/projeto-profissional/app
ln -sf ../.env .env # o .env REAL está na RAIZ do repo, não em app/ (dotenv do app só lê app/.env); linka ou defina as env abaixo
export NODE_ENV=development # development pula a exigência de JWT_SECRET forte (NODE_ENV=production exigiria JWT >32 chars)
export MONGO_URI="mongodb://192.168.112.3:27017/app_db" # IP do container, não localhost
export SEED_PASSWORD_FILE="/home/pedro/Documentos/comum/senhas-projetos.md"
export PORT=4450
npm start
Verificação: curl -s -o /dev/null -w '%{http_code}' http://localhost:4450/ → 200; /demo/start → 302 com Set-Cookie; /demo → 200 com id="board". Atenção: esse processo roda no shell do agente e MORRE se a sessão fechar — para deixar permanente use docker compose up --build (precisa de JWT_SECRET no .env). Os node_modules já têm dompurify/katex/marked vendored em public/vendor/, então npm install não é estritamente necessário se já existirem. config/env.js usa PORT default 4450, então sem .env de PORT sobe em 4450 mesmo assim.
- 95. handoff-resume SEM
HANDOFF.md: o working tree sujo É o documento de continuidade. Esta base costuma ser deixada com dezenas de arquivos modificados + não rastreados (feature de domínio incompleta, ex.: task manager com board/painel/profissionais). Não há HANDOFF.md — o skill handoff-resume assume que existe, mas aqui não existe. Procedimento: (1) git status --short + git diff --stat para ver o real estado; (2) leia os diffs e os arquivos novos (models/service/controller/routes/views/js) para reconstruir o que ficou pendente; (3) cruze com os sintomas relatados ("não está no ar na 4450" = processo nunca subiu, ver pitfall 94). Não invente um HANDOFF do zero — o git é a fonte. Dica: git diff --stat HEAD + git ls-files --others --exclude-standard dá o panorama completo em duas linhas.
- 96. Verificação SSR de tabela populada por JS dá falso FAIL. Páginas como Projetos/Profissionais entregam o
<tbody> VAZIO no servidor (o SSR monta só o shell + <table>/<tbody id=...>); as linhas são geradas pelo public/js/*.js após o fetch. Um curl/http.get no HTML servido NÃO acha os botões icon-btn js-edit — mas o browser (pós-JS) os renderiza. Ao verificar, confirme (a) o shell no SSR (id="proj-rows", class="dom-table") e (b) o JS no disco gera os botões (pjs.includes('icon-btn js-edit')), ou navegue no browser e confira o snapshot. Não marque como bug só porque o HTML cru não tem as linhas. Mesma razão pela qual um hermes-verify-*.js que lê o HTML servido não vê a tabela — ele precisa ler o JS ou navegar. (Complementa o pitfall 19/45: ache o gerador, não confie no HTML cru.)
Todo projeto derivado ganha um mapeamento elegante de status HTTP e uma
página de erro rericada (código grande, título, mensagem, ação de
recuperação e botão Voltar). É usado internamente e pelo suporte futuro.
Onde vive:
src/routes/status.routes.js — catálogo HTTP_CATALOG (código, nome,
classe success/redirect/client/server, retryable, descrição) + rota
GET /api/status/:code (JSON estruturado, 404 se não mapeado). Exporta
HTTP_CATALOG para a view reusar. Montada em routes/index.js sob /status
→ vira /api/status/:code.
src/routes/pages.routes.js — rota GET /status que renderiza
views/status.ejs (tabela filtrável via ?q=, form GET, sem JS inline).
src/middleware/errorHandler.js — além do mapeamento de erro existente,
exporta ERROR_CATALOG (por status: title + action de recuperação) e
catalogFor(status). A view error.ejs agora recebe title, action,
details e backUrl. O botão Voltar aponta para o Referer
same-origin (ou /), nunca javascript: (bloqueado pela CSP).
views/status.ejs, views/error.ejs + regras .status-*/.error-* em
public/css/main.css.
tests/paginas.test.js — cobre /status (lista, filtro, JSON, 404,
ausência de script inline) e renderiza cada status 4xx/5xx forçando um
AppError num mini-app isolado (rota de teste ANTES dos handlers, senão o
notFoundHandler come 404).
Regras ao estender:
- Adicionar um status novo = só acrescentar a entrada em
HTTP_CATALOG
(status.routes) e, se for erro, em ERROR_CATALOG (errorHandler). Nunca
espalhar ifs pelo handler.
ERROR_CATALOG deve cobrir todo 4xx/5xx presente em HTTP_CATALOG (há teste
de consistência).
- Manter tokens do
DESIGN.md (cores, raio, elevação única). Botão Voltar e
"Página inicial" usam .btn-primary/.btn-outline.
Três bancos isolados (produção / teste / demo) — um app, vários databases
Todo projeto derivado sobe três bancos físicos isolados numa só aplicação:
app_db (produção), app_test_db (teste) e app_demo_db (demo). Cada banco é
uma connection própria (via connection.useDb) e os models são registrados
por connection num registry — não há model global. O acesso é por prefixo
de rota (/app, /test, /demo); um único cookie token carrega o mode
(banco) no payload do JWT e o auth recusa token de modo diferente (um
token de demo não abre a produção).
Quando Pedro pedir botão de demonstração na landing ou três bancos, use
use este padrão (e não um usuário de demo dentro da produção):
references/ambiente-demo-publico.md. Padrão de i18n (PT/EN/ES/FR) + tema
claro/escuro + landing por instância: references/i18n-e-tema.md.
Arquitetura (padrão implementado e testado)
config/db.js → connectDb(): mongoose.createConnection(baseUri) (sem DB no
final) e getModeConn(mode) faz mainConn.useDb(MODE_DB[mode], {useCache:true})
(cacheado). MODE_DB = { production:'app_db', test:'app_test_db', demo:'app_demo_db' }.
O nome do database é derivado do final da MONGO_URI (tira tudo após a
última /), então MONGO_URI=mongodb://.../app_demo_db → banco app_demo_db.
models/registry.js → getModels(conn): registra {User, Project, CatalogItem, AuditLog} na connection (cache por WeakMap de conn) e devolve
o objeto. Os *.model.js exportam o SCHEMA, não o model — o registry cria
o model na connection certa.
middleware/selectDb.js → selectDb(mode): injeta em req mode, conn
(getModeConn(mode)) e models (getModels(conn)). Montado ANTES de cada
grupo de rotas: app.use('/api/app', selectDb('production'), apiRoutes) etc.
middleware/auth.js → signToken(user, mode) assina com payload.mode;
resolveUser(token, mode, models) bate payload.mode === mode e usa
req.models.User (não o model global). pageAuth também passa req.mode/
req.models.
- Services recebem
models via req (não require('../models/x') global):
controllers passam req.models para o service. É a mudança mais invasiva —
fazer o registry + selectDb e depois catar cada require('../models/...')
nos services/controllers.
server.js semeia os 3 bancos no boot: produção só admin; teste admin +
demo; demo banco completo (carregarDemo). NODE_ENV=production NUNCA popula
demo.
app.js monta as rotas em /api/app, /api/test, /api/demo mais um alias
/api que aponta para produção (preserva testes antigos que usavam
/api/auth/login). Páginas iguais em /app, /test, /demo + alias /.
Tabela de ambientes
Arquitetura FINAL (confirmada pelo Pedro): UMA instância, porta 4450, os 3
bancos rodam SIMULTÂNEOS. Não são 3 instâncias/processos/portas — é um só
docker-compose.yml que sobe a app na 4450 e semeia app_db + app_test_db
app_demo_db no mesmo Mongo. A landing (/) oferece os 3 botões; o .env
(NODE_ENV) controla production/staging (testes), mas a demo é sempre
acessível pela landing, independente do modo. O usuário demo (demo1) é criado
só em app_demo_db (ver carregarDemo({ skipAutoUser })).
|
Produção |
Teste |
Demo |
| Host port |
4450 (instância única) |
4450 |
4450 |
| Prefixo |
/app |
/test |
/demo |
| Banco |
app_db (volume) |
app_test_db (volume) |
app_demo_db (volume) |
NODE_ENV |
production |
staging |
demo |
| Landing |
botão "Entrar" → /app/login |
botão "Entrar no teste" → /test/login |
botão "Demo" → /demo/start (autologa) |
| População |
só o admin |
admin + usuários demo |
banco completo + autologa |
| Rate limit |
ativo |
RATE_LIMIT_DISABLED=true |
RATE_LIMIT_DISABLED=true |
NÃO volte a 3 instâncias/3 portas salvo o Pedro pedir explicitamente de
novo — esta foi a correção dele após eu ter implementado 3 composes separados
(4450 teste / 4451 produção / 4452 demo) que ele rejeitou ("uma porta só ag
…(truncated)
1---2name: projeto-profissional3description: Bootstrap a new professional repo from Pedro's hardened Node/Express+MongoDB+EJS base template (JWT auth, admin/user roles, admin-controlled registration, security defaults, full root markdown set), and maintain it — dual prod/test Docker stacks, k6 load testing, Jest suite. Use whenever Pedro asks to start a new project/repo, "criar um projeto novo", "primeiro commit", a base/modelo/template for GitHub, to add login+admin to a fresh app, or to load-test / dockerize / benchmark one of these projects.4---56# Projeto Profissional — base template for new repos78Pedro's canonical starting point for any new web project. Instead of scaffolding9from scratch (and re-deciding security every time), copy the vetted template and10adapt it.1112**Template location:** `/home/pedro/Repositorios/templates/projeto-profissional`13(git-initialised, 44 passing tests, boot-verified, load-tested with real k6 numbers).1415**Origin:** distilled from `/home/pedro/Repositorios/academicos/sistema-academico`,16keeping that project's layered architecture but stripping the academic domain.1718## When to use1920- "cria um projeto novo / um repositório novo / o primeiro commit"21- "quero um modelo base pro meu GitHub"22- "preciso de login com admin nesse app"23- Any new Node web app for Pedro — start here, don't hand-roll auth.2425## Stack (do not swap without being asked)2627Node 20 + Express · MongoDB/Mongoose · EJS SSR + vanilla JS (**no bundler, no28React, no build step**) · Zod · JWT HS256 · Jest + Supertest +29mongodb-memory-server · Docker Compose.3031## Procedure32331. **Copy, don't regenerate.**34 `cp -r /home/pedro/Repositorios/templates/projeto-profissional <destino>`35 then `rm -rf <destino>/.git <destino>/app/node_modules` and `git init`.362. **Rename** in `app/package.json` (name, description), `.env.example` and37 `docker-compose.yml` (DB name, `APP_NAME`), and the brand in38 `app/views/partials/header.ejs` + `app/views/landing.ejs`.393. **Adjust roles** if needed — the `role` enum lives in BOTH40 `src/models/user.model.js` and `src/schemas/admin.schemas.js`. Change together.414. **Add the domain** following the chain: model → Zod schema → service →42 controller → routes → register in `routes/index.js` → test in `tests/`.43 For a domain with several entities, write the layers in that order for ALL44 entities before touching views — the suite can go green before any UI45 exists, which is the cheapest place to find modelling mistakes. Money,46 monthly competence, weighted average cost and CSP-safe SVG charts:47 `references/money-and-charts.md`.48 When Pedro names an app category ("controle financeiro", "gestão de X"),49 look up the established open-source players first and port their *model*50 (Firefly III, Actual Budget, Ghostfolio for finance) instead of inventing51 entities — then say which ones you drew from.52 Ship a `scripts/seed-demo.js` with realistic data so the screens can be53 inspected for real; guard it like `seed-carga.js` (pitfall 26). Quando o54 domínio pedido é "bastante simples" (ex.: task manager board + calendário),55 use o padrão copy-paste em `references/task-manager-simples.md` — sem56 drag-and-drop nem redesenho do shell. If the app has more than one domain, treat them as **optional modules** from57 the start (`MODULE_<NOME>` flags, guard per request, dashboard desacoplado):58 `references/optional-modules.md`. Retrofitting modularity later means59 rewriting the dashboard.605. **Backup do banco de produção**: copie `templates/backup.sh` para61 `<repo>/scripts/backup.sh` (mongodump dentro do container → zip com62 timestamp + MANIFEST de restauração, chmod 600, retenção configurável).63 Copie também `templates/reset-senha.js` para `<repo>/app/scripts/` — sem ele,64 perder a senha do admin só se resolve escrevendo hash na mão no banco.65 Acesso às duas stacks, criação de usuário e o 429 do limiter:66 `references/operacao-e-acesso.md`. Todo projeto derivado ganha um67 `docs/operacao.md` com esse conteúdo.686. **Verify for real** before reporting done:69 `cd app && npm install && npm test` (must be green), then boot-smoke it.70 See `scripts/smoke-boot.js` — copy it into `app/` and run it there71 (it needs `mongodb-memory-server` from the app's own node_modules; running72 it from `/tmp` fails with MODULE_NOT_FOUND).73 For the structural invariants Jest can't reach (stack isolation, k674 scenario, CSP, workflows) run `scripts/verify-stacks.sh <destino>`, and for75 live brute-force behaviour (which Jest cannot cover at all) run76 `scripts/verify-bruteforce.sh <destino> 3 30` — it boots both stacks, asserts77 the exact `401 401 401 429` sequence, and tears them down via `trap`.787. **Commit** and confirm `git ls-files` shows no `.env` and no `node_modules`.7980## Non-negotiable design decisions8182Carry these into every derived project; they are the point of the template.8384- **No public self-registration.** Admin creates accounts85 (`POST /api/admin/users`); server generates a temp password shown exactly86 once, account is born with `mustChangePassword`.87- **Strict layers**: Route → Controller → Service → Model. Services never88 receive `req`; they take validated data + `userId` explicitly.89- **Zod on every POST/PUT/PATCH** via `validate(schema)`.90- **`AppError(msg, status)`** for expected errors; `errorHandler` is the only91 place that formats an error response.92- **CSP without `unsafe-inline`** ⇒ zero inline `<script>` in views; all JS in93 `public/js/`, wired through the `pageScript` footer variable.94- **Secrets only via `.env`** read by `config/env.js`; `.env.example` holds95 empty keys, never real values.9697## Security baseline already wired9899bcrypt cost 12 + `select:false` on the hash · 12-char password policy ·100account lockout 3 attempts/30min · timing-safe dummy-hash compare (anti-enumeration) ·101identical `/forgot-password` response either way · `tokenValidAfter` for global102session revocation · csrfGuard (Origin/Referer) · sanitizeInput (NoSQL103injection) · Helmet+CSP · rate limits · CORS allowlist (never `*`) · 100kB body104cap · AuditLog with 180-day TTL · non-root read-only container.105106Full rationale: `references/security-baseline.md`.107108## Brute-force protection is TWO layers — tune them together109110Anti-brute-force lives in two independent places, and changing one without the111other silently disables it:112113| Layer | Where | Protects | Env vars |114|---|---|---|---|115| IP rate limit | `middleware/rateLimiters.js` | the **service** (one noisy IP) | `RATE_LIMIT_AUTH_MAX`, `RATE_LIMIT_AUTH_WINDOW_MIN` |116| Account lockout | `services/authService.js` → `lockedUntil` | the **account** (distributed credential stuffing) | `MAX_FAILED_ATTEMPTS`, `LOCKOUT_MIN` |117118Current default: **3 attempts / 30 min on both**. When Pedro asks for "3119tentativas, bloqueio de 30 min", he means the observable behaviour — set both120layers. Leaving the account lockout at a higher count than the IP limit means121the lockout can *never* fire (the IP limiter answers 429 first), so the layer122that defends against distributed attacks is dead code.123124Both are read from `config/env.js` — no magic numbers in `authService.js`.125Defaults are pinned by `tests/config.test.js`.126127Verify end-to-end, not just by reading config: the limiters are **disabled128under `NODE_ENV=test`**, so Jest structurally *cannot* prove the limit. Only a129running container can:130131```bash132for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' \133 -X POST localhost:4447/api/auth/login -H 'Content-Type: application/json' \134 -d '{"email":"admin@example.com","password":"errada123456"}'; done135# esperado exatamente: 401 401 401 429136curl -sD- -o /dev/null ... | grep -i ratelimit-policy # RateLimit-Policy: 3;w=1800137```138139Assert the **exact sequence** (`"401 401 401 429 "`), not "contains 429" — the140loose version passes even if it locks on the first attempt. Then confirm the141correct password *still* returns 429 (proves real blocking, not error142counting), and that `lockedUntil` is ~30 min ahead in Mongo.143144## Uma instância, teste de carga via NODE_ENV145146O template sobe como **uma instância** na porta `4450` (`docker-compose.yml`147apenas). Para teste de carga, suba com `NODE_ENV=staging` (desativa o rate148limit) — não há mais um segundo compose de teste. A demo é sempre acessível149pela landing. Veja `docs/load-testing.md`.150151| | Instância única |152|---|---|153| File | `docker-compose.yml` |154| DB | `app_db`, `app_test_db`, `app_demo_db` (mesma instância Mongo) |155| Port | `${BIND_ADDR:-127.0.0.1}:4450` |156| `NODE_ENV` | `production` (ou `staging` para carga, sem rate limit) |157| Rate limiting | ativa em `production`, desativada em `staging` |158159```bash160NODE_ENV=staging JWT_SECRET=$(openssl rand -base64 48) docker compose up -d --build161```162163## Load testing (k6)164165Full methodology, measured numbers and capacity planning:166`references/load-testing.md`. Scenario lives at `loadtest/carga.js` with five167profiles (`smoke`, `carga`, `estresse`, `pico`, `auth`) and k6 thresholds that168fail CI when crossed.169170Reference measurements (i5-9400F 6 cores, single instance): **100 VUs → 0%171errors, 214 req/s**; 200 VUs → still 0% errors but latency over target. The172bottleneck is always **login** (bcrypt cost 12 is CPU-bound by design,173~4.5 logins/s per instance); reads and SSR cost ~3-6 ms. The app is stateless,174so the answer is horizontal scaling — **never quietly lower the bcrypt cost to175win a benchmark.**176177## Pitfalls (all hit for real)1781791. **JWT `iat` is in SECONDS, truncated.** Comparing `payload.iat * 1000 <180 tokenValidAfter.getTime()` rejects legitimate tokens minted in the same181 second. Normalise both sides to seconds. Covered by a test — keep it.1822. **`passwordHash` has `select:false`.** Any query that must compare a183 password needs `.select('+passwordHash')`, or `bcrypt.compare` silently184 receives `undefined`.1853. **`COOKIE_SECURE=true` over plain HTTP** ⇒ browser discards the cookie:186 login returns 200 and the session still doesn't stick. Classic LAN/VPN trap.1874. **`upgradeInsecureRequests`** must stay off while serving HTTP, or CSS/JS188 fail to load and the page renders unstyled.1895. **Express 5 makes `req.query` a getter** — `sanitizeInput` mutates in place;190 do not "simplify" it to `req.query = {...}`.1916. **Never build a `RegExp` from user input unescaped** (user search) — escape192 metacharacters or it's a ReDoS.1937. **`npm ci` in CI needs `package-lock.json` committed.**1948. **Load-test scripts must reuse the session token.** Logging in on *every*195 k6 iteration is a login-storm, not realistic traffic: with bcrypt 12 it196 drove 200 VUs to 60s latency and 6% errors and made the app look broken.197 Log in once per VU, reuse the token, and isolate the worst case in its own198 `auth` profile.1999. **`grep -q 'app_db'` also matches `app_test_db`.** Substring asserts cannot200 tell the two stacks apart — compare the *full* resolved URI for equality.201 Also export a dummy `JWT_SECRET` before `docker compose config`, or202 interpolation fails and the command returns empty (a silent false pass).20310. **The container serves the image, not your edited file.** After changing a204 view/CSS, `docker restart` keeps the stale build-time copy — rebuild205 (`up -d --build`) before believing a visual fix didn't work.20611. **`dotenv` loads the project `.env` and overrides env vars you pass** when207 testing config guards. Run such probes with `cwd` outside the project208 (e.g. `/tmp`) or the guard appears not to fire.20912. **The k6 container runs as its own UID** and cannot traverse a `0700` home210 directory to reach a bind mount ("permission denied" reading the script).211 Set `user: "0:0"` on that ephemeral test container instead of loosening212 permissions on the host.21313. **A port change is a repo-wide edit, not a compose edit.** The host port214 appears in both compose files, `.env.example`, the `PORT` default in215 `config/env.js`, the k6 `BASE_URL` default, the readiness curl in216 `carga.yml`, the nginx `proxy_pass` in `docs/deployment.md`, and the dev217 URL in README/AGENTS/CLAUDE/CONTRIBUTING. Grep for the old number and218 confirm zero hits before committing. Only the *host* side changes — the219 container keeps listening on 5000 (`4447:5000`).22014. **Tightening a limit invalidates existing tests.** The old221 "bloqueia após 5 tentativas" loop still passes with a limit of 3 (it just222 over-shoots), so it silently stops testing anything. When a threshold223 changes, rewrite the assertion to pin the *exact* boundary: two failures224 must still return 401, the third must arm the lockout.22515. **`127.0.0.1` in the compose port binding means "this machine only".**226 The page simply won't open from another host and the app logs look227 perfectly healthy — nothing errors, so it reads like a firewall problem.228 Publish via `${BIND_ADDR:-127.0.0.1}` and set `BIND_ADDR` to the reachable229 interface (Pedro's Tailscale IP is `100.120.54.126`; `0.0.0.0` only behind230 a firewall). Update `APP_BASE_URL` to match, and keep `COOKIE_SECURE=false`231 over plain HTTP or the session won't stick (see pitfall 3).23216. **A missing `JWT_SECRET` fails at compose *interpolation*, not at boot.**233 `docker compose up` aborts with "required variable JWT_SECRET is missing"234 and no container is created — easy to misread as an app crash. Create235 `.env` (chmod 600, git-ignored) with236 `echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env` first.23717. **Once a real `.env` exists, it poisons default-value checks.** `docker238 compose config` merges it, so a probe asserting the template's built-in239 fallback (e.g. `BIND_ADDR` defaulting to `127.0.0.1`) reads Pedro's local240 value and reports a false failure. Isolate with241 `docker compose --env-file /dev/null ...` when verifying defaults. Same242 family as pitfall 11.24318. **Inserting prose into a markdown table splits it in two.** Patching a244 README by anchoring on a `| row |` and appending a paragraph lands the text245 *between* rows and silently breaks the table. Anchor on the sentence246 *after* the table instead, then re-read the rendered section to confirm.24719. **A new `<%= var %>` in a view is a runtime 500, not a lint error.** Routes248 call bare `res.render('landing')`, so an EJS variable nobody passes only249 explodes when the page is requested. Register shared values once with250 `app.locals.appName = env.appName` in `app.js` instead of threading them251 through every route, and verify by compiling the template directly:252 `node -e "require('ejs').render(fs.readFileSync('views/landing.ejs','utf8'),{appName:'X'})"`.25320. **An in-page anchor needs its target to exist.** `href="#topo"` silently254 does nothing unless some element carries `id="topo"` — put it on `<body>`255 in the header partial. Prefer the pure-anchor + `scroll-behavior: smooth`256 combo over JS: the CSP forbids inline scripts anyway.25721. **The template's `.field`/`.card` rules defeat the `hidden` attribute.**258 `hidden` only sets `display:none` through the UA stylesheet, so any class259 rule with `display: block` wins and a field you "hid" from JS stays on260 screen — the JS looks broken while `el.hidden === true`. Add261 `[hidden] { display: none !important; }` to `main.css` once per derived262 project. Diagnose this class of bug by comparing `el.hidden` against263 `getComputedStyle(el).display`, not by re-reading the JS.26422. **A new derived project collides with the template's own ports/project265 name.** The template stack is usually still up on 4447/4446 under compose266 project `pp`. Before the first `up -d`, pick fresh host ports AND a fresh267 project name (`name:` key + `-p`), then do pitfall 13's repo-wide sweep in268 one pass over the known file list:269 `perl -pi -e 's/4447/4451/g; s/4446/4450/g; s/-p pp\b/-p fa/g; s/app_db/<novo>_db/g' <files>`270 Rename the DB too — two projects sharing `app_db` in your head is how a271 demo seed lands in the wrong volume. Then grep for the old numbers/names272 and confirm zero hits, INCLUDING inside `tests/config.test.js`, whose seed273 guard asserts a literal DB name.27423. **Point a booted stack at a scratch DB with an override file, not by275 editing the committed compose.** Write three lines to276 `/tmp/<proj>-demo.override.yml` setting `MONGO_URI` to a `*_demo` database277 and boot with `-f docker-compose.yml -f /tmp/....yml`. Keeps the repo clean278 and the demo reproducible.27924. **`docker compose` must run from the repo root, not from `app/`.** From the280 Node subdirectory it fails with `open .../app/docker-compose.yml: no such281 file or directory`, which reads like a missing file rather than a wrong cwd.28225. **After a rebuild the *browser* still serves the old CSS/JS.** Pitfall 10283 covers the container; the other half is client cache, and it produces the284 exact same symptom ("my fix did nothing"). Confirm the served bytes with285 `curl -s host/css/main.css | tail`, and in the page use286 `fetch(url,{cache:'reload'})` before concluding the fix failed.28726. **Any destructive seed needs a DB-name guard and a test for it.** A demo288 seeder that `deleteMany({})` on every collection is total data loss if289 pointed at production. Copy the `seed-carga.js` pattern — refuse unless the290 URI matches `/test|demo/i` — and pin it in `tests/config.test.js` alongside291 the existing guard.292 27. **Página vs JSON de mapeamento moram em prefixes de montagem diferentes.**293 `routes/index.js` é montado sob `/api` no `app.js`; `pages.routes.js` é294 montado sob `/`. Uma rota `router.get('/status')` dentro de um arquivo sob295 `/api` vira `/api/status`, não `/status`. Por isso o mapeamento HTTP se296 divide: o JSON `GET /api/status/:code` fica em `status.routes.js` (definido297 como `router.get('/:code')` e montado em `routes/index.js` sob `/status` →298 resultado `/api/status/:code`); a *página* `GET /status` fica em299 `pages.routes.js`. Misturar os dois num só arquivo faz a página ou o JSON300 sumirem (vieram 404 até acertar esse split).301 28. **Catálogos separados: importe de onde cada um vive.** `HTTP_CATALOG` está302 em `status.routes.js`; `ERROR_CATALOG`/`catalogFor` estão em303 `errorHandler.js`. Um teste que faz304 `require('../src/routes/status.routes').ERROR_CATALOG` recebe `undefined` e305 quebra com `Cannot read properties of undefined`. Importe cada um do seu306 módulo.307 29. **Testar variante de erro exige mini-app com handler DEPOIS da rota de308 teste.** Para renderizar cada status 4xx/5xx, monte um `express()` isolado,309 registre `app.get('/_e_:code', (req,res,next)=>next(new AppError('x',Number(req.params.code))))`310 e SÓ DEPOIS `app.use(notFoundHandler); app.use(errorHandler)`. Se o311 `notFoundHandler` vier antes, ele responde 404 para a rota de teste e o312 status forçado nunca chega ao `errorHandler`.313 30. **Botão "Voltar" não pode usar `javascript:` — a CSP bloqueia.** `href=314 "javascript:history.back()"` é script inline disfarçado e a CSP315 (`script-src 'self'`) o impede; o clique não faz nada. Passe `backUrl` do316 servidor usando o `Referer` same-origin (cai em `/` se ausente/externo).317 Mesma regra do "Voltar ao topo", que é âncora pura `#topo`.318 31. **Script de verificação ad-hoc precisa rodar de dentro de `app/`.** Rodar de319 `/tmp` falha com `MODULE_NOT_FOUND` (mongodb-memory-server, supertest etc.320 estão em `app/node_modules`). Copie o script para `app/` com nome321 `hermes-verify-*.js` ou use `cwd` em `app/`. Vale para `smoke-boot.js` e322 qualquer probe que exija os deps do app — não confunda com o pitfall 11, que323 manda rodar probe de *config* de `/tmp` justamente para o `.env` não324 sobrescrever o ambiente.325 32. **README não é só 'como rodar' — Pedro quer que ensine a DESENVOLVER,326 RODAR e DOCUMENTAR.** Ao tocar no README do template (ou de projeto327 derivado), inclua: (a) seção **Desenvolvimento** — fluxo de branch328 `feat/*`/`fix/*`, `.env`, loop dev→test→verify, lint DESIGN.md e329 `npm audit`, Conventional Commits e arquitetura em camadas; (b) seção330 **Documentação** — tabela de responsabilidade dos arquivos de doc331 (README/AGENTS/CLAUDE/CONTRIBUTING/SECURITY/CHANGELOG/DESIGN/docs) e regras332 práticas (métrica só real, CSP na view, tokens no DESIGN.md, CHANGELOG333 humano). Não entregue README que só lista comandos de instalação.334 33. **O status de validação deste template é 422, não 400.** Testes novos335 escritos por instinto (`expect(res.status).toBe(400)`) falham em massa336 contra Zod/`validate()`. Confirme com uma chamada real antes de escrever a337 bateria inteira. Idem para `idParamSchema`: id malformado dá 422.338 34. **Fixture curta demais vira "Cannot read properties of undefined".**339 Schemas usam `.min(2)` em `description`/`nickname`, então um helper de340 teste que manda `'X'`/`'A'` recebe 422 e `res.body.<entidade>` fica341 `undefined` — o erro estoura três linhas depois, ao ler `._id`, e parece342 bug do service. Ao ver `undefined` lendo o corpo de um POST no teste,343 cheque o status da resposta ANTES de investigar o código.344 35. **A suíte precisa de `--forceExit`.** `npx jest` sozinho pendura após o345 último teste (handle aberto do mongodb-memory-server/app) e estoura o346 timeout do comando — parece suíte travada ou falhando quando na verdade347 passou. Rode `npx jest --forceExit` e leia a linha `Tests:`.348 36. **`jest.resetModules()` para recarregar flags mata a conexão do349 mongoose.** Remontar o app por teste (para reler env) cria uma instância350 nova do mongoose que não enxerga a conexão do `setupDb`; cada teste morre351 em timeout de 5s sem mensagem útil. A saída não é remontar — é fazer a352 config ser lida **por getter** e o guard rodar **por request** (ver353 "Módulos opcionais"), aí um único app cobre todas as combinações só354 mexendo em `process.env`.355 37. **Rota transversal não pode morar sob o prefixo de um módulo.**356 `GET /api/financas/dashboard` some junto quando finanças é desligado,357 mesmo que ele agregue os outros módulos. Monte-a na raiz da API358 (`GET /api/dashboard`, em `routes/index.js`) e atualize o fetch da view.359 38. **PUT vs POST: confira a rota real antes de escrever o inventário.**360 Orçamento é upsert de envelope, logo `PUT /api/financas/orcamentos`. Um361 teste de inventário que assume POST recebe 404 e parece rota faltando.362 39. **O container é read-only: script novo entra pela IMAGEM, não por `cp`.**363 O baseline roda o container com rootfs read-only, então364 `docker compose cp arquivo app:/app/scripts/` falha com *"container rootfs365 is marked read-only"* — parece permissão do host, mas é o hardening366 funcionando. Commite o arquivo e rode `up -d --build app`.367 40. **Guard que responde antes do roteamento cega o teste de 404.** Em368 `/api/admin/*` o `requireRole` devolve 403 antes de o Express concluir que369 a rota não existe, então um caminho ERRADO (`/usuarios` em vez de `/users`)370 passa no `not.toBe(404)` e no `toContain(403)`. O inventário fica verde371 testando rota inexistente, e o defeito só aparece num `curl` real. Copie os372 caminhos de admin do `admin.routes.js`; nunca os digite de memória.373374 41. **`AppError` expõe `statusCode`, não `status`.** Um probe que assere375 `(e) => e.status === 409` recebe `undefined`, o `assert.rejects` reprova376 com *"validation function is expected to return true"* e parece que o377 service não lançou o erro — quando lançou o certo. Use378 `e.statusCode`. (Nos testes de rota isso não aparece, porque lá se lê379 `res.status` do Supertest — daí o instinto errado.)380 42. **Ampliar um módulo para o domínio genérico é renomeação em ~8 arquivos.**381 `moto` → `veiculos` (carro + moto) toca service/controller/rotas/schemas,382 mas também `routes/index.js`, `config/env.js` (getter **e** `toJSON`),383 `pages.routes.js`, o bloco do `relatorioService.dashboard()` (a chave do384 payload muda e quebra a view que lia `d.moto`), `.env.example` e a doc.385 Receita completa, incluindo o discriminador `type`, o filtro por tipo em386 coleções filhas e o consumo por combustível de carro flex:387 `references/optional-modules.md`, seção "Renomear ou ampliar um módulo".388389 43. **"Melhore o X" pode se referir a algo que NÃO EXISTE.** Pedro pediu390 "o pdf de extrato tem que ficar melhor" — não havia exportação nenhuma no391 projeto, era item de roadmap (issue #2). Sair "melhorando" vira construir392 do zero uma feature com escopo adivinhado. Antes de aceitar um verbo de393 melhoria, `grep` pela feature; se não existir, **diga isso e pergunte o394 escopo** em vez de assumir. (Ele não respondeu no tempo do `clarify`, e aí395 seguir com a opção mais completa foi o certo — mas o aviso explícito de396 que era construção, não ajuste, é obrigatório no relatório.)397398 44. **Agregado sobre coleção heterogênea precisa de teste com DOIS donos.**399 `$group: {_id: null, min/max}` de odômetro somando carro + moto inventou400 "34.410 km rodados, 237 km/l" e passou em toda a suíte, porque todo teste401 existente tinha um veículo só. Sempre que um número derivar de min/max/402 amplitude sobre linhas de entidades diferentes, escreva o caso com dois403 registros distintos — é a única fixture que reprova a fórmula errada.404 Detalhe e o snippet corrigido em `references/optional-modules.md` (item 6).405406 45. **`pageScript` recebe o NOME da página, não o caminho.** O footer monta407 `<script src="/js/<%= pageScript %>.js">`. Passar `'/js/veiculos.js'` gera408 `/js//js/veiculos.js.js` → 404 do script. Sintoma cruel: a página renderiza409 inteira (é SSR), a API responde 200 em todo endpoint, o console **não410 registra erro nenhum** — só as tabelas ficam vazias, o que parece bug de411 service ou de banco. Confira com412 `[...document.querySelectorAll('script')].map(s=>s.src)` antes de413 investigar o backend. Convenção: `{ pageScript: 'veiculos' }`; página sem414 JS chama `include('partials/footer')` sem argumento (passar `null` também415 quebra).416417 46. **O front recebe `vehicleId` populado como OBJETO nas listagens.** O418 service faz `.populate('vehicleId', 'nickname type')`, então419 `veiculos.find(v => v._id === String(item.vehicleId))` nunca casa e a420 coluna inteira vira `—`. Escreva um helper que aceite os dois formatos:421 `const nome = (ref) => !ref ? '—' : (typeof ref === 'object' ? ref.nickname : lista.find(v => v._id === String(ref))?.nickname ?? '—')`.422423 47. **`new Date(iso).toLocaleDateString('pt-BR')` volta um dia.** A data é424 gravada como `2026-07-05T00:00:00.000Z`; no fuso do Brasil (UTC-3) o425 browser renderiza **04/07**. A tabela inteira fica um dia atrás do que a426 pessoa digitou. Formate sempre em UTC:427 `new Date(iso).toLocaleDateString('pt-BR', { timeZone: 'UTC' })`. Vale428 para toda view que exibe data vinda do Mongo.429430 48. **Item ativo da navbar: injete `currentPath` uma vez, não rota a rota.**431 `app.use((req, res, next) => { res.locals.currentPath = req.path; next(); })`432 no `app.js` (logo após `app.locals.modules`) evita repetir a variável em433 cada `res.render` — e um `res.render` esquecido é um item de menu que nunca434 acende.435436 49. **`doc.text()` no rodapé do pdfkit CRIA página nova — e são DUAS causas.**437 Escrever o rodapé avança o cursor, então um extrato de 1 página sai com 3438 (uma para o conteúdo, uma para "Página N de M", uma para "Gerado em").439 Corrigir só metade não resolve, e o sintoma é idêntico:440 (a) passe `{ lineBreak: false, width: larguraUtil }` em toda escrita441 posicionada de rodapé; **(b) o `rodapeY` tem de cair DENTRO da área útil**442 — `pageAltura - margins.bottom + 14` fica *abaixo* dela e pagina mesmo com443 `lineBreak:false`; use `- 12`. Arrume as duas de uma vez e reconte.444 Conte com extrator de verdade (`pymupdf`: `d.page_count`), não com445 `grep -c '/Type /Page'` no binário, que erra por causa dos streams.446 Demais armadilhas de diagramação (cabeçalho truncado em 1 letra, data ISO447 quebrando a coluna, acento que é dado e não fonte) estão na skill448 `document-exports`, com script `scripts/verify-pdf-export.sh`.449450 50. **Validar seed/guard ANTES de qualquer I/O.** Um script cuja trava roda451 depois do `mongoose.connect` falha com `ECONNREFUSED` quando apontado para452 um host inacessível — mensagem que esconde o motivo real e derruba o teste453 que casa com `/Recusado/`. A checagem de nome de banco é a primeira linha454 executável do arquivo, antes de conectar. (Complementa o pitfall 26.)455456 51. **Extrair lógica de um script para um módulo quebra o teste do script.**457 `tests/config.test.js` executa `scripts/seed-*.js` como subprocesso e casa458 com a mensagem de erro. Ao mover a lógica para `src/seeds/*.seed.js`,459 re-rode aquele arquivo de teste especificamente — a suíte de domínio passa460 e esconde a regressão do guard de segurança.461462 52. **Para testar flags de boot, monte vários apps — nunca463 `jest.resetModules()`.** Quando a flag é lida em `createApp()` (e não por464 getter), cada cenário precisa da sua instância. `jest.resetModules()`465 recarrega o mongoose num registro novo, perde a conexão do `setupDb` e466 **todo teste morre em timeout de 5s sem mensagem útil** — parece deadlock.467 A saída é `require` do `createApp` UMA vez no topo e uma fábrica que só468 troca `process.env` ao redor da chamada:469470 ```js471 const { createApp } = require('../src/app');472 function appCom(flags = {}) {473 const antes = { FLAG_A: process.env.FLAG_A }; // salve só as chaves usadas474 Object.assign(process.env, flags);475 const app = createApp();476 Object.assign(process.env, antes); // nunca `process.env = antes`477 return app;478 }479 ```480 Complementa o pitfall 36: lá a solução foi getter + guard por request;481 aqui, quando a leitura é mesmo no boot, é esta fábrica.482483 53. **Middleware de autenticação montado em `/` engole a landing.** Um484 autologin registrado como `app.use('/', mw)` autentica o visitante ANTES485 de a rota `/` rodar; ela então vê `req.user` e redireciona para `/app` —486 a página que explica o produto fica **inalcançável justamente na487 instância de demonstração**. Cada rota passa isoladamente no teste, então488 só aparece navegando. Exclua a raiz: `app.use(/^(?!\/$).*/, mw)`. Regra489 geral: middleware que muda o estado de autenticação precisa declarar de490 quais rotas ele fica de fora, e a landing é sempre uma delas.491492 54. **Ao "melhorar" texto de UI, cheque se o dado também precisa mudar.**493 Um modelo de visão acusou "acentos errados" num PDF já corrigido — os494 acentos que faltavam estavam nos **dados do seed** (`Salario`,495 `Condominio`), não no gerador. Renderização e conteúdo são camadas496 distintas: `grep` nas strings do serviço E no seed antes de concluir.497498- **94. Para subir o app NO HOST (fora do Docker) e testar na 4450, o Mongo fica INATINGÍVEL em `localhost:27017`.** O container `projeto-profissional-mongo-1` NÃO publica a porta 27017 no host — só expõe para a rede interna do compose (`docker inspect` mostra `"27017/tcp": null`). Então `npm start` do `app/` falha com `ECONNREFUSED 127.0.0.1:27017`. O host ALCANÇA o Mongo pelo IP do container (`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'` → ex.: `192.168.112.3`, confirme a cada máquina). Receita de subida local (sem Docker, apontando pro Mongo do container):499 ```bash500 cd /home/pedro/Repositorios/templates/projeto-profissional/app501 ln -sf ../.env .env # o .env REAL está na RAIZ do repo, não em app/ (dotenv do app só lê app/.env); linka ou defina as env abaixo502 export NODE_ENV=development # development pula a exigência de JWT_SECRET forte (NODE_ENV=production exigiria JWT >32 chars)503 export MONGO_URI="mongodb://192.168.112.3:27017/app_db" # IP do container, não localhost504 export SEED_PASSWORD_FILE="/home/pedro/Documentos/comum/senhas-projetos.md"505 export PORT=4450506 npm start507 ```508 Verificação: `curl -s -o /dev/null -w '%{http_code}' http://localhost:4450/` → 200; `/demo/start` → 302 com Set-Cookie; `/demo` → 200 com `id="board"`. **Atenção:** esse processo roda no shell do agente e MORRE se a sessão fechar — para deixar permanente use `docker compose up --build` (precisa de `JWT_SECRET` no `.env`). Os node_modules já têm `dompurify`/`katex`/`marked` vendored em `public/vendor/`, então `npm install` não é estritamente necessário se já existirem. `config/env.js` usa `PORT` default 4450, então sem `.env` de PORT sobe em 4450 mesmo assim.509- **95. handoff-resume SEM `HANDOFF.md`: o working tree sujo É o documento de continuidade.** Esta base costuma ser deixada com dezenas de arquivos modificados + não rastreados (feature de domínio incompleta, ex.: task manager com board/painel/profissionais). Não há `HANDOFF.md` — o skill `handoff-resume` assume que existe, mas aqui não existe. Procedimento: (1) `git status --short` + `git diff --stat` para ver o real estado; (2) leia os diffs e os arquivos novos (models/service/controller/routes/views/js) para reconstruir o que ficou pendente; (3) cruze com os sintomas relatados ("não está no ar na 4450" = processo nunca subiu, ver pitfall 94). Não invente um HANDOFF do zero — o `git` é a fonte. Dica: `git diff --stat HEAD` + `git ls-files --others --exclude-standard` dá o panorama completo em duas linhas.510- **96. Verificação SSR de tabela populada por JS dá falso FAIL.** Páginas como Projetos/Profissionais entregam o `<tbody>` VAZIO no servidor (o SSR monta só o shell + `<table>`/`<tbody id=...>`); as linhas são geradas pelo `public/js/*.js` após o `fetch`. Um `curl`/`http.get` no HTML servido NÃO acha os botões `icon-btn js-edit` — mas o **browser** (pós-JS) os renderiza. Ao verificar, confirme (a) o shell no SSR (`id="proj-rows"`, `class="dom-table"`) e (b) o JS no disco gera os botões (`pjs.includes('icon-btn js-edit')`), ou navegue no browser e confira o snapshot. Não marque como bug só porque o HTML cru não tem as linhas. Mesma razão pela qual um `hermes-verify-*.js` que lê o HTML servido não vê a tabela — ele precisa ler o JS ou navegar. (Complementa o pitfall 19/45: ache o gerador, não confie no HTML cru.)511512Todo projeto derivado ganha um **mapeamento elegante de status HTTP** e uma513**página de erro rericada** (código grande, título, mensagem, ação de514recuperação e botão Voltar). É usado internamente e pelo suporte futuro.515516**Onde vive:**517- `src/routes/status.routes.js` — catálogo `HTTP_CATALOG` (código, nome,518 classe success/redirect/client/server, `retryable`, descrição) + rota519 `GET /api/status/:code` (JSON estruturado, 404 se não mapeado). Exporta520 `HTTP_CATALOG` para a view reusar. Montada em `routes/index.js` sob `/status`521 → vira `/api/status/:code`.522- `src/routes/pages.routes.js` — rota `GET /status` que renderiza523 `views/status.ejs` (tabela filtrável via `?q=`, form GET, sem JS inline).524- `src/middleware/errorHandler.js` — além do mapeamento de erro existente,525 exporta `ERROR_CATALOG` (por status: `title` + `action` de recuperação) e526 `catalogFor(status)`. A view `error.ejs` agora recebe `title`, `action`,527 `details` e `backUrl`. O botão **Voltar** aponta para o `Referer`528 same-origin (ou `/`), nunca `javascript:` (bloqueado pela CSP).529- `views/status.ejs`, `views/error.ejs` + regras `.status-*`/`.error-*` em530 `public/css/main.css`.531- `tests/paginas.test.js` — cobre `/status` (lista, filtro, JSON, 404,532 ausência de script inline) e renderiza cada status 4xx/5xx forçando um533 `AppError` num mini-app isolado (rota de teste ANTES dos handlers, senão o534 `notFoundHandler` come 404).535536**Regras ao estender:**537- Adicionar um status novo = só acrescentar a entrada em `HTTP_CATALOG`538 (status.routes) e, se for erro, em `ERROR_CATALOG` (errorHandler). Nunca539 espalhar `if`s pelo handler.540- `ERROR_CATALOG` deve cobrir todo 4xx/5xx presente em `HTTP_CATALOG` (há teste541 de consistência).542- Manter tokens do `DESIGN.md` (cores, raio, elevação única). Botão Voltar e543 "Página inicial" usam `.btn-primary`/`.btn-outline`.544545## Três bancos isolados (produção / teste / demo) — um app, vários databases546547Todo projeto derivado sobe **três bancos físicos isolados** numa só aplicação:548`app_db` (produção), `app_test_db` (teste) e `app_demo_db` (demo). Cada banco é549uma **connection própria** (via `connection.useDb`) e os models são registrados550**por connection** num registry — não há model global. O acesso é por **prefixo551de rota** (`/app`, `/test`, `/demo`); um único cookie `token` carrega o `mode`552(banco) no payload do JWT e o `auth` **recusa token de modo diferente** (um553token de demo não abre a produção).554555Quando Pedro pedir **botão de demonstração na landing** ou **três bancos**, use556use este padrão (e não um usuário de demo dentro da produção):557`references/ambiente-demo-publico.md`. Padrão de i18n (PT/EN/ES/FR) + tema558claro/escuro + landing por instância: `references/i18n-e-tema.md`.559560### Arquitetura (padrão implementado e testado)561562- `config/db.js` → `connectDb()`: `mongoose.createConnection(baseUri)` (sem DB no563 final) e `getModeConn(mode)` faz `mainConn.useDb(MODE_DB[mode], {useCache:true})`564 (cacheado). `MODE_DB = { production:'app_db', test:'app_test_db', demo:'app_demo_db' }`.565 O nome do database é **derivado do final da `MONGO_URI`** (tira tudo após a566 última `/`), então `MONGO_URI=mongodb://.../app_demo_db` → banco `app_demo_db`.567- `models/registry.js` → `getModels(conn)`: registra `{User, Project,568 CatalogItem, AuditLog}` na connection (cache por `WeakMap` de conn) e devolve569 o objeto. **Os `*.model.js` exportam o SCHEMA, não o model** — o registry cria570 o model na connection certa.571- `middleware/selectDb.js` → `selectDb(mode)`: injeta em `req` `mode`, `conn`572 (`getModeConn(mode)`) e `models` (`getModels(conn)`). Montado ANTES de cada573 grupo de rotas: `app.use('/api/app', selectDb('production'), apiRoutes)` etc.574- `middleware/auth.js` → `signToken(user, mode)` assina com `payload.mode`;575 `resolveUser(token, mode, models)` **bate `payload.mode === mode`** e usa576 `req.models.User` (não o model global). `pageAuth` também passa `req.mode`/577 `req.models`.578- **Services recebem `models` via `req`** (não `require('../models/x')` global):579 controllers passam `req.models` para o service. É a mudança mais invasiva —580 fazer o registry + `selectDb` e depois catar cada `require('../models/...')`581 nos services/controllers.582- `server.js` semeia os 3 bancos no boot: produção só admin; teste admin +583 demo; demo banco completo (`carregarDemo`). `NODE_ENV=production` NUNCA popula584 demo.585- `app.js` monta as rotas em `/api/app`, `/api/test`, `/api/demo` **mais um alias586 `/api` que aponta para produção** (preserva testes antigos que usavam587 `/api/auth/login`). Páginas iguais em `/app`, `/test`, `/demo` + alias `/`.588589### Tabela de ambientes590591**Arquitetura FINAL (confirmada pelo Pedro): UMA instância, porta 4450, os 3592bancos rodam SIMULTÂNEOS.** Não são 3 instâncias/processos/portas — é um só593`docker-compose.yml` que sobe a app na `4450` e semeia `app_db` + `app_test_db`594+ `app_demo_db` no mesmo Mongo. A landing (`/`) oferece os 3 botões; o `.env`595(`NODE_ENV`) controla `production`/`staging` (testes), mas a demo é sempre596acessível pela landing, independente do modo. O usuário demo (`demo1`) é criado597**só** em `app_demo_db` (ver `carregarDemo({ skipAutoUser })`).598599| | Produção | Teste | Demo |600|---|---|---|---|601| Host port | `4450` (instância única) | `4450` | `4450` |602| Prefixo | `/app` | `/test` | `/demo` |603| Banco | `app_db` (volume) | `app_test_db` (volume) | `app_demo_db` (volume) |604| `NODE_ENV` | `production` | `staging` | `demo` |605| Landing | botão "Entrar" → `/app/login` | botão "Entrar no teste" → `/test/login` | botão "Demo" → `/demo/start` (autologa) |606| População | **só o admin** | admin + usuários demo | banco completo + autologa |607| Rate limit | ativo | `RATE_LIMIT_DISABLED=true` | `RATE_LIMIT_DISABLED=true` |608609**NÃO volte a 3 instâncias/3 portas** salvo o Pedro pedir explicitamente de610novo — esta foi a correção dele após eu ter implementado 3 composes separados611(4450 teste / 4451 produção / 4452 demo) que ele rejeitou ("uma porta só ag612613…(truncated)