> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Mi plantilla NestJS 12 + Prisma 7 para APIs en producción: cómo está hecha, cómo usarla y qué aprendí probándola
- URL: https://blog.devutilites.com/nestjs-prisma-production-starter/
- Published: 2026-10-24T06:49:31.000Z
- Updated: 2026-10-25T11:58:44.000Z
- Description: nestjs-prisma-production-starter por dentro: auth en tres modos, sesiones revocables al instante, rotación de refresh tokens con detección de robo, Argon2id, 2FA con TOTP, Prisma 7 y Zod 4. Con ataques reales contra una instancia local y lo que queda por pulir.
- Author: Luis Collantes
- Tags: Backend, NestJS

Hace unas semanas publiqué [mi plantilla de Hono + Prisma para APIs en producción](https://blog.devutilites.com/hono-prisma-production-starter/). Esta es su gemela en NestJS: [**nestjs-prisma-production-starter**](https://github.com/Collanteslu/nestjs-prisma-production-starter?ref=blog.devutilites.com), un repositorio de GitHub marcado como *template* (MIT) con una API REST lista para llevar a producción: sesiones con estado que se pueden revocar al instante, rotación de refresh tokens con detección de robo, Argon2id, 2FA con TOTP, recuperación de contraseña, rate limiting en base de datos, Prisma 7 sobre SQLite (o Turso), validación con Zod y documentación OpenAPI con Scalar.

La idea es la misma que con Hono: no quiero otro «hola mundo», quiero algo que puedas clonar, arrancar y desplegar sin reinventar la parte aburrida y peligrosa. Y como con Hono, **no me limito a contarte lo que dice el README**: la he descargado, compilado, probado y atacado (en local, contra mi propia instancia) para contarte qué hay de verdad dentro, qué funciona y qué todavía no está pulido.

🧪

**Cómo lo he probado.** La primera pasada la hice el 24 de octubre de 2026 (hora de Madrid) sobre el commit `758a0ac` de `main`, en Linux con **Node 24.21.0 y npm 11.19.0**. El 25 de octubre repetí lo que cambia sobre la rama del [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com) (commit `623e447`), que corrige lo que yo mismo había encontrado; en cada medida te digo de qué commit es. Versiones reales instaladas: **NestJS 12.2.0** sobre **Express 5.3.0**, **Prisma 7.10.1**, **Zod 4.6.7**, **TypeScript 6.0.3**, Jest 30.5.2 y ESLint 10.13.0\. Las salidas de terminal son reales (recortadas cuando era largo). Lo que **no** he podido comprobar está marcado como «sin probar». Docker sí lo he probado (Docker 26.1.5, la imagen construida y ejecutada, `docker-compose` y Trivy 0.76.1); quedan sin verificar el SBOM, el despliegue en Coolify, el envío real de correo por SMTP, Turso, el funcionamiento con varias instancias a la vez y la ejecución de la CI de GitHub con los cambios del PR. Las partes que describo **leyendo el código** en lugar de ejecutarlo las marco como «lectura de código».

⚠️

**Actualización del 25 de octubre de 2026: las correcciones están en el [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com), ya fusionado.** Al publicar este artículo dejé una lista larga de cosas por pulir. Las que se podían arreglar sin tocar la lógica de autenticación las he corregido en ese PR (rama `fix/production-hardening`, diez commits): **`npm audit` pasa de 4 avisos altos a 0** (un `overrides` de `multer`), **Trivy pasa de 1 alto a 0**, salen cinco dependencias que nadie importaba, la imagen vuelve a **`node:24-alpine` fijada por *digest*** (antes `node:25`) y baja de **719 a 570 MB**, el entorno se valida **antes** de migrar, los logs de producción salen en texto plano sin colores, las pruebas ya no pueden tocar una base de datos real y hay **84 pruebas en lugar de 62**. **Fusioné el PR el 25 de octubre a las 12:55 (hora de Madrid) y `main` ya contiene las correcciones** (fusión por *squash*, commit `611dc0f`; su árbol de Git es idéntico al de la rama que medí, `623e447`). El artículo describe ese estado y, donde algo cambió, te cuento cómo era y cómo es; cuando digo «en `main`» para contar cómo era algo, me refiero al estado anterior a la fusión (`758a0ac`). Lo que **no** he tocado, y por qué (las decisiones de diseño y los cambios de CI), está en «Lo que queda por pulir». Todo lo medido es local: la CI de GitHub no se ha ejecutado con el PR.

## Qué es y para quién es

Es una API REST de «tareas» con usuarios, que sirve de esqueleto para cualquier backend serio. El dominio es deliberadamente pequeño (tareas, usuarios, sesiones, auditoría) para que todo el esfuerzo se vea en lo que importa: **autenticación, seguridad, datos, pruebas y despliegue**.

Lo más distintivo es que tiene **tres niveles de autenticación** que eliges con una variable (`AUTH_MODE`):

![Tres modos de autenticación: none, basic y full](https://blog.devutilites.com/content/images/2026/10/nestjs-starter-modos.png)

Tres modos de autenticación: none, basic y full

Con `none` tienes una API pública sin usuarios; con `basic`, usuarios, sesiones, roles y auditoría; con `full` (el valor por defecto), además verificación de email, recuperación de contraseña y 2FA. Lo que no aplica **no se monta en el router ni aparece en OpenAPI**. Lo he comprobado arrancando los tres modos:

| Modo  | Paths en OpenAPI | Operaciones | POST /api/auth/login                     | GET /api/tasks sin token |
| ----- | ---------------- | ----------- | ---------------------------------------- | ------------------------ |
| none  | 4                | 7           | 404                                      | 200                      |
| basic | 17               | 23          | 400 (existe; con {} falla la validación) | 401                      |
| full  | 25               | 31          | existe                                   | 401                      |

Es para ti si quieres una base con las decisiones de seguridad ya tomadas (y razonadas), y quieres **NestJS** porque tu equipo ya lo usa, porque te gusta su estructura modular o porque la inyección de dependencias te encaja. Si lo que buscas es algo más pequeño y rápido de leer, tienes [la de Hono](https://blog.devutilites.com/hono-prisma-production-starter/); al final del artículo te cuento cuándo elegiría una u otra.

## Descarga: cuatro formas de tenerla

| Forma                                    | Cómo                                                                                             |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Usar como plantilla** (la recomendada) | Botón **Use this template** en GitHub: crea un repositorio tuyo con el código y sin el historial |
| **GitHub CLI**                           | gh repo create mi-api --template Collanteslu/nestjs-prisma-production-starter --private --clone  |
| **Clonar**                               | git clone https://github.com/Collanteslu/nestjs-prisma-production-starter.git                    |
| **ZIP**                                  | *Code → Download ZIP* en la página del repositorio                                               |

**Requisitos:** Node.js **24.9 o superior** y npm **11 o superior** (están en `engines`; yo la he probado con Node 24.21.0 y no he probado versiones anteriores).

El repositorio está marcado como plantilla en GitHub (`is_template: true`) y tiene licencia MIT. Es un repositorio muy joven: `main` tiene 30 commits, todos entre el 22 y el 23 de octubre de 2026 (el PR #8 añade diez más).

### Puesta en marcha en tres pasos

```bash
git clone https://github.com/Collanteslu/nestjs-prisma-production-starter.git
cd nestjs-prisma-production-starter
npm ci
cp .env.example .env
```

Abre `.env` y rellena los dos secretos JWT (la app **no arranca** sin ellos, salvo con `AUTH_MODE=none`):

```bash
openssl rand -base64 48   # úsalo para JWT_SECRET
openssl rand -base64 48   # y otro distinto para JWT_REFRESH_SECRET
```

Después, cliente de Prisma, migraciones y a correr:

```bash
npx prisma generate
npx prisma migrate deploy
npm run dev
```

La API escucha en `http://localhost:3011`. En desarrollo, con la base de datos vacía, se siembran dos usuarios de ejemplo (`admin@example.com` y `ana@example.com`, contraseña `password123`) y tres tareas. **En producción nunca se siembran demos**: solo se crea el administrador inicial si defines `ADMIN_EMAIL` y `ADMIN_PASSWORD`.

⚠️

**Un tropiezo que me encontré:** para ejecutar los tests hay que usar `npm test`, no `npx jest`. El script añade `--experimental-vm-modules`; sin él fallan 10 de las 13 suites de hoy (cuando lo medí por primera vez eran 7 de 9) con «Must use import to load ES Module». Está en el `package.json`, pero conviene saberlo.

## Qué trae y qué versiones

| Pieza                         | Qué es                                                              | Versión probada               |
| ----------------------------- | ------------------------------------------------------------------- | ----------------------------- |
| **NestJS**                    | Framework (módulos, DI, guards, pipes, interceptores) sobre Express | 12.2.0 (Express 5.3.0)        |
| **Prisma 7**                  | ORM con migraciones versionadas                                     | 7.10.1                        |
| **libSQL**                    | Adaptador de Prisma para SQLite local o Turso                       | @prisma/adapter-libsql 7.10.1 |
| **Zod 4**                     | Validación de entrada y esquemas de OpenAPI                         | 4.6.7                         |
| **nestjs-zod**                | Integración Zod ↔ Nest/OpenAPI, **vendorizada** en vendor/          | 5.5.0                         |
| **@nestjs/swagger + Scalar**  | OpenAPI 3.0 generado y consola interactiva en /docs                 | 12.0.4 / 1.2.31               |
| **@node-rs/argon2**           | Hash de contraseñas Argon2id (nativo)                               | 2.2.2                         |
| **@nestjs/jwt**               | Firma y verificación de JWT (HS256)                                 | 12.0.3                        |
| **helmet**                    | Cabeceras de seguridad                                              | 8.3.0                         |
| **nodemailer**                | Correo (SMTP) para recuperación y verificación                      | 10.2.1                        |
| **Jest + supertest**          | Pruebas unitarias e integración                                     | 30.5.2 / 7.3.1                |
| **ESLint + Prettier + Husky** | Calidad de código y *hooks* de git                                  | 10.13.0 / 3.9.10 / 9.1.7      |
| **TypeScript**                | Modo estricto                                                       | 6.0.3                         |

Dos decisiones que merece la pena entender:

- **`nestjs-zod` está vendorizada.** La versión publicada declara *peer dependencies* que se quedan en Nest y Swagger 11, y esta plantilla va con Nest 12\. En vez de forzar `--legacy-peer-deps` o un `overrides`, el repositorio incluye una copia compilada en `vendor/nestjs-zod` y la instala con `file:`. Funciona, pero significa que **esa pieza no se actualiza sola**.
- **Express 5, no Fastify.** Nest 12 usa Express 5 por defecto y la plantilla se apoya en ello (por ejemplo, el comodín de rutas `{*splat}` de path-to-regexp v8, que verás en la parte de autenticación).

Si quieres entender cómo piensa NestJS por dentro antes de meterte en la plantilla, te recomiendo [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/); y para ver cómo se compara con los demás, [la comparativa de Hono, Fastify, Express y NestJS](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/).

## La estructura del proyecto

```text
nestjs-prisma-production-starter/
├── prisma/                 # schema.prisma + 10 migraciones versionadas
├── src/
│   ├── main.ts             # arranque: orden estricto (ver más abajo)
│   ├── configure-app.ts    # helmet, CORS, límites de body, Swagger/Scalar
│   ├── app.module.ts       # AppModule.forRoot(env): monta módulos según AUTH_MODE
│   ├── config/             # env.ts (Zod) + tokens de inyección
│   ├── database/           # PrismaService, MigrationsGuard, SeedService
│   ├── auth/               # login/registro/refresh/logout, JwtAuthGuard, AdminGuard
│   ├── sessions/           # sesiones, rotación y revocación de refresh tokens
│   ├── users/  tasks/  audit/
│   ├── mfa/                # TOTP + códigos de recuperación
│   ├── mail/               # recuperación, verificación y cola EmailOutbox
│   ├── rate-limit/         # guard, bloqueo por cuenta y almacén en BD
│   ├── jobs/               # CleanupService (cada hora)
│   ├── common/             # pipe, interceptores, filtro, middleware, utilidades
│   └── generated/          # cliente de Prisma (generado, no se versiona)
├── test/                   # 13 suites de Jest
├── contract/               # harness HTTP ejecutable (11 escenarios)
├── bruno/                  # colección de Bruno (32 peticiones)
├── scripts/                # bruno-smoke.sh, ops-rehearsal.sh, lockout-key.mjs, docker-smoke.sh (PR #8)
├── vendor/nestjs-zod/      # integración Zod vendorizada
├── Dockerfile  docker-compose.yml  docker-entrypoint.sh
└── AGENTS.md               # documentación larga (unas 500 líneas) del comportamiento
```

Los scripts de `npm` que vas a usar:

| Script                         | Qué hace                                               |
| ------------------------------ | ------------------------------------------------------ |
| npm run dev                    | nest start --watch                                     |
| npm run build / npm start      | Compila a dist/ / arranca node dist/main.js            |
| npm run typecheck              | tsc --noEmit                                           |
| npm run lint                   | ESLint                                                 |
| npm test / npm run test:cov    | Jest (con y sin cobertura)                             |
| npm run contract               | Harness de contrato HTTP                               |
| npm run db:deploy / db:migrate | prisma migrate deploy / migrate dev                    |
| npm run test:e2e               | test-api.sh: recorrido de sesiones y bloqueos con curl |

Y por dónde pasa una petición, de un vistazo:

![Por dónde pasa una petición en la plantilla: Express, guards, pipe, controlador, interceptor, Prisma](https://blog.devutilites.com/content/images/2026/10/nestjs-starter-arquitectura-v2.png)

Por dónde pasa una petición en la plantilla: Express, guards, pipe, controlador, interceptor, Prisma

La parte de Express (`configureApp()`) hace lo propio de HTTP; Nest ordena después **guards → pipe → controlador → interceptor → filtro de excepciones**. Todo lo global se registra en `AppModule` con los tokens `APP_GUARD`, `APP_PIPE`, `APP_INTERCEPTOR` y `APP_FILTER`:

```ts
{ provide: APP_GUARD, useExisting: JwtAuthGuard },   // 1º: ¿quién eres? (deny-by-default)
{ provide: APP_GUARD, useClass: RateLimitGuard },    // 2º: ¿cuántas veces?
{ provide: APP_PIPE, useClass: ContractValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },   // sobre de respuesta
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
```

Detalle que me gusta: `JwtAuthGuard` se registra con `useExisting`, no con `useClass`, para que el guard global y el `AuthPrefixMiddleware` compartan **la misma instancia** y la verificación del token exista una sola vez en el código.

## Configuración que falla rápido

Lo primero que hace `main.ts` es `loadEnv()`: valida **todas** las variables de entorno con Zod y, si algo está mal, la app **se niega a arrancar** con un mensaje que dice qué falta. Las reglas de producción son estrictas. Esto es lo que pasa de verdad (salidas reales):

```text
# Sin secretos (AUTH_MODE=full por defecto)
❌ Critical startup failure: Invalid or missing environment configuration:
{
  "JWT_SECRET": [
    "JWT_SECRET is required unless AUTH_MODE=none (generate one with: openssl rand -base64 48)"
  ],
  "JWT_REFRESH_SECRET": [ "JWT_REFRESH_SECRET is required unless AUTH_MODE=none (…)" ]
}
exit=1

# NODE_ENV=production con el mismo secreto en los dos JWT y sin clave de MFA
{
  "JWT_REFRESH_SECRET": [ "JWT_REFRESH_SECRET must differ from JWT_SECRET" ],
  "MFA_ENCRYPTION_KEY": [ "MFA_ENCRYPTION_KEY (>= 32 chars) is required in production with AUTH_MODE=full so TOTP secrets are encrypted with a dedicated, stable key (…)" ]
}
exit=1

# NODE_ENV=production con CORS_ORIGINS=*
{
  "CORS_ORIGINS": [ "CORS_ORIGINS=\"*\" is rejected in production: list the real origins, or set CORS_ALLOW_WILDCARD=true to allow any origin deliberately" ]
}
exit=1
```

Comprobé todas las reglas de producción de una en una: secretos de menos de 16 caracteres (rechazados), mismo secreto para ambos JWT (rechazado **en cualquier entorno**, porque un refresh token se aceptaría como access token), `CORS_ORIGINS=*` (rechazado salvo `CORS_ALLOW_WILDCARD=true`), `MAIL_TRANSPORT=log` en producción (rechazado, porque escribiría en el log los enlaces de recuperación, que son credenciales vivas) y SMTP con `ignoreTLS=true` (rechazado). Cada una termina con código de salida 1.

El fragmento de `env.ts` que lo hace posible usa `superRefine` de Zod 4:

```ts
.superRefine((cfg, ctx) => {
  const authEnabled = cfg.AUTH_MODE !== "none";
  if (authEnabled) {
    for (const key of ["JWT_SECRET", "JWT_REFRESH_SECRET"] as const) {
      if (!cfg[key]) ctx.addIssue({ code: "custom", path: [key],
        message: `${key} is required unless AUTH_MODE=none (generate one with: openssl rand -base64 48)` });
    }
  }
  if (authEnabled && cfg.JWT_SECRET && cfg.JWT_SECRET === cfg.JWT_REFRESH_SECRET) {
    // Same secret in ANY environment: a refresh token would then verify as an access token
    ctx.addIssue({ code: "custom", path: ["JWT_REFRESH_SECRET"],
      message: "JWT_REFRESH_SECRET must differ from JWT_SECRET" });
  }
  if (cfg.NODE_ENV !== "production") return;
  // … endurecimiento solo para producción …
})
```

Y el `.env.example` viene comentado en español, con el *porqué* de cada variable (proxy, CORS, rate limits, TTL de recuperación…). Lo que más vas a tocar:

| Variable                                    | Para qué                                                                                 |
| ------------------------------------------- | ---------------------------------------------------------------------------------------- |
| AUTH\_MODE                                  | none, basic o full (por defecto)                                                         |
| JWT\_SECRET, JWT\_REFRESH\_SECRET           | Dos secretos distintos; en producción, de 32+ caracteres                                 |
| MFA\_ENCRYPTION\_KEY (+ \_PREVIOUS)         | Cifra los secretos TOTP; obligatoria en producción con full; la anterior permite rotarla |
| DATABASE\_URL                               | file:./dev.db por defecto; TURSO\_DATABASE\_URL \+ TURSO\_AUTH\_TOKEN lo sustituyen      |
| TRUST\_PROXY, TRUST\_PROXY\_HOPS            | Para obtener la IP real detrás de un proxy (más abajo)                                   |
| CORS\_ORIGINS                               | Orígenes permitidos; \* solo en desarrollo                                               |
| ENABLE\_DOCS                                | /docs y /openapi.json; apagados por defecto en producción                                |
| MAIL\_TRANSPORT, SMTP\_URL, APP\_URL        | none / log / smtp                                                                        |
| ADMIN\_EMAIL, ADMIN\_PASSWORD               | Administrador inicial en producción                                                      |
| LOGIN\_RATE\_LIMIT\_MAX, LOGIN\_LOCKOUT\_\* | Límites y bloqueo por cuenta                                                             |
| RATE\_LIMIT\_ON\_DB\_FAILURE                | local (por defecto) o open si cae el almacén de contadores                               |

### Referencia completa de variables

El esquema de `src/config/env.ts` define **36 variables**. Esta es la tabla completa, sacada del código (no del README), con el valor por defecto que aplica la aplicación:

| Variable                                          | Por defecto                          | Notas                                                                      |
| ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------- |
| PORT                                              | 3011                                 | Se convierte a número                                                      |
| NODE\_ENV                                         | development                          | development, production o test                                             |
| AUTH\_MODE                                        | full                                 | none, basic o full                                                         |
| JWT\_SECRET                                       | —                                    | Obligatoria salvo AUTH\_MODE=none; mínimo 16 caracteres (32 en producción) |
| JWT\_REFRESH\_SECRET                              | —                                    | Igual, y **distinta** de JWT\_SECRET en cualquier entorno                  |
| DATABASE\_URL                                     | file:./dev.db                        | SQLite local                                                               |
| TURSO\_DATABASE\_URL, TURSO\_AUTH\_TOKEN          | —                                    | Si existen, sustituyen al fichero local                                    |
| TRUST\_PROXY                                      | false                                | Solo true o 1 lo activan                                                   |
| TRUST\_PROXY\_HOPS                                | 1                                    | Entero entre 1 y 10                                                        |
| ADMIN\_EMAIL, ADMIN\_PASSWORD                     | —                                    | Administrador inicial; la contraseña, de 8 a 72 caracteres                 |
| CORS\_ORIGINS                                     | \* (desarrollo) / vacío (producción) | Lista separada por comas                                                   |
| CORS\_ALLOW\_WILDCARD                             | false                                | Permite \* en producción a conciencia                                      |
| ENABLE\_DOCS                                      | true salvo en producción             | /docs y /openapi.json                                                      |
| LOGIN\_RATE\_LIMIT\_MAX                           | 10                                   | Por IP y minuto                                                            |
| REFRESH\_RATE\_LIMIT\_MAX                         | 30                                   | Por IP y minuto                                                            |
| REGISTER\_RATE\_LIMIT\_MAX                        | 5                                    | Por IP y hora                                                              |
| RECOVERY\_RATE\_LIMIT\_MAX, MFA\_RATE\_LIMIT\_MAX | 10                                   | Por IP                                                                     |
| LOGIN\_LOCKOUT\_MAX\_FAILURES                     | 5                                    | Fallos antes de bloquear la cuenta                                         |
| LOGIN\_LOCKOUT\_MINUTES                           | 15                                   | Duración del bloqueo                                                       |
| AUDIT\_RETENTION\_DAYS                            | 90                                   | 0 desactiva el borrado de auditoría                                        |
| MAIL\_TRANSPORT                                   | log (desarrollo) / none (producción) | none, log, smtp o memory                                                   |
| SMTP\_URL                                         | —                                    | smtps://usuario:clave@host:465; obligatoria con smtp                       |
| MAIL\_FROM                                        | NestJS API <no-reply@localhost>      | Remitente                                                                  |
| APP\_URL                                          | http://localhost:3000                | Base de los enlaces de los correos                                         |
| PASSWORD\_RESET\_TTL\_MINUTES                     | 60                                   | Caducidad del enlace de recuperación                                       |
| EMAIL\_VERIFY\_TTL\_HOURS                         | 24                                   | Caducidad del enlace de verificación                                       |
| REQUIRE\_EMAIL\_VERIFICATION                      | false                                | Exige verificar el email para entrar                                       |
| MFA\_ENCRYPTION\_KEY                              | —                                    | 32+ caracteres; obligatoria en producción con full                         |
| MFA\_ENCRYPTION\_KEY\_PREVIOUS                    | —                                    | Solo durante una rotación                                                  |
| MFA\_ISSUER                                       | NestJS API                           | Nombre que ve la app autenticadora                                         |
| LOG\_LEVEL                                        | —                                    | fatal… trace o silent                                                      |
| REFRESH\_REUSE\_GRACE\_SECONDS                    | 10                                   | Ventana en la que un refresh repetido es 409 y no robo                     |
| RATE\_LIMIT\_ON\_DB\_FAILURE                      | local                                | local o open                                                               |

Fíjate en un detalle: las cadenas **vacías cuentan como «sin definir»**. Es lo que hace que el `docker-compose.yml` pueda pasar `CORS_ORIGINS=${CORS_ORIGINS:-}` sin romper nada: si no defines la variable, llega vacía y se aplica el valor por defecto.

Además de los límites de cada variable, hay **reglas cruzadas**. Las ejecuté llamando a `parseEnv()` con cada combinación (salida real, una línea por caso):

```text
RECHAZA AUTH_MODE=none + ADMIN_EMAIL → ADMIN_EMAIL / ADMIN_PASSWORD have no effect with AUTH_MODE=none (there are no users)
RECHAZA MAIL_TRANSPORT=smtp sin SMTP_URL → SMTP_URL is required when MAIL_TRANSPORT=smtp
RECHAZA REQUIRE_EMAIL_VERIFICATION sin transporte que entregue → … needs a mail transport that delivers (MAIL_TRANSPORT=smtp), otherwise nobody could verify
RECHAZA REQUIRE_EMAIL_VERIFICATION con AUTH_MODE=basic → … needs AUTH_MODE=full (email verification is off otherwise)
RECHAZA MFA_ENCRYPTION_KEY_PREVIOUS sin la actual → … only works together with MFA_ENCRYPTION_KEY
RECHAZA producción + smtp sin APP_URL → APP_URL is required in production when MAIL_TRANSPORT=smtp (it builds the emailed links)
RECHAZA producción + APP_URL en http → APP_URL must use https in production when MAIL_TRANSPORT=smtp
RECHAZA ADMIN_PASSWORD de 73 caracteres → Too big: expected string to have <=72 characters
RECHAZA TRUST_PROXY_HOPS=0 → Too small: expected number to be >=1
OK      producción mínima (2 JWT distintos + MFA_ENCRYPTION_KEY) → CORS_ORIGINS=[], MAIL_TRANSPORT=none
```

Las reglas que solo se aplican en producción, leídas directamente en el `superRefine` (estas no las ejecuté una a una: son lectura de código): `MAIL_TRANSPORT=log` se rechaza porque escribiría en los logs los enlaces de recuperación con credenciales vivas; `CORS_ORIGINS=*` se rechaza salvo `CORS_ALLOW_WILDCARD=true`; los secretos JWT necesitan 32 caracteres; `MAIL_TRANSPORT=memory` se rechaza («solo para pruebas»); y una `SMTP_URL` con `ignoreTLS=true` o `rejectUnauthorized=false` se rechaza para que nadie degrade el canal a texto plano.

La última línea es el mínimo absoluto para arrancar en producción: dos secretos JWT distintos de 32+ caracteres y la clave de MFA. Y fíjate en lo que implica el resultado: **CORS queda vacío (ningún origen cruzado) y el correo en `none`** hasta que lo configures. Es una configuración cerrada por defecto, que es lo que se espera de una plantilla de producción.

💡

**Un matiz sobre `MFA_ENCRYPTION_KEY`.** Fuera de producción es opcional: si no la defines, la clave de cifrado de los secretos TOTP se **deriva de `JWT_SECRET`** (con HKDF-SHA256). Es cómodo para desarrollar, pero rotar el secreto JWT dejaría inservibles todos los autenticadores enrolados. Por eso en producción con `AUTH_MODE=full` es obligatoria: la clave de cifrado necesita un ciclo de vida independiente de la de firma.

## Prisma 7: modelo, migraciones y cliente

### Nueve modelos pequeños

El esquema tiene nueve tablas con su propio propósito (`prisma/schema.prisma`):

| Modelo          | Para qué                                                                                   |
| --------------- | ------------------------------------------------------------------------------------------ |
| User            | Usuario, rol (user/admin), bloqueo, borrado lógico, y los campos de MFA                    |
| Session         | Una fila por sesión: isActive, expiresAt, IP y *user agent*                                |
| RefreshToken    | Hash SHA-256 del refresh token, usedAt (rotación) y a qué sesión pertenece                 |
| Task            | Tarea con borrado lógico (deletedAt); userId es opcional para el modo none                 |
| AuditLog        | Registro de auditoría con índices por fecha, entidad, acción y usuario                     |
| AuthToken       | Tokens de recuperación/verificación (solo el hash), **atados al email** al que se enviaron |
| EmailOutbox     | Cola persistente de correos con estado, intentos y reintento con espera                    |
| RecoveryCode    | Códigos de recuperación de MFA (solo hash), uno por fila                                   |
| RateLimitBucket | Contadores de rate limiting (rl:login:IP, lockout:…) en la propia BD                       |

Un detalle de diseño: el cliente de Prisma se crea con **`omit` global** para que `password`, `totpSecret` y `totpLastStep` **no salgan en ninguna consulta** salvo que lo pidas explícitamente (`omit: { password: false }`). Es una de esas decisiones que evitan filtrar un hash por un `include` descuidado, y lo comprobé en el ataque: pedir `include=user,password` no devuelve ningún campo de contraseña.

### Migraciones versionadas, nunca `db push`

Hay **diez migraciones** en `prisma/migrations/` (de `0_init` a `20261023090922_email_outbox`). El arranque comprueba que el historial de la base coincide con el del repositorio; si no, **no arranca**. Esto es lo que ocurre en producción con una base nueva sin migrar (salida real, recortada):

```text
ERROR [Bootstrap] Migration history does not match prisma/migrations:
  Pending: 0_init, 20260929000000_hash_refresh_tokens_and_indexes, 20261001211304_rate_limit_buckets, …
MigrationsGuard
exit=1
```

Mensaje claro y *fail closed*. En contenedor, el `docker-entrypoint.sh` es quien aplica las migraciones (`prisma migrate deploy`) antes de arrancar. Lo ejecuté primero **sin Docker**, directamente con `sh ./docker-entrypoint.sh node dist/main.js` y un `DATABASE_URL=file:…/data/prod.db` en un directorio que no existía:

```text
🚀 Aplicando migraciones de base de datos...
SQLite database prod.db created at file:/…/data/prod.db
10 migrations found in prisma/migrations
Applying migration `0_init`
…
Applying migration `20261023090922_email_outbox`
⚡ Iniciando servidor NestJS...
```

El script crea el directorio, aplica las 10 migraciones, arranca el servidor, `/healthz` responde y `/docs` da 404 (producción). Y cuando la base **existe pero no tiene historial** de migraciones (un caso real si migras desde otra herramienta), no inventa nada: se niega a hacer el *baseline* automático y te da el procedimiento manual:

```text
Error: P3005
The database schema is not empty. …
❌ No se aplicaron las migraciones.
   La base de datos existe SIN historial de migraciones (P3005). Procedimiento manual:
     1. Copia de seguridad del fichero/BD actual.
     2. Verificar que el esquema existente es el esperado …
     3. Solo si el esquema coincide:  node node_modules/prisma/build/index.js migrate resolve --applied 0_init
     4. node node_modules/prisma/build/index.js migrate deploy
   Nunca ejecutes resolve en un esquema que no hayas verificado.
exit=1
```

Esto es lo que quiero de una plantilla de producción: que **un fallo se note en el arranque y no a las tres de la madrugada**.

### El cliente: libSQL, WAL y Turso

`PrismaService` extiende `PrismaClient` con el adaptador `PrismaLibSql`. Si defines `TURSO_DATABASE_URL`, se usa Turso; si no, el fichero local. Al arrancar aplica los *pragmas* de SQLite (en mi log: `SQLite pragmas applied at startup (journalMode=wal)`) y el adaptador espera hasta 5 segundos al único escritor de SQLite en vez de fallar con `SQLITE_BUSY` bajo concurrencia.

💡

**SQLite en producción no es un pecado** para muchas APIs pequeñas y medianas (un solo proceso, un volumen, copias de seguridad sencillas). Lo que sí exige es que **no escales a varias réplicas escribiendo en el mismo fichero**. Para eso la plantilla da el camino de Turso (libSQL remoto); lo que **no he probado** es Turso en sí, porque no tengo una base de Turso a mano. Para PostgreSQL tendrías que cambiar proveedor, adaptador y revisar las migraciones, como expliqué en [la plantilla de Hono](https://blog.devutilites.com/hono-prisma-production-starter/).

### El *seed*

`SeedService` corre tras la comprobación de migraciones. Fuera de producción siembra los dos usuarios demo y tres tareas; con `AUTH_MODE=none` siembra dos tareas públicas de ejemplo; **en producción** solo crea el administrador inicial si la base está vacía y has definido `ADMIN_EMAIL` y `ADMIN_PASSWORD`. Lo verifiqué: en el arranque en producción, el login de `ana@example.com` da 401 (no existe) y el del administrador del entorno da 200.

## El punto de entrada: orden estricto

El `main.ts` documenta su propio orden y es muy didáctico:

```ts
const env = loadEnv();                         // 1. valida .env con Zod (o aborta)
Logger.overrideLogger(nestLogLevels(env.LOG_LEVEL));   // 2. nivel de log
const app = await NestFactory.create<NestExpressApplication>(AppModule.forRoot(env), {
  bodyParser: false,   // el body se configura de forma explícita en configureApp()
  abortOnError: false,
});
await configureApp(app, env, features);        // 3. helmet, CORS, límites, docs
await app.init();
await prisma.applySqlitePragmas();             // 4. pragmas de SQLite
await assertMigrationsUpToDate(prisma);        // 5. migraciones al día o exit(1)
await app.get(SeedService).run();              // 6. seed
cleanup.start();  outbox?.start();             // 7. limpieza horaria y worker de correo
installShutdownHooks(app, …);                  // 8. cierre ordenado (tope 10 s)
await app.listen(env.PORT, "0.0.0.0");         // 9. y solo entonces, escuchar
```

Dos observaciones: la app **solo escucha cuando todo lo anterior ha ido bien**, y en el apagado (`SIGTERM`) cierra el servidor, para los trabajos y desconecta Prisma. Lo medí: del `SIGTERM` a la salida pasan **39–47 ms** sin tráfico.

`configureApp()` fija el resto: `helmet`, una CSP específica solo para `/docs`, `Referrer-Policy: no-referrer` y `Cache-Control: no-store` en todas las respuestas, CORS por lista, y un `json({ limit: 100 KB })` con un manejador de errores de *body* que responde 400 (JSON mal formado) o 413 (demasiado grande). También desactiva `x-powered-by` y los *etags*, y usa el *query parser* `simple`.

## Módulos e inyección de dependencias: el mapa completo

Si vienes de Hono o de Express, esta es la parte de NestJS que más cuesta al principio y la que más compensa después. Por eso la recorro entera, con el código real.

### Un módulo es una caja con puertas

En Nest todo vive dentro de un `@Module`. Un módulo declara qué **controladores** monta, qué **proveedores** (servicios) crea, qué **importa** de otros módulos y qué **exporta** para que otros lo usen. Lo que no exportas es privado. Esta plantilla tiene **14 módulos**: el raíz, seis globales (disponibles en todas partes sin importarlos) y siete «de funcionalidad». Este es el reparto, sacado de los ficheros `*.module.ts`:

| Módulo                          | Global | Qué aporta                                                                           | Quién lo ve                                                          |
| ------------------------------- | ------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| ConfigProviderModule            | sí     | APP\_ENV y APP\_FEATURES (el entorno validado y las capacidades según AUTH\_MODE)    | todos                                                                |
| DatabaseModule                  | sí     | PrismaService                                                                        | todos                                                                |
| RateLimitModule                 | sí     | RateLimitService, LoginLockoutService, PasswordConfirmLockoutService, RateLimitGuard | todos                                                                |
| AuditModule                     | sí     | AuditService                                                                         | todos                                                                |
| MailModule                      | sí     | MailService, AuthTokensService, AccountMailService, EmailOutboxService               | todos                                                                |
| MfaModule                       | sí     | TotpService, MfaService                                                              | todos (el login los necesita siempre)                                |
| SessionsModule                  | no     | TokensService, SessionsService y las rutas /api/sessions                             | AuthModule, UsersModule, RecoveryModule, MfaRoutesModule lo importan |
| AuthModule                      | no     | /api/auth/\* (registro, login, refresh, logout)                                      | solo con AUTH\_MODE distinto de none                                 |
| UsersModule, AuditLogsModule    | no     | /api/users, /api/audit-logs                                                          | solo con autenticación                                               |
| RecoveryModule, MfaRoutesModule | no     | recuperación de contraseña, verificación de email y rutas de 2FA                     | solo con AUTH\_MODE=full                                             |
| TasksModule                     | no     | /api/tasks, el recurso de ejemplo                                                    | siempre (pública con none)                                           |

Fíjate en `RateLimitModule`, `AuditModule` o `MfaModule`: son globales porque casi cualquier controlador los necesita y repetir `imports: [AuditModule]` en cada módulo sería ruido. El precio es el de siempre con lo global: un módulo nuevo puede usar `AuditService` sin declarar de dónde viene, y eso oculta dependencias. Es una decisión razonable aquí; en un proyecto con veinte módulos de dominio yo preferiría importar explícitamente.

### `AppModule.forRoot(env)`: un módulo dinámico

El módulo raíz no es un decorador estático. Es una **función** que recibe el entorno ya validado y devuelve la definición del módulo:

```ts
static forRoot(env: Env): DynamicModule {
  const features = featuresOf(env);
  return {
    module: AppModule,
    imports: [
      ConfigProviderModule.forEnv(env),
      DatabaseModule, RateLimitModule, AuditModule, MailModule,
      MfaModule,
      TasksModule,
      ...(features.auth ? [SessionsModule, AuthModule, UsersModule, AuditLogsModule] : []),
      ...(features.accountSecurity ? [RecoveryModule, MfaRoutesModule] : []),
    ],
    controllers: [HealthController, SystemController],
    providers: [ SeedService, CleanupService, JwtAuthGuard, /* … APP_GUARD, APP_PIPE, … */ ],
  };
}
```

Eso resuelve algo que en Hono se hace con `if` al montar rutas: con `AUTH_MODE=none` **las rutas de autenticación no existen** (no es que devuelvan 404 por una condición, es que el módulo nunca se carga). El entorno entra por un *token* de inyección, no por una variable global:

```ts
export const APP_ENV = Symbol("APP_ENV");
// en el proveedor:   { provide: APP_ENV, useValue: env }
// en cualquier clase: constructor(@Inject(APP_ENV) private readonly env: Env) {}
```

La consecuencia práctica es la que más me gusta de este diseño: **los tests pueden construir la aplicación con el entorno que quieran** (`AppModule.forRoot(loadEnv())` con otras variables) sin tocar `process.env` más allá de lo imprescindible.

💡

**Cómo leer un constructor de Nest.** `constructor(private readonly prisma: PrismaService, @Inject(APP_ENV) private readonly env: Env)` significa «dame una instancia compartida de `PrismaService` (la resuelvo por el tipo) y el valor registrado bajo `APP_ENV` (lo resuelvo por el símbolo)». Las interfaces y los tipos de TypeScript no existen en tiempo de ejecución, por eso lo que no es una clase necesita un token con `@Inject`.

### Los cinco proveedores globales y su orden

`AppModule` registra cinco piezas con los tokens especiales `APP_GUARD`, `APP_PIPE`, `APP_INTERCEPTOR` y `APP_FILTER`. Se aplican a **todas** las rutas, y el orden en que Nest las ejecuta es fijo:

| Orden | Pieza                         | Qué hace en esta plantilla                                                                                      |
| ----- | ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 1     | AuthPrefixMiddleware          | Autentica antes de enrutar en los cuatro prefijos protegidos (para que una subruta inexistente dé 401 y no 404) |
| 2     | JwtAuthGuard                  | Exige Bearer salvo @Public(); comprueba firma, usuario y sesión en la BD                                        |
| 3     | RateLimitGuard                | Solo actúa en rutas con @RateLimit(...)                                                                         |
| 4     | Interceptor (entrada)         | TransformInterceptor lee sus metadatos y deja pasar                                                             |
| 5     | ContractValidationPipe        | Valida body, query y params con Zod                                                                             |
| 6     | El método del controlador     | Tu código                                                                                                       |
| 7     | TransformInterceptor (salida) | Envuelve en { success, data, meta }                                                                             |
| —     | AllExceptionsFilter           | Si cualquier paso lanza, construye el sobre de error                                                            |

Una nota sobre el paso 2: en `AppModule` el guard aparece **dos veces**, `JwtAuthGuard` como proveedor normal y `{ provide: APP_GUARD, useExisting: JwtAuthGuard }`. No es un descuido: el `AuthPrefixMiddleware` necesita **la misma instancia** del guard para llamar a `authenticate()`, y `useExisting` evita que Nest cree dos instancias distintas.

Dos consecuencias de este orden que merece la pena saber, porque no son evidentes:

1. **El rate limit se ejecuta después de autenticar.** Como `/login` y `/register` son `@Public()`, para ellas el guard de JWT es un simple «pasa», y el límite por IP actúa de verdad. Pero en una ruta protegida, un cliente sin token recibe 401 antes de gastar un solo contador.
2. **La validación ocurre antes que tu código.** Tu controlador nunca ve un cuerpo, un `query` o unos `params` sin validar. Lo que evita filtrar campos sensibles en la salida no es un serializador, sino el `omit` global de Prisma (la contraseña y el material de 2FA nunca salen de la base de datos salvo en el login). En `main` había además un `ZodSerializerInterceptor` registrado globalmente que **no validaba nada**: ninguna ruta de la plantilla declara un esquema de respuesta (busqué `ZodResponse` y `ZodSerializerDto` en `src/`: solo aparecía en un comentario del controlador de salud). El PR #8 lo quita para que nadie crea que las respuestas se validan; si algún día quieres validar la salida, añade un DTO de respuesta por ruta y vuelve a registrarlo.

### Decoradores propios: `@Public`, `@AuthUser`, `@RateLimit`

Los decoradores de Nest son metadatos más una convención. Los tres que usa la plantilla ocupan menos de 15 líneas cada uno:

```ts
// @Public(): marca la ruta; el guard lee el metadato con Reflector
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// @AuthUser(): extrae el usuario que el guard dejó en la petición
export const AuthUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): AuthUserPayload =>
    ctx.switchToHttp().getRequest<RequestWithUser>().user as AuthUserPayload,
);
```

Y así quedan en un controlador (el de login, recortado): `@Post("login") @Public() @HttpCode(200) @RateLimit("login") @TopLevelEnvelope()`. Cuatro decoradores y sabes qué hace la ruta sin abrir el método: pública, devuelve 200, limitada y con el sobre «plano». Esa legibilidad por metadatos es el argumento a favor de NestJS; el argumento en contra es que hay que conocer el sistema para entender qué ocurre *entre* los decoradores (el orden de la tabla anterior).

## El sobre de respuesta

Todas las respuestas tienen la misma forma (`TransformInterceptor`), también los errores (`AllExceptionsFilter`), con un `requestId` para rastrear en los logs. Es la misma forma que usa [la plantilla de Hono](https://blog.devutilites.com/hono-prisma-production-starter/):

```json
{
  "success": false,
  "message": "Validation error in request payload",
  "errors": { "title": ["Title must be at least 3 characters long"] },
  "meta": {
    "requestId": "3681cb18-ac3c-4363-a142-0293a3fe5646",
    "timestamp": "2026-10-24T05:51:20.061Z",
    "durationMs": 2.4,
    "apiVersion": "2.0.0"
  }
}
```

Una ruta que no existe responde con el mismo sobre (`"Route not found: GET /nada"`, 404). Lo único que no va envuelto es `/healthz`, a propósito, para que lo consuma un orquestador:

```json
{"status":"healthy","timestamp":"2026-10-24T05:44:46.666Z","uptimeSeconds":7,"database":{"status":"connected","latencyMs":1}}
```

Los logs de Nest, por el contrario, **no** son JSON: salen en texto con el formato `[Nest] pid - fecha LOG [Contexto] mensaje`. En `main`, **dentro de un contenedor llevaban además códigos de color ANSI** salvo que definieras `NO_COLOR=1` (lo medí: 62 líneas con secuencias `ESC` sin la variable, 0 con ella). **El PR #8 lo corrige**: en producción el logger se crea con `colors: false` (el nivel sale de `LOG_LEVEL`, en `src/logging.ts`, con su prueba) y en la imagen del PR conté **0 líneas con `ESC` de 110** sin tocar nada. Siguen siendo texto, no JSON (ver «Lo que queda por pulir»).

## Validación y errores: el viaje de una petición mala

El 80 % del trabajo de una API real es decir «no» con educación y con la misma forma siempre. Aquí hay tres piezas que lo hacen y que conviene entender juntas: los esquemas de Zod, el `ContractValidationPipe` y el `AllExceptionsFilter`.

### Un esquema, tres usos

Los esquemas viven en `src/schemas/request.schemas.ts` (Zod 4) y se usan para tres cosas a la vez: validar la entrada, tipar el controlador y generar OpenAPI. El pegamento es `createZodDto` de `nestjs-zod` (versión 5.5.0, **vendorizada** en `vendor/` porque la plantilla fija esa versión):

```ts
export const createTaskSchema = z.object({ title: z.string().min(3, "Title must be at least 3 characters long").max(100) /* … */ });
export class CreateTaskRequest extends createZodDto(createTaskSchema) {}

// en el controlador:
async create(@Body() dto: CreateTaskRequest) { /* dto ya está validado y tipado */ }
```

El truco es que `CreateTaskRequest` es **una clase real** (Nest necesita clases para los metadatos de `@Body()`) cuyo contenido se deriva del esquema. Con `class-validator` tendrías el esquema repartido en decoradores; aquí es un objeto que puedes reutilizar, componer (`.extend`, `.pick`, `...listParams`) y probar suelto.

### El pipe: el mensaje depende de dónde falló

`ContractValidationPipe` envuelve al `ZodValidationPipe` de `nestjs-zod` por una razón concreta: la librería solo lleva el `ZodError`, y la API de referencia (la de Hono) devuelve un **mensaje distinto según el origen** del fallo. El pipe lo traduce. Salida real contra mi instancia local:

```text
POST /api/tasks   {"title":"ab","status":"nope"}
HTTP 400
{"success":false,"message":"Validation error in request payload",
 "errors":{"title":["Title must be at least 3 characters long"]}, "meta":{…}}

GET /api/tasks?limit=-5
HTTP 400
{"success":false,"message":"Invalid request query parameters",
 "errors":{"limit":["Too small: expected number to be >=1"]}, "meta":{…}}
```

Mira la primera: yo envié también `"status":"nope"` y **no** aparece en `errors`. Es el comportamiento por defecto de `z.object`: los campos desconocidos se **descartan en silencio**, no se rechazan. Es lo contrario de `.strict()`. Para una API pública suele ser lo cómodo; si prefieres que un cliente con un campo mal escrito se entere, tendrías que añadir `.strict()` en los esquemas. Y es la razón por la que mi prueba de asignación masiva (`role: "admin"` en el registro) terminó en `role: "user"`: el campo no llega al servicio.

### El filtro: la tabla de traducción

`AllExceptionsFilter` es un `@Catch()` sin argumentos: atrapa **todo**. Lo que hace es una cadena de `if` ordenada de lo más específico a lo más general. La tabla, leída del código y confirmada con las pruebas del repositorio y con curl donde se indica:

| Qué se lanza                                    | Código    | Mensaje                                                       | Comprobado             |
| ----------------------------------------------- | --------- | ------------------------------------------------------------- | ---------------------- |
| Error de *body-parser* entity.parse.failed      | 400       | Malformed JSON in request body                                | curl                   |
| entity.too.large (más de 100 KB)                | 413       | Payload Too Large: … maximum permitted limit of 100 KB.       | curl                   |
| ZodValidationException                          | 400       | Validation error in request payload \+ errors                 | curl                   |
| ZodSerializationException                       | 500       | Internal server error. (el detalle va al log)                 | solo lectura de código |
| Prisma P2002 (único violado)                    | 409       | Conflict: A record with the same unique value already exists. | lectura de código      |
| Prisma P2025 (no encontrado)                    | 404       | Resource not found.                                           | lectura de código      |
| Otro error conocido de Prisma                   | 500       | Internal server error.                                        | lectura de código      |
| RateLimitedException                            | 429       | cuerpo propio con retryAfterSeconds \+ cabecera Retry-After   | curl                   |
| RetryAfterException (bloqueo de cuenta)         | 429       | sobre estándar + Retry-After                                  | curl                   |
| NotFoundException de enrutado («Cannot GET /x») | 404       | Route not found: GET /x                                       | curl                   |
| Cualquier otra HttpException                    | su código | su mensaje (y details si lo lleva)                            | curl                   |
| Cualquier otra cosa                             | 500       | Internal server error.                                        | lectura de código      |

Tres detalles finos que me parecen buenas decisiones:

- **El 404 de enrutado se reescribe.** Nest responde por defecto `Cannot GET /nada`; el filtro lo cambia a `Route not found: GET /nada` para que sea idéntico al de la plantilla de Hono. Sin eso, un cliente tendría que tratar dos formatos de 404.
- **El 500 no filtra nada en producción.** Solo con `NODE_ENV=development` el mensaje del error interno se incluye en `details`; con `production` el cuerpo es siempre `Internal server error.` y la traza va al log. (Esto lo leo del código; no provoqué un 500 real para verlo.)
- **Los errores de *body-parser* no pasan por el filtro.** Express procesa el cuerpo antes de que Nest enrute, así que el 400/413 sale de un *middleware* de error en `configure-app.ts`, no del filtro. Los dos usan el mismo constructor de cuerpos (`buildErrorPayload`) para que el resultado sea idéntico; hay incluso una rama «cinturón y tirantes» en el filtro por si algún día cambia el orden.

### Un 404 que no es lo que parece

Una petición `GET /api/tasks/no-es-uuid` devuelve **404** («Task with ID 'no-es-uuid' not found»), no 400\. Los identificadores se tratan como cadenas opacas: no se exige formato UUID en la ruta. Es coherente con el diseño (un id que no existe y un id mal formado son lo mismo para el cliente) y evita validar de más, pero conviene saberlo si esperabas un 400.

### Registro: el hueco entre «comprobar» y «crear»

El `register` hace primero un `findUnique` por email y, si no existe, un `create`. Entre ambas operaciones hay un hueco en el que otra petición puede colarse. Lo que decide el resultado no es el código de la aplicación, sino el **índice único** de la base de datos y el filtro (`P2002` → 409). Lo probé con 8 peticiones simultáneas con el mismo email, tres rondas, y luego con 12 en la batería de ataques:

```text
ronda 1 (8 registros simultáneos, mismo email):   1 × 201   7 × 409
ronda 2:                                          1 × 201   7 × 409
ronda 3:                                          1 × 201   7 × 409
12 registros simultáneos del mismo email:         201: 1, 409: 11
SELECT email, count(*) … GROUP BY email  →  una fila por email (3 filas, count=1)
logs: 0 líneas ERROR
```

Exactamente un ganador, ningún 500 y ninguna fila duplicada. Esa es la diferencia entre «comprobar y luego insertar» a secas y hacerlo con una restricción única detrás: la comprobación previa es solo para dar un mensaje amable; la garantía la da el índice.

## Autenticación por dentro

Este es el corazón de la plantilla y la parte en la que más tiempo he invertido. Te lo cuento en el orden en que ocurre.

![Sesiones con estado: por qué un token válido puede dar 401](https://blog.devutilites.com/content/images/2026/10/nestjs-starter-sesion.png)

Sesiones con estado: por qué un token válido puede dar 401

### 1\. Contraseñas: Argon2id, con un truco contra el *timing*

`src/common/utils/password.ts` usa **Argon2id** con los parámetros mínimos recomendados por OWASP (19 MiB de memoria, 2 iteraciones, 1 hilo):

```ts
const ARGON2_OPTIONS = { memoryCost: 19_456, timeCost: 2, parallelism: 1 } as const;

export async function comparePassword(plainPassword: string, stored: string): Promise<boolean> {
  if (stored.startsWith("$argon2")) return verify(stored, plainPassword);
  if (stored.startsWith("$2")) return bcrypt.compare(plainPassword, stored); // legacy
  return false;   // formato desconocido: falla cerrado
}

export function needsRehash(stored: string): boolean {
  return !stored.startsWith("$argon2");
}
```

Tres cosas buenas aquí. **Primera:** un formato de hash desconocido **nunca autentica**. **Segunda:** si vienes de una versión antigua con bcrypt, los hashes antiguos siguen valiendo y se **actualizan en el sitio tras el siguiente login correcto** (migración progresiva, sin reescribir nada en masa). **Tercera:** cuando el email **no existe**, el login compara contra un *hash falso* real (`getDummyHash()`), para que existir o no existir tarde lo mismo. Lo comprobé en la base de datos: los hashes guardados tienen la forma `$argon2id$v=19$m=19456,t=2,p=1$…`.

¿Y el *timing*? Medí el login de un email existente con contraseña incorrecta contra uno inexistente, en varias rondas (limpiando los contadores entre una y otra): las medianas se solapan (17,9–34,7 ms existente, 18–23,4 ms inexistente) y un login correcto tarda unos 17 ms. No es una prueba estadística seria, pero **no vi ninguna diferencia sistemática**.

⚠️

**No te dejes engañar por la velocidad.** En mi banco de pruebas el login con Argon2id tarda \~20–30 ms (23 ms el 24 de octubre, 28–33 ms el 25) y el de la plantilla de Hono (bcryptjs a 12 rondas) \~300 ms. Eso no significa que la de Nest sea «más segura» por ir más rápido: Argon2id a los mínimos de OWASP es una buena opción y es memory-hard, pero es un coste menor. Si quieres más resistencia, sube `memoryCost`/`timeCost` y mide.

### 2\. El login, paso a paso

El método `login` de `auth.controller.ts` hace, en este orden:

1. **Bloqueo por cuenta antes de mirar la contraseña** (5 fallos → 429 durante 15 minutos), incluso para emails que no existen.
2. Busca el usuario (con `omit: { password: false }` solo aquí) y compara con el hash real o con el falso.
3. Si falla: `lockout.recordFailure(email)` y un registro `LOGIN_FAILED` en auditoría con un **seudónimo** del email (nunca la dirección en claro).
4. Solo después de acertar la contraseña comprueba si la cuenta está bloqueada o sin verificar (así no se puede enumerar el estado de una cuenta sin saber su contraseña).
5. Si el usuario tiene 2FA activado, exige el código TOTP o un código de recuperación (`MFA_REQUIRED` / `MFA_INVALID`).
6. *Rehash* a Argon2id si hace falta, reinicio del contador y creación de la sesión.

```json
{
  "success": true,
  "message": "Authentication successful",
  "accessToken": "eyJhbGciOiJIUzI1NiIs…",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs…",
  "expiresIn": 900,
  "sessionId": "24bd1ca5-75b1-42b1-8…",
  "user": { "id": "user-2", "name": "Ana García", "email": "ana@example.com", "role": "user" },
  "meta": { "requestId": "20e9a244-…", "timestamp": "2026-10-24T05:51:19.946Z", "durationMs": 137.34, "apiVersion": "2.0.0" }
}
```

El *access token* dura 15 minutos y lleva el `sessionId`; el *refresh token* se firma con **otro secreto** y la sesión tiene un tope absoluto de 7 días. Los refresh tokens se guardan **solo como hash SHA-256**.

### 3\. El guard que lo revisa todo en cada petición

`JwtAuthGuard` es global: **todas las rutas exigen autenticación salvo las marcadas con `@Public()`** (deny-by-default, mejor que al revés). Este es su corazón, recortado:

```ts
payload = this.jwt.verify<JwtPayload>(token, { secret: this.env.JWT_SECRET, algorithms: ["HS256"] });
// …
const [user, session] = await Promise.all([
  this.prisma.user.findUnique({ where: { id: payload.userId },
    select: { id: true, role: true, isBlocked: true, blockedReason: true, deletedAt: true } }),
  this.prisma.session.findUnique({ where: { id: payload.sessionId },
    select: { userId: true, isActive: true, expiresAt: true } }),
]);
if (!user || user.deletedAt)  throw new UnauthorizedException("…no longer exists.");
if (user.isBlocked)           throw new ForbiddenException("Access denied: Account has been suspended…");
if (!session?.isActive || session.userId !== user.id || session.expiresAt < new Date())
  throw new UnauthorizedException("Session has been revoked or expired. Please sign in again.");
request.user = { userId, sessionId, email, role: user.role };   // el rol sale de la BD
```

Por eso un token **con la firma perfecta** puede dar 401 (la sesión se revocó) o 403 (el usuario está bloqueado) antes de que caduque, y por eso el **rol se lee de la base de datos** y no del token: si degradas a un administrador, el cambio vale en la siguiente petición. El precio: **dos lecturas de BD por petición autenticada** (son por clave primaria y van en paralelo con `Promise.all`).

Hay una pieza más, el `AuthPrefixMiddleware`, que existe por una razón sutil: un guard global solo se ejecuta en rutas que **coinciden**. Sin él, un sondeo a `/api/tasks/lo/que/sea` **sin token** devolvería 404 y filtraría qué rutas existen. Con él, los cuatro prefijos protegidos (`/api/sessions`, `/api/users`, `/api/tasks`, `/api/audit-logs`) y todas sus subrutas dan 401 sin token. Con Express 5 el comodín se escribe `{*splat}`:

```ts
...["api/sessions", "api/users", "api/tasks", "api/audit-logs"].flatMap((prefix) => [
  { path: prefix, method: RequestMethod.ALL },
  { path: `${prefix}/{*splat}`, method: RequestMethod.ALL },
]),
```

Lo comprobé con curl: `GET /api/tasks/no/existe` sin token → **401**; con token → **404**.

### 4\. Rotación de refresh tokens y detección de robo

Cada refresh token solo vale **una vez**. El truco para que dos peticiones simultáneas no ganen las dos es un `updateMany` atómico dentro de una transacción:

```ts
const rotated = await this.prisma.$transaction(async (tx) => {
  const claimed = await tx.refreshToken.updateMany({
    where: { tokenHash, sessionId, usedAt: null, expiresAt: { gt: now } },
    data: { usedAt: now },
  });
  if (claimed.count !== 1) return false;       // alguien lo usó antes que yo
  await tx.refreshToken.create({ data: { tokenHash: hashToken(next.refreshToken), userId: user.id, sessionId, expiresAt: next.refreshExpiresAt } });
  return true;
});
// si no ganó: ¿fue hace menos de REFRESH_REUSE_GRACE_SECONDS (10 s)? → 409 (concurrente)
// si fue hace más → se considera robo: revokeSession(sessionId) y 401
```

Lo que pasó en mis pruebas reales:

| Prueba                                                        | Resultado                                                                          |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Refresh con un token válido                                   | 200, par nuevo                                                                     |
| Reusar ese mismo token al instante                            | **409** («already rotated by a concurrent request»)                                |
| 3 refresh simultáneos con el mismo token                      | **200, 409, 409** (exactamente uno gana)                                           |
| Reusar un token ya rotado pasados 11 s                        | **401** y la sesión queda **revocada**; 1 fila TOKEN\_REUSE\_DETECTED en auditoría |
| Usar el *access* token como refresh, y el refresh como Bearer | 401 en ambos casos                                                                 |

Es decir: la «familia» de tokens es la sesión, y revocarla no desloguea al usuario en sus otros dispositivos. Matiz honesto: la ventana de gracia de 10 s significa que un atacante que reproduzca un token **dentro** de esos segundos solo recibe un 409, no una revocación.

### 5\. Fuerza bruta, bloqueo y rate limiting

Hay **dos capas** y conviene no confundirlas:

- **Por IP** (`RateLimitGuard`): contadores de ventana fija **en la base de datos** (`RateLimitBucket`), así que valen para varias instancias. Se aplica solo a las rutas con `@RateLimit(...)`: login 10/min, refresh 30/min, registro 5/h, recuperación y MFA. Devuelve `X-RateLimit-Limit` y `X-RateLimit-Remaining`. Si el almacén falla, en las rutas críticas aplica una ventana **local en memoria** (`RATE_LIMIT_ON_DB_FAILURE=local`) en vez de abrirse.
- **Por cuenta** (`LoginLockoutService`): 5 fallos → 15 minutos de bloqueo, con una clave seudonimizada, comprobado **antes** de la contraseña.

```text
intentos 1–5 con contraseña incorrecta → 401
intento 6 (incluso con la contraseña CORRECTA) → 429, Retry-After: 900
la sesión admin que ya estaba abierta → sigue funcionando
11ª petición de login desde una IP → 429
13 emails distintos desde la misma IP → 429 a partir del 11º
rotando X-Forwarded-For / CF-Connecting-IP / X-Real-IP con TRUST_PROXY=false → 429 igualmente
```

Un matiz que conviene saber: el bloqueo por cuenta es también una **palanca de denegación**: cualquiera puede dejar a un usuario sin poder iniciar sesión 15 minutos (sus sesiones abiertas no se ven afectadas). Es un compromiso habitual y está documentado, pero es un compromiso.

### 6\. 2FA con TOTP y códigos de recuperación

Con `AUTH_MODE=full`, un usuario puede activar TOTP (SHA-1, 6 dígitos, 30 s, ±1 ventana). Los detalles que importan y que **comprobé de extremo a extremo**:

- Para iniciar la configuración (`/api/auth/mfa/setup`) hay que reconfirmar la contraseña: con una incorrecta, **403**.
- El secreto se guarda **cifrado con AES-256-GCM** (en la BD empieza por `v1.`); la clave es `MFA_ENCRYPTION_KEY`, y con `MFA_ENCRYPTION_KEY_PREVIOUS` se puede **rotar** sin perder los dispositivos ya enrolados (el ensayo `ops-rehearsal.sh mfa-rotation` pasó: K1 → K1+K2 → K2, con el secreto recifrado de forma perezosa).
- `enable` con `000000` → 400; con un código real → devuelve **10 códigos de recuperación** (solo se guardan sus hashes).
- Login sin código → `MFA_REQUIRED`; código erróneo → `MFA_INVALID` (y cuenta como fallo para el bloqueo); código correcto → 200.
- **Un código TOTP no se puede reutilizar** (`totpLastStep`): repetirlo da 401\. Un código de recuperación funciona **una vez** y luego da 401.

💡

Un matiz de uso que me hizo perder un rato: el código TOTP que consumes en `enable` **no sirve** para el login siguiente si estás en el mismo intervalo de 30 s, por la protección contra repetición. Es el comportamiento correcto, pero en un script de pruebas hay que esperar al siguiente intervalo.

### 7\. Verificación de email y recuperación de contraseña

Los correos no se envían dentro de la petición HTTP: se encolan en `EmailOutbox` y un *worker* los entrega con **reintentos acotados** (5 intentos, con espera). Los enlaces llevan un token aleatorio de un solo uso del que solo se guarda el **hash SHA-256**, y está **atado al email** al que se envió: si el usuario cambia de email, el enlace viejo deja de valer. `forgot-password` valida el cuerpo y responde sin revelar si la cuenta existe (hay una prueba del contrato que lo fija; con `MAIL_TRANSPORT=none` lo vi devolver 202).

Esta parte la comprobé con el *harness* de contrato (grupo `recovery`, 11 pruebas en verde) usando un servidor SMTP de pruebas que incluye el propio repositorio. **No he probado un envío real** con un proveedor SMTP.

### 8\. El limitador por dentro: una fila, una sentencia

El limitador por IP y el bloqueo por cuenta comparten una sola tabla, `RateLimitBucket`, que es lo más simple que se puede imaginar:

```sql
CREATE TABLE "RateLimitBucket" ("key" TEXT NOT NULL PRIMARY KEY, "count" INTEGER NOT NULL, "resetAt" DATETIME NOT NULL)
```

Después de seis intentos de login fallidos a `ana@example.com` y un séptimo con la contraseña correcta (que también dio 429), esto es lo que había en la tabla (consulta real a la base de datos de mi instancia local):

```text
('rl:login:127.0.0.1',                       7, '2026-10-24T06:35:14.252+00:00')   ← 7 peticiones en la ventana de 1 min
('lockout:a163bdfbd5cea78c30b92db0067a6552', 5, '2026-10-24T06:49:14.395+00:00')   ← 5 fallos, bloqueo 15 min
```

Dos cosas se ven a simple vista. El 6.º intento ya devolvió 429 y **no sumó** al contador de fallos (se quedó en 5): la comprobación de bloqueo va antes de la contraseña. Y la clave de bloqueo no lleva el email, lleva un **seudónimo** (`lockout:a163…`), un HMAC con una clave derivada de `JWT_SECRET`. Quien lea la tabla no obtiene direcciones de correo.

El código que incrementa el contador es esta función de 30 líneas:

```ts
async hit(key: string, windowMs: number): Promise<Bucket> {
  for (let attempt = 0; attempt < 3; attempt++) {
    const now = new Date();
    // 1) incremento atómico de una ventana viva
    const updated = await this.prisma.rateLimitBucket.updateMany({
      where: { key, resetAt: { gt: now } }, data: { count: { increment: 1 } },
    });
    if (updated.count === 0) {
      // 2) no hay ventana viva: borra la caducada (si existe) y abre una nueva
      await this.prisma.rateLimitBucket.deleteMany({ where: { key, resetAt: { lte: now } } });
      try {
        return await this.prisma.rateLimitBucket.create({ data: { key, count: 1, resetAt: new Date(now.getTime() + windowMs) }, select: { count: true, resetAt: true } });
      } catch (error) {
        // 3) otra instancia abrió la ventana primero: reintenta como incremento
        if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") continue;
        throw error;
      }
    }
    const bucket = await this.prisma.rateLimitBucket.findUnique({ where: { key }, select: { count: true, resetAt: true } });
    if (bucket) return bucket;
  }
  throw new Error(`Could not update rate limit bucket '${key}'`);
}
```

No hay *leer–modificar–escribir* en código de aplicación: cada paso es una sentencia atómica y la carrera se resuelve con la clave primaria (el `P2002`) y un bucle de hasta tres intentos. Es un diseño que funciona con **varias instancias** compartiendo una base de datos (Turso), que es justo para lo que está pensado.

Para el bloqueo, `recordFailure` añade una pieza más: tras sumar el fallo llama a `slide()`, que **empuja `resetAt` hacia adelante**. Es una ventana deslizante: el bloqueo se mide desde el **último** fallo, no desde el primero. Sin ello, cinco fallos repartidos a lo largo de 14 minutos desbloquearían la cuenta un minuto después del último.

Y el guard, cuando la tabla no está disponible, no se limita a «abrir la mano»:

```ts
// RATE_LIMIT_ON_DB_FAILURE=local (por defecto): login, refresh, register, recuperación y MFA
// siguen limitados con una ventana en memoria del proceso; el resto de rutas falla abierto.
// Además cuenta métricas: storeFailures, localAllowed, localBlocked, openAllowed.
```

⚠️

**La carrera del bloqueo, medida.** El bloqueo por cuenta comprueba al principio y registra el fallo al final, así que una ráfaga realmente simultánea puede pasar la comprobación antes de que el primer fallo quede anotado. Lo medí con 30 logins fallidos contra la misma cuenta, lanzados con `Promise.all` desde Node (todos a la vez), contra una instancia local con la configuración por defecto y con el límite por IP elevado a 1000 para aislar el bloqueo por cuenta: Escenario 401 429 Valores por defecto (límite por IP: 10/min), `Promise.all`10 20 Límite por IP a 1000, `Promise.all` **30**0 Valores por defecto, 30 procesos `curl` lanzados en segundo plano 6 24 Con la configuración por defecto, el límite por IP corta la ráfaga en 10 (y el bloqueo de cuenta aún no se ha activado en ese momento). Sin ese límite, **las 30 peticiones pasaron por la comprobación** y solo la siguiente (la correcta) recibió el 429 de `Retry-After: 900`. Con `curl`, que arranca cada proceso con unos milisegundos de diferencia, el bloqueo se activó tras 6\. Conclusión práctica: «5 intentos» son **5 intentos en serie**; un atacante con muchas IPs y peticiones paralelas puede gastar más. No lo considero grave, pero es la cifra honesta.

### 9\. TOTP: comprobado contra los vectores de la RFC 6238

La plantilla **no usa ninguna librería de TOTP**: implementa HOTP/TOTP en unas 80 líneas con `node:crypto` (`createHmac("sha1", …)`, truncamiento dinámico, `timingSafeEqual`). Eso se puede fiar si y solo si coincide con los vectores oficiales, así que los ejecuté contra `TotpService` compilado. El apéndice B de la RFC 6238 da códigos de 8 dígitos para el secreto ASCII `12345678901234567890`; el servicio emite 6, que son los **seis últimos** dígitos:

```text
secreto base32: GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
t=59           RFC: 94287082 → últimos 6: 287082 | servicio: 287082 OK
t=1111111109   RFC: 07081804 → últimos 6: 081804 | servicio: 081804 OK
t=1111111111   RFC: 14050471 → últimos 6: 050471 | servicio: 050471 OK
t=1234567890   RFC: 89005924 → últimos 6: 005924 | servicio: 005924 OK
t=2000000000   RFC: 69279037 → últimos 6: 279037 | servicio: 279037 OK
```

Cinco de cinco. Y el comportamiento alrededor del código, también real:

```text
mismo paso, lastStep=null                  → 56666666   (paso aceptado: se devuelve el contador)
mismo paso, lastStep=paso                  → null       (repetición rechazada)
código del paso anterior (+30 s después)   → 56666666   (dentro de la ventana ±1)
código con 90 s de retraso                 → null       (fuera de ventana)
código '12345' (5 dígitos)                 → null
```

El método `verifyTotp` recorre **siempre** los tres candidatos (paso−1, paso, paso+1) y no sale al primer acierto, con `timingSafeEqual` en cada comparación: así el tiempo de respuesta no revela cuál coincidió. La condición `step > lastStep` impide reutilizar un código, y `MfaService` la convierte en una sentencia atómica (`updateMany … WHERE totpLastStep IS NULL OR totpLastStep < step`): dos peticiones simultáneas con el mismo código no pueden ganar las dos.

El cifrado en reposo, ejecutado igualmente:

```text
cifrado: v1 . 16 car. . 22 car. . 43 car.          ← versión . IV (12 bytes) . etiqueta GCM . texto cifrado
dos cifrados del mismo secreto iguales?  false      ← IV aleatorio
descifrar con K1:                       {"secret":"GEZD…","fromPreviousKey":false}
descifrar con K2 (previous=K1):         {"secret":"GEZD…","fromPreviousKey":true}   ← y MfaService lo recifra con K2
descifrar con una clave ajena      →    Unable to decrypt TOTP secret: no configured MFA key matches
cifrado manipulado (1 carácter)    →    Unable to decrypt TOTP secret: no configured MFA key matches
```

Esa última línea es la gracia de AES-GCM: no solo cifra, **autentica**. Cambiar un solo carácter del texto cifrado hace que el descifrado falle en vez de devolver basura. Y los códigos de recuperación (`xguy-uxd6-6b4o-b7km`, 16 caracteres base32 = 80 bits aleatorios) se guardan solo como SHA-256: 80 bits de entropía hacen innecesario un hash lento.

### 10\. La bandeja de salida de correo: simulada con tiempo virtual

La verificación de email y la recuperación de contraseña dependen de que el correo salga aunque el proveedor falle o el proceso se reinicie. Eso lo resuelve `EmailOutboxService`, un patrón conocido (*transactional outbox*): la petición solo **escribe una fila** (`EmailOutbox`) y un *worker* la entrega, con reintentos. Las piezas, en orden:

- **Reclamar una fila** es atómico: `updateMany WHERE id = … AND status = 'pending'` pasa la fila a `sending`. Dos instancias no pueden entregar el mismo correo; la que pierde la carrera busca la siguiente.
- **Filas huérfanas.** Si un *worker* muere con la fila en `sending`, a los 5 minutos otro la vuelve a `pending`.
- **El token se crea al entregar**, no al encolar. Así el token en claro **nunca se guarda**: en la fila solo hay `userId` y `type`.
- **Fallos terminales sin reintentar**: con `MAIL_TRANSPORT=none`, o si la cuenta ya no existe o está bloqueada, la fila se retira en el acto (no tiene sentido reintentar).

El comentario del código decía «5 intentos, ≈15 minutos» (en `main` sigue diciéndolo). Quise comprobarlo y lo hice sin red ni SMTP: instancié el servicio compilado con una base de datos en memoria falsa y un transporte que siempre falla, y avancé un reloj virtual:

```text
WARN  Email delivery failed (attempt 1/5, type=email_verify), retrying in 30s
t=   0 s → intento 1 fallido, estado=pending
WARN  Email delivery failed (attempt 2/5, …), retrying in 60s
t=  30 s → intento 2 fallido, estado=pending
WARN  Email delivery failed (attempt 3/5, …), retrying in 120s
t=  90 s → intento 3 fallido, estado=pending
WARN  Email delivery failed (attempt 4/5, …), retrying in 240s
t= 210 s → intento 4 fallido, estado=pending
ERROR Email delivery abandoned after 5 attempts (type=email_verify)
t= 450 s → intento 5 fallido, estado=failed
métricas: {"enqueued":0,"sent":0,"retried":4,"failed":1}
```

El resultado real: **se rinde a los 450 segundos (7,5 minutos), no a los ≈15 minutos del comentario**. La tabla de esperas tenía cinco valores (30, 60, 120, 240 y 480 s), pero el quinto intento es el último y, al fallar, no hay un «siguiente»: la espera de 480 s nunca se usaba. Es un detalle menor (el usuario siempre puede pedir otro enlace), pero es el tipo de cosa que solo aparece cuando ejecutas el código. **El PR #8 deja la tabla en cuatro esperas (30, 60, 120 y 240 s) y corrige el comentario a «7,5 minutos»: el comportamiento no cambia, solo deja de mentir.** (La traza de arriba es la medida original sobre `758a0ac`; no la repetí con el cambio.)

### 11\. La limpieza horaria

`CleanupService` arranca con un ciclo inmediato y un temporizador cada hora (con `unref()`, para no impedir que el proceso termine). Qué borra y cuándo:

| Qué                          | Se borra cuando                                                                               |
| ---------------------------- | --------------------------------------------------------------------------------------------- |
| Sesiones                     | caducadas, o inactivas desde hace más de 24 h (los refresh tokens caen por ON DELETE CASCADE) |
| Refresh tokens               | caducados, o rotados hace más de 24 h                                                         |
| Tokens de correo (AuthToken) | caducados, o usados hace más de un día                                                        |
| Códigos de recuperación      | usados hace más de 30 días (los no usados se quedan hasta que se reinicie el 2FA)             |
| Ventanas del limitador       | resetAt ya pasado                                                                             |
| Auditoría                    | más antigua que AUDIT\_RETENTION\_DAYS (90 por defecto; 0 la conserva siempre)                |
| Bandeja de salida            | filas sent/failed de hace más de 30 días                                                      |

Hay una sutileza que me gustó: purgar un refresh token rotado **no rompe la detección de robo**. Un token ya purgado que alguien reproduzca no coincide con ninguna fila, y el código lo trata igual que uno reutilizado (revoca la sesión). Y los fallos de limpieza se atrapan y se registran: un error en la limpieza nunca tumba la petición ni el proceso.

## Los recursos: tareas, usuarios, sesiones y auditoría

Con `AUTH_MODE=full` el OpenAPI generado tiene **25 paths, 31 operaciones y 18 esquemas**. Así se reparten (las etiquetas son las de Scalar):

| Grupo                   | Rutas                                                                                                   | Quién                                         |
| ----------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| **System**              | GET /healthz (más GET / con el índice de la API)                                                        | Público                                       |
| **Auth**                | POST /api/auth/register, login, refresh, logout                                                         | Público (con rate limit) salvo logout         |
| **Auth (recuperación)** | forgot-password, reset-password, verify-email, resend-verification                                      | Público (con rate limit)                      |
| **MFA**                 | POST /api/auth/mfa/setup, enable, disable · DELETE /api/users/:id/mfa                                   | Usuario autenticado · admin                   |
| **Sessions**            | GET /api/sessions/me, DELETE /api/sessions/:sessionId, POST /api/sessions/revoke-all                    | Usuario                                       |
| **Tasks**               | GET/POST /api/tasks, GET/PUT/DELETE /api/tasks/:id, POST /api/tasks/:id/restore                         | Usuario (solo las suyas; admin con scope=all) |
| **Users**               | GET/POST /api/users, GET/PUT/DELETE /api/users/:id, PATCH …/block, PATCH …/role, POST …/revoke-sessions | Admin                                         |
| **Audit**               | GET /api/audit-logs                                                                                     | Admin                                         |

Las listas aceptan paginación, ordenación y filtros con corchetes, y `include` para relaciones. Un ejemplo real con el administrador de la demo (todas las tareas, las que contienen «UI», con su usuario):

```bash
curl -g "localhost:3011/api/tasks?scope=all&filter[title][contains]=UI&include=user&sort=-createdAt" \
  -H "authorization: Bearer $TOKEN"
```

```json
{
  "success": true,
  "data": [
    { "id": "task-3", "userId": "user-2", "title": "Design Frontend UI",
      "description": "Build web dashboard consuming the REST API.", "completed": false,
      "deletedAt": null, "createdAt": "2026-10-24T05:43:37.489Z",
      "user": { "id": "user-2", "email": "ana@example.com", "name": "Ana García" } }
  ],
  "pagination": { "total": 1, "page": 1, "limit": 10, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false },
  "meta": { "requestId": "7cf2680b-…", "timestamp": "2026-10-24T05:44:12.010Z", "durationMs": 8.09, "apiVersion": "2.0.0" }
}
```

Reglas de acceso que verifiqué con un usuario normal: **403** en todas las rutas de administrador (usuarios, auditoría, `scope=all`), y **404** (no 403) al pedir una tarea o una sesión ajena, para que no se pueda sondear qué identificadores existen. Los ataques de asignación masiva no funcionan: registrarse con `role: "admin"` da `role: "user"`, un `PUT` con `role` no lo cambia y una tarea enviada con `userId`, `id` o `deletedAt` ignora esos campos. Las tareas se borran de forma lógica (`DELETE`) y se pueden restaurar (`POST …/restore`). Además hay protecciones del **último administrador**: no se puede degradar, bloquear ni borrar (409/400).

## Documentación OpenAPI y Scalar

La documentación se genera con `@nestjs/swagger` y la integración con Zod, y se sirve con Scalar en `/docs` (y el JSON en `/openapi.json`). Verifiqué que `/docs` responde 200 `text/html` en desarrollo y **404 en producción** (salvo `ENABLE_DOCS=true`), con una CSP propia solo para esa ruta. La spec es OpenAPI 3.0.0 con un esquema de seguridad `BearerAuth`, y el repositorio incluye una prueba que la valida con `@apidevtools/swagger-parser`. Y, como te enseñé arriba, **los módulos desactivados por `AUTH_MODE` desaparecen de la documentación**.

A diferencia de la de Hono, donde cada ruta se declara una sola vez y la documentación sale del mismo código que valida, aquí la validación (Zod) y la documentación (decoradores `@ApiOperation`…) se escriben en sitios distintos del controlador. Funciona y la prueba de contrato ayuda a que no se desincronicen, pero **la posibilidad de que deriven existe**.

## Tutorial: añadir un recurso nuevo (notas privadas), de punta a punta

Hasta aquí he descrito la plantilla. Ahora la uso. El ejercicio: añadir un recurso `Note` (notas privadas de cada usuario) con CRUD completo, siguiendo las convenciones del repositorio. Lo hice en una **copia** del repositorio, no en el original, y todo lo que ves a continuación está ejecutado: el código, las migraciones, las pruebas y las respuestas. El resultado fue **un modelo de Prisma, una migración, 4 ficheros nuevos, 3 líneas en `app.module.ts` (con el `import`) y 7 pruebas**.

💡

**Antes de empezar**: copia el repositorio, ejecuta `npm install` y exporta `DATABASE_URL=file:/ruta/a/tu.db` solo para los comandos de Prisma. Para `npm test`, **desexporta** `DATABASE_URL`: la suite crea sus propias bases de datos temporales y, si la variable está definida, en `main` 21 de las 62 pruebas fallan (lo sufrí; está en «Problemas reales y cómo los resolví»). El PR #8 lo corrige: la suite fuerza siempre una base SQLite temporal.

### Paso 1: el modelo (`prisma/schema.prisma`)

```prisma
model Note {
  id        String   @id @default(uuid())
  userId    String
  title     String
  body      String   @default("")
  pinned    Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  // Listado del dueño: primero las fijadas, luego las más recientes
  @@index([userId, pinned, createdAt])
}
```

Y en `model User`, la relación inversa: `notes Note[]`. Dos decisiones: `onDelete: Cascade` (si se borra un usuario, se van sus notas, igual que ocurre con sus sesiones) y un índice compuesto que coincide con la consulta del listado (dueño → fijadas → fecha).

### Paso 2: la migración

```text
npx prisma migrate dev --name notes
```

Prisma generó `prisma/migrations/20261024061724_notes/migration.sql` con este contenido, sin que yo escribiera una línea de SQL:

```sql
-- CreateTable
CREATE TABLE "Note" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "userId" TEXT NOT NULL,
    "title" TEXT NOT NULL,
    "body" TEXT NOT NULL DEFAULT '',
    "pinned" BOOLEAN NOT NULL DEFAULT false,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL,
    CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE INDEX "Note_userId_pinned_createdAt_idx" ON "Note"("userId", "pinned", "createdAt");
```

La migración se **versiona en git** y es lo que ejecuta el contenedor al arrancar (`prisma migrate deploy`). Un detalle curioso: al guardar el esquema, `prisma format` **realineó las columnas de otro modelo** (`EmailOutbox`) que yo no toqué. Es solo espacio en blanco, pero te ensucia el *diff*: revisa siempre qué ficheros cambian antes de hacer *commit*.

### Paso 3: los esquemas de Zod (`src/schemas/request.schemas.ts`)

```ts
/** Notas: crear (los valores por defecto los pone Zod) */
export const createNoteSchema = z.object({
  title: z.string().trim()
    .min(1, { message: "Title is required" })
    .max(100, { message: "Title cannot exceed 100 characters" })
    .meta({ example: "Idea para el viernes" }),
  body: z.string().max(5000, { message: "Body cannot exceed 5000 characters" }).optional().default(""),
  pinned: z.boolean().optional().default(false),
});

/** Notas: actualizar. Sin `default()`: un PUT parcial no debe pisar campos que no envía */
export const updateNoteSchema = z
  .object({
    title: z.string().trim().min(1).max(100).optional(),
    body: z.string().max(5000).optional(),
    pinned: z.boolean().optional(),
  })
  .refine((data) => Object.keys(data).length > 0, {
    message: "Provide at least one field to update (title, body or pinned)",
  });

/** Notas: query del listado */
export const noteQuerySchema = z.object({
  ...listParams,
  pinned: z.enum(["true", "false"]).optional(),
  sortBy: z.enum(["createdAt", "title"]).default("createdAt"),
});
```

Mira la diferencia entre crear y actualizar: en **crear**, `body` y `pinned` llevan `.default()`; en **actualizar**, no. Si los llevaran, un `PUT` con solo `{ "title": "nuevo" }` pondría `body` a `""` y `pinned` a `false`: **borraría datos sin avisar**. Es un error clásico con Zod y lo cubre una de las pruebas. El `.refine` rechaza un cuerpo vacío (`{}`), y `listParams` reutiliza la paginación del resto de la API.

### Paso 4: DTO, servicio, controlador y módulo

El DTO son tres líneas (`createZodDto` convierte cada esquema en una clase):

```ts
export class CreateNoteRequest extends createZodDto(createNoteSchema) {}
export class UpdateNoteRequest extends createZodDto(updateNoteSchema) {}
export class NoteQuery extends createZodDto(noteQuerySchema) {}
```

El servicio es el lugar para la regla importante: **toda consulta lleva el `userId` del dueño**, de modo que olvidarlo sea imposible por construcción.

```ts
@Injectable()
export class NotesService {
  constructor(private readonly prisma: PrismaService) {}

  async list(userId: string, q: { page: number; limit: number; search?: string; pinned?: string; sortBy: "createdAt" | "title"; order: "asc" | "desc" }) {
    const where: Prisma.NoteWhereInput = { userId };
    if (q.pinned !== undefined) where.pinned = q.pinned === "true";
    if (q.search) where.OR = [{ title: { contains: q.search } }, { body: { contains: q.search } }];
    const [total, items] = await Promise.all([
      this.prisma.note.count({ where }),
      this.prisma.note.findMany({
        where, skip: (q.page - 1) * q.limit, take: q.limit,
        orderBy: [{ pinned: "desc" }, { [q.sortBy]: q.order }],   // las fijadas siempre primero
      }),
    ]);
    return { total, items };
  }

  /** 404 tanto si no existe como si es de otro usuario: no se puede sondear qué ids existen */
  async getOwned(userId: string, id: string) {
    const note = await this.prisma.note.findFirst({ where: { id, userId } });
    if (!note) throw new NotFoundException(`Note with ID '${id}' not found.`);
    return note;
  }

  create(userId: string, data: { title: string; body: string; pinned: boolean }) {
    return this.prisma.note.create({ data: { ...data, userId } });
  }
  async update(userId: string, id: string, data: Prisma.NoteUpdateInput) {
    await this.getOwned(userId, id);
    return this.prisma.note.update({ where: { id }, data });
  }
  async remove(userId: string, id: string) {
    await this.getOwned(userId, id);
    await this.prisma.note.delete({ where: { id } });
  }
}
```

Fíjate en `getOwned`: devuelve **404 tanto si la nota no existe como si es de otro usuario**. Un 403 le diría a un atacante «ese id existe, pero no es tuyo». El 404 no filtra nada.

El controlador es donde se ve el estilo de Nest: decoradores para la ruta, la documentación OpenAPI y la inyección. Estos son los puntos clave (completo, son 80 líneas):

```ts
@ApiTags("Notes")
@ApiBearerAuth("BearerAuth")
@Controller("api/notes")
export class NotesController {
  constructor(private readonly notes: NotesService, private readonly audit: AuditService) {}

  @Get()
  @ApiOperation({ summary: "Listar mis notas", description: "Paginado; las fijadas salen primero." })
  async list(@Query() q: NoteQuery, @AuthUser() user: AuthUserPayload) {
    const { total, items } = await this.notes.list(user.userId, q);
    return { data: items, pagination: buildPagination(total, q.page, q.limit) };
  }

  @Post()
  async create(@Body() dto: CreateNoteRequest, @AuthUser() user: AuthUserPayload, @Req() req: Request) {
    const note = await this.notes.create(user.userId, dto);
    await this.audit.record(req, { userId: user.userId, action: "CREATE", entity: "Note", entityId: note.id });
    return { message: "Note created successfully.", data: note };
  }
  // PUT :id, DELETE :id y GET :id siguen el mismo patrón
}
```

Nota que **no hay ningún `@UseGuards`**. No hace falta: el guard de JWT es global y deny-by-default, así que un controlador nuevo nace protegido. Tampoco construyo el sobre de respuesta ni gestiono errores: devuelvo `{ data, pagination }` y el `TransformInterceptor` lo envuelve; lanzo `NotFoundException` y el filtro lo convierte al formato de error. Cada pieza hace una cosa, y yo solo escribo la que es mía.

El módulo, por último, tiene diez líneas:

```ts
@Module({ controllers: [NotesController], providers: [NotesService] })
export class NotesModule {}
```

Ni `PrismaService` ni `AuditService` aparecen en `imports`: son globales (ver la tabla de módulos).

### Paso 5: registrarlo (y el *gotcha* que me dio un 404)

En `app.module.ts` hay que hacer **dos** cosas, y la segunda es la que se olvida:

```ts
// 1) montar el módulo solo cuando hay autenticación
...(features.auth ? [SessionsModule, AuthModule, UsersModule, AuditLogsModule, NotesModule] : []),

// 2) añadir el prefijo al AuthPrefixMiddleware
...["api/sessions", "api/users", "api/tasks", "api/audit-logs", "api/notes"].flatMap(/* … */)
```

Lo primero es obvio: con `AUTH_MODE=none` las notas no tienen dueño, así que el módulo no se monta. Lo segundo no lo es. Escribí primero la prueba «una subruta inexistente sin token debe dar 401» y **falló con 404**:

```ts
await http().get("/api/notes").expect(401);
await http().get("/api/notes/a/b/c").expect(401);   // sin el prefijo en el middleware: 404
```

Sin el prefijo, la ruta `/api/notes` sí era 401 (el guard global la cubre), pero `/api/notes/a/b/c` daba 404 y, con ello, filtraba que `/api/notes` es un prefijo real. Añadí `"api/notes"` a la lista y pasó. **Esa es la trampa de la plantilla**: la protección profunda de rutas depende de una lista escrita a mano. Y, como cuento en la sección de pruebas, **en `758a0ac` ninguna prueba del repositorio la vigilaba**: si añadías un recurso y olvidabas la línea, nadie te avisaba. Mi prueba la convierte en una comprobación automática. El PR #8 añade `test/auth-prefix.middleware.spec.ts`, que recorre los cuatro prefijos originales, pero esa lista de la prueba también está escrita a mano: **un recurso nuevo sigue necesitando su propia comprobación**, como la mía.

### Paso 6: las pruebas

Las pruebas siguen el estilo del repositorio (`supertest` contra la aplicación real, sin simular la base de datos): `test/notes.spec.ts` tiene 7 casos y cubre lo que importa de este recurso. Estos son los casos, con los nombres tal como los escribí en el fichero:

```text
 PASS  test/notes.spec.ts
  notes: un recurso nuevo de punta a punta
    ✓ exige autenticación, también en subrutas que no existen
    ✓ valida el cuerpo y aplica los valores por defecto
    ✓ lista solo las notas propias, con las fijadas primero
    ✓ una nota ajena responde 404, igual que una que no existe
    ✓ un PUT parcial no pisa los campos que no envía y uno vacío da 400
    ✓ borrar y dejar rastro en la auditoría
    ✓ aparece en la especificación OpenAPI
```

La suite completa con mis 7 pruebas nuevas (sobre `758a0ac`, con las 62 de entonces):

```text
Test Suites: 10 passed, 10 total
Tests:       69 passed, 69 total
Time:        10.366 s
```

Es decir, **62 originales + 7 nuevas = 69**, en 10 segundos. La rama del PR #8 tiene hoy 84 pruebas en 13 suites; no repetí el tutorial encima de ella. Durante el desarrollo me encontré dos problemas más, ambos con su solución en «Problemas reales y cómo los resolví»: un 429 inesperado al registrar tantos usuarios de prueba desde la misma IP (el límite de registro es 5 por hora) y el `DATABASE_URL` exportado que rompía la suite (corregido en el PR #8).

### Paso 7: probarlo con curl

Levanté la copia con `node dist/main.js` y probé a mano. Respuestas reales (con los `meta` recortados):

```text
# crear (el título llega con espacios; Zod lo recorta y pone los valores por defecto)
POST /api/notes {"title":"  Idea para el viernes  ","pinned":true}
HTTP 201
{"success":true,"message":"Note created successfully.",
 "data":{"id":"19ba614d-fd75-46e4-826a-4e0129af2467","userId":"080ff971-…","title":"Idea para el viernes","body":"","pinned":true,
         "createdAt":"2026-10-24T06:33:58.922Z","updatedAt":"2026-10-24T06:33:58.922Z"},"meta":{…}}

# crear con datos inválidos (dos errores a la vez, agrupados por campo)
POST /api/notes {"title":"","body":123}
HTTP 400
{"success":false,"message":"Validation error in request payload",
 "errors":{"title":["Title is required"],"body":["Invalid input: expected string, received number"]},"meta":{…}}

# listar con limit=2 (3 notas en total: la fijada primero, luego la más reciente)
GET /api/notes?limit=2
HTTP 200
{"success":true,"data":[ {"title":"Idea para el viernes","pinned":true,…}, {"title":"Llamar al banco","pinned":false,…} ],
 "pagination":{"total":3,"page":1,"limit":2,"totalPages":2,"hasNextPage":true,"hasPrevPage":false},"meta":{…}}

# buscar
GET /api/notes?search=banco   →   ['Llamar al banco']  {total: 1, page: 1, limit: 10, totalPages: 1, …}

# PUT vacío
PUT /api/notes/<id> {}
HTTP 400
{"success":false,"message":"Validation error in request payload",
 "errors":{"_root":["Provide at least one field to update (title, body or pinned)"]},"meta":{…}}
```

### Paso 8: la documentación se actualiza sola

Sin tocar nada de OpenAPI, el documento pasó de **25 rutas y 31 operaciones a 27 rutas y 36 operaciones** (`/api/notes` y `/api/notes/{id}`), y de 18 a 20 esquemas (`CreateNoteRequest` y `UpdateNoteRequest`). Eso es lo mejor de generar la especificación desde el código: no hay un fichero que se te olvide.

⚠️

**Un fallo mío que el OpenAPI delata.** El esquema `noteQuerySchema` hereda `listParams`, que incluye `sort` e `include`. Aparecen en la documentación de `GET /api/notes`… pero **mi servicio no los implementa**: los ignora. La documentación generada promete algo que el código no cumple. Si reutilizas un esquema compartido, comprueba qué parámetros documentas de más. (En mi tutorial lo dejé así para mostrarlo; en un proyecto real o los implementas o usas `.omit()` en el esquema.)

### Lo que cuesta añadir un recurso

Contando solo lo que escribí: un modelo de 14 líneas, \~30 de esquemas Zod, 4 ficheros de código (DTO, servicio, controlador, módulo: unas 150 líneas en total), 3 líneas de registro y 108 de pruebas. Cada pieza tiene su sitio y ninguna es opcional. En Hono hay menos ceremonia (rutas y esquemas en un par de ficheros); aquí hay más ficheros, pero cada uno es predecible, y en un equipo grande eso es una ventaja: nadie discute dónde va cada cosa.

## Probar la API

### Las pruebas del repositorio (reales, en mi máquina)

| Qué                              | Resultado                                                                                                                                                            |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| npm run typecheck                | Limpio (\~2 s)                                                                                                                                                       |
| npm run lint                     | Limpio (\~2 s)                                                                                                                                                       |
| npm test (Jest 30)               | **84 pruebas en 13 suites, todas en verde**, en unos 12 s (en 758a0ac: 62 en 9 suites, 8–10 s)                                                                       |
| npm run test:cov                 | Cobertura: **67,21 %** sentencias, 53,27 % ramas, 63,34 % funciones, 67,03 % líneas (en 758a0ac: 66,38 / 51,64 / 63,05 / 66,14)                                      |
| npm run contract — modo full     | **95 pruebas, 0 fallos** (7,2 s)                                                                                                                                     |
| npm run contract — modo basic    | **76 pruebas, 0 fallos** (recuperación y MFA se saltan: no existen)                                                                                                  |
| npm run contract — modo none     | **32 pruebas, 0 fallos**                                                                                                                                             |
| bash scripts/bruno-smoke.sh      | **29 peticiones conformes, 0 inesperadas**; Users/Delete User se omite a propósito (borra usuarios demo). Medido el 24 de octubre en 758a0ac; no lo repetí con el PR |
| bash test-api.sh                 | Recorrido completo de sesiones y bloqueos con curl, en verde (24 de octubre, 758a0ac; no lo repetí con el PR)                                                        |
| npx prettier --check .           | **1 fichero** con formato distinto (.github/workflows/ci.yml, que el PR no toca); en 758a0ac eran 26                                                                 |
| npm audit y npm audit --omit=dev | **0 vulnerabilidades** las dos (en 758a0ac: 4 altas, todas de multer)                                                                                                |

Las 13 suites de Jest son `app`, `auth-prefix.middleware`, `concurrency`, `email-outbox`, `env-guards`, `lockout-key`, `logging`, `password`, `rate-limit-resilience`, `system-docs`, `test-isolation`, `totp` y `trust-proxy` (las cuatro que no estaban en `main` son `auth-prefix.middleware`, `logging`, `system-docs` y `test-isolation`). Me gusta que **la concurrencia tenga su propia suite**: comprueba que de varios refresh simultáneos gana exactamente uno, que la revocación gana a un refresh que corre a la vez, y que los contadores de rate limit son atómicos (admiten exactamente `max`). Y `test-isolation` fija que la suite nunca escribe en una base remota ni habla con un servidor de correo real.

El *harness* de contrato (`npm run contract`) arranca la app con una base de datos nueva por modo y ejecuta 11 escenarios HTTP (system, mounting, auth, sessions, tasks, users, audit, recovery, mfa, ratelimit, ratelimit-login) con un servidor SMTP de pruebas incluido. Fija los comportamientos concretos que tenía la plantilla de Hono: ids de la semilla, mensajes, códigos de estado. Es lo que da sentido al «paridad» del que habla el README.

⚠️

**La cobertura de Jest (67 %) es modesta** y las suites son pocas (84 pruebas, frente a las 148 de la plantilla de Hono en la rama de su PR #23). Lo compensan el *harness* de contrato y Bruno, que ejercitan la API real por HTTP, pero si vas a modificar mucho la lógica de negocio, **escribe tus propias pruebas**. Eso sí: `jest.config.js` fija **mínimos de cobertura por fichero** para las rutas críticas (guard de rate limit, bloqueo, `jwt-auth.guard`, hashing, TOTP…), una buena práctica.

💡

**Una trampa que me pasó a mí (corregida en el PR #8).** En `main`, el `setup-env.ts` de Jest usaba `process.env.DATABASE_URL ??= …`: si tenías `DATABASE_URL` exportado en tu shell, **los tests lo usaban** (y escribían en esa base). Yo lo tenía apuntando a una base de laboratorio ya usada y fallaron 21 de 62; al quitarlo, volvieron a pasar las 62\. El PR hace que el `setup-env.ts` **fuerce siempre una base SQLite temporal** y limpie también `TURSO_DATABASE_URL` y `SMTP_URL`, con una prueba que lo fija: lo comprobé exportando las tres variables y las 84 pruebas pasaron sin crear el fichero al que apuntaba `DATABASE_URL`. Si trabajas sobre `main`, haz `unset DATABASE_URL` antes de `npm test`.

### Si rompo el código, ¿lo notan las pruebas? Un experimento de mutaciones

Que una suite esté en verde no dice mucho; lo que importa es si se pondría en rojo ante un fallo. Así que hice un experimento sencillo, de los que se llaman **pruebas de mutación** hechas a mano: copié el repositorio, **rompí a propósito** once trozos de código críticos (de uno en uno, restaurando después cada uno), y ejecuté las dos suites que trae la plantilla: las 62 pruebas de Jest y el «arnés de contrato» (95 pruebas HTTP contra el servidor compilado). Cada mutante es una frase: «¿qué pasaría si alguien cambiara esto?». (El experimento es del 24 de octubre, sobre `758a0ac`; el único mutante que repetí sobre la rama del PR #8 es M8, más abajo.)

| #   | Mutación (lo que rompí)                                                 | Jest (62)                                         | Contrato (95)        |
| --- | ----------------------------------------------------------------------- | ------------------------------------------------- | -------------------- |
| M1  | Refresh: leer y luego escribir, en lugar del updateMany atómico         | **sobrevive**                                     | **sobrevive**        |
| M1b | Igual, con una espera de 25 ms entre leer y escribir (carrera ampliada) | **sobrevive**                                     | **sobrevive**        |
| M2  | Refresh: quitar la condición usedAt: null                               | detectado (1 fallo)                               | detectado (1 fallo)  |
| M3  | Guard: dejar de comprobar la sesión                                     | detectado (1 fallo)                               | no compila           |
| M4  | Limitador: contador de leer-modificar-escribir                          | **sobrevive**                                     | **sobrevive**        |
| M5  | TOTP: permitir reutilizar un código                                     | detectado (1 fallo)                               | **sobrevive**        |
| M6  | Login: el hash «falso» deja de ser un hash real                         | detectado (1 fallo)                               | **sobrevive**        |
| M7  | Env: aceptar el mismo secreto en los dos JWT                            | detectado (1 fallo)                               | **sobrevive**        |
| M8  | AuthPrefixMiddleware: quitar el prefijo api/tasks                       | **sobrevive** (con el PR #8 lo detecta: 2 fallos) | **sobrevive**        |
| M9  | Prisma: quitar el omit global de la contraseña                          | **sobrevive**                                     | detectado (5 fallos) |
| M10 | Bloqueo de cuenta: contar fallos pero no bloquear nunca                 | detectado (1 fallo)                               | no compila           |

*(«No compila»: TypeScript rechazó mi mutación antes de poder ejecutar el arnés. Es una defensa gratis, pero no una prueba de que la suite lo vería.)*

Lectura honesta del resultado: de 11 mutantes, **7 los detecta alguna de las dos suites y 4 sobreviven a las dos** (M1, M1b, M4 y M8; con el PR #8, M8 deja de sobrevivir). Y se ve algo interesante: las dos suites son **complementarias**. Jest atrapa fallos de lógica fina (TOTP, el hash falso, el entorno); el arnés de contrato atrapa lo que se ve desde fuera (que la contraseña no salga en las respuestas). Ninguna de las dos por sí sola vale: Jest se dejó pasar M9; el contrato se dejó pasar M5, M6 y M7.

Los supervivientes merecen un párrafo cada uno:

- **M8, el prefijo olvidado.** Es el fallo que me dio el 404 en el tutorial de «notas». Si alguien quita `api/tasks` de la lista del middleware, **ninguna prueba falla**, y a partir de ahí `GET /api/tasks/lo/que/sea` sin token devuelve 404 en vez de 401\. No hay explotación directa (no se expone ningún dato), pero sí un filtrado de qué rutas existen. La solución eran unas pocas líneas de prueba, y es lo primero que añadí en mi copia (mi prueba de «notas» ya lo hace para su prefijo). **El PR #8 incorpora esa prueba, y repetí M8 sobre la rama: quito `api/tasks` de la lista y ahora fallan 2 pruebas.** No repetí los otros diez mutantes.
- **M1 y M1b, la rotación no atómica.** Aquí está lo más instructivo. El código real usa un `updateMany` condicional, que es lo correcto. Pero cuando lo sustituí por «leer y luego escribir», ninguna suite lo notó, ni siquiera ampliando la carrera con 25 ms de espera. La razón es **SQLite**: con una base de datos embebida de un solo escritor, las transacciones interactivas se serializan y el código no atómico *parece* atómico. Dicho de otro modo: **la prueba de concurrencia de esta plantilla no puede distinguir el código correcto del incorrecto en SQLite**. Con Turso (varias instancias contra una base de datos remota) la carrera sí existiría. El código está bien; lo que falta es una prueba que lo demuestre en un entorno donde importe.
- **M4, el contador del limitador.** Sustituí el `updateMany` atómico por «leer el contador, sumar uno y escribir». Ninguna de las dos suites lo notó, por la misma razón que en M1: con un solo escritor la carrera no se materializa, y los tests de límite hacen peticiones secuenciales. El código real es correcto; lo que falta es una prueba que lo demuestre bajo concurrencia real.

⚠️

**Lo que NO prueba este experimento.** Son 11 mutaciones elegidas por mí, no un barrido automático (como Stryker), así que no es una «puntuación de mutación» formal. Elegí los puntos donde un fallo duele: autenticación, sesiones, 2FA, límites y secretos. Cuenta como una auditoría dirigida, no como una cobertura. Y la cobertura de líneas (no la medí) sería un número aparte.

Un último detalle de higiene, y una corrección de mi propio experimento: en una primera pasada del arnés de contrato, M2 y M4 mostraron decenas de fallos con `fetch failed`. No eran fallos de la plantilla ni detección real: yo mismo había matado con `pkill` el servidor de otra prueba que corría en paralelo en la misma máquina. Repetí **todos** los mutantes del arnés de contrato con la máquina en reposo y los números de la tabla son los de esa repetición limpia (M2 pasó de «22 fallos» a 1 y M4 de «detectado» a «sobrevive»). Lo cuento porque un experimento contaminado que no se corrige es peor que no hacerlo.

### La integración continua

El workflow «CI & Typecheck» ejecuta: `npm audit`, comprobación de que las migraciones coinciden con el esquema (`migrate diff`), *typecheck*, build, ESLint, construcción de la imagen Docker, escaneo con **Trivy** (falla con vulnerabilidades altas o críticas con arreglo disponible), un **SBOM** CycloneDX como artefacto, Jest con cobertura y la **matriz de contrato** (`full`/`basic`/`none`) más el humo de Bruno. Las acciones están fijadas por SHA y Dependabot revisa npm, Docker y Actions cada semana. El último run de `main` (`758a0ac`, el 23 de octubre a las 19:42, hora de Madrid) terminó en verde. La parte de **Docker y Trivy sí la ejecuté yo en local** (resultados en «Docker y despliegue»; adelanto: en `758a0ac` Trivy marcaba un `multer` alto que habría hecho fallar ese paso, y con el PR #8 sale limpio); el **SBOM** no lo generé, así que de ese paso me fío del historial de CI. El PR #8 **no toca ningún workflow** (el token con el que lo subí no tiene permiso `workflow`), así que la CI no se ha ejecutado con los cambios, sigue sin `prettier --check` y todo lo anterior lo comprobé en local.

## Lo que he atacado (y qué pasó)

Todo lo que sigue lo lancé **solo contra mi instancia local** (base de datos de laboratorio, puertos de loopback). Es una batería con *scripts* propios (`fetch` de Node), no una herramienta de pentesting, y no sustituye una auditoría; pero cubre lo que más suele romperse:

| Ataque                                                                                                                         | Resultado                                                                  |
| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| JWT manipulado: role=admin sin resignar, alg=none, secreto equivocado, caducado, firma alterada, Bearer vacío, esquema Basic   | **401** en todos                                                           |
| Reuso de refresh (inmediato, concurrente ×3, tras 11 s)                                                                        | 409 · 200/409/409 · 401 con sesión revocada y auditoría                    |
| Fuerza bruta de login (6 intentos, correcto incluido)                                                                          | **429** a partir del 6º, Retry-After: 900                                  |
| Rotar X-Forwarded-For, CF-Connecting-IP, X-Real-IP con TRUST\_PROXY=false                                                      | Siguen contando como la misma IP → 429                                     |
| Inyección SQL (' OR '1'='1, ' OR 1=1; DROP TABLE User; --…) en el login, en search/filter/sort/completed y en el id de la ruta | 401/400/404, las tablas intactas                                           |
| Filtrar por un campo no permitido; pedir password con include                                                                  | El filtro se ignora; el hash no sale                                       |
| Asignación masiva (rol en registro y PUT, userId/id/deletedAt en tareas)                                                       | Ignorado en todos los casos                                                |
| Un título con <script> y SQL                                                                                                   | Se guarda tal cual: es una API JSON, escapar al pintar es cosa del cliente |
| Acceso a rutas de admin / a recursos ajenos con un usuario normal                                                              | 403 / 404                                                                  |
| Cuerpo de 110 KB; JSON mal formado                                                                                             | **413** / **400** en el sobre estándar                                     |
| Revocación inmediata (logout, revoke-all, bloquear al usuario, último admin)                                                   | La siguiente petición ya da 401 / 403; el último admin está protegido      |

Estos son los números del `TRUST_PROXY` real (14 logins con emails distintos, `TRUST_PROXY=true`, `TRUST_PROXY_HOPS=1`), que es donde más suelen equivocarse las plantillas:

| Escenario                                                                                | Resultado                                                                                                                                   |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Cliente directo que rota X-Forwarded-For en cada petición (sin proxy que reescriba)      | **Eludía el límite** (todos 401): es la advertencia de la documentación, la red debe estar cerrada y el proxy debe sobrescribir la cabecera |
| Proxy que **añade** su IP (falsa-i, 203.0.113.9)                                         | 429 a partir del 11º: se lee la IP de la derecha                                                                                            |
| Proxy que sobrescribe X-Forwarded-For y recibe CF-Connecting-IP y X-Real-IP falsificadas | 429 a partir del 11º: esas cabeceras **se ignoran**                                                                                         |

Es el mismo problema de la IP real que anoté en [la plantilla de Hono](https://blog.devutilites.com/hono-prisma-production-starter/); aquí `TRUST_PROXY_HOPS` es un número y solo se lee `X-Forwarded-For` desde la derecha. La parte de «mi red tiene que estar cerrada» sigue siendo responsabilidad tuya.

### Segunda tanda: asignación masiva, tipos, tokens falsos y rutas

Con la primera tanda cubrí fuerza bruta, inyección, JWT y cabeceras. En la segunda fui a por lo que un revisor de seguridad probaría después. Todo contra mi instancia local (nada fuera de mi máquina), con respuestas reales:

| Prueba                                                                         | Resultado                                                                                           |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| Registro con \_\_proto\_\_, constructor.prototype y role=admin                 | **201** pero role=user; un registro posterior sigue siendo normal (sin contaminación de prototipos) |
| Esa cuenta recién creada hace GET /api/users                                   | **403** («requires administrator privileges»)                                                       |
| PUT de una tarea con \_\_proto\_\_ y un userId ajeno                           | 200, pero el userId sigue siendo el mío                                                             |
| Login con Content-Type: text/plain                                             | **400**                                                                                             |
| Login con formulario application/x-www-form-urlencoded                         | **200** (¡acepta formularios!)                                                                      |
| JSON con 90 000 corchetes anidados (<100 KB)                                   | 400 («Malformed JSON»), sin colgar el servidor                                                      |
| Objeto anidado a 5 000 niveles                                                 | 401 (se trata como credenciales inválidas)                                                          |
| Query con search de 20 000 caracteres / 2 000 parámetros repetidos             | **431** (cabeceras demasiado grandes)                                                               |
| Título de 101 caracteres                                                       | 400                                                                                                 |
| Contraseña de 76 bytes (19 caracteres de 4 bytes)                              | 400 («Password cannot exceed 72 bytes (accented characters and emoji take more than one)»)          |
| Registrar el mismo email con otras mayúsculas / con espacios                   | 409 / 409 (se normaliza)                                                                            |
| 12 registros simultáneos del mismo email                                       | {201: 1, 409: 11}                                                                                   |
| Refresh token usado como access token, y al revés                              | 401 / 401                                                                                           |
| Token con la **firma válida** pero algoritmo HS512                             | 401                                                                                                 |
| Token firmado con la clave **real** y role=admin, sesión de un usuario normal  | **403** (el rol sale de la base de datos)                                                           |
| Firma válida con un sessionId inexistente / sin sessionId                      | 401 / 401                                                                                           |
| PATCH /api/tasks, DELETE /api/auth/login, GET /api/auth/login                  | 404 en las tres (el mismo sobre de error)                                                           |
| GET /.env, /dist/main.js, /prisma/schema.prisma, /docs/%2e%2e/.env y similares | 404 en todas                                                                                        |
| X-Request-Id con <script> / de 500 caracteres                                  | se ignora y se genera un UUID (36 caracteres)                                                       |
| X-Request-Id: ok-id-123                                                        | se respeta tal cual                                                                                 |

Lo que más me gusta de esta tabla es la fila del token «firmado con la clave real»: aunque un atacante consiguiera el secreto JWT y fabricara un token con `role=admin`, **no obtendría privilegios**, porque la plantilla ignora el rol del token y lo lee de la base de datos en cada petición. Ese es el coste de dos consultas por petición que mencioné antes, y aquí se ve lo que compra.

⚠️

**Un hallazgo menor, el formulario urlencoded.** `POST /api/auth/login` acepta `application/x-www-form-urlencoded` además de JSON (lo comprobé con `curl -d 'email=…&password=…'`: **200**). No es una vulnerabilidad por sí misma, pero amplía la superficie: un formulario HTML en otro dominio puede enviar un `POST` de este tipo sin una petición previa de CORS (*preflight*). La API no usa cookies, así que no hay CSRF clásico (el token va en una cabecera que el navegador no añade solo), pero si algún día añadieras sesiones por cookie, esto sería lo primero que vigilar. Lo apunto como «bajo» y con una solución trivial: rechazar cualquier `Content-Type` que no sea JSON.

## Endurecimiento de seguridad: lo que hay y lo que falta

Resumen de lo que hay, todo comprobado salvo lo marcado:

- **Cabeceras** (helmet + propias), verificadas en una respuesta: `Content-Security-Policy`, `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Opener-Policy` y `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, más `X-Request-Id` y `Server-Timing`.
- **Fail closed** en configuración, migraciones y formatos de hash desconocidos (visto arriba).
- **Secretos**: JWT distintos, de 32+ caracteres en producción; clave de MFA dedicada y rotable.
- **Datos personales** en logs de seguridad: el email se seudonimiza; las contraseñas y TOTP nunca salen en consultas (`omit`).
- **CORS** por lista en producción; en desarrollo es `*`, y lo verifiqué (`Access-Control-Allow-Origin: *`); en producción solo respondió al origen configurado.
- **Documentación apagada** en producción (`/docs` y `/openapi.json` → 404).
- **Contenedor** (probado, ver «Docker y despliegue»): usuario `node`, sistema de ficheros de solo lectura, `cap_drop: ALL`, `no-new-privileges`, 512 MB y 1 CPU, puerto publicado solo en `127.0.0.1`.
- **Cadena de suministro** en CI: base fijada por *digest*, acciones fijadas por SHA, `npm audit` (0 avisos en la rama del PR #8), Trivy (lo ejecuté en local; 0 altos o críticos con arreglo) y SBOM (sin probar localmente).
- **Copias de seguridad cifradas**: el ensayo `ops-rehearsal.sh backup-restore` hace un `VACUUM INTO`, cifra el resultado con **age** (ChaCha20-Poly1305), comprueba que un fichero manipulado **se rechaza**, restaura y verifica que el login y el grupo `system` del contrato funcionan sobre lo restaurado. Lo ejecuté tras instalar `age` 1.2.1 y pasó entero.

Esto es lo que devuelve `GET /healthz` en una instancia local (recortado):

```text
HTTP/1.1 200 OK
X-Request-Id: 548dad19-62a2-4dec-b324-9f1c925eb14e
Content-Security-Policy: default-src 'self';base-uri 'self';…;script-src 'self';…;upgrade-insecure-requests
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Cache-Control: no-store
Access-Control-Allow-Origin: *        # modo desarrollo
Server-Timing: total;dur=1.5

{"status":"healthy","timestamp":"…","uptimeSeconds":1,"database":{"status":"connected","latencyMs":0}}
```

Y lo que **no** hace (o hace con matices) está en «Lo que queda por pulir» más abajo.

## Docker y despliegue

🧪

**Probado de verdad.** Esta vez sí tengo Docker en mi máquina de pruebas: construí la imagen sin caché, la ejecuté con las opciones del compose, levanté el propio `docker-compose.yml`, vi pasar el `HEALTHCHECK` y la escaneé con Trivy. Lo hice primero con `758a0ac` (24 de octubre) y, tras las correcciones, con la rama del PR #8 (25 de octubre). Primero describo los ficheros (los del PR) y luego, en «Lo que pasó cuando la construí y la ejecuté de verdad», están los resultados, incluidos los fallos.

### El `Dockerfile`

Dos etapas sobre `node:24-alpine` fijada por *digest* (en `main` era `node:25-alpine`, una línea sin LTS; el PR #8 vuelve a 24, que coincide con `engines`, con la CI y con `AGENTS.md`): la primera instala dependencias, genera el cliente de Prisma y compila; la segunda instala solo producción (`npm ci --omit=dev --ignore-scripts`, reconstruyendo únicamente los paquetes de Prisma que necesitan descargar su motor, y borrando `typescript`, que entra como *peer* opcional de Prisma), **borra de la imagen final `npm`, `npx`, `corepack` y `yarn`** (menos superficie, y se van las dependencias que npm vendoriza consigo mismo, con CVEs propios; en `main` solo se borraban `npm` y `npx`), copia `dist` y el cliente generado, y corre como usuario `node`. Como ya no hay `npx` en la imagen, el `docker-entrypoint.sh` ejecuta la CLI de Prisma directamente con `node node_modules/prisma/build/index.js`, y **valida el entorno con el propio `loadEnv()` de la aplicación antes de migrar**:

```dockerfile
ENV DATABASE_URL=file:/app/data/prod.db
…
RUN npm ci --omit=dev --ignore-scripts \
  && npm rebuild @prisma/engines prisma \
  && rm -rf node_modules/typescript node_modules/@typescript \
  && npm cache clean --force
…
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn-* \
  && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
USER node
…
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD wget -qO- "http://127.0.0.1:${PORT}/healthz" >/dev/null || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]
```

```sh
# docker-entrypoint.sh (fragmento): primero validar, después migrar
node -e 'require("./dist/config/env.js").loadEnv()'
PRISMA="node node_modules/prisma/build/index.js"
$PRISMA migrate deploy
```

### `docker-compose.yml`

```yaml
ports:
  - "127.0.0.1:3011:3011"      # nunca en todas las interfaces
read_only: true
tmpfs: [ /tmp ]
security_opt: [ "no-new-privileges:true" ]
cap_drop: [ ALL ]
mem_limit: 512m
cpus: "1.0"
volumes:
  - sqlite_data:/app/data      # el fichero SQLite vive aquí
```

Los secretos se leen del entorno con `${JWT_SECRET:?…}`: si faltan, `docker compose` ni arranca.

### Lo que pasó cuando la construí y la ejecuté de verdad

Instalé Docker 26.1.5 en mi máquina de pruebas (con `dockerd` lanzado a mano, sin systemd), `docker-compose` 2.26.1 y Trivy 0.76.1, y lo repetí todo con el commit `758a0ac` (24 de octubre) y otra vez, tras las correcciones, con la rama del PR #8 (25 de octubre). Estas son las medidas de la rama del PR, con lo que medí en `758a0ac` entre paréntesis:

| Qué                                                                | Resultado                                                                                                                                                                                                                                     |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Construcción sin caché                                             | **19,7 s** (antes, 20,2 s)                                                                                                                                                                                                                    |
| Tamaño de la imagen                                                | **570 MB** (antes, 719 MB): la capa de npm ci --omit=dev pesa 396 MB (antes, 493 MB) y dist/ ocupa unos 700 kB; el node\_modules de producción sigue en unos 424 MiB por la CLI de Prisma y sus motores                                       |
| Base                                                               | Alpine 3.24.3 con **Node v24.22.1** (antes, Alpine 3.23.4 con Node v25.9.0); el proceso corre como uid=1000(node)                                                                                                                             |
| Arranque hasta /healthz 200 (migraciones incluidas, volumen vacío) | unos 3–4 s (3,6 s con docker run, contando el arranque del contenedor)                                                                                                                                                                        |
| Raíz de solo lectura                                               | touch /app/x → Read-only file system; /tmp y /app/data sí son escribibles                                                                                                                                                                     |
| Privilegios                                                        | CapEff: 0000000000000000 y NoNewPrivs: 1 dentro del contenedor                                                                                                                                                                                |
| Sin npm ni compilador                                              | which npm npx corepack yarn tsc dentro del contenedor no encuentra ninguno                                                                                                                                                                    |
| HEALTHCHECK                                                        | healthy (el script scripts/docker-smoke.sh del PR lo espera y pasa entero; antes, starting a los 5 s y healthy a los 40 s)                                                                                                                    |
| /docs y /openapi.json en producción                                | 404 los dos                                                                                                                                                                                                                                   |
| Parada con docker stop                                             | **156 ms**, código de salida 0, y el log dice Received SIGTERM: closing application... (antes, 73 ms: son medidas únicas con docker stop y no sé atribuir la diferencia; la aplicación en sí sale en unos 40 ms, ver «Rendimiento y consumo») |
| Persistencia                                                       | Recreé el contenedor con el mismo volumen: No pending migrations to apply., el sembrado de demo desactivado y el login del administrador funciona (medido en 758a0ac; no lo repetí con el PR)                                                 |

En `758a0ac` lo ejecuté con las mismas opciones del `docker-compose.yml` (`--read-only`, `--tmpfs /tmp`, `--cap-drop ALL`, `no-new-privileges`, 512 MB, 1 CPU, volumen en `/app/data`) y **después con el propio `docker-compose up -d`**: el contenedor quedó `Up … 127.0.0.1:3011->3011/tcp`, y `docker inspect` confirmó `ReadonlyRootfs=true`, `CapDrop=[ALL]`, memoria 536.870.912 bytes, 1 CPU y `no-new-privileges:true`. Sobre la rama del PR repetí la ejecución con esas opciones (`docker run`) y el `scripts/docker-smoke.sh`; no volví a levantar el `docker-compose`, porque el PR no toca el de Nest. Un detalle útil: `docker compose` lee **automáticamente el `.env` del directorio**, así que para comprobar que sin variables no arranca tuve que usar `--env-file /dev/null`; entonces sí:

```text
error while interpolating services.api.environment.[]: required variable JWT_SECRET is missing a value: Define JWT_SECRET en el entorno o en .env
```

Mi primer intento de arrancar el contenedor (en `758a0ac`) falló por una razón instructiva: las variables no llegaron al contenedor (un despiste mío con `sudo`) y el log fue este (recortado):

```text
🚀 Aplicando migraciones de base de datos...
SQLite database prod.db created at file:/app/data/prod.db
10 migrations found in prisma/migrations
Applying migration `0_init`
…
Applying migration `20261023090922_email_outbox`
⚡ Iniciando servidor NestJS...
❌ Critical startup failure: Invalid or missing environment configuration:
{
  "JWT_SECRET": [ "JWT_SECRET is required unless AUTH_MODE=none (…)", "JWT_SECRET must contain at least 32 characters in production (…)" ],
  "MFA_ENCRYPTION_KEY": [ "MFA_ENCRYPTION_KEY (>= 32 chars) is required in production with AUTH_MODE=full so TOTP secrets are encrypted with a dedicated, stable key (…)" ]
}
```

Dos lecturas: el fallo es **claro y a prueba de errores** (dice qué variable falta y cómo generarla), pero fíjate en el orden: **en `main` las migraciones se aplican antes de validar el entorno**, porque el script de entrada migra y solo después arranca Node. Con un despliegue mal configurado la base de datos ya se ha modificado cuando la aplicación se niega a arrancar. Lo comprobé después con un volumen vacío y sin secretos: el contenedor salía con código 1 y dejaba un `prod.db` de **192 KB** con las migraciones aplicadas.

**El PR #8 invierte el orden.** El entrypoint ejecuta primero el `loadEnv()` de la aplicación. Repetí la prueba con un volumen vacío y sin secretos: el contenedor sale con código 1 y el mismo mensaje, **pero ya no hay migraciones ni `prod.db`: el volumen sigue vacío**.

```text
❌ Critical startup failure: Invalid or missing environment configuration:
{
  "JWT_SECRET": [ "JWT_SECRET is required unless AUTH_MODE=none (…)", "JWT_SECRET must contain at least 32 characters in production (…)" ],
  "JWT_REFRESH_SECRET": [ … ],
  "MFA_ENCRYPTION_KEY": [ … ]
}
```

🧪

**Trivy: de 1 alto a 0.** En `758a0ac`, Trivy 0.76.1 sobre `nestjs-starter:lab` daba el sistema operativo (Alpine 3.23.4) **limpio (0)** y **1 HIGH en las dependencias de Node: `multer` 2.4.0**, con arreglo en 2.5.0 (denegación de servicio por una petición abortada durante el filtrado asíncrono de ficheros; es el mismo paquete que marca `npm audit`). Con el PR #8 (`overrides` de `multer`, Node 24, imagen reconstruida) el mismo comando, con los parámetros del workflow (`--ignore-unfixed --severity HIGH,CRITICAL`), devuelve **0**: Alpine 3.24.3 con 0 y las dependencias de Node con 0\. Ojo con lo que significa: `--ignore-unfixed` esconde lo que todavía no tiene arreglo. El workflow de CI no ha cambiado (`exit-code: '1'`), así que **por lo que dice el fichero, ese paso ahora pasaría**; no he ejecutado la CI de GitHub, esto es el escáner en local más la lectura del workflow.

Dos cosas más que solo se ven al ejecutarla en contenedor:

- **Los logs ya no salen con colores ANSI en producción (corregido en el PR #8).** En `758a0ac`, dentro del contenedor `ConsoleLogger` pintaba con códigos de escape si no se le decía lo contrario: sin `NO_COLOR`, **62 líneas** del arranque contenían secuencias `ESC`; con `-e NO_COLOR=1`, **0**. En la imagen del PR, sin ninguna variable, conté **0 líneas con `ESC` de 110** (`docker logs`). Si usas `main`, pon `NO_COLOR=1` en tu entorno; los logs siguen siendo texto, no JSON.
- **El consumo en contenedor se mide de dos maneras.** En `758a0ac`, con la aplicación en reposo, `docker stats` marcaba **79,4 MiB** y `VmRSS` dentro del contenedor **148.020 kB (≈144,6 MiB)**. Con la imagen del PR (Node 24.22.1) medí **93,8 MiB** y **160.880 kB (≈157 MiB)**, unos segundos después de arrancar y de hacer un login; no sé cuánto de la subida es de la versión de Node y cuánto de cuándo se mide, y no repetí la medida con el mismo protocolo. No son medidas contradictorias: `docker stats` descuenta la caché de ficheros del cgroup y `VmRSS` cuenta las páginas residentes, incluidas las que vienen del binario de Node mapeado (esta explicación es mía, no la he verificado con el cgroup). Para comparar con otra plantilla, usa **siempre la misma medida**.

Quedan **sin probar** la generación real del SBOM y el despliegue en Coolify (más abajo).

### Desplegar en Coolify

Los pasos serían los mismos que con la plantilla de Hono (te los conté en [el artículo de Coolify](https://blog.devutilites.com/coolify-paas-autoalojado/)): crear la aplicación desde el repositorio con el *build pack* de **Dockerfile**, puerto **3011**, un **volumen persistente en `/app/data`** (sin él pierdes la base de datos en cada despliegue), y estas variables:

```bash
NODE_ENV=production
AUTH_MODE=full
JWT_SECRET=…                 # openssl rand -base64 48
JWT_REFRESH_SECRET=…         # otro distinto
MFA_ENCRYPTION_KEY=…         # otro más, y no lo pierdas
CORS_ORIGINS=https://app.tudominio.com
ADMIN_EMAIL=tu@correo.com
ADMIN_PASSWORD=…             # solo se usa si la BD está vacía
TRUST_PROXY=true
TRUST_PROXY_HOPS=1           # Traefik; 2 si hay Cloudflare delante de Traefik
MAIL_TRANSPORT=smtp
SMTP_URL=smtps://usuario:clave@smtp.tudominio.com:465
APP_URL=https://api.tudominio.com
```

⚠️

**Sin probar: el despliegue real en Coolify.** No lo he hecho en esta ronda. Las variables de arriba salen del `.env.example` y de las reglas de `env.ts` que sí he ejercitado (por ejemplo, que `MFA_ENCRYPTION_KEY` es obligatoria y `CORS_ORIGINS` no puede ser `*`). Y recuerda lo del proxy: `TRUST_PROXY=true` solo es seguro si **no se puede llegar a la app saltándose el proxy** y el proxy sobrescribe `X-Forwarded-For`.

Si publicas detrás de Cloudflare, te interesarán [Cloudflare Tunnel a fondo](https://blog.devutilites.com/cloudflare-tunnel-a-fondo/) (así no abres ningún puerto) y [WAF y DDoS a fondo](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/) como segunda línea delante de los límites de la propia API.

## Lista de comprobación antes de salir a producción

Esta es la lista que yo seguiría con la plantilla, ordenada por «lo que más duele si se olvida». En la última columna te digo **cómo lo sé**: *probado* (lo ejecuté), *código* (lo deduzco leyendo los ficheros) o *sin probar*.

| #  | Qué hacer                                                                                                                                                                               | Por qué                                                                                                                                      | Cómo lo sé                                                                                        |
| -- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 1  | Generar **tres secretos distintos** (JWT\_SECRET, JWT\_REFRESH\_SECRET, MFA\_ENCRYPTION\_KEY) con openssl rand -base64 48 y **guardar la clave de MFA fuera del servidor**              | Sin la clave de MFA no se pueden descifrar los secretos TOTP de los usuarios                                                                 | Probado: el arranque falla sin ellos; el cifrado y la rotación, con el ensayo                     |
| 2  | Montar un **volumen persistente** en /app/data                                                                                                                                          | Sin volumen pierdes la base SQLite en cada despliegue                                                                                        | Probado con volumen de Docker                                                                     |
| 3  | Programar **copias cifradas** con ops-rehearsal.sh backup-restore (o tu propio VACUUM INTO \+ age) y **ensayar la restauración**                                                        | Una copia que nunca has restaurado no es una copia                                                                                           | Probado (el ensayo pasa entero)                                                                   |
| 4  | Fijar CORS\_ORIGINS a tus dominios (no \*)                                                                                                                                              | En producción la configuración rechaza \*                                                                                                    | Probado                                                                                           |
| 5  | Si hay proxy: TRUST\_PROXY=true y TRUST\_PROXY\_HOPS con el número exacto de saltos; que el proxy **sobrescriba** X-Forwarded-For y que no se pueda llegar a la app saltándose el proxy | Si no, el rate limit y el bloqueo ven la IP del proxy (o una falsificada)                                                                    | Probado en local con un proxy de prueba                                                           |
| 6  | Poner MAIL\_TRANSPORT=smtp y un SMTP\_URL real, y APP\_URL con tu dominio público                                                                                                       | Con el transporte none la recuperación de contraseña y la verificación **no entregan nada** (el correo falla definitivamente)                | Probado con el transporte none; SMTP real **sin probar**                                          |
| 7  | Mantener el overrides de multer (^2.5.0) hasta que Nest lo incluya                                                                                                                      | npm audit y Trivy lo marcaban como alto                                                                                                      | Probado: con el override, 0 avisos y 0 en Trivy. Está en el PR #8; en main tienes que añadirlo tú |
| 8  | En main: NO\_COLOR=1 en el entorno del contenedor                                                                                                                                       | Logs sin secuencias ANSI (el PR #8 ya lo hace en producción)                                                                                 | Probado                                                                                           |
| 9  | Añadir un **límite global** de peticiones en tu proxy o en Cloudflare                                                                                                                   | El rate limit de la plantilla solo cubre auth, recuperación y MFA; /api/tasks no tiene                                                       | Probado (lectura y ataques)                                                                       |
| 10 | Decidir si expones /healthz fuera de tu red                                                                                                                                             | Es público y devuelve *uptime* y latencia de la base de datos: útil para el orquestador, pero informa a cualquiera                           | Probado                                                                                           |
| 11 | Comprobar que la versión de Node de la imagen, la de la CI y engines coinciden                                                                                                          | En main no coincidían (imagen en 25, CI en 24); el PR #8 las alinea en 24                                                                    | Probado (versiones)                                                                               |
| 12 | Cambiar la contraseña del administrador inicial y **quitar ADMIN\_PASSWORD del entorno** tras el primer arranque                                                                        | Solo se usa si la base está vacía                                                                                                            | Código (y el arranque con volumen vacío lo creó)                                                  |
| 13 | Revisar qué haces con /api/auth/login aceptando también application/x-www-form-urlencoded                                                                                               | Ver la fila 12 de «Lo que queda por pulir»                                                                                                   | Probado                                                                                           |
| 14 | Si vas a tener **varias instancias**, probar el rate limit en base de datos y el *outbox* con tu base remota (Turso) antes de fiarte                                                    | Pensado para ello, no medido                                                                                                                 | **Sin probar**                                                                                    |
| 15 | Antes de desplegar una imagen nueva, ejecutar scripts/docker-smoke.sh <imagen>                                                                                                          | Arranca con el sistema de ficheros de solo lectura, migra, comprueba el login del administrador, el HEALTHCHECK y el usuario sin privilegios | Probado (solo está en el PR #8)                                                                   |

Hay una más que no cabe en una tabla: **lee `AGENTS.md`** (unas 500 líneas). Es la mejor documentación del repositorio, mejor que el README, y recoge el porqué de cada decisión de seguridad.

## Rendimiento y consumo

Medí las dos plantillas el 25 de octubre en la misma máquina, compiladas (`dist/`), con `LOG_LEVEL=error`, bases SQLite nuevas y `autocannon` (20 conexiones, 8 s), **tres rondas alternadas cada una**, sobre las ramas de los dos PR (Nest `623e447`, Hono `fc44204`) y con Node 24.21.0\. Es una referencia orientativa: la máquina es compartida y esto no es un banco de pruebas serio. La primera ronda de NestJS salió más lenta en el login (55 ms de mediana en caliente, frente a 28 y 33 en las otras dos) y no sé por qué, así que doy las tres.

|                              | NestJS (3 rondas)                 | Hono (3 rondas)                   |
| ---------------------------- | --------------------------------- | --------------------------------- |
| Arranque hasta /healthz 200  | 909 / 883 / 882 ms                | 626 / 642 / 677 ms                |
| Memoria en reposo (RSS)      | 186 / 188 / 188 MiB               | 154 / 153 / 154 MiB               |
| Memoria tras la carga        | 442 / 439 / 437 MiB               | 374 / 384 / 392 MiB               |
| Primer login (en frío)       | 203 / 148 / 173 ms                | 310 / 300 / 334 ms                |
| Login (mediana, en caliente) | 55 / 28 / 33 ms                   | 288 / 283 / 305 ms                |
| GET /healthz                 | 5.444 / 6.011 / 6.060 req/s       | 3.859 / 4.174 / 3.994 req/s       |
| GET /api/tasks autenticado   | 930 / 904 / 876 req/s (≈21–22 ms) | 756 / 742 / 772 req/s (≈25–26 ms) |
| SIGTERM → salida             | 42 / 43 / 34 ms                   | 45 / 28 / 53 ms                   |

(En `758a0ac` y `644a5d2`, el 24 de octubre y con dos rondas, salió algo parecido: arranque 826 / 700 ms frente a 657 / 670 ms, RSS en reposo 186 / 189 frente a 153 / 150 MiB y `/api/tasks` a 904 / 878 frente a 758 / 712 req/s.)

La lectura honesta: **no hay diferencias que cambien una decisión**. NestJS consume unos 34 MiB más en reposo y arranca unos 240 ms más tarde; en rendimiento bruto de rutas va por delante en esta prueba (unas 900 frente a unas 750 req/s en `/api/tasks`). La diferencia grande en login, de **unas 10 veces**, es **el algoritmo de hash**, no el framework (bcryptjs a 12 rondas frente a Argon2id a los mínimos de OWASP), así que no es una comparación de frameworks.

## Hono o NestJS: cuándo elegir cuál

Son dos plantillas con **el mismo contrato HTTP** y distinta filosofía. Estos son los datos reales de cada repositorio el 25 de octubre, en la rama de su PR de correcciones (Hono en `fc44204`, [PR #23](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com); NestJS en `623e447`, [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com)). Los dos están ya fusionados en `main` (el 25 de octubre: Hono a las 12:54, NestJS a las 12:55, hora de Madrid; el árbol de Git de `main` es idéntico al de cada rama). Antes de la fusión, `main` estaba en `644a5d2` (Hono, 7 de octubre) y `758a0ac` (NestJS, 23 de octubre):

|                                                    | **Hono + Prisma 7**                                                                                                                                                               | **NestJS 12 + Prisma 7**                                                                                                                                                 |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Código de src/ (sin generado)                      | 5.303 líneas                                                                                                                                                                      | 6.622 líneas                                                                                                                                                             |
| Pruebas                                            | **148** en 18 ficheros (Vitest)                                                                                                                                                   | **84** en 13 suites (Jest) + contrato (95/76/32) + Bruno                                                                                                                 |
| Hash de contraseñas                                | bcryptjs, 12 rondas (\~300 ms por login)                                                                                                                                          | Argon2id (m=19 MiB, t=2), login de \~20–30 ms; bcrypt solo para migrar                                                                                                   |
| Logs                                               | JSON con pino                                                                                                                                                                     | Texto de ConsoleLogger (no JSON; sin colores ANSI en producción desde el PR #8)                                                                                          |
| Rutas + validación + docs                          | Una sola declaración (@hono/zod-openapi)                                                                                                                                          | Controlador + Zod + decoradores de Swagger                                                                                                                               |
| Cliente tipado para el front                       | hc<AppType>, sin generar código                                                                                                                                                   | No                                                                                                                                                                       |
| 2FA, recuperación, modos de auth, rate limit en BD | Sí                                                                                                                                                                                | Sí                                                                                                                                                                       |
| Cola de correo persistente (EmailOutbox)           | No                                                                                                                                                                                | Sí                                                                                                                                                                       |
| Rotación de la clave de cifrado de MFA             | No                                                                                                                                                                                | Sí (MFA\_ENCRYPTION\_KEY\_PREVIOUS)                                                                                                                                      |
| Docker                                             | node:24-alpine fijada por *digest*, sin npm ni corepack en la imagen, read\_only y cap\_drop en el compose (imagen de 547 MB; la construí y la ejecuté con \--read-only; Trivy 0) | node:24-alpine fijada por *digest*, sin npm ni corepack en la imagen, read\_only, cap\_drop, Trivy y SBOM en la CI (imagen de 570 MB; la construí y la ejecuté; Trivy 0) |
| Arranque / RSS en reposo                           | \~640 ms / \~154 MiB                                                                                                                                                              | \~880 ms / \~188 MiB                                                                                                                                                     |
| npm audit hoy                                      | 0 avisos (en main: 9, 3 altos)                                                                                                                                                    | 0 avisos (en main: 4 altos, todos de multer)                                                                                                                             |
| Dónde corre                                        | Node, y Hono en sí corre en Workers, Bun, Deno…                                                                                                                                   | Solo Node                                                                                                                                                                |

(Un aviso: en el [artículo de Hono](https://blog.devutilites.com/hono-prisma-production-starter/) conté 60 pruebas y Node 20/22; el repositorio ha seguido creciendo desde entonces: `main` tiene 144 pruebas y la rama de su PR #23, 148\. Los números de la tabla son los del repositorio, no los del artículo.)

**Elige Hono** si quieres lo más pequeño y fácil de leer, un cliente tipado de extremo a extremo para tu frontend, logs JSON de serie, o si te interesa poder mover el código a **otros entornos de ejecución** (Workers, Bun…). Te lo conté con detalle en [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/).

**Elige NestJS** si tu equipo ya trabaja con Nest o va a crecer (módulos, inyección de dependencias, convenciones que todo el mundo reconoce), si prefieres que cada responsabilidad (guard, pipe, interceptor, filtro) tenga un sitio, o si valoras lo operativo que trae esta plantilla (cola de correo, rotación de claves, ensayos de copia de seguridad, contenedor endurecido). El precio es más código, más «ceremonia» y un arranque algo más pesado.

**Y si dudas entre más opciones**, [Express a fondo](https://blog.devutilites.com/express-a-fondo/), [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/) y [la comparativa de los cuatro](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/) te dan el contexto de por qué existen las dos plantillas.

## Problemas reales y cómo los resolví

Esta es la sección que más me habría gustado encontrar antes de empezar. Son los tropiezos que tuve de verdad al trabajar con la plantilla, con el síntoma exacto (para que lo encuentres si te pasa) y la solución que funcionó.

| #  | Síntoma                                                                                         | Causa                                                                                                                                          | Solución                                                                                                                                                                                                               |
| -- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1  | npx jest falla con Must use import to load ES Module: …/node\_modules/@nestjs/testing/index.js  | La suite usa módulos ES y necesita las opciones de Node que lleva el script de npm test                                                        | Usa **npm test** (ejecuta node --experimental-vm-modules … jest.js), no npx jest                                                                                                                                       |
| 2  | En main, con una base de datos usada, **21 de las 62 pruebas fallan**                           | test/setup-env.ts hace process.env.DATABASE\_URL ??= …: si lo tienes exportado, la suite **usa tu base** en lugar de la temporal               | unset DATABASE\_URL antes de npm test (y exporta DATABASE\_URL solo para los comandos de Prisma). Corregido en el PR #8: setup-env.ts fuerza la base temporal                                                          |
| 3  | migrate deploy falla con P3005 («the database schema is not empty»)                             | La base ya tenía las tablas pero **sin historial de migraciones** (por ejemplo, creada con db push)                                            | **No** hagas resolve a ciegas. El propio docker-entrypoint.sh te da el procedimiento: copia de seguridad, migrate diff para comprobar que el esquema es el esperado y solo entonces migrate resolve --applied 0\_init  |
| 4  | En mis pruebas nuevas, **429 al registrar** el 6º usuario                                       | El registro tiene un límite de **5 por hora y por IP**, y la suite registra más desde la misma IP                                              | Subir REGISTER\_RATE\_LIMIT\_MAX y LOGIN\_RATE\_LIMIT\_MAX con process.env **antes** de que se llame a loadEnv() en el setup-env.ts de tu copia                                                                        |
| 5  | GET /api/notes/a/b/c sin token → **404** en lugar de 401                                        | Un recurso nuevo no está en la lista de prefijos de AuthPrefixMiddleware                                                                       | Añadir el prefijo (api/notes y api/notes/{\*splat}) y una prueba; en 758a0ac era el mutante M8, que sobrevivía; el PR #8 añade la prueba de los cuatro prefijos de siempre, pero la de tu recurso nuevo la escribes tú |
| 6  | Mi prueba de «arranque sin secretos» **arrancaba igual**                                        | dotenv cargó el .env del repositorio                                                                                                           | Lanzarla desde otro directorio o con \--env-file /dev/null                                                                                                                                                             |
| 7  | El contenedor arranca, migra y **luego se niega a arrancar** por falta de MFA\_ENCRYPTION\_KEY  | El script de entrada migra antes de validar el entorno                                                                                         | Define las tres claves antes del primer despliegue; corregido en el PR #8: ahora valida antes de migrar                                                                                                                |
| 8  | En main, los logs del contenedor llenos de ^\[\[32m…                                            | ConsoleLogger pintaba con ANSI si no había NO\_COLOR                                                                                           | NO\_COLOR=1 en el entorno (probado: 0 líneas con ESC); el PR #8 ya lo evita en producción                                                                                                                              |
| 9  | El contenedor aparece como health: starting durante casi medio minuto aunque la API ya responde | El HEALTHCHECK solo ejecuta su primera comprobación tras \--interval=30s                                                                       | No es un fallo: espera o ejecuta curl localhost:3011/healthz a mano                                                                                                                                                    |
| 10 | npm audit fix --force propone **bajar** @nestjs/platform-express a 11.1.1                       | npm resuelve el aviso de multer degradando Nest                                                                                                | **No lo hagas**: añade el overrides de multer a 2.5.0 (ya incluido en el PR #8)                                                                                                                                        |
| 11 | El login de administrador con la contraseña de producción falla en **desarrollo**               | ADMIN\_EMAIL/ADMIN\_PASSWORD solo se aplican en producción; en desarrollo el sembrado crea admin@example.com y ana@example.com con password123 | Usa esas cuentas en local (y recuerda que **no** existen en producción)                                                                                                                                                |
| 12 | POST /api/sessions/login devuelve Missing or malformed Bearer Token                             | La ruta de login es **/api/auth/login**; /api/sessions es un recurso protegido                                                                 | Usa la ruta correcta; el mensaje sale del middleware de prefijos, no del login                                                                                                                                         |

Los números 2, 7, 8 y 10 no son errores de uso, sino de cómo estaba empaquetada la plantilla en `main`; el PR #8 (ya fusionado) los corrige.

## Lo que aprendí probándola

1. **Un guard global solo vigila las rutas que existen.** Con un guard `deny-by-default` casi todo queda protegido, pero un sondeo sin token a una subruta inexistente bajo `/api/tasks`, `/api/users`… respondería 404 en lugar de 401, y eso filtra la forma de tu API. El `AuthPrefixMiddleware` lo evita (lo comprobé: `GET /api/tasks/x/y/z` sin token → **401**; una ruta fuera de esos prefijos, `/api/noexiste`, → 404) y es un buen recordatorio de **probar el caso feo, no solo el camino feliz** (y de que ese caso tenga su prueba: el PR #8 añade la que faltaba).
2. **Compartir la instancia importa.** `useExisting` en lugar de `useClass` para el guard global permite que el middleware y el guard usen exactamente el mismo objeto y el token se verifique una vez.
3. **Las operaciones atómicas ganan a leer y luego escribir.** La rotación de refresh tokens con `updateMany` condicionado es lo que hace que «de tres refresh a la vez, gana uno» sea cierto, y tiene su propia suite para demostrarlo.
4. **Comprobar el bloqueo antes de la contraseña** (y compararla contra un hash falso cuando la cuenta no existe) evita que el login revele cuentas.
5. **Que algo sea rápido no lo hace más seguro**, y que algo sea lento tampoco: la diferencia de unas 10 veces en el login con Hono es el algoritmo, no el framework.
6. **La protección contra repetición tiene sus costes de UX**: el TOTP que usas para activar el 2FA no vale para el siguiente login en el mismo intervalo de 30 s.
7. **Mis propios errores de prueba me enseñaron cosas.** Mis tests fallaban solo porque tenía una variable de entorno exportada, y mi prueba de «arranque sin secretos» arrancó igualmente porque `dotenv` cargó el `.env` del repositorio (hay que lanzarla desde otro directorio). Las dos cosas ya están anotadas para que no te pasen (y la primera, corregida en el PR #8).
8. **Un harness de contrato entre dos implementaciones es una gran idea**: es lo que permite mantener dos plantillas con el mismo comportamiento sin que se separen en silencio.

## Lo que queda por pulir

Quiero ser tan honesto aquí como en el artículo de Hono. En la primera pasada (24 de octubre, `758a0ac`) **no encontré ningún fallo crítico en mis pruebas de seguridad**, pero sí catorce cosas que arreglaría. El 25 de octubre corregí en el [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com) las que se podían arreglar sin tocar la lógica de autenticación; **el PR ya está fusionado**, y la columna «Estado» distingue lo que está **corregido en el PR #8** (y por tanto ya en `main`; era lo que estaba roto en `758a0ac`) de lo que **sigue igual**. Severidad según mi criterio:

| #  | Severidad                   | Qué (en 758a0ac)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Estado y qué haría                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| -- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1  | **Media-baja**              | **npm audit --audit-level=high fallaba** (4 avisos altos, todos de multer 2.0.0–2.4.0, GHSA-q7qp-rjg7-q26m) **y Trivy marcaba la imagen** (1 HIGH: multer 2.4.0, arreglo en 2.5.0). @nestjs/platform-express@12.2.0 fija multer@2.4.0 exacto. La plantilla **no tiene rutas de subida de ficheros**, así que el riesgo práctico era bajo, pero esos dos pasos de la CI podían ponerse en rojo (el último run, el 23 de octubre, fue verde). Ojo: el npm audit fix --force que sugiere npm instalaría @nestjs/platform-express@11.1.1, un **descenso** que no quieres | **Corregido en el PR #8**: "overrides": { "multer": "^2.5.0" }. Medido en la rama: npm audit 0 (también \--omit=dev) y Trivy 0; typecheck, build, lint, 84 pruebas y el contrato (95/76/32) en verde. Cuando Nest publique una versión que incluya multer 2.5, se puede quitar el override                                                                                                                                                                                                                                         |
| 2  | **Baja**                    | **Cinco dependencias declaradas que no se importaban en ningún sitio**: @nestjs/config, @nestjs/schedule, nestjs-pino, pino y (en desarrollo) pino-pretty (lo comprobé buscando en todo src/ y test/). Y como consecuencia, los **logs de producción no son JSON** sino el texto de Nest                                                                                                                                                                                                                                                                             | **Corregido en el PR #8**: las cinco dependencias están fuera y el lockfile pasa de 846 a 812 paquetes (recuento del 25 de octubre sobre los dos package-lock.json). **Sigue igual**: los logs son texto. Para JSON, Nest trae new ConsoleLogger({ json: true, colors: false }) (lo probé **aislado**), pero no lo he integrado en main.ts                                                                                                                                                                                         |
| 3  | **Baja**                    | **Versiones de Node que no coincidían**: la imagen usaba node:25-alpine (una línea *no LTS*, tras un bump de Dependabot), engines pide \>=24.9, la CI prueba con Node 24 y AGENTS.md decía node:24-alpine. Es decir, **las pruebas no corrían en la versión que se despliega**                                                                                                                                                                                                                                                                                       | **Corregido en el PR #8**: la imagen es node:24-alpine fijada por *digest* (Node 24.22.1 al construirla), coherente con engines, la CI y AGENTS.md. Cuando salga la siguiente LTS habrá que cambiar el *digest*; Dependabot propone el bump                                                                                                                                                                                                                                                                                        |
| 4  | **Baja**                    | **La documentación y los comentarios arrastraban el origen «port»**: muchos comentarios decían «port of the reference…», la semilla incluye una tarea «Learn Hono Framework», el README era muy breve (91 líneas, en inglés) y mandaba a AGENTS.md para casi todo, mientras .env.example está en español                                                                                                                                                                                                                                                             | **Corregido en parte en el PR #8**: «the reference» pasa a «the Hono starter» (solo comentarios; lo verifiqué con el diff), AGENTS.md y el README se actualizan (100 líneas, con una sección de Docker). **Sigue igual**: la semilla de desarrollo aún crea «Learn Hono Framework», el README sigue siendo breve y en inglés, y .env.example en español                                                                                                                                                                            |
| 5  | **Baja**                    | **prettier --check . marcaba 26 ficheros** y la CI solo ejecuta ESLint                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | **Corregido en parte**: el PR formatea los 26 ficheros (en un commit aparte, solo formato) y añade vendor/ a .prettierignore; ahora solo falla .github/workflows/ci.yml. **Sigue igual**: la CI no ejecuta prettier --check (no he podido tocar workflows: el token con el que subí el PR no tiene permiso workflow)                                                                                                                                                                                                               |
| 6  | **Baja**                    | **Los tests respetaban un DATABASE\_URL exportado** (??= en setup-env.ts) y escribían en esa base; con una base usada, 21 de 62 fallaron. Tampoco ignoraban TURSO\_DATABASE\_URL ni SMTP\_URL                                                                                                                                                                                                                                                                                                                                                                        | **Corregido en el PR #8**: setup-env.ts fuerza una base SQLite temporal y limpia las variables de Turso y SMTP; test-isolation.spec.ts lo fija. Comprobado exportando las tres: 84 pruebas en verde                                                                                                                                                                                                                                                                                                                                |
| 7  | **Cosmética**               | GET / anunciaba documentationUrl: "/docs" en producción aunque /docs da 404; el log dice Application is running on: http://127.0.0.1:3011 aunque escucha en 0.0.0.0; un comentario «Constant-time bcrypt comparison» cuando ya es Argon2id; los comentarios de bruno-smoke.sh se contradicen sobre las devDependencies; **el comentario de la bandeja de salida decía «≈15 minutos» y el código se rinde a los 450 s (7,5 min)**                                                                                                                                     | **Corregido en parte en el PR #8**: GET / solo anuncia /docs si están activas (ENABLE\_DOCS=true en producción), el comentario de bcrypt se actualiza y el del *outbox* dice ahora 7,5 minutos (la tabla de esperas pasa a cuatro valores; **el comportamiento no cambia**). **Siguen igual**: la línea de log con 127.0.0.1 (la vi también en la imagen del PR) y los comentarios de bruno-smoke.sh                                                                                                                               |
| 8  | **Diseño** (no es un fallo) | El **bloqueo por cuenta** permite que cualquiera deje fuera a un usuario 15 minutos (denegación de servicio dirigida). El **registro** revela si un email ya existe (409: enumeración de usuarios). El **rate limit** solo cubre las rutas decoradas (auth, recuperación, MFA), no /api/tasks. La ventana de **10 s** de gracia del refresh permite una repetición con respuesta 409\. Las claves de rate limit guardan la **IP en claro**. /healthz es público y muestra *uptime* y latencia de BD. Argon2id está en el **mínimo** de OWASP                         | **Sin cambios, a propósito**: son *trade-offs* de diseño y cambiarlos toca la lógica de autenticación, así que el PR #8 no los toca y los dejo a tu criterio. Lo que haría: aceptar el bloqueo por cuenta si tu amenaza es la fuerza bruta (y no un atacante que conozca emails), o bloquear por pareja cuenta+IP; responder igual al registrar (y confirmar por email) si te importa la enumeración; revisar el rate limit ruta a ruta y poner un límite global en tu proxy; subir los parámetros de Argon2 si tu amenaza lo pide |
| 9  | **Test**                    | **62 pruebas y 66 % de cobertura** (la de Hono tiene 144); nestjs-zod está **vendorizada** y no se actualizará sola; la colección de Bruno no cubre MFA ni recuperación (sí el contrato)                                                                                                                                                                                                                                                                                                                                                                             | **Mejorado en el PR #8**: 84 pruebas (22 nuevas: prefijos, logs, GET / y aislamiento) y 67,2 % de cobertura; Hono tiene 148\. **Sigue igual**: nestjs-zod vendorizada y la colección de Bruno. Añade pruebas propias al extender                                                                                                                                                                                                                                                                                                   |
| 10 | **Baja**                    | **Docker**: las **migraciones se aplicaban antes de validar el entorno**, los **logs salían con colores ANSI** salvo NO\_COLOR=1, y la imagen pesaba **719 MB** (493 MB solo de npm ci --omit=dev)                                                                                                                                                                                                                                                                                                                                                                   | **Corregido en el PR #8**: el entrypoint valida primero (con un volumen vacío y sin secretos, el volumen se queda vacío), los logs de producción salen sin colores y la imagen baja a **570 MB** (396 MB de npm ci, sin typescript, npm, corepack ni yarn). **Sigue igual**: el node\_modules de producción ocupa unos 424 MiB por la CLI de Prisma                                                                                                                                                                                |
| 11 | **Media-baja** (pruebas)    | **Dos huecos que descubrió mi experimento de mutaciones**: (a) AuthPrefixMiddleware **no tenía ninguna prueba** (M8); (b) **la suite de concurrencia no distingue el código atómico del que no lo es** en SQLite (M1, M1b y M4 sobreviven a las dos suites)                                                                                                                                                                                                                                                                                                          | (a) **Corregido en el PR #8**: test/auth-prefix.middleware.spec.ts (unitaria y a través del router); repetí M8 y ahora fallan 2 pruebas. (b) **Sigue igual**: haría falta una prueba contra una base con varios escritores (Turso) o que inyecte una pausa en la transacción                                                                                                                                                                                                                                                       |
| 12 | **Baja**                    | **El login acepta application/x-www-form-urlencoded** además de JSON (200 con curl -d). La API no usa cookies, así que no hay CSRF clásico, pero amplía la superficie y permite enviar un login desde un formulario de otro dominio sin *preflight* de CORS                                                                                                                                                                                                                                                                                                          | **Sigue igual** (el PR no lo toca). Restringir el login a application/json                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 13 | **Cosmética**               | **El ZodSerializerInterceptor estaba registrado globalmente pero ninguna ruta declaraba un esquema de respuesta**, así que no validaba nada de lo que sale                                                                                                                                                                                                                                                                                                                                                                                                           | **Corregido en el PR #8**: se quita del AppModule (y del diagrama de este artículo). Si quieres validar la salida, añade un DTO de respuesta por ruta y vuelve a registrarlo                                                                                                                                                                                                                                                                                                                                                       |
| 14 | **Diseño**                  | **El bloqueo de cuenta bajo una ráfaga realmente simultánea** deja pasar más de 5 intentos: comprueba al principio y registra el fallo al final. Medido: con el límite por IP desactivado, las **30 peticiones pasaron la comprobación**; con la configuración por defecto, el límite por IP corta en 10                                                                                                                                                                                                                                                             | **Sin cambios, a propósito** (diseño). Aceptable porque el limitador por IP lo cubre; si quieres cerrarlo, haz la comprobación y el contador en una sola sentencia                                                                                                                                                                                                                                                                                                                                                                 |

Otras cosas que quedan fuera del PR y que conviene tener presentes: los cambios de CI (formato, `npm audit` también de desarrollo) los he dejado sin hacer por el permiso del token; npm 11 solo avisa de los *scripts* de instalación de las dependencias (`@prisma/engines`) y una versión futura de npm podría exigir declararlos; y `@scarf/scarf`, una dependencia transitiva del paquete de Scalar, tiene un script de instalación que envía estadísticas de uso: no se ejecuta en la imagen (`--ignore-scripts`), pero sí en un `npm install` de desarrollo.

**Sin probar** (lo repito para que no haya malentendidos): la generación real del SBOM y la propia ejecución de la CI de GitHub (tampoco con los cambios del PR #8); el envío real de correo por SMTP; Turso; el despliegue en Coolify; y varias instancias a la vez contra la misma base (el rate limit en BD está pensado para eso, pero no lo he medido).

## Conclusión

Después de instalarla, compilarla, construirla en Docker, pasarle sus pruebas (62 en `main`, 84 en la rama del PR #8), 95 escenarios de contrato, 29 peticiones de Bruno, dos baterías de ataques, un experimento de mutaciones y los ensayos de operación (rotación de la clave de MFA y copia de seguridad cifrada), y de añadirle un recurso nuevo con su prueba (69 con las mías, sobre `758a0ac`), mi opinión es clara: **es una plantilla seria**. Las decisiones difíciles (sesiones con estado, rotación con detección de robo, bloqueo antes de la contraseña, *fail closed* en configuración, IP real detrás de un proxy, copias cifradas) están bien tomadas **y demostradas con pruebas**, no solo escritas en un README. Y donde no lo están, la propia suite y los ensayos me han dicho dónde mirar.

Lo que más me gusta: el modo `AUTH_MODE` que desmonta de verdad lo que no usas, la rotación de refresh con su suite de concurrencia, el contenedor endurecido que arranca y se para limpio (sale con código 0 al recibir `SIGTERM`), y que la propia plantilla te enseñe cómo se rompe (mensajes claros al arrancar, ensayos operativos). Lo que más le faltaba lo he corregido en el [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com), a la espera de que se fusione: la auditoría de `multer` (de 4 avisos altos a 0, y Trivy a 0), las dependencias sobrantes, la versión de Node (imagen, CI y `engines` alineadas en 24), el orden «validar y después migrar», los colores en los logs, el aislamiento de las pruebas y la prueba del middleware que el experimento de mutaciones echó en falta. Lo que queda son sobre todo decisiones de diseño que he preferido dejarte a ti (el bloqueo de cuenta como palanca de denegación de servicio, la enumeración de usuarios en el registro y la cobertura del rate limit), los logs JSON, una prueba de concurrencia que distinga el código atómico en SQLite y los cambios de CI.

Frente a la de Hono, Nest gana en operaciones y seguridad «de fábrica» y pierde en tamaño, en pruebas unitarias y en el cliente tipado. Si no tienes preferencia de framework, empieza por la de Hono por su simplicidad; si tu equipo ya vive en Nest, esta es una base muy buena.

### Enlaces

- Repositorio: [Collanteslu/nestjs-prisma-production-starter](https://github.com/Collanteslu/nestjs-prisma-production-starter?ref=blog.devutilites.com)
- Correcciones de este artículo: [PR #8](https://github.com/Collanteslu/nestjs-prisma-production-starter/pull/8?ref=blog.devutilites.com) (Nest, fusionado el 25 de octubre) y [PR #23](https://github.com/Collanteslu/hono-prisma-production-starter/pull/23?ref=blog.devutilites.com) (Hono, fusionado el 25 de octubre)
- La gemela en Hono: [Mi plantilla Hono + Prisma 7](https://blog.devutilites.com/hono-prisma-production-starter/) y [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/)
- El framework: [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/)
- El contexto: [Express a fondo](https://blog.devutilites.com/express-a-fondo/), [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/) y [la comparativa de los cuatro](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/)
- Despliegue: [Coolify, tu PaaS autoalojado](https://blog.devutilites.com/coolify-paas-autoalojado/), [Cloudflare Tunnel a fondo](https://blog.devutilites.com/cloudflare-tunnel-a-fondo/) y [WAF y DDoS a fondo](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/)