Ir al contenido
Don’tPanic

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

Las decisiones de seguridad que una IA falla en silencio vienen ya tomadas, documentadas y probadas.

Elige lo que tu sistema necesita. Recibe un comando. El código llega con el nombre de tu proyecto en todo —paquetes, base de datos, variables de entorno— y con las decisiones difíciles ya tomadas como toca.

Diez preguntas en lenguaje llano. Puedes saltarte cualquiera.

Comando del preset por defecto
npx create-dontpanic 'Acme Corp'

Necesita Node 24 y pnpm.

78.533
líneas de TypeScript que compilan, pasan el lint y pasan los tests
5
recursos intercambiables por variable de entorno, sin tocar la lógica
2 min
del npx a pnpm dev, con la base de datos migrada y el admin sembrado

Errores que pasan el review

La prueba

Nada de esto es hipotético. Son errores que producen código que compila, pasa los tests y pasa el code review — y que aparece meses después, en un usuario que no eres tú. Cada uno está ya decidido en el boilerplate, con el motivo al lado de la decisión y el test nombrado debajo.

01 · Inicio de sesión social

La identidad social vinculada por la dirección de correo

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

Qué ocurre

Las direcciones corporativas se reciclan. Ana se va, RR. HH. entrega ana@empresa.com al siguiente contratado, él entra con Google y hereda la cuenta de Ana: historial, permisos, todo. Nadie ha entrado por la fuerza: el sistema hizo exactamente lo que estaba escrito, y el test, que tenía un solo usuario, pasó.

En DontPanic

La clave de la identidad es el providerAccountId inmutable — sub en Google y Apple, el id numérico en GitHub — con @@unique([provider, providerAccountId]). La columna email de oauth_accounts es para mostrar y puede estar desactualizada. Y una dirección que el proveedor no marcó como verificada no vincula nada: el callback devuelve unverified_email.

cubierto por tests · 46 casosoauth.service.spec.ts

02 · Segundo factor

La sesión emitida en el callback de OAuth sin comprobar el segundo factor

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

Qué ocurre

Quien activó el código de seis dígitos a propósito descubre que «entrar con Google» nunca lo pide. El inicio de sesión social queda estrictamente más débil que escribir la contraseña, y el segundo factor pasa a ser opcional para quien sepa qué botón pulsar. El TwoFactorGateGuard no lo detecta: comprueba que el 2FA está habilitado, nunca que esta sesión haya pasado por él.

En DontPanic

Si twoFactorEnabled, el callback no emite sesión: crea el mismo ticket que crearía POST /auth/login, lo entrega en una cookie de cinco minutos y un solo uso, y redirige a /login?twofactor=1. Cookie y no query string: la query string acaba en el historial del navegador, en la cabecera Referer y en el log de todos los proxies del camino.

cubierto por tests · 46 casosoauth.service.spec.ts

03 · Aislamiento

El aislamiento entre empresas confiado al where de la aplicación

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

Qué ocurre

La garantía se ha vuelto disciplina humana, repetida en cada consulta, por todos los que entren al equipo después de ti. El primer findUnique({ where: { id } }) por clave primaria — escrito con prisa, o por un agente que no conocía la regla — devuelve la fila de otra empresa. Y no falla: devuelve datos, con estado 200.

En DontPanic

El aislamiento es de Postgres, no de la aplicación: Row Level Security, con el scope declarado por SET LOCAL dentro de la transacción de la petición. Sin scope alguno, current_setting(…, true) devuelve NULL y la política no coincide — olvidar el scope da un resultado vacío, nunca la fila de la empresa equivocada. El filtro de la aplicación sigue ahí, como comodidad; la garantía es la de abajo.

cubierto por tests · 19 casosprisma.service.spec.ts

04 · Sesiones

El restablecimiento de contraseña que no cierra las sesiones abiertas

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

Qué ocurre

La persona cambia la contraseña precisamente porque sospecha que alguien entró. El hash nuevo no invalida nada: el refresh token del intruso sigue renovándose solo, y él se queda dentro de la cuenta mucho después del cambio — indefinidamente, mientras siga usando el sistema.

En DontPanic

resetPassword graba la contraseña nueva y consume el token en la misma transacción y, después del commit, llama a revokeAllForUser: toda sesión existente muere, registrada en la auditoría como un cierre de sesión deliberado. El refresh rotativo cierra el resto: un token antiguo presentado de nuevo revoca la familia entera.

cubierto por tests · 6 casosauth.service.spec.ts

05 · Base de datos

La DATABASE_URL apuntando al propietario de la base de datos

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

Qué ocurre

