Zum Inhalt springen
Don’tPanic

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

Die Sicherheitsentscheidungen, die eine KI still falsch trifft, sind hier schon getroffen, dokumentiert und getestet.

Wähle, was dein System braucht. Bekomme einen Befehl. Der Code kommt mit dem Namen deines Projekts in allem — Pakete, Datenbank, Umgebungsvariablen — und mit den schwierigen Entscheidungen bereits richtig getroffen.

Zehn Fragen in normaler Sprache. Jede davon lässt sich überspringen.

Befehl des Standard-Presets
npx create-dontpanic 'Acme Corp'

Braucht Node 24 und pnpm.

78.533
Zeilen TypeScript, die kompilieren, den Lint und die Tests bestehen
5
Ressourcen, per Umgebungsvariable austauschbar, ohne die Logik anzufassen
2 Min.
von npx bis pnpm dev, Datenbank migriert und Admin per Seed angelegt

Fehler, die das Review bestehen

Der Beweis

Nichts davon ist hypothetisch. Es sind Fehler, die Code erzeugen, der kompiliert, die Tests besteht und das Code Review besteht — und Monate später auftaucht, bei einem Nutzer, der nicht du bist. Jeder davon ist im Boilerplate schon entschieden, mit der Begründung neben der Entscheidung und dem Test darunter benannt.

01 · Social Login

Die Social-Identität über die E-Mail-Adresse zugeordnet

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

Was passiert

Firmenadressen werden weiterverwendet. Ana geht, die Personalabteilung gibt ana@firma.de an die nächste Einstellung, er meldet sich mit Google an und erbt Anas Konto: Historie, Berechtigungen, alles. Niemand ist eingebrochen — das System hat genau das getan, was dort stand, und der Test, der einen einzigen Benutzer hatte, war grün.

In DontPanic

Der Identitätsschlüssel ist die unveränderliche providerAccountIdsub bei Google und Apple, die numerische id bei GitHub — mit @@unique([provider, providerAccountId]). Die Spalte email in oauth_accounts dient der Anzeige und darf veraltet sein. Und eine Adresse, die der Provider nicht als verifiziert markiert hat, verknüpft nichts: der Callback gibt unverified_email zurück.

durch Tests abgedeckt · 46 Fälleoauth.service.spec.ts

02 · Zweiter Faktor

Die Session im OAuth-Callback ausgegeben, ohne den zweiten Faktor zu prüfen

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

Was passiert

Wer den sechsstelligen Code absichtlich eingeschaltet hat, stellt fest, dass „mit Google anmelden“ ihn nie verlangt. Social Login wird strikt schwächer als das Eintippen des Passworts, und der zweite Faktor wird optional für jeden, der weiß, welchen Knopf er drücken muss. Der TwoFactorGateGuard fängt das nicht: er prüft, dass 2FA aktiviert ist, nie, dass diese Session sie durchlaufen hat.

In DontPanic

Bei twoFactorEnabled gibt der Callback keine Session aus: er erzeugt dasselbe Ticket, das POST /auth/login erzeugen würde, liefert es in einem Cookie mit fünf Minuten Laufzeit und Einmalverwendung, und leitet auf /login?twofactor=1 um. Cookie und nicht Query-String — ein Query-String landet im Browser-Verlauf, im Referer-Header und im Log jedes Proxys auf dem Weg.

durch Tests abgedeckt · 46 Fälleoauth.service.spec.ts

03 · Isolation

Die Isolation zwischen Firmen dem where der Anwendung überlassen

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

Was passiert

Die Garantie ist zu menschlicher Disziplin geworden, wiederholt in jeder Abfrage, von allen, die nach dir ins Team kommen. Das erste findUnique({ where: { id } }) über den Primärschlüssel — in Eile geschrieben, oder von einem Agenten, der die Regel nicht kannte — gibt die Zeile einer anderen Firma zurück. Und es scheitert nicht: es liefert Daten, mit Status 200.

In DontPanic

Die Isolation gehört Postgres, nicht der Anwendung: Row Level Security, mit dem per SET LOCAL in der Request-Transaktion deklarierten Scope. Ohne jeden Scope gibt current_setting(…, true) NULL zurück und die Policy trifft nie zu — den Scope zu vergessen ergibt ein leeres Ergebnis, nie die Zeile der falschen Firma. Der Filter in der Anwendung bleibt, als Bequemlichkeit; die Garantie ist die darunter.

