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 vêm já tomadas, documentadas e testadas.

Escolha o que o seu sistema precisa. Receba um comando. O código chega com o nome do seu projecto em tudo — pacotes, base de dados, variáveis de ambiente — e com as escolhas difíceis já feitas como deve ser.

Dez perguntas em linguagem corrente. Pode saltar qualquer uma.

Comando da predefinição 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 substituíveis por variável de ambiente, sem tocar na lógica
2 min
do npx ao pnpm dev, com a base de dados migrada 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 utilizador que não é você. Cada um está decidido no boilerplate, com o motivo ao lado da decisão e o teste nomeado em baixo.

01 · Início de sessão social

A identidade social associada pelo endereço de 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 da empresa é reciclado. A Ana sai, os recursos humanos devolvem ana@empresa.pt ao contratado seguinte, ele entra com o Google e herda a conta da Ana: histórico, permissões, tudo. Ninguém invadiu nada — o sistema fez exactamente o que estava escrito, e o teste, que tinha um utilizador 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 apresentação e pode estar velho. E um endereço que o fornecedor não marcou como verificado não associa nada: o callback devolve unverified_email.

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

02 · Segundo factor

A sessão emitida no callback do OAuth sem verificar o segundo factor

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

O que acontece

Quem activou o código de seis dígitos de propósito descobre que “entrar com o Google” nunca o pede. O início de sessão social fica estritamente mais fraco que escrever a palavra-passe, e o segundo factor passa a ser opcional para quem souber em que botão clicar. O TwoFactorGateGuard não apanha: ele verifica que o 2FA está activado, 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-o num cookie de cinco minutos e uso único, e redirecciona para /login?twofactor=1. Cookie e não query string — a query string entra no histórico do navegador, no header Referer e no log de todos os proxies do caminho.

coberto por testes · 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 tornou-se disciplina humana, repetida em cada consulta, por todos os que entrarem na equipa 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 estado 200.

No DontPanic

O isolamento é do Postgres, não da aplicação: Row Level Security, com o escopo declarado por SET LOCAL dentro da transacção do pedido. 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 testes · 19 casosprisma.service.spec.ts

04 · Sessões

A reposição de palavra-passe que não encerra 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 palavra-passe justamente porque desconfia que alguém entrou. O hash novo não invalida nada: o refresh token do invasor continua a renovar-se sozinho, e ele fica dentro da conta muito depois da troca — indefinidamente, enquanto continuar a usar o sistema.

No DontPanic

O resetPassword grava a palavra-passe nova e o consumo do token na mesma transacção e, depois do commit, chama revokeAllForUser — todas as sessões existentes morrem, registadas na auditoria como logout deliberado. O refresh rotativo fecha o resto: um token antigo reapresentado revoga a família inteira.

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

05 · Base de dados

A DATABASE_URL a apontar para o proprietário da base de dados

.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. Todas as políticas passam a ser 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 liga-se com uma role restrita, criada NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; o proprietário fica apenas em DATABASE_ADMIN_URL, para migrate e seed. A API recusa arrancar em produção se detectar superuser. E a suíte e2e corre sob a role restrita — é isso que faz o teste de isolamento provar algo em vez de repetir a intenção do código.

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

Mais cinco, pelo mesmo desenho

  • Activar trustProxy: true para acabar com um 429 indevido. Confiar em todos os hops é aceitar qualquer X-Forwarded-For — e o navegador pode defini-lo, porque não está na lista de forbidden headers do fetch: um balde novo de rate limit a cada pedido. Aqui o IP é contado a partir da direita, com CLIENT_IP_TRUSTED_HOPS, e o BFF apaga todos os headers de forwarding vindos do navegador.
  • Ler a base de dados num guard, antes de o escopo de tenant existir. O Nest corre guards antes dos interceptors, logo a política de RLS devolve zero linhas, o guard conclui “este utilizador não tem 2FA” e deixa passar — sem erro e sem log. Aqui, um guard que lê a base de dados abre escopo próprio e falha fechado.
  • Enviar o e-mail de convite dentro da transacção. Um rollback entrega um link válido a apontar para uma empresa que não existe, e não fica registo para o suporte encontrar. Aqui, o issue() grava no tx de quem o chamou e o envio acontece depois do commit.
  • Responder “esta conta usa início de sessão social” num login com palavra-passe. Torna-se um oráculo: é possível enumerar, cronometrando o formulário, exactamente quais endereços não têm palavra-passe. Aqui o erro é o genérico de sempre e paga o mesmo custo de Argon2 — o verifyPassword(null, …) verifica contra o hash de algo que ninguém conhece antes de responder false.
  • Contar lugares antes de gravar o utilizador. 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 transacção da escrita.

