Aller au contenu
Don’tPanic

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

Les décisions de sécurité qu’une IA rate en silence sont déjà prises, documentées et testées.

Choisissez ce dont votre système a besoin. Recevez une commande. Le code arrive avec le nom de votre projet partout — paquets, base de données, variables d’environnement — et les choix difficiles déjà faits comme il faut.

Dix questions en langage courant. Vous pouvez en sauter n’importe laquelle.

Commande du preset par défaut
npx create-dontpanic 'Acme Corp'

Nécessite Node 24 et pnpm.

78 533
lignes de TypeScript qui compilent, passent le lint et passent les tests
5
ressources interchangeables par variable d’environnement, sans toucher à la logique
2 min
du npx à pnpm dev, base migrée et admin créé par le seed

Des erreurs qui passent la revue

La preuve

Rien ici n’est hypothétique. Ce sont des erreurs qui produisent du code qui compile, passe les tests et passe la revue — et qui ressort des mois plus tard, chez un utilisateur qui n’est pas vous. Chacune est déjà tranchée dans le boilerplate, avec la raison à côté de la décision et le test nommé en dessous.

01 · Connexion sociale

L’identité sociale rapprochée par l’adresse 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);

Ce qui arrive

Les adresses professionnelles sont recyclées. Ana quitte l’entreprise, les RH donnent ana@societe.com au suivant, il se connecte avec Google et hérite du compte d’Ana : historique, permissions, tout. Personne n’a rien forcé — le système a fait exactement ce qui était écrit, et le test, qui n’avait qu’un utilisateur, est passé.

Dans DontPanic

La clé d’identité est le providerAccountId immuable — sub chez Google et Apple, l’id numérique chez GitHub — avec @@unique([provider, providerAccountId]). La colonne email de oauth_accounts sert à l’affichage et peut être périmée. Et une adresse que le fournisseur n’a pas marquée comme vérifiée ne rattache rien : le callback renvoie unverified_email.

couvert par des tests · 46 casoauth.service.spec.ts

02 · Second facteur

La session émise dans le callback OAuth sans vérifier le second facteur

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

Ce qui arrive

Celui qui a activé le code à six chiffres volontairement découvre que « se connecter avec Google » ne le demande jamais. La connexion sociale devient strictement plus faible que la saisie du mot de passe, et le second facteur devient optionnel pour qui sait sur quel bouton cliquer. Le TwoFactorGateGuard ne l’attrape pas : il vérifie que la 2FA est activée, jamais que cette session est passée par elle.

Dans DontPanic

Si twoFactorEnabled, le callback n’émet pas de session : il crée le même ticket que POST /auth/login créerait, le livre dans un cookie de cinq minutes à usage unique, et redirige vers /login?twofactor=1. Un cookie et pas une query string — la query string finit dans l’historique du navigateur, dans l’en-tête Referer et dans les logs de tous les proxys du trajet.

couvert par des tests · 46 casoauth.service.spec.ts

03 · Isolation

L’isolation entre entreprises confiée au where de l’application

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 } }); }

Ce qui arrive

La garantie est devenue une discipline humaine, répétée à chaque requête, par tous ceux qui rejoindront l’équipe après vous. Le premier findUnique({ where: { id } }) par clé primaire — écrit dans l’urgence, ou par un agent qui ne connaissait pas la règle — renvoie la ligne d’une autre entreprise. Et il n’échoue pas : il renvoie des données, avec un statut 200.

Dans DontPanic

L’isolation appartient à Postgres, pas à l’application : Row Level Security, avec le scope déclaré par SET LOCAL dans la transaction de la requête. Sans aucun scope, current_setting(…, true) renvoie NULL et la policy ne correspond jamais — oublier le scope donne un résultat vide, jamais la ligne de la mauvaise entreprise. Le filtre applicatif reste là, par commodité ; la garantie est celle du dessous.

couvert par des tests · 19 casprisma.service.spec.ts

04 · Sessions

La réinitialisation de mot de passe qui ne coupe pas les sessions ouvertes

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.' };

Ce qui arrive

La personne change son mot de passe précisément parce qu’elle soupçonne une intrusion. Le nouveau hash n’invalide rien : le refresh token de l’intrus continue de se renouveler tout seul, et il reste dans le compte longtemps après le changement — indéfiniment, tant qu’il continue d’utiliser le système.

Dans DontPanic

resetPassword écrit le nouveau mot de passe et consomme le jeton dans la même transaction puis, après le commit, appelle revokeAllForUser — toute session existante meurt, enregistrée dans l’audit comme une déconnexion volontaire. Le refresh rotatif fait le reste : un ancien jeton représenté révoque toute la famille.

couvert par des tests · 6 casauth.service.spec.ts

05 · Base de données

La DATABASE_URL pointée sur le propriétaire de la base

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

Ce qui arrive

