# Projeto Profissional

> 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.

- Skill: `pedroiff0/projeto-profissional` (Agent Skill, multi-file: 24 files)
- Install (CLI): `npx skillmds@latest add pedroiff0/projeto-profissional`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pedroiff0/projeto-profissional/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: pedroiff0 (https://skillmd.com/u/pedroiff0)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/pedroiff0/projeto-profissional

---


# 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

1. **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`.
2. **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`.
3. **Adjust roles** if needed — the `role` enum lives in BOTH
   `src/models/user.model.js` and `src/schemas/admin.schemas.js`. Change together.
4. **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.
5. **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.
6. **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`.
7. **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:

```bash
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` |

```bash
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)

1. **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.
2. **`passwordHash` has `select:false`.** Any query that must compare a
   password needs `.select('+passwordHash')`, or `bcrypt.compare` silently
   receives `undefined`.
3. **`COOKIE_SECURE=true` over plain HTTP** ⇒ browser discards the cookie:
   login returns 200 and the session still doesn't stick. Classic LAN/VPN trap.
4. **`upgradeInsecureRequests`** must stay off while serving HTTP, or CSS/JS
   fail to load and the page renders unstyled.
5. **Express 5 makes `req.query` a getter** — `sanitizeInput` mutates in place;
   do not "simplify" it to `req.query = {...}`.
6. **Never build a `RegExp` from user input unescaped** (user search) — escape
   metacharacters or it's a ReDoS.
7. **`npm ci` in CI needs `package-lock.json` committed.**
8. **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.
9. **`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).
10. **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.
11. **`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.
12. **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.
13. **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`).
14. **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.
15. **`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).
16. **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.
17. **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.
18. **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.
19. **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'})"`.
20. **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.
21. **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.
22. **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.
23. **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.
24. **`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.
25. **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.
26. **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.
    27. **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).
    28. **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.
    29. **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`.
    30. **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`.
    31. **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.
    32. **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.
    33. **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.
    34. **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.
    35. **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:`.
    36. **`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`.
    37. **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.
    38. **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.
    39. **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`.
    40. **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.

    41. **`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.)
    42. **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".

    43. **"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.)

    44. **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).

    45. **`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).

    46. **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 ?? '—')`.

    47. **`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.

    48. **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.

    49. **`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`.

    50. **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.)

    51. **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.

    52. **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:

      ```js
      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.

    53. **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.

    54. **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):
  ```bash
  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 `if`s 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)
