Vai al contenuto
Don’tPanic

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

Le decisioni di sicurezza che una IA sbaglia in silenzio sono già prese, documentate e testate.

Scegli ciò di cui il tuo sistema ha bisogno. Ricevi un comando. Il codice arriva con il nome del tuo progetto ovunque — pacchetti, database, variabili d’ambiente — e con le scelte difficili già fatte come si deve.

Dieci domande in lingua corrente. Puoi saltarne qualunque.

Comando del preset predefinito
npx create-dontpanic 'Acme Corp'

Servono Node 24 e pnpm.

78.533
righe di TypeScript che compilano, passano il lint e passano i test
5
risorse sostituibili con una variabile d’ambiente, senza toccare la logica
2 min
dall’npx a pnpm dev, con il database migrato e l’admin creato dal seed

Errori che passano la review

La prova

Niente di tutto questo è ipotetico. Sono errori che producono codice che compila, passa i test e passa la code review — e che salta fuori mesi dopo, su un utente che non sei tu. Ognuno è già deciso nel boilerplate, con la ragione accanto alla decisione e il test nominato sotto.

01 · Login social

L’identità social collegata tramite l’indirizzo 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);

Cosa succede

Gli indirizzi aziendali vengono riciclati. Ana se ne va, le risorse umane assegnano ana@azienda.it al nuovo assunto, lui entra con Google ed eredita l’account di Ana: storico, permessi, tutto. Nessuno ha forzato niente — il sistema ha fatto esattamente quello che c’era scritto, e il test, che aveva un solo utente, è passato.

In DontPanic

La chiave dell’identità è il providerAccountId immutabile — sub su Google e Apple, l’id numerico su GitHub — con @@unique([provider, providerAccountId]). La colonna email di oauth_accounts serve per la visualizzazione e può essere vecchia. E un indirizzo che il provider non ha marcato come verificato non collega nulla: il callback restituisce unverified_email.

coperto da test · 46 casioauth.service.spec.ts

02 · Secondo fattore

La sessione emessa nel callback OAuth senza controllare il secondo fattore

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

Cosa succede

Chi ha attivato il codice a sei cifre di proposito scopre che «entra con Google» non lo chiede mai. Il login social diventa strettamente più debole che digitare la password, e il secondo fattore diventa opzionale per chi sa quale bottone premere. Il TwoFactorGateGuard non lo prende: verifica che la 2FA sia abilitata, mai che questa sessione ci sia passata.

In DontPanic

Se twoFactorEnabled, il callback non emette sessione: crea lo stesso ticket che creerebbe POST /auth/login, lo consegna in un cookie da cinque minuti e a uso singolo, e reindirizza a /login?twofactor=1. Cookie e non query string — la query string finisce nella cronologia del browser, nell’header Referer e nei log di ogni proxy sul percorso.

coperto da test · 46 casioauth.service.spec.ts

03 · Isolamento

L’isolamento tra aziende affidato al where dell’applicazione

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

Cosa succede

La garanzia è diventata disciplina umana, ripetuta in ogni query, da tutti quelli che entreranno nel team dopo di te. Il primo findUnique({ where: { id } }) per chiave primaria — scritto di fretta, o da un agente che non conosceva la regola — restituisce la riga di un’altra azienda. E non fallisce: restituisce dati, con stato 200.

In DontPanic

L’isolamento è di Postgres, non dell’applicazione: Row Level Security, con lo scope dichiarato da SET LOCAL dentro la transazione della richiesta. Senza alcuno scope, current_setting(…, true) restituisce NULL e la policy non combacia — dimenticare lo scope dà un risultato vuoto, mai la riga dell’azienda sbagliata. Il filtro nell’applicazione resta, come comodità; la garanzia è quella sotto.

coperto da test · 19 casiprisma.service.spec.ts

04 · Sessioni

Il reset della password che non chiude le sessioni aperte

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

Cosa succede

La persona cambia la password proprio perché sospetta che qualcuno sia entrato. Il nuovo hash non invalida niente: il refresh token dell’intruso continua a rinnovarsi da solo, e lui resta dentro l’account molto dopo il cambio — indefinitamente, finché continua a usare il sistema.

In DontPanic

resetPassword scrive la password nuova e consuma il token nella stessa transazione e poi, dopo il commit, chiama revokeAllForUser: ogni sessione esistente muore, registrata nell’audit come logout deliberato. Il refresh rotativo chiude il resto: un token vecchio ripresentato revoca l’intera famiglia.

coperto da test · 6 casiauth.service.spec.ts

05 · Database

La DATABASE_URL puntata al proprietario del database

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

Cosa succede