Un SUPERUSER —y cualquier rol con BYPASSRLS— ignora Row Level Security incluso con FORCE ROW LEVEL SECURITY. Toda política se vuelve decoración, y el aislamiento vuelve a depender de que ninguna consulta olvide un where. Peor aún: tus tests de aislamiento pasan, porque ejercitan el filtro de la aplicación, que está ahí y está bien.

En DontPanic

La aplicación se conecta con un rol restringido, creado NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; el propietario queda solo en DATABASE_ADMIN_URL, para migrate y seed. La API se niega a arrancar en producción si detecta un superuser. Y la suite e2e corre con el rol restringido: es lo que hace que el test de aislamiento demuestre algo en vez de repetir la intención del código.

cubierto por tests · 5 casostenant-isolation.e2e-spec.ts

Cinco más, con el mismo patrón

  • Activar trustProxy: true para quitarse de encima un 429 indebido. Confiar en todos los hops es aceptar cualquier X-Forwarded-For — y el navegador puede ponerla, porque no está en la lista de forbidden headers de fetch: un cubo nuevo de rate limit en cada petición. Aquí la IP se cuenta desde la derecha, con CLIENT_IP_TRUSTED_HOPS, y el BFF borra toda cabecera de forwarding que venga del navegador.
  • Leer la base de datos en un guard, antes de que exista el scope de tenant. Nest ejecuta los guards antes de los interceptors, así que la política de RLS devuelve cero filas, el guard concluye «este usuario no tiene 2FA» y deja pasar — sin error y sin log. Aquí, un guard que lee la base de datos abre su propio scope y falla cerrado.
  • Enviar el correo de invitación dentro de la transacción. Un rollback entrega un enlace válido que apunta a una empresa que no existe, y no deja registro que el soporte pueda encontrar. Aquí, issue() escribe en el tx de quien lo llama y el envío ocurre después del commit.
  • Responder «esta cuenta usa inicio de sesión social» en un login con contraseña. Eso es un oráculo: se puede enumerar, cronometrando el formulario, exactamente qué direcciones no tienen contraseña. Aquí el error es el genérico de siempre y paga el mismo coste de Argon2 — verifyPassword(null, …) verifica contra el hash de algo que nadie conoce antes de responder false.
  • Contar plazas antes de crear el usuario. Dos peticiones simultáneas leen «queda una» y ambas crean: contar no bloquea nada. Aquí el pg_advisory_xact_lock, por empresa y por recurso, está dentro de la misma transacción que la escritura.

Diez preguntas. Un comando al final.

Una pregunta por pantalla, en lenguaje llano, con lo que cambia en el sistema escrito debajo. Nada de catorce interruptores de golpe.

sin registro · puedes volver en cualquier paso

Cómo funciona

Cuatro pasos, y el cuarto es pnpm dev.

  1. 01

    Responde las preguntas

    Aquí mismo, una por una. Cada una dice qué cambia en el código si respondes sí o no. Puedes saltártelas con «usar el recomendado».

  2. 02

    Copia el comando

    La última pantalla muestra un solo comando, con tus decisiones dentro. Hay enlace compartible, por si quieres discutir la configuración con el equipo antes.

  3. 03

    Ejecútalo en la terminal

    Baja el código, renombra todo a tu proyecto —paquetes, base de datos, variables, contenedores—, levanta Postgres y Redis en Docker y siembra la base de datos.

  4. 04

    pnpm dev

    API en :4201, web en :4200, correo capturado por Mailpit en :4207. El acceso del admin sembrado está en el README.

El rename se demuestra, no se revisa

El nombre del proyecto aparece en sitios que ninguna revisión humana cubre. La puerta es mecánica: el CI genera con un nombre de prueba, ejecuta grep -ri exigiendo cero apariciones del nombre antiguo y solo entonces instala, comprueba tipos y corre la suite completa, e2e incluido.

  • 531 apariciones en 199 archivos, en tres cajas distintas.
  • Dentro del SQL que crea el rol restringido de Postgres, donde una sustitución parcial produce un rol sin GRANT — y el síntoma es «cero filas», no un error.
  • En nombres de base de datos y de bucket a la vez, donde SQL rechaza el guion y S3 rechaza el guion bajo.

Qué incluye

La plantilla es el repositorio real de DontPanic, en la tag que declara el generador. No es una versión de demostración: es el código que ejecuta su propio CI.

El stack

TecnologíaQué resuelve
NestJS + FastifyAPI, con Fastify por debajo
Next.js (App Router)Web, con el BFF que habla con la API en lugar del navegador
Prisma 7 + PostgreSQLBase de datos, con driver adapters y Row Level Security
ZodContratos de petición y respuesta, compartidos entre API y web
Argon2 + JWTContraseña y sesión, con refresh rotativo y detección de reutilización
BullMQ + RedisCola duradera, con el worker en un proceso aparte
Jest + Vitest + Testing LibraryTests: unitarios, de componente y e2e
Turborepo + pnpmMonorepo, con caché de build