Un SUPERUSER — et tout rôle avec BYPASSRLS — ignore Row Level Security même avec FORCE ROW LEVEL SECURITY. Toute policy devient décorative, et l’isolation redevient dépendante du fait qu’aucune requête n’oublie un where. Pire : vos tests d’isolation passent, parce qu’ils exercent le filtre applicatif, qui est bien là et qui est juste.

Dans DontPanic

L’application se connecte avec un rôle restreint, créé NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS ; le propriétaire ne vit que dans DATABASE_ADMIN_URL, pour migrate et seed. L’API refuse de démarrer en production si elle détecte un superuser. Et la suite e2e tourne sous le rôle restreint — c’est ce qui fait que le test d’isolation prouve quelque chose au lieu de répéter l’intention du code.

couvert par des tests · 5 castenant-isolation.e2e-spec.ts

Cinq autres, même schéma

  • Activer trustProxy: true pour faire disparaître un 429 injustifié. Faire confiance à tous les hops, c’est accepter n’importe quel X-Forwarded-For — et le navigateur peut le définir, puisqu’il n’est pas dans la liste des forbidden headers de fetch : un nouveau seau de rate limit à chaque requête. Ici l’IP est comptée depuis la droite, avec CLIENT_IP_TRUSTED_HOPS, et le BFF supprime tout en-tête de forwarding venant du navigateur.
  • Lire la base dans un guard, avant que le scope de tenant existe. Nest exécute les guards avant les interceptors, donc la policy RLS renvoie zéro ligne, le guard conclut « cet utilisateur n’a pas la 2FA » et laisse passer — sans erreur ni log. Ici, un guard qui lit la base ouvre son propre scope et échoue fermé.
  • Envoyer l’e-mail d’invitation à l’intérieur de la transaction. Un rollback livre un lien valide pointant vers une entreprise qui n’existe pas, et ne laisse aucune trace pour le support. Ici, issue() écrit dans le tx de l’appelant et l’envoi a lieu après le commit.
  • Répondre « ce compte utilise la connexion sociale » sur un login par mot de passe. C’est un oracle : en chronométrant le formulaire, on énumère exactement les adresses sans mot de passe. Ici l’erreur est la même erreur générique et paie le même coût Argon2 — verifyPassword(null, …) vérifie contre le hash de quelque chose que personne ne connaît avant de renvoyer false.
  • Compter les sièges avant d’écrire l’utilisateur. Deux requêtes simultanées lisent « il en reste un » et créent toutes les deux : compter ne verrouille rien. Ici, pg_advisory_xact_lock, par entreprise et par ressource, est dans la même transaction que l’écriture.

Dix questions. Une commande à la fin.

Une question par écran, en langage courant, avec ce que ça change dans le système écrit en dessous. Pas quatorze interrupteurs d’un coup.

sans inscription · on peut revenir à chaque étape

Comment ça marche

Quatre étapes, et la quatrième est pnpm dev.

  1. 01

    Répondez aux questions

    Ici même, une à la fois. Chacune dit ce qui change dans le code si vous répondez oui ou non. On peut passer avec « utiliser la recommandation ».

  2. 02

    Copiez la commande

    Le dernier écran affiche une seule commande, avec vos choix dedans. Il y a un lien partageable, si vous voulez discuter la configuration avec l’équipe avant.

  3. 03

    Lancez-la dans le terminal

    Elle télécharge le code, renomme tout pour votre projet — paquets, base, variables, conteneurs —, monte Postgres et Redis dans Docker et amorce la base.

  4. 04

    pnpm dev

    API sur :4201, web sur :4200, e-mail capturé par Mailpit sur :4207. L’identifiant de l’admin amorcé est dans le README.

Le renommage est prouvé, pas relu

Le nom du projet apparaît là où aucune relecture humaine ne va. La porte est mécanique : la CI génère avec un nom de test, lance grep -ri en exigeant zéro occurrence de l’ancien nom, et seulement ensuite installe, vérifie les types et lance toute la suite, e2e inclus.

  • 531 occurrences dans 199 fichiers, en trois casses différentes.
  • Dans le SQL qui crée le rôle Postgres restreint, où un remplacement partiel donne un rôle sans GRANT — et le symptôme est « zéro ligne », pas une erreur.
  • Dans des noms de base et de bucket à la fois, là où SQL refuse le tiret et S3 refuse le souligné.

Ce qu’il y a dedans

Le template est le vrai dépôt DontPanic, au tag que le générateur déclare. Ce n’est pas une version de démonstration : c’est le code qui fait tourner sa propre CI.

La stack

TechnologieCe qu’elle résout
NestJS + FastifyAPI, avec Fastify dessous
Next.js (App Router)Web, avec le BFF qui parle à l’API à la place du navigateur
Prisma 7 + PostgreSQLBase de données, avec driver adapters et Row Level Security
ZodContrats de requête et de réponse, partagés entre l’API et le web
Argon2 + JWTMot de passe et session, avec refresh rotatif et détection de réutilisation
BullMQ + RedisFile durable, avec le worker dans un processus séparé
Jest + Vitest + Testing LibraryTests : unitaires, de composant et e2e
Turborepo + pnpmMonorepo, avec cache de build