Un SUPERUSER — e qualunque role con BYPASSRLS — ignora Row Level Security anche con FORCE ROW LEVEL SECURITY. Ogni policy diventa decorazione, e l’isolamento torna a dipendere dal fatto che nessuna query dimentichi un where. Peggio: i tuoi test di isolamento passano, perché esercitano il filtro applicativo, che c’è ed è corretto.

In DontPanic

L’applicazione si collega con un role ristretto, creato NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; il proprietario vive solo in DATABASE_ADMIN_URL, per migrate e seed. L’API rifiuta di partire in produzione se rileva un superuser. E la suite e2e gira con il role ristretto — è questo che fa sì che il test di isolamento dimostri qualcosa invece di ripetere l’intenzione del codice.

coperto da test · 5 casitenant-isolation.e2e-spec.ts

Altri cinque, con lo stesso schema

  • Attivare trustProxy: true per far sparire un 429 indebito. Fidarsi di tutti gli hop significa accettare qualunque X-Forwarded-For — e il browser può impostarlo, perché non è nella lista dei forbidden headers di fetch: un secchio nuovo di rate limit a ogni richiesta. Qui l’IP è contato da destra, con CLIENT_IP_TRUSTED_HOPS, e il BFF cancella ogni header di forwarding che arriva dal browser.
  • Leggere il database in un guard, prima che lo scope del tenant esista. Nest esegue i guard prima degli interceptor, quindi la policy di RLS restituisce zero righe, il guard conclude «questo utente non ha la 2FA» e lascia passare — senza errore e senza log. Qui un guard che legge il database apre uno scope proprio e fallisce chiuso.
  • Spedire l’e-mail di invito dentro la transazione. Un rollback consegna un link valido che punta a un’azienda che non esiste, e non lascia nessun record che il supporto possa trovare. Qui issue() scrive nel tx del chiamante e l’invio avviene dopo il commit.
  • Rispondere «questo account usa il login social» a un login con password. È un oracolo: cronometrando il form si enumera esattamente quali indirizzi non hanno password. Qui l’errore è lo stesso generico di sempre e paga lo stesso costo di Argon2 — verifyPassword(null, …) verifica contro l’hash di qualcosa che nessuno conosce prima di restituire false.
  • Contare i posti prima di scrivere l’utente. Due richieste simultanee leggono «ne resta uno» ed entrambe creano: contare non blocca niente. Qui il pg_advisory_xact_lock, per azienda e per risorsa, sta nella stessa transazione della scrittura.

Dieci domande. Un comando alla fine.

Una domanda per schermata, in lingua corrente, con quello che cambia nel sistema scritto sotto. Niente quattordici interruttori tutti insieme.

senza registrazione · si può tornare indietro in ogni passo

Come funziona

Quattro passi, e il quarto è pnpm dev.

  1. 01

    Rispondi alle domande

    Qui, una alla volta. Ognuna dice cosa cambia nel codice se rispondi sì o no. Si può saltare con «usa il consigliato».

  2. 02

    Copia il comando

    L’ultima schermata mostra un comando solo, con le tue scelte dentro. C’è un link condivisibile, se vuoi discutere la configurazione col team prima.

  3. 03

    Eseguilo nel terminale

    Scarica il codice, rinomina tutto sul tuo progetto — pacchetti, database, variabili, container —, tira su Postgres e Redis in Docker e popola il database.

  4. 04

    pnpm dev

    API su :4201, web su :4200, e-mail catturata da Mailpit su :4207. Le credenziali dell’admin creato dal seed sono nel README.

Il rename è dimostrato, non riletto

Il nome del progetto compare in posti che nessuna revisione umana copre. Il cancello è meccanico: la CI genera con un nome di prova, esegue grep -ri pretendendo zero occorrenze del nome vecchio e solo allora installa, controlla i tipi ed esegue tutta la suite, e2e compresa.

  • 531 occorrenze in 199 file, in tre diverse forme di maiuscole.
  • Dentro l’SQL che crea il role ristretto di Postgres, dove una sostituzione parziale produce un role senza GRANT — e il sintomo è «zero righe», non un errore.
  • In nomi di database e di bucket insieme, dove l’SQL rifiuta il trattino e S3 rifiuta il trattino basso.

Cosa c’è dentro

Il template è il repository reale di DontPanic, al tag che il generatore dichiara. Non è una versione dimostrativa: è il codice che fa girare la propria CI.

Lo stack

TecnologiaCosa risolve
NestJS + FastifyAPI, con Fastify sotto
Next.js (App Router)Web, con il BFF che parla con l’API al posto del browser
Prisma 7 + PostgreSQLDatabase, con driver adapter e Row Level Security
ZodContratti di richiesta e risposta, condivisi tra API e web
Argon2 + JWTPassword e sessione, con refresh rotativo e rilevamento del riuso
BullMQ + RedisCoda durevole, con il worker in un processo separato
Jest + Vitest + Testing LibraryTest: unitari, di componente ed e2e
Turborepo + pnpmMonorepo, con cache di build

