Ir para o conteúdo
Don’tPanic

Boilerplate SaaS · NestJS · Next.js · Prisma · Postgres

As decisões de segurança que uma IA erra em silêncio já vêm tomadas, documentadas e testadas.

Escolha o que o seu sistema precisa. Receba um comando. O código chega com o nome do seu projeto em tudo — pacotes, banco, variáveis de ambiente — e com as escolhas difíceis já feitas do jeito certo.

Dez perguntas em linguagem de gente. Dá para pular qualquer uma.

Comando do preset padrão
npx create-dontpanic 'Acme Corp'

Precisa de Node 24 e pnpm.

78.533
linhas de TypeScript que compilam, passam no lint e passam nos testes
5
recursos plugáveis por variável de ambiente, sem tocar na lógica
2 min
do npx ao pnpm dev, com o banco migrado e o admin semeado

Erros que passam no review

A prova

Nada aqui é hipotético. São erros que produzem código que compila, passa no teste e passa no code review — e que aparecem meses depois, num usuário que não é você. Cada um está decidido no boilerplate, com o motivo ao lado da decisão e o teste nomeado embaixo.

01 · Login social

A identidade social casada pelo e-mail

apps/api/src/modules/auth/oauth/oauth.service.ts
// callback do provedor: quem é esta pessoa? const user = await prisma.user.findUnique({ where: { email: profile.email }, }); if (user) return issueSession(user);

O que acontece

Endereço corporativo é reciclado. A Ana sai da empresa, o RH devolve ana@empresa.com ao próximo contratado, ele entra com o Google e herda a conta da Ana: histórico, permissões, tudo. Ninguém invadiu nada — o sistema fez exatamente o que estava escrito, e o teste, que tinha um usuário só, passou.

No DontPanic

A chave da identidade é o providerAccountId imutável — sub no Google e na Apple, o id numérico no GitHub — com @@unique([provider, providerAccountId]). O email em oauth_accounts é campo de exibição e pode estar velho. E e-mail que o provedor não marcou como verificado não vincula nada: o callback devolve unverified_email.

coberto por teste · 46 casosoauth.service.spec.ts

02 · Segundo fator

A sessão emitida no callback do OAuth sem checar o segundo fator

apps/api/src/modules/auth/oauth/oauth.service.ts
const account = await findLinkedAccount( provider, profile.sub, ); return issueSession(account.userId);

O que acontece

Quem ligou o código de seis dígitos de propósito descobre que “entrar com o Google” nunca o pede. O login social fica estritamente mais fraco que digitar a senha, e o segundo fator passa a ser opcional para quem souber em qual botão clicar. O TwoFactorGateGuard não pega: ele verifica que o 2FA está habilitado, nunca que esta sessão passou por ele.

No DontPanic

Se twoFactorEnabled, o callback não emite sessão: cria o mesmo ticket que POST /auth/login criaria, entrega num cookie de cinco minutos e uso único, e redireciona para /login?twofactor=1. Cookie e não query string — query string entra no histórico do navegador, no header Referer e no log de todo proxy no caminho.

coberto por teste · 46 casosoauth.service.spec.ts

03 · Isolamento

O isolamento entre empresas confiado ao where da aplicação

apps/api/src/modules/records/records.service.ts
// todo método repete o filtro, para sempre findOne(id: string, tenantId: string) { return prisma.record.findFirst({ where: { id, tenantId }, }); } // e então alguém escreve este: byId(id: string) { return prisma.record.findUnique({ where: { id } }); }

O que acontece

A garantia virou disciplina humana, repetida em cada consulta, por todo mundo que entrar no time depois de você. O primeiro findUnique({ where: { id } }) por chave primária — escrito com pressa, ou por um agente que não conhecia a regra — devolve a linha de outra empresa. E não falha: devolve dados, com status 200.

No DontPanic

O isolamento é do Postgres, não da aplicação: Row Level Security, com o escopo declarado por SET LOCAL dentro da transação do request. Sem escopo nenhum, current_setting(…, true) devolve NULL e a política não casa — esquecer o escopo dá resultado vazio, nunca a linha da empresa errada. O filtro na aplicação continua lá, como conveniência; a garantia é a de baixo.

coberto por teste · 19 casosprisma.service.spec.ts

04 · Sessões

O reset de senha que não derruba as sessões abertas

apps/api/src/modules/auth/services/auth.service.ts
// "senha trocada, problema resolvido" await prisma.user.update({ where: { id: record.userId }, data: { passwordHash }, }); return { message: 'Password updated.' };

O que acontece

A pessoa troca a senha justamente porque desconfia que alguém entrou. O hash novo não invalida nada: o refresh token do invasor continua renovando sozinho, e ele fica dentro da conta muito depois da troca — indefinidamente, enquanto continuar usando o sistema.

