Review Hub
Visualizador auto-hospedado de code reviews compartilháveis, com controle de acesso. Você (admin) sobe reviews — em Markdown/JSON estruturado (renderizado num tema consistente) ou em HTML self-contained — e libera para viewers específicos; cada viewer entra e vê só o que você atribuiu.
- Servidor enxuto — Node + stdlib, três libs de apoio (
marked,sanitize-html,gray-matter). Sem build. - Reviews estruturados — um contrato (findings, severidade, sugestões de código) validado na entrada e renderizado no tema padrão, então todos ficam com o mesmo visual.
- Acompanhamento por finding — resumo agregado, status
Aberto,Resolvido,IgnoradoeEm discussão, histórico simples, comentários internos e filtros por severidade, status, categoria, arquivo e busca. - Entrega enxuta no PR — gera um comentário curto em Markdown com resumo, principais achados e link para o review completo.
- Magic link para viewer — admin pode gerar link manual ou enviar convite por email quando um provedor HTTP estiver configurado.
- Formatação por IA (opcional) — cola um review cru e a IA reorganiza no contrato (endpoint compatível com a API OpenAI; a chave fica só no servidor).
- Store em arquivo JSON (
data/db.json, escrita atômica). - Sessão em cookie assinado (HMAC), senhas com scrypt, CSRF double-submit.
- Isolamento — reviews rodam num
<iframe sandbox>; o markdown passa por sanitização.
Rodar
Requer Node 18+ (recomendado 20.12+ para o carregamento automático do .env).
cd review-hub
npm install # marked, sanitize-html, gray-matter
cp .env.example .env # opcional — dá pra rodar sem
ADMIN_EMAIL=voce@empresa.com ADMIN_PASSWORD=troque-isso PORT=4000 npm start
npm start roda node --env-file-if-exists=.env src/server.js. Se seu Node não tiver o
flag --env-file, exporte as variáveis inline (como no exemplo acima) ou use node src/server.js.
No primeiro boot ele cria o admin. Se você não definir ADMIN_PASSWORD, uma senha
aleatória é gerada e impressa uma vez no console — anote. O admin só é semeado no
primeiro boot; para trocar email/senha depois, apague data/db.json e suba de novo.
Acesse http://localhost:4000, entre como admin, cadastre viewers e crie reviews.
Testar
npm test
Os testes usam node:test sem dependências extras. Há testes unitários para parser,
comentário PR e use cases de aplicação, incluindo reviews, assignments, status,
comentários por finding, magic links, settings e jobs de IA. Testes de integração sobem
o servidor em porta livre com DATA_FILE temporário para validar acesso, CSRF, API JSON,
status, comentários por finding e magic links sem tocar data/db.json.
Variáveis de ambiente
| Variável | Padrão | Descrição |
|---|---|---|
PORT | 4000 | Porta HTTP. |
DATA_FILE | data/db.json | Caminho do store JSON. Em hosts com volume persistente, aponte para o mount do volume. |
SESSION_SECRET | (gerado) | Segredo do HMAC de sessão. Defina um valor estável para as sessões sobreviverem a restarts; se vazio, é gerado e persistido em data/db.json. |
ADMIN_EMAIL | admin@local | Email do admin semeado no 1º boot. |
ADMIN_PASSWORD | (gerado) | Senha do admin. Se vazia, uma aleatória é impressa uma vez. |
MAX_BODY_BYTES | 8388608 | Limite do corpo da requisição (reviews em HTML podem ser grandes). |
MAGIC_LINK_TTL_MS | 604800000 | Validade dos magic links de viewer em milissegundos (padrão: 7 dias). |
APP_LOCALE | pt-BR | Idioma padrão para textos de sistema em convites por email e comentário PR. Suporta pt-BR e en. |
LLM_API_KEY | (vazio) | Chave da IA de formatação. Sem ela, o recurso fica desativado; o resto funciona igual. |
LLM_BASE_URL | https://integrate.api.nvidia.com/v1 | Endpoint compatível com a API OpenAI (NVIDIA NIM, OpenAI, etc.). |
LLM_MODEL | minimaxai/minimax-m3 | Modelo usado na formatação. |
LLM_TEMPERATURE | 0.2 | Temperatura padrão (ajustável na tela). |
LLM_MAX_TOKENS | 8192 | Tamanho máx. da resposta da IA (ajustável na tela). |
EMAIL_PROVIDER_URL | (vazio) | Endpoint HTTP que recebe convites por email via POST JSON. Para Resend, use https://api.resend.com/emails. Sem ele, envio por email fica desativado. |
EMAIL_PROVIDER_TOKEN | (vazio) | Token opcional enviado como Authorization: Bearer ... ao provedor de email. |
EMAIL_FROM | (vazio) | Remetente incluído no payload do provedor. Obrigatório para Resend, ex.: Apontiq <reviews@apontiq.com>. |
EMAIL_SUBJECT | (localizado) | Assunto dos convites por email. Se vazio, usa o padrão de APP_LOCALE; se preenchido, sobrescreve o texto localizado. |
Para comprar domínio e configurar Resend do zero, siga docs/infra/domain-email-resend-setup.md.
A decisão de marca/domínio proposta está registrada em
docs/adr/2026-07-06-public-domain-and-resend.md.
Formatos de review
Cada review tem um campo format:
structured (recomendado)
Enviado como Markdown com frontmatter ou como JSON — mesma estrutura, duas
codificações. Você pode colar o conteúdo no textarea ou carregar um arquivo .md,
.markdown ou .json; o arquivo é lido no browser e o conteúdo fica disponível para
revisão antes de salvar. É validado na entrada e renderizado no tema padrão.
Obrigatório: title e ao menos 1 finding com severity, title e body.
Cada finding renderizado ganha uma âncora determinística e um botão Copiar link, útil para apontar colegas diretamente para um achado específico.
O status de cada finding fica separado do conteúdo do review: novos achados começam como Aberto, admins podem alterar para Resolvido, Ignorado ou Em discussão, e viewers apenas visualizam. Cada mudança real registra uma atividade recente com status anterior, novo status, admin e horário.
Cada finding também pode receber comentários internos. Admins e viewers atribuídos podem
comentar pela página do review; a rota /raw isolada mostra a discussão em modo leitura.
Os comentários ficam em findingComments, separados do arquivo estruturado original.
Antes da toolbar, o review mostra um resumo de acompanhamento com contagens por status,
severidade, categoria e arquivo. Esse resumo usa apenas findings[], findingStates,
findings[].category e findings[].file; não cria uma nova persistência.
A toolbar do review combina busca, severidade, status, categoria e arquivo. Categorias
aparecem como chips com contagem; arquivos aparecem em um seletor para manter reviews
grandes escaneáveis. Esses filtros usam findings[].category e findings[].file, sem
mudar o contrato ou persistir preferências.
| Campo | Nível | Obrigatório | Descrição |
|---|---|---|---|
title | topo | sim | Título do review. |
subtitle, repo, pr, author | topo | não | Metadados. |
verdict | topo | não | approve | comment | request_changes. |
summary | topo | não | Markdown com a visão geral. |
findings[] | topo | sim | Lista com ≥ 1 item. |
findings[].severity | finding | sim | critical | high | medium | low | info. |
findings[].title | finding | sim | Título curto. |
findings[].body | finding | sim | Markdown com a explicação. |
findings[].category | finding | não | Ex.: correctness, security, performance, style. |
findings[].file | finding | não | Caminho do arquivo. |
findings[].lines | finding | não | [42] ou [42, 48]. |
findings[].suggestion | finding | não | { before, after } (diff) ou { code } (substituição), com language opcional. |
Se algo faltar ou for inválido, o app recusa e mostra o erro apontando o campo.
Sugestões com language reconhecida recebem syntax highlight simples no relatório. Se a
linguagem for desconhecida, o código continua sendo exibido como texto escapado, sem
realce.
Exemplo — Markdown (examples/sample-review.md):
---
title: "PR #482 — Cadastro de assinaturas" # aspas: título com # quebra o YAML
repo: kiskadi/billing
pr: 482
verdict: request_changes
---
Resumo do review em **markdown** (opcional).
## high · correctness · N+1 ao listar assinaturas
file: app/controllers/subscriptions_controller.rb:18-24
Explicação do problema em markdown...
```suggestion ruby
- @subscriptions = current_account.subscriptions
+ @subscriptions = current_account.subscriptions.includes(:plan)
```
No Markdown: o frontmatter carrega os metadados; o texto antes do primeiro ## é o
summary; cada ## severidade · categoria · título abre um finding; uma linha
file: caminho:linhas é opcional; e um bloco ```suggestion <linguagem> com linhas
- /+ vira um diff (ou, sem -/+, uma substituição).
Exemplo — JSON: ver examples/sample-review.json.
html (legado)
Cole um HTML self-contained (documento completo ou fragmento com <style>). Ele é
servido cru dentro do <iframe sandbox>, mantendo o visual próprio. Útil para importar
relatórios prontos, mas sem consistência visual nem busca/filtro.
Formatação por IA (opcional)
Se LLM_API_KEY estiver definida, ao tentar salvar um review estruturado fora do formato,
o app abre um modal com os erros e oferece ✨ Formatar com IA — que reorganiza o
texto no contrato (não inventa achados).
- Assíncrono: a formatação roda como um job em background (
POST /admin/ai/format→jobId; o cliente faz polling emGET /admin/ai/format/:jobId) com spinner e tempo decorrido — não trava a conexão, então modelos lentos de raciocínio não dão timeout. - Configurável: temperatura e tamanho máx. de resposta, globais (painel → Configurações
da IA, persistidos em
data/db.json) e por formatação (no modal). - A chave vive só no servidor — nunca vai para o browser nem para o git (
.envé gitignored).
Sem API key, dá para usar uma IA externa (ChatGPT, Claude): o formulário tem 📋 Copiar
prompt para IA (o mesmo prompt está em examples/prompt-ia.md) — cole numa IA com o diff
e traga o .md pronto.
Entrega no PR
Depois de criar o review, o app abre uma tela de sucesso com o link do review, viewers
atribuídos e botões para Copiar link, Comentário PR e Abrir review. O botão
Comentário PR copia um comentário curto em Markdown com veredito, contagem por
severidade, até 5 achados principais e o link para o review completo. Cole esse texto no
PR em vez de publicar o review inteiro como comentário longo. Os rótulos de sistema desse
comentário seguem APP_LOCALE; títulos, resumo e achados continuam no idioma do review.
O link respeita o mesmo controle de acesso do review: o colega precisa estar logado e atribuído ao review para abrir.
Para reduzir fricção, o admin pode gerar um magic link para um viewer existente no painel. O link é de uso único, expira, pode ser revogado e apenas autentica aquele viewer: ele não torna nenhum review público nem concede acesso fora das atribuições.
Se EMAIL_PROVIDER_URL estiver configurada, o painel também mostra Enviar convite:
o app cria um magic link e envia um POST JSON ao provedor externo com destinatário,
assunto, texto, HTML e metadados. Se o envio falhar, a tela ainda mostra o link para envio
manual. O texto do convite segue APP_LOCALE, exceto quando EMAIL_SUBJECT sobrescreve
o assunto.
Dentro do review completo, cada finding também tem Copiar link. Use esse permalink quando a conversa precisar apontar para um achado específico.
Admins também podem alterar o status de cada finding dentro do review completo. Viewers
enxergam o status e a atividade recente, mas não recebem o seletor de edição. A rota
/raw isolada é leitura; a edição depende da página /reviews/:id, que faz o POST
autenticado pelo shell do app.
Admins e viewers atribuídos também podem comentar em cada finding dentro do review completo. Esses comentários ficam internos ao Review Hub nesta fase; não sincronizam com GitHub/GitLab.
Documentação embutida
Logado como admin, /admin/docs documenta o contrato (com os enums puxados do próprio
validador) e a API HTTP, incluindo os contratos JSON em /api/* e um fluxo curl
para criar reviews por script.
Documentação no repositório
AGENTS.md define as regras para contribuições e manutenção de documentação. Toda mudança
deve atualizar este README.md; se afetar a experiência dentro do app, atualize também a
documentação embutida em src/views.js (docsPage).
Para documentação mais profunda, use a árvore docs/:
docs/product/— roadmap, próximas ações e direção de produto.docs/adr/— decisões arquiteturais importantes.docs/features/— especificações, fluxos e decisões de produto.docs/infra/— contratos de API, plano de monorepo, deploy, segredos, backups e operação.
ADRs iniciais já registram as principais decisões: JSON file store na fase manual,
evolução para SQLite antes de Postgres, política de acesso por login/atribuição e
persistência de status/histórico por finding separada do conteúdo do review. A decisão
proposta para nome público, domínio e Resend está em
docs/adr/2026-07-06-public-domain-and-resend.md.
O roadmap de débitos técnicos está em docs/product/technical-debt-roadmap.md. A direção
arquitetural registrada é evoluir incrementalmente para monorepo com apps/api em
NestJS, apps/web em React/Vite e pacotes internos de domínio/aplicação antes de trocar
o runtime atual. O plano operacional para essa transição está em
docs/infra/monorepo-incremental-plan.md; a decisão atual é não criar workspace vazio e
mover apenas pacotes compartilhados reais.
Para email transacional, docs/infra/domain-email-resend-setup.md descreve a compra de
domínio, configuração de DNS, verificação do Resend e preenchimento das variáveis
EMAIL_PROVIDER_URL, EMAIL_PROVIDER_TOKEN, EMAIL_FROM, EMAIL_SUBJECT e APP_LOCALE.
Para hospedagem pública inicial, docs/infra/wasmer-hosting-spike.md registra o sandbox
gratuito na Wasmer. A ADR em docs/adr/2026-07-06-wasmer-hosting-spike.md aceita Wasmer
para testes efêmeros, mas rejeita esse ambiente para reviews definitivos enquanto não
houver volume persistente novo ou banco suportado.
O roadmap de produto também registra internacionalização como frente futura: pt-BR
permanece o padrão e en já existe na camada inicial para convites por email e comentário
PR. Conteúdo do review não é traduzido automaticamente.
Segurança
- Senhas via scrypt + salt; comparação em tempo constante.
- Sessão: cookie
sidhttpOnly, assinado com HMAC-SHA256 (SameSite=Lax). - CSRF: token double-submit em todas as mutações (POST, PUT, DELETE).
- Acesso: viewer só abre um review se estiver atribuído;
/reviews/:id/rawtem o mesmo gate. - Magic links: token aleatório de uso único; o store guarda apenas hash, expiração, uso e
revogação. O link autentica o viewer, mas não concede reviews fora de
assignments. - Reviews rodam em
<iframe sandbox="allow-scripts allow-popups allow-downloads">(semallow-same-origin), então o conteúdo não acessa a sessão nem o DOM do app. - O markdown dos reviews estruturados passa por
marked+sanitize-html(allowlist de tags).
Estrutura
src/
application/ use cases, validações de aplicação e jobs de IA
config.js env + defaults
domain/ regras puras compartilhadas por app/renderização
store.js persistência JSON (users, reviews, assignments, magicLinks, settings)
infrastructure/ adapters/ports para infraestrutura atual
api-json.js handlers JSON em /api/* paralelos à UI server-rendered
auth.js scrypt (senha), HMAC (sessão), CSRF
http.js helpers de request/response (inclui sendJson)
review-format.js parse (JSON/Markdown) + validação do contrato
render-review.js estrutura → HTML no tema (acompanhamento, badges, sugestões, filtros)
syntax-highlight.js realce seguro de sugestões de código
pr-comment.js comentário curto em Markdown para colar no PR
llm.js formatação por IA (endpoint compatível com OpenAI)
views.js tema + templates HTML (páginas, formulário, modal, docs)
server.js servidor + rotas HTTP
examples/
sample-review.md modelo estruturado (Markdown)
sample-review.json modelo estruturado (JSON)
prompt-ia.md prompt para gerar reviews com uma IA externa
pr-13256-review.html exemplo de review em HTML
docs/
product/ roadmap e próximas ações
features/ specs e template de features
adr/ decisões arquiteturais e template de ADR
infra/ contratos HTTP JSON, monorepo, operação, schema SQLite e migração futura
test/
*.test.js testes node:test unitários e de integração
app.yaml config candidata para spike na Wasmer
wasmer.toml pacote candidato usando edgejs-quickjs no spike Wasmer
.wasmerignore exclusões de deploy (secrets, data, backups, logs)
data/db.json criado em runtime (gitignored)
Modelo de dados
- users —
admin(você) eviewer(quem você cadastra). - reviews —
format(structured|html),title,subtitle, edata(estruturado) oubodyHtml(legado). Em reviews estruturados,findingStatesguarda o status por finding,findingStatusHistoryguarda as mudanças efindingCommentsguarda comentários internos por achado, todos separados dedata. - assignments — quais viewers veem quais reviews (o admin vê todos).
- magicLinks — tokens de uso único para autenticar viewers; o JSON guarda só o hash, expiração, uso e revogação.
- meta — segredo de sessão e configurações da IA.
Preparação SQLite
O runtime atual continua usando data/db.json. A migração futura para SQLite já tem um
contrato inicial documentado em docs/infra/sqlite-schema.sql e
docs/infra/sqlite-migration.md: preservar IDs, migrar com upserts idempotentes, manter
assignments como gate de acesso e guardar apenas token_hash para magic links.
API HTTP
Há duas superfícies HTTP:
- UI server-rendered: rotas HTML atuais, com corpo
application/x-www-form-urlencoded. - API JSON: rotas paralelas em
/api/*, com corpoapplication/json, documentadas emdocs/infra/api-contracts.md.
Corpo application/x-www-form-urlencoded. Toda mutação (POST) exige _csrf igual ao cookie csrf.
| Método | Rota | Acesso | O quê |
|---|---|---|---|
| GET | /healthz | pública | Healthcheck operacional para hospedagem; retorna JSON sem sessão nem dados sensíveis. |
| POST | /login | pública | email, password, _csrf → cookie sid. |
| GET | /magic/:token | pública | Consome magic link válido, cria sessão do viewer e redireciona para /. |
| POST | /logout | login | Encerra a sessão. |
| GET | /reviews/:id | login + acesso | Página do review (iframe). |
| GET | /reviews/:id/pr-comment | login + acesso | Comentário curto em Markdown para colar no PR. |
| GET | /reviews/:id/raw | login + acesso | HTML do review (servido no iframe). |
| POST | /reviews/:id/findings/:findingId/status | admin | Atualiza status (open, resolved, ignored, discussing) e registra histórico se houve mudança real. |
| POST | /reviews/:id/findings/:findingId/comments | login + acesso | Adiciona comentário interno ao finding. Campo: body. |
| GET | /admin | admin | Painel (reviews, viewers, configurações). |
| GET | /admin/docs | admin | Documentação do contrato + API. |
| GET | /admin/reviews/:id/success | admin | Tela pós-criação com link, comentário PR e viewers atribuídos. |
| GET/POST | /admin/reviews/new, /admin/reviews | admin | Formulário / criar review. |
| GET/POST | /admin/reviews/:id/edit, /admin/reviews/:id | admin | Formulário / editar. |
| POST | /admin/reviews/:id/delete | admin | Excluir review. |
| POST | /admin/ai/validate | admin | Valida source (sem IA) → JSON { ok, errors }. |
| POST | /admin/ai/format | admin | Inicia formatação por IA → JSON { jobId }. |
| GET | /admin/ai/format/:jobId | admin | Status do job → JSON { status, result, errors }. |
| POST | /admin/settings | admin | Salva temperatura / tokens da IA. |
| POST | /admin/users, /admin/users/:id/delete | admin | Cria / remove viewer. |
| POST | /admin/users/:id/magic-links | admin | Gera magic link de uso único para um viewer. |
| POST | /admin/users/:id/invitations | admin | Gera magic link e envia convite por email se configurado. |
| POST | /admin/magic-links/:id/revoke | admin | Revoga magic link ainda ativo. |
Principais rotas JSON:
| Método | Rota | Acesso | O quê |
|---|---|---|---|
| GET/POST/DELETE | /api/session | pública/login | CSRF, login e logout por JSON. |
| GET/POST/PUT/DELETE | /api/reviews, /api/reviews/:id | login/admin | Lista, cria, lê, atualiza e exclui reviews. |
| GET/PUT | /api/reviews/:id/assignments | admin | Lê e atualiza viewers atribuídos. |
| PUT | /api/reviews/:id/findings/:findingId/status | admin | Atualiza status por finding. |
| GET | /api/reviews/:id/comments | login + acesso | Lista comentários internos do review. |
| POST | /api/reviews/:id/findings/:findingId/comments | login + acesso | Cria comentário interno por finding. |
| GET/POST/DELETE | /api/viewers, /api/viewers/:id | admin | Lista, cria e remove viewers. |
| GET/POST/DELETE | /api/viewers/:id/magic-links, /api/viewers/:id/invitations, /api/magic-links/:id, /api/magic-links/consume | admin/pública | Lista, gera, envia convite, consome e revoga magic links. |
| GET/PUT | /api/settings | admin | Lê e salva settings de IA. |
| POST/GET | /api/ai/validate, /api/ai/format, /api/ai/format/:jobId | admin | Valida review e controla jobs de formatação. |
Expor para outras pessoas
Estado atual: Wasmer está funcionando como sandbox gratuito de teste, com dados
efêmeros. A URL pública atual é
https://apontiq-review-hub-douglas-kurotaki-97.wasmer.app.
O spike foi executado com a CLI autenticada em 2026-07-06. O runtime antigo
wasmer/edgejs falhou por import N-API ausente; o runtime atual
wasmer/edgejs-quickjs roda o servidor Node. Localmente, use wasmer run --net . para
permitir que server.listen abra socket. Novos apps com volume foram recusados nas
regiões testadas (us-socal1 e be-mons1), então app.yaml usa
DATA_FILE=/tmp/review-hub/apontiq-sandbox-db.json, sem volume persistente.
Fallback rápido para demonstração local:
zrok share public http://localhost:4000
Defina um
SESSION_SECRETestável em qualquer ambiente público para que as sessões sobrevivam a restarts.
Sandbox Wasmer:
Arquivos preparados:
app.yaml— appapontiq-review-hub, ownerdouglas_kurotaki_97eDATA_FILE=/tmp/review-hub/apontiq-sandbox-db.jsonefêmero.wasmer.toml— pacote usandowasmer/edgejs-quickjspara executarsrc/server.js..wasmerignore— evita enviar.env,.git,data/, backups e logs.
Fluxo para o próximo spike:
npm ci --omit=dev
npm test
npm start
curl http://localhost:4000/healthz
wasmer login
OWNER=<seu-usuario-ou-namespace>
wasmer package build --check
wasmer run --net .
curl http://localhost:4000/healthz
wasmer deploy --publish-package --bump --owner "$OWNER"
# cadastre os secrets conforme docs/infra/wasmer-hosting-spike.md
wasmer app info
O runbook completo está em docs/infra/wasmer-hosting-spike.md.
Próximos passos possíveis
- Configurar secrets no dashboard da Wasmer:
ADMIN_EMAIL,ADMIN_PASSWORD,SESSION_SECRET,EMAIL_PROVIDER_*eLLM_*. - Testar login admin, criação de review descartável, convite Resend e acesso viewer na URL Wasmer.
- Apontar
app.apontiq.compara a Wasmer depois que os secrets e/healthzestiverem validados. - Escolher persistência real depois: volume, banco gerenciado, VPS pequena ou migração do store.
- Expandir internacionalização para UI completa e documentação embutida.
- Extrair
packages/domainapenas quando houver consumidor real fora desrc/. - Trocar o runtime JSON para SQLite quando houver necessidade operacional clara; deploy num host real (Fly/Render/VPS).