durch Tests abgedeckt · 19 Fälleprisma.service.spec.ts

04 · Sessions

Das Zurücksetzen des Passworts, das offene Sessions nicht beendet

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

Was passiert

Die Person wechselt das Passwort genau deshalb, weil sie vermutet, dass jemand hineingekommen ist. Der neue Hash macht nichts ungültig: das Refresh-Token des Eindringlings erneuert sich weiter von selbst, und er bleibt im Konto, lange nach dem Wechsel — unbegrenzt, solange er das System weiter benutzt.

In DontPanic

resetPassword schreibt das neue Passwort und verbraucht das Token in einer Transaktion und ruft danach, nach dem Commit, revokeAllForUser auf — jede bestehende Session stirbt, im Audit als bewusster Logout vermerkt. Das rotierende Refresh erledigt den Rest: ein erneut vorgelegtes altes Token widerruft die ganze Familie.

durch Tests abgedeckt · 6 Fälleauth.service.spec.ts

05 · Datenbank

Die DATABASE_URL auf den Eigentümer der Datenbank gerichtet

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

Was passiert

Ein SUPERUSER — und jede Rolle mit BYPASSRLS — ignoriert Row Level Security selbst bei FORCE ROW LEVEL SECURITY. Jede Policy wird Dekoration, und die Isolation hängt wieder daran, dass keine Query je ein where vergisst. Schlimmer: deine Isolationstests bestehen, weil sie den Filter der Anwendung prüfen, der vorhanden und korrekt ist.

In DontPanic

Die Anwendung verbindet sich mit einer eingeschränkten Rolle, angelegt als NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; der Eigentümer lebt nur in DATABASE_ADMIN_URL, für migrate und seed. Die API verweigert den Start in Produktion, wenn sie einen Superuser erkennt. Und die e2e-Suite läuft unter der eingeschränkten Rolle — das ist, was den Isolationstest etwas beweisen lässt, statt die Absicht des Codes zu wiederholen.

durch Tests abgedeckt · 5 Fälletenant-isolation.e2e-spec.ts

Fünf weitere, gleiches Muster

  • trustProxy: true einschalten, damit ein unberechtigter 429 verschwindet. Allen Hops zu vertrauen heißt, jedes X-Forwarded-For zu akzeptieren — und der Browser darf es setzen, denn es steht nicht auf der Forbidden-Header-Liste von fetch: pro Request ein neuer Rate-Limit-Eimer. Hier wird die IP von rechts gezählt, mit CLIENT_IP_TRUSTED_HOPS, und das BFF löscht jeden Forwarding-Header, der aus dem Browser kommt.
  • In einem Guard die Datenbank lesen, bevor der Tenant-Scope existiert. Nest führt Guards vor Interceptors aus, also gibt die RLS-Policy null Zeilen zurück, der Guard schließt „dieser Benutzer hat kein 2FA“ und lässt durch — ohne Fehler und ohne Log. Hier öffnet ein Guard, der die Datenbank liest, seinen eigenen Scope und fällt geschlossen aus.
  • Die Einladungs-E-Mail innerhalb der Transaktion verschicken. Ein Rollback liefert einen gültigen Link, der auf eine Firma zeigt, die es nicht gibt, und lässt keinen Datensatz zurück, den der Support finden könnte. Hier schreibt issue() in das tx des Aufrufers, und der Versand passiert nach dem Commit.
  • „Dieses Konto nutzt Social Login“ als Antwort auf einen Passwort-Login. Das ist ein Oracle: durch Messen der Antwortzeit lässt sich genau aufzählen, welche Adressen kein Passwort haben. Hier ist der Fehler der übliche generische und zahlt denselben Argon2-Preis — verifyPassword(null, …) prüft gegen den Hash von etwas, das niemand kennt, bevor es false zurückgibt.
  • Plätze zählen, bevor der Benutzer geschrieben wird. Zwei gleichzeitige Requests lesen „einer ist noch frei“ und beide legen an: Zählen sperrt nichts. Hier liegt pg_advisory_xact_lock, je Firma und je Ressource, in derselben Transaktion wie der Schreibvorgang.