De fábrica

Acceso y sesión

Contraseña con Argon2, sesión en cookie httpOnly, refresh rotativo con detección de reutilización — un token robado tira la familia entera. Cambiar la contraseña cierra las demás sesiones.

Aislamiento en la base de datos

Row Level Security en Postgres, con el scope declarado por petición. Una tabla nueva con tenantId se protege sola: SELECT app.apply_tenant_rls(); al final de la migración.

Invitaciones y onboarding

Token guardado solo como hash, como máximo una invitación pendiente por correo (índice único parcial) y el correo saliendo después del commit — nunca dentro de la transacción.

Cinco cambios por variable

Storage, correo, caché, cola y captcha detrás de interfaces: STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Trabajo en segundo plano

BullMQ sobre Redis, con el worker en un proceso aparte y el tenant viajando junto al job. Sin él, el job vería una base de datos vacía y diría que fue bien.

Tests que lo demuestran

Unitarios con la base de datos simulada, e2e contra un Postgres real con el rol restringido, y el kit de UI del web en Vitest.

La parte que nadie escribe

Cada decisión de seguridad tiene un archivo en docs/decisions/ y una sección en CLAUDE.md, con el motivo y lo que pasa si alguien la deshace. Es lo que un agente lee antes de escribir — y lo que tú lees seis meses después, cuando no recuerdas por qué está así.

Los números

78.533
líneas de TypeScript
~99%
de statements cubiertos en la API, con threshold aplicado en el CI
100%
de statements cubiertos en el kit de UI del web
531
apariciones del nombre sustituidas en 199 archivos, probadas por grep

Preguntas

Las que merecen una respuesta honesta antes de ejecutar el comando.

¿Qué se prueba exactamente?

La matriz de presets, íntegra: el CI genera un proyecto de cada preset, exige cero apariciones del nombre antiguo y ejecuta install, typecheck, unitarios y e2e. Más all-on, all-off y cada feature desactivada de forma aislada sobre el preset SaaS. Catorce features booleanas son 16.384 combinaciones, y el CI no prueba 16.384 proyectos: las combinaciones fuera de esa matriz están permitidas y no probadas — y el CLI lo dice, en una línea, sin dramatismo. Un boilerplate que promete garantías que no verifica es peor que uno que declara el límite.

¿Y si no quiero multi-tenancy?

--no-multi-tenant lo esconde, no lo arranca. El proyecto nace con un tenant fijo creado en el seed, el scope siempre abierto en él, y el selector de empresa, el panel /platform y el SUPERADMIN fuera de la interfaz. Row Level Security se queda, y sigue demostrado por tenant-isolation.e2e-spec.ts; el coste es una columna indexada y un predicado que Postgres resuelve con una constante. Arrancarlo significaría mantener dos versiones de todo el acceso a datos — y la versión sin RLS es justamente la que no podemos demostrar segura.

¿Puedo actualizar después?

El proyecto generado es tuyo, no una dependencia: no hay pnpm update que traiga novedades de DontPanic a su interior, y es a propósito — vas a editar ese código el primer día. Lo que sí hay es reproducibilidad: la misma receta con la misma versión de la plantilla genera el mismo proyecto hoy y dentro de dos años, así que puedes generar de nuevo y comparar diffs cuando quieras adoptar algo del upstream.

¿Y la licencia?

MIT, en el generador y en la plantilla. Lo que sale del npx es tuyo: sin atribución obligatoria, sin royalties, sin cláusula que cambie de valor si tu producto crece. Puedes cerrar el código de lo que generes.

¿Necesito Docker?

Para ejecutar la suite de tests, no: los adapters memory, console y local existen justamente para funcionar sin nada levantado. Para desarrollar de verdad necesitas un Postgres — y el docker compose del proyecto levanta Postgres, Redis, MinIO y Mailpit en puertos que no chocan con los tuyos. Si ya tienes esos servicios, apunta el .env hacia ellos y genera con --no-docker.

¿Funciona con Claude Code, Cursor y similares?

El proyecto generado trae un CLAUDE.md podado a las features que elegiste: solo las secciones que existen en tu código. Ahí están las decisiones de seguridad y el motivo de cada una, en el formato que un agente lee antes de escribir. El efecto secundario es probablemente lo que te trajo aquí: el contexto se gasta en tu producto y no en redescubrir cómo se hace la rotación de refresh tokens.