Dez perguntas. Um comando no fim.

Uma pergunta por ecrã, em linguagem corrente, com o que muda no sistema escrito por baixo. Nada de catorze interruptores de uma vez.

sem registo · dá para voltar em qualquer passo

Como funciona

Quatro passos, e o quarto é pnpm dev.

  1. 01

    Responda às perguntas

    Aqui no site, uma de cada vez. Cada uma diz o que muda no código se responder sim ou não. Pode saltar com «usar o recomendado».

  2. 02

    Copie o comando

    O último ecrã mostra um comando só, com as suas escolhas lá dentro. Tem link partilhável, se quiser discutir a configuração com a equipa antes.

  3. 03

    Corra no terminal

    Descarrega o código, renomeia tudo para o seu projecto — pacotes, base de dados, variáveis, container —, levanta Postgres e Redis no Docker e semeia a base de dados.

  4. 04

    pnpm dev

    API em :4201, web em :4200, e-mail capturado pelo Mailpit em :4207. O início de sessão do admin semeado está no README.

O rename é provado, não conferido

O nome do projecto aparece em sítios que nenhuma revisão humana cobre. O portão é mecânico: o CI gera com um nome de teste, corre grep -ri a exigir zero ocorrências do nome antigo e só então instala, verifica tipos e corre a suíte inteira, e2e incluído.

  • 531 ocorrências em 199 ficheiros, 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 um erro.
  • Em nome de base de dados 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 corre o próprio CI.

A stack

TecnologiaO que resolve
NestJS + FastifyAPI, com Fastify por baixo
Next.js (App Router)Web, com o BFF que fala com a API em vez do navegador
Prisma 7 + PostgreSQLBase de dados, com driver adapters e Row Level Security
ZodContratos de pedido e resposta, partilhados entre API e web
Argon2 + JWTPalavra-passe e sessão, com refresh rotativo e detecção de reutilização
BullMQ + RedisFila durável, com worker em processo separado
Jest + Vitest + Testing LibraryTestes: unitários, de componente e e2e
Turborepo + pnpmMonorepo, com cache de build

De fábrica

Acesso e sessão

Palavra-passe com Argon2, sessão em cookie httpOnly, refresh rotativo com detecção de reutilização — token roubado derruba a família inteira. Trocar a palavra-passe encerra as outras sessões.

Isolamento na base de dados

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

Convites e onboarding

Token guardado apenas como hash, no máximo um convite pendente por e-mail (índice único parcial) e o e-mail a sair depois do commit — nunca dentro da transacçã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 a viajar junto com o job. Sem ele, o job veria uma base de dados vazia e diria que correu bem.

Testes que provam

Unitários com a base de dados simulada, e2e contra um Postgres a sério 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 ficheiro em docs/decisions/ e uma secçã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 já não se lembra por que 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 ficheiros, provadas por grep

Perguntas

As que merecem uma resposta honesta antes de correr o comando.

O que é testado, exactamente?

A matriz de predefinições, na íntegra: o CI gera um projecto de cada predefinição, exige zero ocorrências do nome antigo e corre install, typecheck, unitários e e2e. Mais all-on, all-off e cada feature desactivada isoladamente sobre a predefinição SaaS. Catorze features booleanas são 16 384 combinações, e o CI não testa 16 384 projectos: combinações fora dessa matriz são permitidas e não testadas — e o CLI di-lo, numa linha, sem drama. Um boilerplate que promete garantias que não verifica é pior que um que declara o limite.

E se eu não quiser multi-tenancy?

O --no-multi-tenant esconde, não arranca. O projecto nasce com um tenant fixo criado no seed, o escopo sempre aberto nele, e o selector 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. Arrancá-lo significaria manter duas versões de todo o acesso a dados — e a versão sem RLS é justamente a que não podemos provar segura.

Posso actualizar depois?

O projecto 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 — vai editar este código no primeiro dia. O que existe é reprodutibilidade: a mesma receita com a mesma versão do template gera o mesmo projecto hoje e dentro de dois anos, portanto é possível gerar de novo e comparar diffs quando quiser adoptar 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 royalties, sem cláusula que muda de valor se o seu produto crescer. Pode fechar o código do que gerar.

Preciso de Docker?

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

Funciona com Claude Code, Cursor e afins?

O projecto gerado traz um CLAUDE.md podado para as features que escolheu — só as secçõ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 secundário é o que provavelmente o trouxe aqui: o contexto é gasto no seu produto, não em redescobrir como se faz refresh token com rotação.