Zehn Fragen. Am Ende ein Befehl.

Eine Frage pro Bildschirm, in normaler Sprache, mit dem, was sie im System ändert, direkt darunter. Keine vierzehn Schalter auf einmal.

ohne Registrierung · jederzeit einen Schritt zurück

So funktioniert es

Vier Schritte, und der vierte ist pnpm dev.

  1. 01

    Die Fragen beantworten

    Hier, eine nach der anderen. Jede sagt, was sich im Code ändert, wenn du ja oder nein antwortest. Überspringen geht mit „Empfehlung übernehmen“.

  2. 02

    Den Befehl kopieren

    Der letzte Bildschirm zeigt einen einzigen Befehl, mit deinen Entscheidungen darin. Dazu ein teilbarer Link, falls du die Konfiguration vorher im Team besprechen willst.

  3. 03

    Im Terminal ausführen

    Er lädt den Code, benennt alles auf dein Projekt um — Pakete, Datenbank, Variablen, Container —, startet Postgres und Redis in Docker und befüllt die Datenbank.

  4. 04

    pnpm dev

    API auf :4201, Web auf :4200, E-Mail von Mailpit auf :4207 abgefangen. Der Login des angelegten Admins steht im README.

Das Umbenennen wird bewiesen, nicht durchgesehen

Der Projektname steht an Stellen, die keine menschliche Durchsicht erreicht. Das Tor ist mechanisch: die CI generiert mit einem Testnamen, lässt grep -ri laufen und verlangt null Treffer des alten Namens — und erst dann wird installiert, typgeprüft und die ganze Suite inklusive e2e ausgeführt.

  • 531 Vorkommen in 199 Dateien, in drei verschiedenen Schreibweisen.
  • Innerhalb des SQL, das die eingeschränkte Postgres-Rolle anlegt, wo eine teilweise Ersetzung eine Rolle ohne GRANT ergibt — und das Symptom „null Zeilen“ lautet, nicht „Fehler“.
  • In Datenbank- und Bucket-Namen gleichzeitig, wo SQL den Bindestrich ablehnt und S3 den Unterstrich.

Was drin ist

Das Template ist das echte DontPanic-Repository, am Tag, den der Generator angibt. Keine Demo-Variante: es ist der Code, der die eigene CI fährt.

Der Stack

TechnologieWas sie löst
NestJS + FastifyAPI, mit Fastify darunter
Next.js (App Router)Web, mit dem BFF, das anstelle des Browsers mit der API spricht
Prisma 7 + PostgreSQLDatenbank, mit Driver Adapters und Row Level Security
ZodRequest- und Response-Contracts, geteilt zwischen API und Web
Argon2 + JWTPasswort und Session, mit rotierendem Refresh und Reuse-Erkennung
BullMQ + RedisDauerhafte Queue, mit dem Worker in einem eigenen Prozess
Jest + Vitest + Testing LibraryTests: Unit, Komponente und e2e
Turborepo + pnpmMonorepo, mit Build-Cache

Ab Werk

Zugang und Session

Passwort mit Argon2, Session im httpOnly-Cookie, rotierendes Refresh mit Reuse-Erkennung — ein gestohlenes Token reißt die ganze Familie mit. Das Passwort zu wechseln beendet die anderen Sessions.

Isolation in der Datenbank

Row Level Security in Postgres, mit dem Scope je Request deklariert. Eine neue Tabelle mit tenantId schützt sich selbst: SELECT app.apply_tenant_rls(); am Ende der Migration.

Einladungen und Onboarding

Token nur als Hash gespeichert, höchstens eine offene Einladung je E-Mail (partieller Unique-Index), und die E-Mail geht nach dem Commit raus — nie innerhalb der Transaktion.

Fünf Wechsel per Variable

Storage, E-Mail, Cache, Queue und Captcha hinter Interfaces: STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Arbeit im Hintergrund

BullMQ auf Redis, mit dem Worker in einem eigenen Prozess und dem Tenant, der mit dem Job mitreist. Ohne ihn sähe der Job eine leere Datenbank und meldete Erfolg.

Tests, die es beweisen