No DontPanic

resetPassword grava a senha nova e o consumo do token na mesma transação e, depois do commit, chama revokeAllForUser — toda sessão existente morre, registrada na auditoria como logout deliberado. O refresh rotativo fecha o resto: um token antigo reapresentado revoga a família inteira.

coberto por teste · 6 casosauth.service.spec.ts

05 · Banco

A DATABASE_URL apontando para o dono do banco

.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app"

O que acontece

SUPERUSER — e qualquer role com BYPASSRLS — ignora Row Level Security mesmo com FORCE ROW LEVEL SECURITY. Toda política vira decoração, e o isolamento volta a depender de nenhuma query esquecer um where. Pior: os seus testes de isolamento passam, porque exercitam o filtro da aplicação, que está lá e está certo.

No DontPanic

A aplicação conecta com uma role restrita, criada NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; o dono do banco fica só em DATABASE_ADMIN_URL, para migrate e seed. A API recusa subir em produção se detectar superuser. E a suíte e2e roda sob a role restrita — é isso que faz o teste de isolamento provar alguma coisa em vez de repetir a intenção do código.

coberto por teste · 5 casostenant-isolation.e2e-spec.ts

Mais cinco, pelo mesmo desenho

  • Ligar trustProxy: true para acabar com um 429 indevido. Confiar em todo hop é aceitar qualquer X-Forwarded-For — e o browser pode setá-lo, porque ele não está na lista de forbidden headers do fetch: um balde novo de rate limit a cada request. Aqui o IP é contado da direita, com CLIENT_IP_TRUSTED_HOPS, e o BFF apaga todo header de forwarding vindo do navegador.
  • Ler o banco num guard, antes de o escopo de tenant existir. O Nest roda guards antes de interceptors, então a política de RLS devolve zero linhas, o guard conclui “este usuário não tem 2FA” e libera — sem erro e sem log. Aqui, guard que lê o banco abre escopo próprio e falha fechado.
  • Disparar o e-mail de convite dentro da transação. Um rollback entrega um link válido apontando para uma empresa que não existe, e não sobra registro para o suporte encontrar. Aqui, issue() grava no tx do chamador e o envio acontece depois do commit.
  • Responder “esta conta usa login social” num login com senha. Vira oráculo: dá para enumerar, cronometrando o formulário, exatamente quais endereços não têm senha. Aqui o erro é o genérico de sempre e paga o mesmo custo de Argon2 — verifyPassword(null, …) verifica contra o hash de algo que ninguém conhece antes de responder false.
  • Contar assentos antes de gravar o usuário. Dois pedidos simultâneos leem “falta um” e ambos criam: contar não tranca nada. Aqui o pg_advisory_xact_lock por empresa e por recurso fica dentro da mesma transação da escrita.

Dez perguntas. Um comando no fim.

Uma pergunta por tela, em linguagem de gente, com o que muda no sistema escrito embaixo. Nada de quatorze interruptores de uma vez.

sem cadastro · dá para voltar em qualquer passo

Como funciona

Quatro passos, e o quarto é pnpm dev.

  1. 01

    Responda as perguntas

    Aqui no site, uma por vez. Cada uma diz o que muda no código se você responder sim ou não. Dá para pular com “usar o recomendado”.

  2. 02

    Copie o comando

    A última tela mostra um comando só, com as suas escolhas dentro. Tem link compartilhável, se você quiser discutir a configuração com o time antes.

  3. 03

    Rode no terminal

    Baixa o código, renomeia tudo para o seu projeto — pacotes, banco, variáveis, container —, sobe Postgres e Redis no Docker e semeia o banco.

  4. 04

    pnpm dev

    API em :4201, web em :4200, e-mail capturado pelo Mailpit em :4207. O login do admin semeado está no README.

O rename é provado, não conferido

O nome do projeto aparece em lugares que nenhuma revisão humana cobre. O portão é mecânico: o CI gera com um nome de teste, roda grep -ri exigindo zero ocorrência do nome antigo e só então instala, tipa e roda a suíte inteira, e2e incluído.

  • 531 ocorrências em 199 arquivos, em três caixas diferentes.
  • Dentro do SQL que cria a role restrita do Postgres, onde uma substituição parcial produz uma role sem GRANT — e o sintoma é “zero linhas”, não erro.
  • Em nome de banco e de bucket ao mesmo tempo, onde o SQL recusa hífen e o S3 recusa sublinhado.

O que vem dentro

O template é o repositório real do DontPanic, na tag que o gerador declara. Não é uma versão de demonstração: é o código que roda o próprio CI.