D’origine

Accès et session

Mot de passe avec Argon2, session en cookie httpOnly, refresh rotatif avec détection de réutilisation — un jeton volé fait tomber toute la famille. Changer le mot de passe coupe les autres sessions.

Isolation dans la base

Row Level Security dans Postgres, avec le scope déclaré par requête. Une table nouvelle avec tenantId se protège seule : SELECT app.apply_tenant_rls(); à la fin de la migration.

Invitations et onboarding

Jeton stocké seulement en hash, au plus une invitation en attente par e-mail (index unique partiel), et l’e-mail qui part après le commit — jamais dans la transaction.

Cinq échanges par variable

Stockage, e-mail, cache, file et captcha derrière des interfaces : STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Travail en arrière-plan

BullMQ sur Redis, avec le worker dans un processus séparé et le tenant qui voyage avec le job. Sans lui, le job verrait une base vide et annoncerait un succès.

Des tests qui prouvent

Unitaires avec la base simulée, e2e contre un vrai Postgres sous le rôle restreint, et le kit d’UI du web dans Vitest.

La partie que personne n’écrit

Chaque décision de sécurité a un fichier dans docs/decisions/ et une section dans CLAUDE.md, avec la raison et ce qui arrive si quelqu’un la défait. C’est ce qu’un agent lit avant d’écrire — et ce que vous lisez six mois plus tard, quand vous ne vous souvenez plus pourquoi c’est comme ça.

Les chiffres

78 533
lignes de TypeScript
~99 %
de statements couverts sur l’API, seuils imposés en CI
100 %
de statements couverts sur le kit d’UI du web
531
occurrences du nom remplacées dans 199 fichiers, prouvées par grep

Questions

Celles qui méritent une réponse honnête avant de lancer la commande.

Qu’est-ce qui est testé, exactement ?

La matrice des presets, intégralement : la CI génère un projet par preset, exige zéro occurrence de l’ancien nom, puis lance install, typecheck, tests unitaires et e2e. Plus all-on, all-off, et chaque feature désactivée isolément par-dessus le preset SaaS. Quatorze features booléennes font 16 384 combinaisons, et la CI ne teste pas 16 384 projets : les combinaisons hors de cette matrice sont autorisées et non testées — et le CLI le dit, en une ligne, sans drame. Un boilerplate qui promet des garanties qu’il ne vérifie pas est pire qu’un qui annonce la limite.

Et si je ne veux pas de multi-tenancy ?

--no-multi-tenant le cache, il ne l’arrache pas. Le projet naît avec un tenant fixe créé par le seed, le scope toujours ouvert sur lui, et le sélecteur d’entreprise, le panneau /platform et le SUPERADMIN hors de l’interface. Row Level Security reste, et reste prouvé par tenant-isolation.e2e-spec.ts ; le coût est une colonne indexée et un prédicat que Postgres résout en constante. L’arracher voudrait dire maintenir deux versions de tout l’accès aux données — et la version sans RLS est précisément celle qu’on ne peut pas prouver sûre.

Puis-je mettre à jour plus tard ?

Le projet généré est le vôtre, pas une dépendance : aucun pnpm update n’y fera entrer des nouveautés de DontPanic, et c’est volontaire — vous allez éditer ce code dès le premier jour. Ce que vous avez, c’est la reproductibilité : la même recette sur la même version du template génère le même projet aujourd’hui et dans deux ans, donc vous pouvez régénérer et comparer les diffs quand vous voulez adopter quelque chose de l’upstream.

Et la licence ?

MIT, sur le générateur et sur le template. Ce qui sort du npx est à vous : pas d’attribution obligatoire, pas de royalties, pas de clause qui change de valeur si votre produit grandit. Vous pouvez fermer le code de ce que vous générez.

Ai-je besoin de Docker ?

Pour lancer la suite de tests, non : les adapters memory, console et local existent justement pour tourner sans rien d’autre. Pour développer sérieusement il vous faut un Postgres — et le docker compose du projet monte Postgres, Redis, MinIO et Mailpit sur des ports qui n’entreront pas en collision avec les vôtres. Si vous avez déjà ces services, pointez le .env dessus et générez avec --no-docker.

Est-ce que ça marche avec Claude Code, Cursor et compagnie ?

Le projet généré embarque un CLAUDE.md taillé aux features que vous avez choisies — seulement les sections qui existent dans votre code. C’est là que vivent les décisions de sécurité, chacune avec sa raison, dans la forme qu’un agent lit avant d’écrire. L’effet de bord est probablement ce qui vous a amené ici : le contexte part dans votre produit au lieu de redécouvrir comment on fait la rotation des refresh tokens.