Di serie

Accesso e sessione

Password con Argon2, sessione in cookie httpOnly, refresh rotativo con rilevamento del riuso — un token rubato tira giù l’intera famiglia. Cambiare la password chiude le altre sessioni.

Isolamento nel database

Row Level Security in Postgres, con lo scope dichiarato per richiesta. Una tabella nuova con tenantId si protegge da sola: SELECT app.apply_tenant_rls(); alla fine della migration.

Inviti e onboarding

Token salvato solo come hash, al massimo un invito in sospeso per e-mail (indice unico parziale) e l’e-mail che parte dopo il commit — mai dentro la transazione.

Cinque scambi per variabile

Storage, e-mail, cache, coda e captcha dietro interfacce: STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Lavoro in background

BullMQ su Redis, con il worker in un processo separato e il tenant che viaggia insieme al job. Senza di lui, il job vedrebbe un database vuoto e direbbe che è andato bene.

Test che lo dimostrano

Unitari con il database simulato, e2e contro un Postgres vero con il role ristretto, e il kit di UI del web in Vitest.

La parte che nessuno scrive

Ogni decisione di sicurezza ha un file in docs/decisions/ e una sezione nel CLAUDE.md, con la ragione e cosa succede se qualcuno la disfa. È quello che un agente legge prima di scrivere — e quello che leggi tu sei mesi dopo, quando non ricordi perché è così.

I numeri

78.533
righe di TypeScript
~99%
di statement coperti nell’API, con soglie imposte in CI
100%
di statement coperti nel kit di UI del web
531
occorrenze del nome sostituite in 199 file, provate con grep

Domande

Quelle che meritano una risposta onesta prima di eseguire il comando.

Cosa viene testato, esattamente?

La matrice dei preset, per intero: la CI genera un progetto per ogni preset, pretende zero occorrenze del nome vecchio ed esegue install, typecheck, unitari ed e2e. Più all-on, all-off e ogni feature spenta singolarmente sopra il preset SaaS. Quattordici feature booleane sono 16.384 combinazioni, e la CI non testa 16.384 progetti: le combinazioni fuori da quella matrice sono permesse e non testate — e il CLI lo dice, in una riga, senza drammi. Un boilerplate che promette garanzie che non verifica è peggio di uno che dichiara il limite.

E se non volessi il multi-tenancy?

--no-multi-tenant lo nasconde, non lo strappa. Il progetto nasce con un tenant fisso creato dal seed, lo scope sempre aperto su di lui, e il selettore di azienda, il pannello /platform e il SUPERADMIN fuori dall’interfaccia. Row Level Security resta, e resta dimostrato da tenant-isolation.e2e-spec.ts; il costo è una colonna indicizzata e un predicato che Postgres risolve con una costante. Strapparlo significherebbe mantenere due versioni di tutto l’accesso ai dati — e la versione senza RLS è proprio quella che non possiamo dimostrare sicura.

Posso aggiornare dopo?

Il progetto generato è tuo, non una dipendenza: non esiste un pnpm update che porti dentro le novità di DontPanic, ed è voluto — modificherai quel codice il primo giorno. Quello che c’è è la riproducibilità: la stessa ricetta con la stessa versione del template genera lo stesso progetto oggi e tra due anni, quindi puoi rigenerare e confrontare i diff quando vuoi adottare qualcosa dall’upstream.

E la licenza?

MIT, sul generatore e sul template. Quello che esce dall’npx è tuo: nessuna attribuzione obbligatoria, nessuna royalty, nessuna clausola che cambia valore se il tuo prodotto cresce. Puoi chiudere il codice di ciò che generi.

Mi serve Docker?

Per eseguire la suite di test, no: gli adapter memory, console e local esistono proprio perché funzioni senza niente in piedi. Per sviluppare seriamente ti serve un Postgres — e il docker compose del progetto tira su Postgres, Redis, MinIO e Mailpit su porte che non collidono con le tue. Se hai già quei servizi, punta il .env su di loro e genera con --no-docker.

Funziona con Claude Code, Cursor e simili?

Il progetto generato porta un CLAUDE.md potato sulle feature che hai scelto — solo le sezioni che esistono nel tuo codice. È lì che stanno le decisioni di sicurezza e la ragione di ciascuna, nella forma che un agente legge prima di scrivere. L’effetto collaterale è probabilmente ciò che ti ha portato qui: il contesto va nel tuo prodotto invece di riscoprire come si fa la rotazione dei refresh token.