Web fullstack gotchas (Node / Express / EJS / jest / Docker)
Coleção de armadilhas que custam iterações inteiras se não forem conhecidas de
antemão. Cada uma abaixo foi caçada na prática; o references/fullstack-gotchas-detail.md
tem os trechos de código exatos que resolveram, references/docker-stale-image.md
tem a receita de rebuild/verificação em Docker, e references/multi-instance-prefix-and-ejs-tdz.md
tem a reprodução exata do bug de prefixo de API em instância demo (401) e do
TDZ de const em include do EJS. references/demo-mode-and-csrf.md tem a
receita de teste da API multi-instância via curl (cookie demo + header Origin
- prefixo
/api/demo), triagem de 401/403 e reseed forçado da demo.references/i18n-raw-key-and-svg-var-fill.mdtem o diagnóstico de chave i18n crua (PT sem bloco), o bug defill="var(--brand)"em SVG (preto), barras sobrepostas emviewBox 0..100+preserveAspectRatio="none", escape de aspas em atributo EJS e o tie-breakerdocker exec grep.
1. EJS <%= %> ESCAPA HTML — nunca ponha tags dentro da expressão
<%= expr %> passa o valor por escape de HTML. Se expr contiver markup
(<strong>, <span>, <a>), ele sai como <strong> e o teste que
procura >2</strong><span>lan... falha mesmo com o dado certo.
- SINTOMA: o texto aparece na página como
<strong>2</strong>"cru" (visível), ou um testetoMatch(/>2<\/strong><span>/)falha apesar do número estar certo. - CORREÇÃO: coloque as tags HTML direto no template e só os VALORES via
<%= %>. Não use template literal com tags dentro de<%= %>. Se precisar mesmo injetar HTML cru, use<%- %>(non-escaping) — mas prefira estruturar no template. - Ver
references/fullstack-gotchas-detail.md→ "EJS escaping".
2. Cache module-level QUEBRA jest (valor stale entre casos)
Um serviço que faz let cache = null; if (cache && ...) return cache; com TTL
de minutos retorna o valor da PRIMEIRA request de toda a suíte para todos os
casos seguintes. Em teste que cria dados e espera contagens exatas, o cache
mentira e o assert falha de forma não-determinística.
- SINTOMA: teste de "números reais" falha esperando
>2</strong>mas recebe>176</strong>(ou null), dependendo da ordem de execução. - CORREÇÃO: desative o cache quando
process.env.NODE_ENV === 'test'(if (!emTeste && cache && ...) return cache;). O cache é otimização de produção; em teste só atrapalha. Não remova o cache do código de produção. - Ver
references/fullstack-gotchas-detail.md→ "module-level cache".
3. Middleware de autologin no Express — a regex define o que NÃO é autenticado
Para autenticar tudo menos a raiz (manter a landing visível mas entrar direto no painel ao clicar em "Entrar"/"Ver demonstração"):
app.use(/^(?!\/$).*/, demoAutologin); // autentica TUDO menos "/"
- NÃO inverta a exclusão para
/login— isso quebra o fluxo: a landing continua visível (bom) mas o botão de login para de entrar sozinho, e o teste de regressão "a landing não redireciona a raiz para o painel" falha. - O middleware de autologin deve setar o cookie e chamar
next(); a rota/login(comoptionalPageAuth) verá o usuário e redireciona para/app— sem formulário. - Ver
references/fullstack-gotchas-detail.md→ "autologin regex".
3b. BUG: autologin que seta apiPrefix SÓ na branch de autenticar trava a demo no painel
Quando a demo roda atrás de nginx (caminho 6b) e o frontend prefixa as APIs com
data-api-prefix, é fácil setar res.locals.apiPrefix='/demo' DENTRO do bloco que
só roda quando não há cookie (if (req.cookies?.token) return next(); antes de
setar o prefixo). Resultado: visitante já autenticado (cookie válido, caso comum
após o 1º acesso) NÃO recebe o prefixo → o header monta links como /app,
/lancamentos (sem /demo) → o nginx manda pro app PRINCIPAL → 401 → a demo
só consegue abrir o painel, não as demais telas.
- REGRA:
res.locals.apiPrefix = '/demo';deve ficar no TOPO do middleware, ANTES do early-return de cookie válido, para valer em TODA request da instância demo. (Ver seção 6c: o common.js lê esse prefixo do<html data-api-prefix>.) - SINTOMA: curl de /demo/api/* com cookie dá 200, mas clicar nos links do nav na
demo cai em 401/login. Correção é mover a linha do prefixo para fora do
if. - Teste de regressão:
GET /demo/lancamentos(com cookie da demo) deve dar 200 e o nav mostrar Painel/Lançamentos/Orçamentos/Investimentos/Veículos/Tutorial.
4. patch tool DUPLICA barras invertidas em .md
Ao editar arquivos Markdown (code fences ``` com \ ou comandos com \\), o
patch pode duplicar \\ (ex.: \\\\ em vez de \\). Isso quebra blocos de
código em docs (curl com \ de quebra de linha vira \\\\).
- CORREÇÃO: depois de qualquer
patchem.mdenvolvendo\, releia o trecho e corrija as barras duplicadas num patch seguinte. Mais seguro: usewrite_filepara reescrever o arquivo quando houver muitas barras.
5. Trocar PORTA de instância Docker exige rebuild + atualizar TODOS os referenciadores
Mudar a porta num docker-compose*.yml ("127.0.0.1:NOVA:5000") não basta:
- Rebuild obrigatório:
docker compose -f <arq> -p <proj> up -d --build. - Atualize TODOS os lugares que citam a porta: o próprio compose (
APP_BASE_URL),.env.example,README.md,docs/*.md,HANDOFF.md, scripts de load (loadtest/carga.jsBASEdefault), e quaisquercurlde exemplo. docker compose -p <proj> down(SEM-v) derruba preservando o volume do Mongo de produção;-vapagaria dados reais.- Ver
references/fullstack-gotchas-detail.md→ "port swap".
6b. Isolar banco de DEMO na MESMA porta (proxy reverso, NÃO 2ª conexão)
Pedido recorrente: "demo acessada por /demo, com banco próprio, na mesma porta das rotas normais". Dois caminhos:
- (A) Proxy reverso nginx na frente [PREFERIDO, zero refactor de lógica]:
uma só porta (ex.: 4460);
location /demo/ { proxy_pass http://app-demo/; }(strip do prefixo) elocation / { proxy_pass http://app; }. Oapp-demoé um 2º container comDEMO_AUTOLOGIN=trueeMONGO_URIapontando pro banco da demo. O banco principal (teste/prod) é escolhido no.envdo containerapp.- Vantagem: services/controllers/models NÃO mudam; o autologin e o bd isolado já funcionam como instâncias separadas. Risco mínimo.
- A landing (no
app) precisa ler os números da demo: dê ao landingService UMA conexão de leitura isolada (mongoose.createConnection(MONGO_URI_DEMO), só para counts) — NÃO acople toda a lógica. Se a var não existir, caia no banco atual (comportamento de teste). Vejareferences/fullstack-gotchas-detail.md→ "demo nginx".
- (B) 2ª conexão mongoose no MESMO app [evitar se puder]: exige registrar os
15+ models num
mongoose.createConnectione injetar "qual banco" em TODOS os services/controllers (hoje usammongoose.model(...)global). Refactor de ~30 arquivos, alto risco de regredir rotas normais. Só vale se o usuário exigir literalmente "mesmo processo".
BUG que pega em (A): o landingService que faz M = { User, Account, ... } para
consultar a demo PRECISA incluir TODOS os models usados (incluindo Transaction),
senão M.Transaction é undefined → throw → catch retorna null e a landing
mostra fallback fixo em vez dos números reais.
6c. BUG de roteamento: APIs do painel NÃO pegam o prefixo /demo -> 401
No caminho (A), a landing e as PÁGINAS da demo ficam sob /demo/* (o nginx manda para app-demo), mas o frontend faz fetch('/api/dashboard') — URL absoluta SEM prefixo. O nginx manda /api/* para o app PRINCIPAL (location /), não para o app-demo. Resultado: a API da demo bate no banco/segredo errado -> 401 Token invalido. A página abre (autologin OK) mas os cards ficam vazios.
- CORREÇÃO backend: o app-demo monta suas rotas TAMBÉM sob /demo, não só em /. Em createApp, quando DEMO_AUTOLOGIN está ligado: app.use('/demo', pageRoutes); app.use('/demo/api', apiLimiter); app.use('/demo/api', csrfGuard); app.use('/demo/api', apiRoutes). Assim /demo/app (página) e /demo/api/dashboard (API) ficam no app-demo, e o nginx (strip do /demo) roteia os dois certo.
- CORREÇÃO frontend: prefixar chamadas de API com /demo quando em demo. NÃO concatene na mão em cada arquivo — centralize no wrapper de fetch (apiRequest em common.js) lendo o prefixo de um atributo data-api-prefix no (ver skill express-csp-runtime-config: NUNCA injete o prefixo via inline, a CSP bloqueia). O demoAutologin seta res.locals.apiPrefix='/demo'; o header.ejs injeta
- SINTOMA que confunde: curl /demo/api/dashboard com cookie da demo dá 200, mas o browser mostra 401. Isso significa o prefixo NÃO está sendo aplicado no JS (common.js antigo em cache, ou script inline bloqueado por CSP).
- DIAGNÓSTICO CSP (pegou nesta sessão): injetei o prefixo via
<script>window.__API_PREFIX__ = '<%= apiPrefix %>';</script>no header.ejs. O helmet vem comscriptSrc: ["'self'"](SEM'unsafe-inline') — logo o script inline é BLOQUEADO silenciosamente. Sintoma exato: o HTML servido CONTÉM o<script>, mastypeof window.__API_PREFIX__ === 'undefined'no console. Não adianta "forçar reload" — o script nunca roda. Correção: não use script inline para config; leia de atributo no<html>(<html data-api-prefix="<%= apiPrefix %>">+document.documentElement .getAttribute('data-api-prefix')no common.js). Respeita a CSP e o prefixo chega ao JS.
6e. Limpeza em cadeia ao REMOVER uma feature do backend (e o erro de import)
Quando se tira uma lógica do backend (ex.: a landing parou de ler os números da
demo via statsDaDemo), não basta apagar o uso na view. Falta de limpeza gera
500 em runtime e/ou testes que não cobrem mais nada.
- Em
pages.routes.jsNÃO removaconst env = require('../config/env');. OerrorHandlerusaenv.nodeEnv— ao apagar esse import junto com outro (landingService), todas as páginas passam a darReferenceError: env is not defined→ 500. Sintoma:paginas.test.jsemodulos.test.jsestouram 500 em /lancamentos, /veiculos etc. Correção: remover SÓ o import órfão (landingService) e manterenv. - Checklist de remoção de feature: (1) view não usa mais o dado → (2) remove o
import órfão no routes, (3) apaga o service file se ninguém mais o importa
(grep ANTES de
rm), (4) tira a env var doconfig/env.jse do compose, (5) ajusta os testes que afirmavam sobre aquele dado (ex.: landing.test que faziatoMatch(/>2<\/strong><span>lançamentos/)) para afirmar o NOVO comportamento (ex.:not.toMatch(/fictícios/i)+toContain('href="/demo/app"')). - Sempre rode
npx jest --forceExitDEPOIS de mexer em imports de routes: oReferenceErrorsó aparece em runtime, não no lint local.
6d. Cache-bust dos assets evita JS stale após rebuild
Mesmo com maxAge 0 em staging/demo, um browser que já abriu a demo antes do deploy pode reter o common.js antigo (sem o prefixo /demo) e continuar a dar 401. Em produção o maxAge 1h piora: todo deploy deixa JS stale por até 1h.
- CORREÇÃO: versione os scripts com query string de build. No app.js: app.locals.assetVersion = process.env.ASSET_VERSION || '1'; e no footer.ejs:ASSET_VERSION por build: ASSET_VERSION=$(git rev-parse --short HEAD) docker compose -p fa up -d --build. Cada deploy muda a URL do asset e o browser busca o JS novo.
- Combine com maxAge: env.nodeEnv === 'production' ? '1h' : 0 no express.static (staging/demo sem cache na verificação; produção com cache).
6. Cache de CSS no BROWSER mascara layout novo
Ao validar visualmente mudanças de CSS, o navegador serve o .css antigo do cache e os screenshots mentem (mostram o layout velho). Não confie só no print.
- CORREÇÃO: confirme o CSS SERVIDO via getComputedStyle no DevTools contexto (browser_console com getComputedStyle(document.querySelector(...))), ou use cache-buster no durante a inspeção. Só declare "funciona" após o computed style confirmar as novas regras.
7. CÓDIGO EDITADO NO DISCO NÃO ENTRA NO CONTAINER — restart NÃO BASTA
Quando o app roda em Docker sem bind mount do código (padrão deste template:
o Dockerfile faz COPY app/ ./ no BUILD, e o docker-compose.yml só faz
build: + ports:), editar app/views, app/public, app/src no disco NUNCA
chega ao container até a imagem ser rebuildada. Um docker compose restart
(ou o usuario "reiniciar a aplicacao") so religa o mesmo container/imagem velha
-> a pagina continua com o layout/codigo ANTIGO e a pessoa diz "ainda nao
surtiu efeito nenhum".
- CORREÇÃO OBRIGATÓRIA:
docker compose build --no-cache app docker compose up -d --force-recreate app--no-cacheforca oCOPY app/a rodar de novo;--force-recreatesobe o container a partir da imagem nova (senaoup -dacha que "ja esta rodando"). - NÃO assuma loopback no curl de verificação: o
BIND_ADDRdo.envcostuma ser um IP não-loopback (ex.:100.120.54.126:4450).curl 127.0.0.1:4450dá exit 7 mesmo com o app healthy. Usedocker port <container>para achar o HostIp:Porta reais e busque por lá. - CONFIRME o novo código com grep nas assinaturas (não só screenshot):
curl -s http://<HostIp>:<Port>/ -o /tmp/live.htmle grepe por marcadores do novo design vs do antigo. Só declare pronto após o grep bater. - Detalhe completo + receita de verificação:
references/docker-stale-image.md.
8. "Faça igual ao projeto X" = REPRODUZA O PADRÃO, NÃO IMPROVISE
Quando o usuario pede "landing igual a do financas-app, design tambem" (ou
qualquer "igual ao de "), a tarefa e reproduzir fielmente o modelo e
o CSS do repositório de referência — não inventar um layout "no mesmo espírito".
Nesta sessão improvisei uma landing genérica (hero centralizado, cards) e o
usuário rejeitou: "simplesmente quebrada, nada da maneira que pedi". A referência
estava no disco em /home/pedro/Repositorios/pessoal/financas-app.
- FLUXO: antes de escrever uma linha, LEIA os arquivos de referência
(
landing.ejs,header.ejs,footer.ejs,main.css,DESIGN.md,common.js) do repo citado e copie a ESTRUTURA (seções, ordem, grids, topbar) e os TOKENS (fonte, cores, sombras, raios) literalmente. Adapte SÓ o conteúdo textual (títulos, copy, idiomas) — mantenha a "casa" idêntica. - Se o repo de referência usa fonte self-hosted (ex.: Inter via
@font-face+.woff2empublic/fonts/), COPIE os arquivos de fonte para o projeto alvo; não caia em "system-ui" por comodidade. - Mantenha as especificidades do projeto alvo que o usuário já validou (ex.:
seletor de idioma por BANDEIRAS SVG em vez de
<select>com emoji) — funda sobre a estrutura copiada, não substitua. - Capture a referência em
references/quando for um padrão recorrente.
9. Multi-instância: NÃO HARDCODE o prefixo de API (erro 401 silencioso)
Apps deste template rodam em 3 modos na MESMA porta via prefixo de rota:
/app, /test, /demo → cada um com seu banco (selectDb). As PÁGINAS e a
navbar já usam o prefixo (setado em res.locals.base por selectDb), mas o
JS do cliente que faz fetch às APIs PRECISA do prefixo /<modo>/ também.
- SINTOMA que confunde: a página abre (200) e mostra os dados do server-render,
mas criar/mover/apagar via fetch dá 401 "Autenticação necessária". Motivo:
o cookie da demo é válido SÓ para o banco demo; o fetch hardcoded
apiRequest('/api/projects/...')bate em/api/*que oapp.jsmapeia para produção (app.use('/api', selectDb('production'), apiRoutes)). Produção não reconhece o cookie demo → 401. curl comOrigincorreto passa do CSRF, mas ainda 401 = prefixo errado, não CSRF nem escopo. - CORREÇÃO: passe o prefixo de API do backend para a view e use no cliente.
No route:
res.locals.apiBase = '/api' + (modo === 'app' ? '' : '/' + modo)(ou reusebase→'/api' + base). Na view, exponha num atributo/data-* (NUNCA script inline — CSP):<section class="board" data-api-base="<%= apiBase %>">. No JS:const apiBase = document.querySelector('.board')?.dataset.apiBase || '/api';eapiRequest(apiBase + '/projects', ...). - REGRA geral: qualquer fetch de API dentro de página logada deve concatenar
o
apiBasedo modo. Não existe/api/projects"neutro" quando há modos. - Ver a nota de CSP na seção 6c (prefixo vem de atributo, não de script inline).
10. BUG EJS: redeclarar const com o nome de um local do include dá TDZ
header.ejs é incluído por várias views com <%- include('partials/header', { modo: modo, base: base }) %>. O EJS compila cada include-passado como uma
variável local da função de render. Se dentro do header você fizer
const modo = ... (mesmo nome), dá "Cannot access 'modo' before
initialization" em tempo de execução — a declaração const colide com o
local já existente (TDZ), e o erro só aparece ao RENDERIZAR, não no lint.
- CORREÇÃO: NUNCA redeclare com
const/letum nome que o include possa passar. Leia o local e derive em OUTRO nome:
e use<% const _modo = (typeof modo !== 'undefined' && modo) || 'app'; const _base = (typeof base !== 'undefined' && base) || (_modo === 'app' ? '' : '/' + _modo); %>_modo/_baseno markup. Melhor ainda: quem seta o modo (selectDb) já jogares.locals.modo/res.locals.base/res.locals.currentPathpara TODAS as views — aí o header só consome, sem redeclarar. - DICA de verificação: um
ejs.renderstandalone com os parâmetros certos pega esse erro antes do rebuild (rode um harnessrender-*-check.jstemporário e apague-o).
6f. BUG de verificação visual: a demo carrega /css/main.css do APP PRINCIPAL, não do app-demo
Em setup com proxy reverso nginx (caminho 6b), a página /demo/app referencia o
stylesheet em /css/main.css (URL absoluta, SEM prefixo /demo/). O nginx roteia
/css/* → location / → app principal, não app-demo. Consequência que custou um
ciclo inteiro de debug: você rebuilda o app-demo com o CSS novo, o curl /demo/css/main.css mostra a regra nova, mas o browser INSISTE no layout velho —
porque document.styleSheets aponta para http://HOST/css/main.css (do principal),
que ainda servia o arquivo antigo. O grep no /demo/css e no /css são arquivos
DIFERENTES.
- REGRA: ao mexer em CSS/JS da demo, rebuilda AMBOS os containers:
docker compose up -d --build app app-demo. Valide o asset do PRINCIPAL:curl -s http://HOST/css/main.css | grep -n 'suaRegraNova'(deve aparecer). Se não aparecer, oapp(principal) não foi rebuildado. - Para o BROWSER DO USUÁRIO (não só o seu) re-buscar o CSS novo, BUMPE o
ASSET_VERSION(oheader.ejsjá versiona o link:/css/main.css?v=<%= assetVersion %>): passeASSET_VERSION=2 docker compose up -d --build app app-demo(o compose lêASSET_VERSION: ${ASSET_VERSION:-1}). Sem o bump, a aba já aberta do usuário usa o cache?v=1e não vê a correção. - Para SUA validação: navegue no browser direto na URL versionada
(
/css/main.css?v=2) para forçar fetch fresco antes de medirgetComputedStyle. - Sintoma clássico de ter esquecido o rebuild do principal: computed-style mostra
valor OLD enquanto o arquivo em
/demo/css(app-demo) já tem o novo. Sempre confira o/css(principal) também.
8b. Conteúdo da UI: NÃO mostre string de conexão de banco
O usuário pediu explicitamente "não informe as strings de conexão do banco de
dados". Elas vivem em config/env.js / .env / docker-compose.yml
(mongodb://...) — NUNCA as interpolate na view. Nomes de banco
(app_db, app_demo_db) são identificadores internos inofensivos, mas
prefira rótulos amigáveis na UI ("Produção"/"Teste"/"Demo") e evite expor
qualquer URI. Grep de defesa: grep -rn 'mongodb://' app/views app/public
deve retornar 0.
11. MODO DEMO: demoBypass para o usuário mexer em tudo (menos usuários)
Pedido recorrente de Pedro: "o usuário demo pode fazer o que quiser no banco
de demo, mas NÃO pode alterar dados de usuários". O demo user é role:'user'
(para NÃO poder editar usuários — /admin fica bloqueado). Para que ele possa
CRUDar qualquer tarefa/projeto/profissional do banco demo (que pertence a
outros donos seedados), adicione um bypass de escopo:
- Em
selectDb:if (mode === 'demo') req.demoBypass = true;(logo após setarreq.mode/req.models). - Nos services de domínio (
projectService,taskService,professionalService): assinedemoBypass = falsee, nas queries/obter,if (role !== 'admin' && !demoBypass) q.ownerId = userId;/throw 403. QuandodemoBypassé true, pula o filtro de dono → o demo user vê/edita TUDO do banco demo. Usuários continuam protegidos porque o service de usuários NÃO recebedemoBypasse o demo user não é admin. - SINTOMA que pega: board do demo mostra só 1-2 tarefas (as que ele "possui")
porque o seed distribui donos. Com
demoBypasso board mostra as 40 seedadas. - CUIDADO: o endpoint de reload da demo (
/api/demo/demo/load?force) costuma terrequireAdmin— o demo user éuser, então dá 403 "Acesso negado para este papel". Se o reload só mexe no banco demo, PERMITA-o para a instância demo (ex.:if (req.mode === 'demo') next();ou honredemoBypass), senão o usuário não consegue repovoar a demo.
12. TESTAR MUTAÇÃO DE API VIA curl: CSRF exige header Origin
O csrfGuard (montado em /api) bloqueia POST/PATCH/DELETE cujo Origin
não bata com Host, e também bloqueia quando Origin está AUSENTE. curl não
manda Origin por padrão → você recebe 403 "Requisicao bloqueada (origem
ausente)", que parece erro de permissão mas é só falta de header.
- RECEITA para testar a API com cookie de demo:
curl -s -b /tmp/demo.cookies -X POST "http://<HostIp>:<Port>/api/demo/tasks" \ -H 'Origin: http://<HostIp>:<Port>' -H 'Content-Type: application/json' \ -d '{"titulo":"Teste","status":"planejado"}' -w '\nHTTP %{http_code}\n' - Para triar o erro: 401 = autenticação (cookie/prefixo errado); 403 "origem
ausente" = falta
Origin; 403 "Acesso negado" = papel. O script do browser (apiRequestcomcredentials:'same-origin') mandaOrigincerto, então o 403 só aparece no curl de teste. - Detalhe + receita completa:
references/demo-mode-and-csrf.md.
13. EJS: variável de loop NÃO pode se chamar t (sombreia o i18n)
Em views que recebem t (a função de tradução: <%= t('nav.panel') %>),
fazer col.items.forEach(function(t){ ... }) sombreia o t dentro do
corpo do loop → "t is not a function" ao chamar t('board.remove'). O erro só
aparece ao RENDERIZAR (não no lint), e só para a view que tem itens.
- CORREÇÃO: nomeie o iterador diferente —
function(task),function(item),function(p). Nuncat,e,mse a view usa esses nomes como helper. - Verifique com
ejs.renderstandalone (harnessrender-*-check.js) passando uma lista com 1 item — pega o erro antes do rebuild.
14. Nav mode-aware: selectDb joga res.locals para TODAS as views
Para a navbar nunca apontar para o app errado em /demo ou /test, não passe
modo/base manualmente em cada include. Em selectDb, depois de req.models,
sete:
res.locals.currentPath = (req.baseUrl || '') + (req.path || '/');
res.locals.modo = mode;
res.locals.base = mode === 'app' ? '' : '/' + mode;
O header.ejs consome modo/base direto (sem redeclarar const — ver seção
10) e monta os links como <%= base %>/projetos, <%= base %>/perfil. Assim o
nav do demo aponta para /demo/* e o usuário navega de verdade. (Em
pageAuth não há currentPath; quem sabe o modo é o selectDb.)
8c. Landing: "só o que o sistema faz + demo", NUNCA falar de 3 ambientes NEM de segurança/JWT
Quando Pedro diz "não pode falar que tem 3 ambientes", RETIRE da landing a
seção de "Produção/Teste/Demo" (grid de bancos, "três espaços isolados"). A
landing deve conter SÓ: (1) o propósito do sistema, (2) um CTA de demo
(/demo/start). Ele rejeitou duas vezes landings que enfatizavam múltiplos
bancos. Nomes de banco (app_db) também saem da UI (ver 8b).
ATUALIZAÇÃO (corrige orientação anterior que dizia para "manter JWT/CSRF como
diferencial"): Pedro pediu EXPLICITAMENTE para REMOVER da landing TUDO que fale
de segurança/JWT/bancos — não só a seção "Como a segurança é feita", mas também
o badge do hero ("Template base · JWT · Admin" → "Template de tarefas · Demo ao
vivo"), os pills do mock ("Bancos isolados" / "JWT httpOnly" → "Quadro Kanban" /
"Foco Pomodoro") e o <meta name="description"> que citava JWT/bancos isolados.
CHECK-LIST ao limpar a landing: grep por JWT|Bancos isolados|httpOnly|banco de dados, isolado deve retornar 0 em views/landing.ejs E em header.ejs (a meta
description vive no parcial). NÃO reintroduza "segurança" como diferencial na
landing — ele não quer esse bloco.
15. Rota duplicada: a PRIMEIRA definição vence, a segunda é sombreada
Em routes/*.js, se houver DUAS router.get('/mesma-rota', ...) (uma antiga
sync que esquece de passar apiBase/dados, e uma nova async correta),
o Express usa a PRIMEIRA que bate — a antiga. Sintoma confuso: a página dá
500 "apiBase is not defined" mesmo existindo uma rota "correta" mais abaixo.
- DIAGNÓSTICO:
grep -n "router.get('/projetos'" src/routes/pages.routes.js— se aparecer 2x, a primeira (sync, semapiBase) está sombreando a segunda. - CORREÇÃO: remova a definição STALE/duplicada (a que não passa os dados),
mantendo só a async que renderiza com
apiBase,projetos, etc. - REGRA: nunca deixe duas definições da mesma rota; o Express não avisa.
16. Reseed da demo INVALIDA o token do usuário demo (ordem de operações)
/demo/start (demoLogin) emite um token cujo sub = o _id do usuário demo.
Se logo em seguida você roda POST /api/demo/demo/load?force=true e o seed
faz deleteMany({email:/@example.com/}) + insertMany (recria o demo user com
NOVO _id), o token antigo aponta para um usuário que não existe mais →
qualquer request depois do reseed dá 401 "Autenticação necessária", mesmo
com o cookie salvo.
- SINTOMA que confunde: o PRÓPRIO POST de reseed dá 200 (o usuário ainda existia
no momento do
auth), mas TODOS os GETs seguintes (board, API) dão 401. - CORREÇÃO: após um reseed
force, REFAÇA o login (/demo/start) para pegar um token com o_idnovo antes de testar as páginas. Não reaproveite o cookie antigo. - Para EVITAR o 401 no reseed em si (o demo user é
role:'user', mas o endpoint tinharequireAdmin): libere o reload para a instância demo — emdemo.routes.jstroquerequireRole('admin')por um guard inline que faznext()quandoreq.mode === 'demo'(ou honrereq.demoBypass), pois o reload só mexe no banco demo. (Ver seção 11 sobredemoBypass.)
16b. Cada PÁGINA que faz fetch precisa do seu próprio data-api-base
O bug da seção 9 (prefixo de API) se repete POR PÁGINA: o board expõe
data-api-base="<%= apiBase %>", mas se você criar /projetos e
/profissionais e esquecer de colocar data-api-base no elemento raiz delas,
o JS dessas páginas cai no fallback '/api' (produção) → 401 no modo demo.
- CHECK-LIST ao criar uma página nova que consome API: confirme que o
data-api-baseestá no<section>/<div>raiz (ex.:<section class="card" data-api-base="<%= apiBase %>">) e que a rota passaapiBase: '/api/' + modo. Sem isso a página abre (200) mas o cadastro/lista falha silenciosamente. - O
apiRequestdo cliente deve lerdocument.querySelector('[data-api-base]')?.dataset.apiBase || '/api'.
17. express.static com maxAge:'1h' faz o navegador reter CSS/JS por 1h (sintoma "não reiniciou")
Quando o app serve assets com express.static(path, { maxAge: '1h' }), o
navegador cacheia main.css/board.js por UMA HORA. Depois de um rebuild que
corrige layout/CSS/JS, o usuário abre a página e vê o layout velho — e diz
"parece não ter sido reiniciado o sistema" / "ainda está com textos brutos",
mesmo o container servindo o arquivo novo. O Cache-Control: public, max-age=3600
no header é a causa exata.
- CORREÇÃO (demo/staging/verificação):
express.static(path, { maxAge: 0, etag: true }). Commax-age=0o navegador SEMPRE revalida (ETag) e baixa o asset novo após cada deploy. Em produção mantenha cache (nodeEnv==='production' ? '1h' : 0), combinando com versionamento de asset (seção 6d) para invalidar por URL. - DIAGNÓSTICO:
curl -sI http://<host>:<port>/css/main.css | grep -i cache-controldeve mostrarmax-age=0(oumax-age=3600só em produção). Se estiver 3600 e o usuário reclamar de CSS velho, essa é a causa — não o rebuild. - NÃO confunda com a imagem Docker (seção 7): aqui o arquivo NOVO está no container, mas o CLIENTE não o busca porque o cache de 1h ainda é válido.
18. Verifique o ASSET SERVIDO, não só o arquivo no disco
Após editar public/js/*.js / public/css/*.css, um rebuild pode ter rodado
ANTES do patch aterrissar no disco — e o container passa a servir a versão
ANTIGA. Sintoma enganador: search_files no arquivo local mostra a mudança, mas
o browser ainda tem o comportamento velho. Nesta sessão o board.js local tinha
renderizarCards (12660 bytes) mas o container servia 12250 bytes SEM a função.
- CORREÇÃO: depois do rebuild,
curl -s http://<host>:<port>/js/board.js -o /tmp/x.jsegrep -c 'marcadorDaSuaMudanca' /tmp/x.js. Só declare pronto quando o GREP no asset SERVIDO bater. Combine com a seção 7 (--no-cache+--force-recreate). - Para CSS:
curl -s .../css/main.css | grep -c 'novaRegra'. Para HTML/EJS:curl -s .../demo/ -o /tmp/p.html && grep -c 'id="task-modal"' /tmp/p.html. - REGRA: "edição no disco" ≠ "servido ao cliente". O grep final é sempre no asset servido, não no arquivo local.
19. Formulário NOVO sem a CLASSE de estilo = "textos brutos"
Ao adicionar um formulário, se o <form> NÃO carregar a classe que detém as
regras de input/textarea/select, os campos aparecem como caixas default do
navegador (sem borda/raio/foco) — o usuário chama de "textos brutos" (campo cru,
não dado cru). Nesta sessão o painel "Nova tarefa" usava id="task-form" SEM
class="cad-form", então o CSS de input (definido para .cad-form input, .cad-form textarea, .cad-form select) nunca se aplicava.
- CHECK-LIST ao criar formulário: confirme que o
<form>(ou o container) tem a classe que carrega o estilo base dos inputs (cad-form/cadastrono template deste projeto). Adicione.cad-form input, .cad-form textarea, .cad-form select { width:100%; padding; border; radius; box-shadow no :focus }se faltar. - Também vale para o
selectcustom (.select) e parafield-hint(texto de ajuda sob o input). O usuário reclama de "bruto" quando vê input sem foco-ring nem padding — é bug de CSS de formulário, trate como tal. - Verificação:
curlno HTML servido egrep 'id="task-form" class="cad-form"'.
Workflow (Pedro): screenshot antes de commitar frontend/feature
Pedro só aprova depois de VER o resultado num screenshot real do browser
(ver skill frontend-visual-verification). NUNCA git commit uma mudança de
frontend/nova feature sem antes: rebuild do container (seção 7), screenshot da
página ao vivo, e aprovação explícita dele. Ele também quer nav mode-aware,
seletor de idioma por BANDEIRAS SVG (não emoji/select), e rodapé
multi-coluna "empresarial".
PASSO-A-PASSO obrigatório ao mexer em CSS/JS da DEMO (caminho 6b):
- Faça a edição no
app/public/...(fonte única — app e app-demo copiam o mesmo arquivo no build). - Rebuilda AMBOS os containers:
docker compose up -d --build app app-demo. NÃO rebuildar só oapp-demo: a demo carrega/css/main.cssdo app PRINCIPAL (seção 6f), então o principal precisa do CSS novo também. - BUMPE
ASSET_VERSION(ex.:ASSET_VERSION=2 docker compose up -d --build app app-demo) para o browser do USUÁRIO re-buscar o asset (senão a aba dele fica no cache?v=1). - Navegue no browser até a URL versionada (
/css/main.css?v=2) para forçar fetch fresco, depois meçagetComputedStylenas duas divs que deveriam alinhar (diff detopdeve ser 0) e só então tire o screenshot para o Pedro. - Confirme o asset SERVIDO do principal:
curl -s http://HOST/css/main.css | grep -n 'suaRegraNova'(seção 18/6f) — não confie só no arquivo no disco.
VERIFICAÇÃO AD-HOC (quando não há suíte de teste): o sistema pode marcar a
verificação como "stale" se você só rodou um script e não reportou o resultado de
forma explícita. Sempre: (1) escreva o script em /tmp/hermes-verify-*.js
(os-safe tempfile, apagado depois), (2) rode contra o comportamento mudado
(render EJS via ejs.render standalone com os params da rota, grep no CSS vivo
servido, curl nas rotas), (3) declare o resultado como "ad-hoc verification",
NÃO "suite green". Para views que usam <%- include %> com locals (ex.:
header.ejs consome modo/base), passe TODOS os locals que a rota realmente
passa (a section 10/TDZ e a seção 15 valem aqui: um ejs.render standalone com
os params certos pega erros de "X is not defined" ANTES do rebuild). Cuidado com
regex de checagem que não casam seletor CSS multi-linha (seção abaixo do 16b):
leia o CSS com search_files/read_file para confirmar a regra existe de fato,
em vez de confiar só no regex do harness.
Gates de validação (sempre rode antes de declarar pronto)
npx jest --forceExit— suíte completa deve ficar 100% verde.npx @google/design.md lint ../DESIGN.md— 0 erros (se o repo tiver DESIGN.md).- Se alterou Docker/portas: rebuild +
curl -s -o /dev/null -w '%{http_code}'nas rotas-chave (ex.: demo/app→ 200,/admin→ 403, produção/app→ 302). Se o app roda EM DOCKER: o "rebuild" acima édocker compose build --no-cache app && docker compose up -d --force-recreate app(ver seção 7) —restartsozinho NÃO re-aplica edições de código.
20. i18n: chave CRUA na tela = chave AUSENTE no idioma ativo (e diagnóstico rápido)
Quando a view renderiza o próprio nome da chave (proj.list, pro.desc, painel.x)
em vez do texto, NÃO é bug do t() — é que a chave não existe no dicionário do
idioma em uso (quase sempre o PT, editado por último e ficando com bloco
incompleto). Sintoma clássico: nav.tasks e landing.* funcionam (outros
blocos OK), mas proj.*/pro.* saem crus — porque en/es/fr TÊM o bloco e o PT NÃO.
- DIAGNÓSTICO isolado (mais rápido que subir o container): require o i18n e
traduza a chave suspeita EM NODE:
Se retornar a própria chave, ela (ou o bloco todo) está ausente emconst { translate } = require('./src/config/i18n'); console.log(translate('pt', 'proj.list')); // 'proj.list' => chave faltando no PT console.log(translate('pt', 'nav.tasks')); // 'Tarefas' => OKDICT.pt. Confirme com grep no arquivo:grep -n "'proj.list':" src/config/i18n.js— se só aparecer em en/es/fr, o PT está incompleto. Adicione o bloco PT faltante. - REGRA ao editar i18n: NUNCA deixe um idioma com bloco parcial. Se adicionou
proj.*para en/es/fr, adicione para PT também. O fallbackDICT.pt[key]só existe se a chave estiver EM PT — não serve para "traduzir" um literal. - Verificação ao vivo:
curl -s -b /tmp/demo.cookies /demo/projetos | grep -c 'proj.list'deve ser 0;grep 'Projetos registrados'deve ser >0.
21. SVG fill="var(--brand)" NÃO resolve (fica PRETO) — use CSS
var() só funciona em CSS, NÃO em atributos de apresentação SVG
(<rect fill="var(--brand)">). O navegador ignora e cai no default preto.
Sintoma: gráfico SVG vira "bloco preto sólido". Mesmo com
.bars svg rect { fill: var(--brand) } no CSS, se o seletor não casar (barra
sem a classe esperada, ou regra ineficaz) o default preto aparece. Correção
robusta: (1) tire o fill="var(...)" do atributo; (2) dê class="bar" à barra;
(3) CSS: .bars svg .bar { fill: var(--brand); }.
- BUG COMPANHEIRO de gráfico de barras: com
viewBox="0 0 100 100"+preserveAspectRatio="none", usarx="6%" width="88%"faz CADA barra ter 88% da largura do viewport → elas SE SOBREPÕEM num único retângulo (bloco sólido), não barras separadas. Use coordenadas em user-space:slot=100/entries; bw=min(slot*0.6,14); x=i*slot+(slot-bw)/2; y=100-h;e renderize os RÓTULOS como HTML abaixo do<svg>(.bars-labels), nunca como<text>dentro do SVG compreserveAspectRatio="none"(texto distorce). - Verificação:
getComputedStyle(svg.querySelector('.bar')).filldeve ser a cor do brand (ex.:rgb(37,99,235)), nãorgb(0,0,0).
22. EJS escapa ASPAS em atributo -> confere data-theme no script de verificação
<html ... data-theme="<%= lpDark ? 'dark' : 'light' %>"> é servido como
data-theme="dark" (o EJS escapa as aspas duplas do atributo para
"). Inofensivo para o navegador (decodifica certo), MAS quebra um check
ingênuo html.includes('data-theme="dark"') no seu script de verificação →
falso FAIL. Correção no harness: confira html.includes('data-theme="dark"')
ou melhor, valide via atributo decodificado (curl ... | grep -o '<html[^>]*>').
NÃO trate o " como bug — é escape correto de EJS.
23. Tie-breaker de deploy stale: docker exec <container> grep decide
Quando grep no ARQUIVO LOCAL mostra a mudança mas o asset SERVIDO (§18) parece velho, o árbitro definitivo é o arquivo DENTRO do container:
docker exec projeto-profissional-app-1 grep -c "proj.list" /app/src/config/i18n.js
docker exec projeto-profissional-app-1 grep -c "renderizarCards" /app/public/js/board.js
Se o container tem a mudança mas o browser não, o problema é cache do cliente
(§17) ou o browser abriu antes do rebuild. Se o container NÃO tem, o rebuild não
incluiu o patch (§7/§18): rode docker compose build --no-cache app && docker compose up -d --force-recreate app de novo. NÃO confie só no grep local.
24. JWT tokenValidAfter default new Date() INVALIDA o token ao nascer
Causa raiz de "não consigo fazer logout, token inválido" e de reset de demo
travado: se o model de usuário tem tokenValidAfter: { type: Date, default: () => new Date() },
TODO usuário novo nasce com sessão ANTERIOR invalidada. O resolveUser do
middleware auth compara payload.iat (segundos, TRUNCADO para baixo) com
user.tokenValidAfter (segundos). Se o token é emitido no mesmo segundo da
criação do usuário (ou há dessincronia de 1ms de clock), o iat truncado fica
MENOR que validAfterSec → resolveUser retorna null → 401 "Token inválido".
- SINTOMA: usuário recém-criado/logado dá 401 em TODAS as APIs logo após o
login; logout (
/api/auth/logout) ou reset da demo (/api/reset-demo) falham. - CORREÇÃO:
tokenValidAfterdefaultnull(usuário novo NÃO tem sessão a invalidar). As ações de invalidação (troca/reset de senha, desativação, logout global) continuam setandotokenValidAfter = new Date()EXPLICITAMENTE no service. Comnull, oif (payload.iat && user.tokenValidAfter)é pulado e o token recém-emitido é válido. - TESTE de regressão: usuário novo tem
tokenValidAfterfalsy; um token emitido porauthService.generateToken(user)passa emGET /api/auth/me(200).
25. Logout/reset NÃO devem exigir token válido (middleware authOptional)
O endpoint de logout existe JUSTAMENTE para invalidar a sessão — se o token já
está expirado/inválido (ex.: por tokenValidAfter no futuro, ou expirado), o
middleware auth barra ANTES do controller rodar, o cookie não é limpo e o
usuário fica "preso" (clica em sair, nada acontece / cai em estado ruim).
- PADRÃO: crie
authOptional— populareq.userse o token for válido, mas NÃO lança 401 quando está ausente/expirado/inválido (try/catch silencioso,next()s
…(truncated)