A stack

TecnologiaO que ela resolve
NestJS + FastifyAPI, com Fastify por baixo
Next.js (App Router)Web, com o BFF que fala com a API no lugar do browser
Prisma 7 + PostgreSQLBanco, com driver adapters e Row Level Security
ZodContratos de request e response, compartilhados entre API e web
Argon2 + JWTSenha e sessão, com refresh rotativo e detecção de reuso
BullMQ + RedisFila durável, com worker em processo separado
Jest + Vitest + Testing LibraryTestes: unit, componente e e2e
Turborepo + pnpmMonorepo, com cache de build

De fábrica

Acesso e sessão

Senha com Argon2, sessão em cookie httpOnly, refresh rotativo com detecção de reuso — token roubado derruba a família inteira. Trocar a senha encerra as outras sessões.

Isolamento no banco

Row Level Security no Postgres, com o escopo declarado por request. Tabela nova com tenantId se protege sozinha: SELECT app.apply_tenant_rls(); no fim da migration.

Convites e onboarding

Token guardado só como hash, no máximo um convite pendente por e-mail (índice único parcial) e o e-mail saindo depois do commit — nunca dentro da transação.

Cinco trocas por variável

Storage, e-mail, cache, fila e captcha atrás de interfaces: STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Trabalho em segundo plano

BullMQ no Redis, com worker em processo separado e o tenant viajando junto com o job. Sem ele, o job veria um banco vazio e diria que deu certo.

Testes que provam

Unitários com o banco mockado, e2e contra um Postgres de verdade sob a role restrita, e o kit de UI do web no Vitest.

A parte que ninguém escreve

Cada decisão de segurança tem um arquivo em docs/decisions/ e uma seção no CLAUDE.md, com o motivo e o que acontece se alguém a desfizer. É o que um agente lê antes de escrever — e o que você lê seis meses depois, quando não lembra por que aquilo está assim.

Os números

78.533
linhas de TypeScript
~99%
de statements cobertos na API, com threshold aplicado no CI
100%
de statements cobertos no kit de UI do web
531
ocorrências do nome trocadas em 199 arquivos, provadas por grep

Perguntas

As que valem uma resposta honesta antes de você rodar o comando.

O que exatamente é testado?

A matriz de presets, integralmente: o CI gera um projeto de cada preset, exige zero ocorrência do nome antigo e roda install, typecheck, unit e e2e. Mais all-on, all-off e cada feature desligada isoladamente sobre o preset SaaS. Quatorze features booleanas são 16.384 combinações, e o CI não testa 16.384 projetos: combinações fora dessa matriz são permitidas e não testadas — e o CLI diz isso, numa linha, sem drama. Um boilerplate que promete garantia que não verifica é pior que um que declara o limite.

E se eu não quiser multi-tenancy?

--no-multi-tenant esconde, não arranca. O projeto nasce com um tenant fixo criado no seed, o escopo sempre aberto nele, e o seletor de empresa, o painel /platform e o SUPERADMIN fora da interface. O Row Level Security continua lá e continua provado pelo tenant-isolation.e2e-spec.ts; o custo é uma coluna indexada e um predicado que o Postgres resolve com constante. Arrancar significaria manter duas versões de todo acesso a dados — e a versão sem RLS é justamente a que não podemos provar segura.

Posso atualizar depois?

O projeto gerado é seu, não uma dependência: não existe pnpm update que traga novidade do DontPanic para dentro dele, e isso é de propósito — você vai editar esse código no primeiro dia. O que existe é reprodutibilidade: a mesma receita com a mesma versão do template gera o mesmo projeto hoje e em dois anos, então dá para gerar de novo e comparar diffs quando quiser adotar algo do upstream.

E a licença?

MIT, no gerador e no template. O que sai do npx é seu: sem atribuição obrigatória, sem royalty, sem cláusula que muda de valor se o seu produto crescer. Você pode fechar o código do que gerar.

Preciso do Docker?

Para rodar a suíte de testes, não: os adapters memory, console e local existem justamente para rodar sem nada em pé. Para desenvolver de verdade você precisa de um Postgres — e o docker compose do projeto sobe Postgres, Redis, MinIO e Mailpit em portas que não colidem com as suas. Se você já tem esses serviços, aponte o .env para eles e gere com --no-docker.

Funciona com Claude Code, Cursor e afins?

O projeto gerado traz um CLAUDE.md podado para as features que você escolheu — só as seções que existem no seu código. É onde estão as decisões de segurança e o motivo de cada uma, no formato que um agente lê antes de escrever. O efeito colateral é o que provavelmente te trouxe aqui: o contexto é gasto no seu produto, não em redescobrir como se faz refresh token com rotação.