Unit-Tests mit gemockter Datenbank, e2e gegen ein echtes Postgres unter der eingeschränkten Rolle, und das UI-Kit des Web-Teils in Vitest.

Der Teil, den niemand schreibt

Jede Sicherheitsentscheidung hat eine Datei in docs/decisions/ und einen Abschnitt in der CLAUDE.md, mit der Begründung und damit, was passiert, wenn jemand sie rückgängig macht. Das liest ein Agent, bevor er schreibt — und das liest du sechs Monate später, wenn du nicht mehr weißt, warum es so ist.

Die Zahlen

78.533
Zeilen TypeScript
~99 %
Statement-Coverage in der API, mit in der CI erzwungenen Schwellen
100 %
Statement-Coverage im UI-Kit des Web-Teils
531
ersetzte Namensvorkommen in 199 Dateien, per grep bewiesen

Fragen

Die, die eine ehrliche Antwort verdienen, bevor du den Befehl ausführst.

Was genau wird getestet?

Die Preset-Matrix, vollständig: die CI generiert ein Projekt je Preset, verlangt null Treffer des alten Namens und führt install, typecheck, Unit- und e2e-Tests aus. Dazu all-on, all-off und jede Feature einzeln abgeschaltet über dem SaaS-Preset. Vierzehn boolesche Features sind 16.384 Kombinationen, und die CI testet keine 16.384 Projekte: Kombinationen außerhalb dieser Matrix sind erlaubt und ungetestet — und das CLI sagt das, in einer Zeile, ohne Drama. Ein Boilerplate, das Garantien verspricht, die es nicht prüft, ist schlechter als eines, das die Grenze benennt.

Und wenn ich keine Multi-Tenancy will?

--no-multi-tenant versteckt sie, es reißt sie nicht heraus. Das Projekt startet mit einem festen, im Seed angelegten Tenant, der Scope immer auf ihn geöffnet, und ohne Firmenumschalter, /platform-Panel und SUPERADMIN in der Oberfläche. Row Level Security bleibt und bleibt durch tenant-isolation.e2e-spec.ts bewiesen; der Preis ist eine indizierte Spalte und ein Prädikat, das Postgres zu einer Konstanten auflöst. Es herauszureißen hieße, zwei Versionen des gesamten Datenzugriffs zu pflegen — und die Version ohne RLS ist genau die, die wir nicht als sicher beweisen können.

Kann ich später aktualisieren?

Das erzeugte Projekt ist deins, keine Abhängigkeit: es gibt kein pnpm update, das Neuerungen von DontPanic hineinträgt, und das ist Absicht — du wirst diesen Code am ersten Tag bearbeiten. Was es gibt, ist Reproduzierbarkeit: dasselbe Rezept mit derselben Template-Version erzeugt heute und in zwei Jahren dasselbe Projekt, du kannst also neu generieren und Diffs vergleichen, wenn du etwas aus dem Upstream übernehmen willst.

Und die Lizenz?

MIT, für Generator und Template. Was aus dem npx fällt, ist deins: keine Pflicht zur Namensnennung, keine Lizenzgebühr, keine Klausel, die ihren Wert ändert, wenn dein Produkt wächst. Du darfst den Quellcode dessen, was du erzeugst, schließen.

Brauche ich Docker?

Für die Testsuite nein: die Adapter memory, console und local existieren genau dafür, dass sie ohne laufende Dienste funktioniert. Für echte Entwicklung brauchst du ein Postgres — und das docker compose des Projekts startet Postgres, Redis, MinIO und Mailpit auf Ports, die nicht mit deinen kollidieren. Wenn du diese Dienste schon hast, zeig mit der .env darauf und generiere mit --no-docker.

Funktioniert das mit Claude Code, Cursor und Co.?

Das erzeugte Projekt bringt eine CLAUDE.md mit, zugeschnitten auf die gewählten Features — nur die Abschnitte, die in deinem Code existieren. Dort stehen die Sicherheitsentscheidungen samt Begründung, in der Form, die ein Agent liest, bevor er schreibt. Der Nebeneffekt ist wahrscheinlich, was dich hierher gebracht hat: der Kontext geht in dein Produkt, statt Refresh-Token-Rotation neu herzuleiten.