> ## 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 Hono + Prisma 7 para APIs en producción: cómo está hecha, cómo usarla y qué aprendí probándola
- URL: https://blog.devutilites.com/hono-prisma-production-starter/
- Published: 2026-10-01T21:26:28.000Z
- Updated: 2026-10-01T21:26:28.000Z
- Description: hono-prisma-production-starter por dentro: sesiones revocables al instante, rotación de tokens con detección de robo, rutas que generan su OpenAPI con Zod 4, Prisma 7 con migraciones, Docker y 60 pruebas. Con descarga, ataques reales y lo poco que queda por pulir.
- Author: Luis Collantes
- Tags: Backend

Cada vez que empiezo una API nueva me pasa lo mismo: la primera semana no la dedico al problema que quiero resolver, sino a lo de siempre. Login, contraseñas con hash, tokens que caducan, cómo cerrar la sesión de un móvil perdido, validar lo que llega, paginar listados, documentar los endpoints, un Dockerfile… Todo eso es necesario, casi nunca es interesante y es muy fácil hacerlo mal.

Así que lo he empaquetado. **\[hono-prisma-production-starter\](https://github.com/Collanteslu/hono-prisma-production-starter)** es una plantilla de GitHub para crear APIs REST con **Hono**, **Prisma 7** y **SQLite** (o Turso). Trae de serie *access* y *refresh tokens* con rotación y detección de robo, **sesiones guardadas en la base de datos que se pueden revocar al instante**, bloqueo de cuentas en tiempo real, borrado lógico, registro de auditoría, filtros y orden dinámicos, rate limiting, una documentación OpenAPI que **se genera sola a partir de las rutas** (Zod 4), migraciones versionadas, Vitest, Biome, una colección de Bruno y Docker.

En este artículo te enseño cómo está hecha por dentro, fichero a fichero y con el código real del repositorio. También cómo descargarla y ponerla en marcha, cómo añadirle un recurso, cómo pasarla a PostgreSQL y cómo desplegarla en Coolify. Y, como en todo el blog, la he **probado de verdad** en mi servidor, intentando romperla.

Ese ejercicio tiene historia. La primera ronda de pruebas encontró varios fallos, uno de ellos de seguridad serio: cualquier usuario podía cambiar la contraseña del administrador. Se corrigieron en la **\[PR #8\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8)**, y después llegaron la **\[#9\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9)** (Docker y endurecimiento para producción), la **\[#10\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10)** (una auditoría completa) y la **\[#11\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11)** (migración a Zod 4 y OpenAPI generado). Cada vez he vuelto a pasar los mismos ataques contra el código nuevo. Te cuento qué quedó arreglado, con la evidencia, y qué sigue pendiente, que ya es poco pero lo hay.

🧪

Todo lo que aparece aquí se ha ejecutado el 29 de septiembre de 2026 sobre `main` (`72e4631`, con las PRs #8 a #11 fusionadas), en contenedores con Node 20.20.2 y 22, Hono 4.13.10, Zod 4.6.5, Prisma 7.10.0 y TypeScript 7.0.2\. Las salidas de terminal son reales; solo he recortado líneas repetidas. Los cambios que propongo (los marco como **probado*) los apliqué en una copia de `main` y pasaron `tsc`, Biome, Vitest y curl.

## Descarga

La plantilla es pública y tiene licencia MIT. Está marcada como **plantilla de GitHub**, así que no hace falta hacer un *fork*: GitHub te crea un repositorio nuevo, tuyo, con todos los ficheros y sin el historial del original.

| Qué                         | Dónde                                                                                                                                                                                                                            |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Repositorio (plantilla)** | [github.com/Collanteslu/hono-prisma-production-starter](https://github.com/Collanteslu/hono-prisma-production-starter?ref=blog.devutilites.com)                                                                                  |
| **Crear tu copia**          | [https://github.com/Collanteslu/hono-prisma-production-starter/generate](https://github.com/Collanteslu/hono-prisma-production-starter/generate?ref=blog.devutilites.com) (lo mismo que el botón *Use this template*)            |
| **Descargar en ZIP**        | [main.zip](https://github.com/Collanteslu/hono-prisma-production-starter/archive/refs/heads/main.zip?ref=blog.devutilites.com)                                                                                                   |
| **Licencia**                | [MIT](https://github.com/Collanteslu/hono-prisma-production-starter/blob/main/LICENSE?ref=blog.devutilites.com): úsala, cámbiala y véndela; solo mantén el aviso de copyright                                                    |
| **Integración continua**    | [GitHub Actions](https://github.com/Collanteslu/hono-prisma-production-starter/actions?ref=blog.devutilites.com): auditoría de dependencias, migraciones, tipos, build, Biome y 60 pruebas de Vitest en cada push y pull request |
| **Requisitos**              | Node.js 22 o 24 (ver la nota de abajo) y npm. Nada más: la base de datos es un fichero                                                                                                                                           |
| **Mis otros proyectos**     | [github.com/Collanteslu](https://github.com/Collanteslu?ref=blog.devutilites.com)                                                                                                                                                |

### Cuatro formas de conseguirla

```bash
# Opción 1: botón "Use this template" en GitHub (recomendada)

# Opción 2: con la CLI de GitHub
gh repo create mi-api --template Collanteslu/hono-prisma-production-starter --private --clone
cd mi-api

# Opción 3: solo el código, sin historial de Git
npx degit Collanteslu/hono-prisma-production-starter mi-api

# Opción 4: clonar tal cual
git clone https://github.com/Collanteslu/hono-prisma-production-starter.git
```

El botón **Use this template** está arriba a la derecha en la página del repositorio. Eliges *Create a new repository*, le pones nombre, decides si es público o privado y listo: tienes tu API con su propio historial desde cero. `gh repo create --template` hace lo mismo desde la terminal, y `npx degit` baja los ficheros sin carpeta `.git`, por si solo quieres curiosear o copiar una parte.

### Puesta en marcha en tres pasos

Esta es la guía rápida del README, ejecutada tal cual en un contenedor limpio: funciona a la primera.

```bash
# 1. Dependencias y variables de entorno
npm install
cp .env.example .env
# La API se niega a arrancar sin dos secretos distintos: los genera esta línea
sed -i.bak "s|^JWT_SECRET=.*|JWT_SECRET=$(openssl rand -base64 48)|; s|^JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$(openssl rand -base64 48)|" .env && rm .env.bak

# 2. Cliente de Prisma y base de datos SQLite (migraciones versionadas)
npx prisma generate           # el cliente se genera en src/generated (no está en Git)
npx prisma migrate deploy

# 3. Arrancar con recarga en caliente
npm run dev                   # http://localhost:3011  ·  documentación en /docs
```

```bash
$ npm run dev
[23:07:03 UTC] INFO: 🌱 Initializing database with default seeded accounts and tasks...
[23:07:03 UTC] INFO: ✔ Database seeded successfully (Admin: admin@example.com, User: ana@example.com)
[23:07:03 UTC] INFO: 🚀 Hono server listening on http://localhost:3011
```

En el primer arranque (fuera de producción), el *seeder* crea dos cuentas de demostración, **admin@example.com** y **ana@example.com**, las dos con `password123`, y tres tareas. Abre `http://localhost:3011/docs` y tendrás toda la API documentada y lista para probar desde el navegador.

⚠️

****Usa Node 22 o 24.** El README dice "Node 20 o superior" y la aplicación funciona con Node 20: las 60 pruebas pasan en Node 20 y en Node 22\. Pero con Node 20, `npm ci` avisa con `EBADENGINE` en 13 paquetes (Vitest 5, Scalar, lint-staged, swagger-parser, undici…), que piden Node 22 o superior. La imagen Docker y el CI todavía usan Node 20.

Antes de hacer nada serio, **pon secretos propios**. El `.env.example` trae `JWT_SECRET=` y `JWT_REFRESH_SECRET=` vacíos a propósito: la API se niega a arrancar sin ellos (antes traía textos de ejemplo que pasaban la validación). En producción exige además 32 caracteres o más y que los dos sean distintos:

```bash
# Dos secretos distintos y largos (en producción la API exige 32 caracteres o más)
openssl rand -base64 48   # -> JWT_SECRET
openssl rand -base64 48   # -> JWT_REFRESH_SECRET
```

Y comprueba que todo está en orden, igual que hace el CI:

```bash
$ npm ci && npx prisma generate
✔ Generated Prisma Client (7.10.0) to ./src/generated/client in 185ms
$ npm run typecheck      # tsc --noEmit del código y de los tests
$ npm run lint           # biome check .
Checked 42 files in 283ms. No fixes applied.
$ npm test               # vitest run
 Test Files  8 passed (8)
      Tests  60 passed (60)
   Duration  38.45s
```

## Qué trae y por qué estas piezas

| Pieza                                        | Versión probada        | Para qué                                                                                       |
| -------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
| **Hono** \+ @hono/node-server                | 4.13.10 · 2.1.1        | Router, middleware, contexto tipado, JWT, cabeceras de seguridad y límite de tamaño integrados |
| **Zod 4** \+ @hono/zod-openapi               | 4.6.5 · 1.6.3          | Una sola definición por ruta valida la petición, tipa el manejador y genera el OpenAPI         |
| **Prisma ORM** (cliente y CLI)               | 7.10.0                 | Esquema, cliente tipado y migraciones, con el generador nuevo prisma-client                    |
| **@prisma/adapter-libsql** \+ @libsql/client | 7.10.0 · 0.18.0        | *Driver adapter*: SQLite local o Turso, sin motor en Rust                                      |
| **bcryptjs**                                 | 3.0.3                  | Hash de contraseñas (12 rondas) en JavaScript puro                                             |
| **Pino** (+ pino-pretty)                     | 10.3.1                 | Logs estructurados: JSON en producción, legibles en desarrollo                                 |
| **Scalar**                                   | 0.12.7                 | Documentación interactiva en /docs                                                             |
| **Vitest** · **Biome** · **husky**           | 5.0.2 · 2.5.14 · 9.1.7 | Pruebas, lint y formato, y comprobación antes de cada commit                                   |
| **TypeScript** \+ **tsx**                    | 7.0.2 · 4.23.15        | Tipado estricto; el compilador nativo tarda \~1 segundo                                        |
| **Bruno**                                    | colección en bruno/    | Cliente de API con las peticiones guardadas en Git                                             |

**¿Por qué Hono?** Porque es pequeño, rápido y está construido sobre los estándares web (`Request`, `Response`, `fetch`). El mismo código podría correr en Bun, Deno o Cloudflare Workers cambiando solo el adaptador. Además trae de serie casi todo lo que una API necesita: JWT, CORS, cabeceras de seguridad, límite de tamaño del cuerpo, logger… El detalle lo tienes en [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/). Y si dudas entre Hono, Fastify, Express o NestJS, en la [comparativa con pruebas reales](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/) verás que Hono y Fastify van muy por delante en rendimiento en Node.

**¿Por qué SQLite?** Porque quería que cualquiera pudiera clonar la plantilla y tenerla funcionando en un minuto, sin levantar PostgreSQL ni Redis. SQLite es un fichero, y para muchísimas APIs (herramientas internas, el backend de una app, un MVP) es más que suficiente. Si necesitas la base de datos fuera del servidor, basta con definir `TURSO_DATABASE_URL` para usar **Turso** (SQLite en la nube) sin tocar código. Y cuando se quede corto, pasar a PostgreSQL es cambiar unas pocas cosas: lo he probado y te lo enseño más abajo.

**¿Por qué Prisma 7?** Por el esquema declarativo, los tipos que genera y los *driver adapters*: desde Prisma 7 el cliente ya no depende de un motor binario en Rust y habla con la base de datos a través de un driver de JavaScript, aquí libSQL.

**¿Por qué Zod 4 con `@hono/zod-openapi`?** Es el cambio de la PR #11 y el que más ha mejorado la plantilla. Antes había tres cosas que mantener a mano y que se desincronizaban: el validador, el tipo del manejador y un `openapi.ts` escrito a mano. Ahora cada ruta se declara **una vez** con `createRoute`, y esa definición valida la petición, tipa la respuesta y publica la documentación.

## La estructura del proyecto

```text
hono-prisma-production-starter/
├── .agents/skills/            # Guías para agentes de IA (crear endpoints, Docker, seguridad…)
├── .cursorrules               # Reglas del proyecto para Cursor y Copilot
├── .github/workflows/ci.yml   # CI: auditoría, migraciones, tipos, build, Biome y Vitest
├── .husky/pre-commit          # lint-staged antes de cada commit
├── biome.json                 # Linter y formateador
├── bruno/                     # Colección de Bruno (Auth, Sessions, Users, Tasks, Audit)
├── docker-entrypoint.sh       # Aplica las migraciones y arranca
├── Dockerfile · docker-compose.yml
├── prisma.config.ts           # Configuración de Prisma 7 (lee DATABASE_URL)
├── prisma/
│   ├── schema.prisma          # User, Session, RefreshToken, Task, AuditLog
│   └── migrations/            # SQL versionado (0_init + hash de refresh tokens)
├── src/
│   ├── config/env.ts          # Variables de entorno validadas con Zod
│   ├── db.ts                  # Cliente de Prisma (SQLite o Turso) y seeder
│   ├── jobs/cleanup.ts        # Limpieza periódica de sesiones y tokens
│   ├── lib/
│   │   ├── audit.ts           # recordAudit(): registro de auditoría
│   │   ├── clientIp.ts        # getClientIp(): IP del cliente (TRUST_PROXY)
│   │   ├── logger.ts          # Pino (JSON en producción, bonito en desarrollo)
│   │   ├── openapi.ts         # createRouter() y ayudas de createRoute
│   │   ├── query.ts           # filter[campo][op]=… y sort=-campo
│   │   ├── relations.ts       # ?include=… con lista blanca
│   │   ├── response.ts        # Sobre de respuesta estándar con meta
│   │   └── validator.ts       # Errores de Zod en un formato único
│   ├── middleware/            # auth.ts · rateLimit.ts · requestId.ts
│   ├── routes/                # auth · sessions · users · tasks · audit
│   ├── schemas/               # index.ts (peticiones) · responses.ts (respuestas)
│   ├── services/sessions.ts   # Emitir, rotar y revocar tokens y sesiones
│   ├── types/index.ts         # Tipos y AppEnv de Hono
│   ├── utils/password.ts      # bcrypt
│   └── index.ts               # Middleware global, rutas, AppType, arranque y cierre
├── tests/                     # Vitest: 60 pruebas en 8 ficheros
├── test-api.sh                # Pruebas de extremo a extremo con curl
└── vitest.config.ts
```

Cada carpeta tiene una sola responsabilidad, para que cuando crezca sepas dónde va cada cosa. `middleware/` es lo que se ejecuta antes de las rutas; `routes/`, un fichero por recurso; `schemas/`, el contrato de lo que la API acepta (`index.ts`) y devuelve (`responses.ts`); `services/`, la lógica de sesiones que comparten varias rutas; y `lib/`, las piezas reutilizables: el sobre de respuesta, los filtros, las relaciones, la auditoría, la IP del cliente y el logger.

El `package.json`, con todos los scripts:

```json
{
  "name": "hono-prisma-production-starter",
  "version": "1.2.0",
  "description": "Plantilla profesional para APIs REST con Hono, Prisma 7, SQLite, JWT, Zod y Scalar OpenAPI",
  "type": "module",
  "license": "MIT",
  "engines": {
    "node": ">=20.0.0"
  },
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json",
    "start": "node dist/index.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "bash test-api.sh",
    "lint": "biome check .",
    "lint:fix": "biome check --write .",
    "format": "biome format --write .",
    "prepare": "husky",
    "test:coverage": "vitest run --coverage",
    "db:migrate": "prisma migrate dev",
    "db:deploy": "prisma migrate deploy",
    "db:generate": "prisma generate"
  },
  "dependencies": {
    "@hono/node-server": "^2.1.1",
    "@hono/zod-openapi": "^1.6.3",
    "@libsql/client": "^0.18.0",
    "@prisma/adapter-libsql": "^7.10.0",
    "@prisma/client": "^7.10.0",
    "@scalar/hono-api-reference": "^0.12.7",
    "bcryptjs": "^3.0.3",
    "dotenv": "^18.0.4",
    "hono": "^4.13.10",
    "pino": "^10.3.1",
    "prisma": "^7.10.0",
    "zod": "^4.6.5"
  },
  "devDependencies": {
    "@apidevtools/swagger-parser": "^13.1.0",
    "@biomejs/biome": "^2.5.14",
    "@types/bcryptjs": "^2.4.6",
    "@types/node": "^26.6.3",
    "@vitest/coverage-v8": "^5.0.2",
    "husky": "^9.1.7",
    "lint-staged": "^17.6.0",
    "pino-pretty": "^13.1.3",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2",
    "vitest": "^5.0.2"
  },
  "lint-staged": {
    "*.{ts,js,json}": [
      "biome check --write"
    ]
  },
  "overrides": {
    "mysql2": "^3.24.5",
    "deepmerge-ts": "^8.0.2"
  }
}
```

El proyecto es **ESM puro** (`"type": "module"`) y compila con `tsc` a `dist/`. En desarrollo, `tsx watch` ejecuta TypeScript directamente y reinicia al guardar. `prepare` instala husky, que antes de cada commit ejecuta `lint-staged` y deja pasar Biome solo por los ficheros cambiados. `npm run typecheck` comprueba también los tipos de los tests, y eso importa: es lo que vigila que el cliente RPC siga teniendo tipos (más abajo).

![Esquema de la plantilla: middleware global (requestId, secureHeaders, logger, cors, prettyJSON, bodyLimit), rutas públicas, /api/auth con rate limit por IP y bloqueo por cuenta, rutas protegidas por authMiddleware (sessions, users, tasks, audit), cliente de Prisma, adaptador libSQL y SQLite o Turso; a los lados, el arranque con migraciones y las tareas en segundo plano](https://blog.devutilites.com/content/images/2026/09/starter-arquitectura-v2.png)

Por dónde pasa una petición: middleware global, tres grupos de rutas y un solo cliente de Prisma sobre SQLite o Turso. Diagrama: devutilites.

## Configuración que falla rápido

Lo primero que hace la aplicación, antes de tocar la base de datos o abrir un puerto, es validar las variables de entorno con Zod:

```typescript
const envSchema = z
  .object({
    PORT: z.coerce.number().default(3011),
    NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
    JWT_SECRET: z
      .string({ error: "JWT_SECRET is required (generate one with: openssl rand -base64 48)" })
      .min(16, "JWT_SECRET must contain at least 16 characters for cryptographic security"),
    JWT_REFRESH_SECRET: z
      .string({
        error: "JWT_REFRESH_SECRET is required (generate one with: openssl rand -base64 48)",
      })
      .min(16, "JWT_REFRESH_SECRET must contain at least 16 characters"),
    DATABASE_URL: z.string().default("file:./dev.db"),
    TURSO_DATABASE_URL: z.string().optional(),
    TURSO_AUTH_TOKEN: z.string().optional(),
    TRUST_PROXY: z
      .string()
      .optional()
      .transform((val) => val === "true" || val === "1"),
    ADMIN_EMAIL: z.string().email().optional(),
    ADMIN_PASSWORD: z.string().min(8).max(72).optional(),
    // Comma-separated list of allowed CORS origins ("*" allows any origin)
    CORS_ORIGINS: z
      .string()
      .default("*")
      .transform((val) =>
        val
          .split(",")
          .map((origin) => origin.trim())
          .filter(Boolean),
      ),
    // Expose /docs and /openapi.json (defaults to true outside production)
    ENABLE_DOCS: z
      .string()
      .optional()
      .transform((val) => (val === undefined ? undefined : val === "true" || val === "1")),
    LOGIN_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(10),
    REFRESH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(30),
    REGISTER_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5),
    // Seconds during which reusing a just-rotated refresh token is treated as a concurrent refresh
    REFRESH_REUSE_GRACE_SECONDS: z.coerce.number().int().min(0).default(10),
  })
  .superRefine((cfg, ctx) => {
    // Production hardening: refuse weak or shared signing secrets
    if (cfg.NODE_ENV !== "production") return;
    for (const key of ["JWT_SECRET", "JWT_REFRESH_SECRET"] as const) {
      if (cfg[key].length < 32) {
        ctx.addIssue({
          code: "custom",
          path: [key],
          message: `${key} must contain at least 32 characters in production (generate one with: openssl rand -base64 48)`,
        });
      }
    }
    if (cfg.JWT_SECRET === cfg.JWT_REFRESH_SECRET) {
      ctx.addIssue({
        code: "custom",
        path: ["JWT_REFRESH_SECRET"],
        message: "JWT_REFRESH_SECRET must differ from JWT_SECRET in production",
      });
    }
  });
```

Si te olvidas de `JWT_SECRET` en el servidor, te enteras **al arrancar**, con un mensaje claro, y no a las tres de la mañana cuando alguien intente entrar. Y en producción hay un `superRefine` que rechaza secretos débiles: menos de 32 caracteres o el mismo valor para los dos tokens (lo probé, las salidas están en la sección de Docker). Además `env` sale tipado: `env.PORT` es un `number` (gracias a `z.coerce`) y `env.TRUST_PROXY` un booleano. Fíjate en cómo se convierte ese booleano: con `transform` y comparando con `"true"` o `"1"`. Parece un detalle, pero la opción "obvia", `z.coerce.boolean()`, convierte el texto `"false"` en `true`.

Justo después del esquema, las variables vacías (`ADMIN_EMAIL=` que deja `docker-compose` cuando no defines nada) se tratan como no definidas. Este es el `.env.example` completo:

```bash
# Configuración del Servidor
PORT=3011
NODE_ENV=development

# Secretos JWT (OBLIGATORIOS, sin valor por defecto: la app no arranca si están vacíos)
# Genera dos valores distintos con:  openssl rand -base64 48
# En producción: mínimo 32 caracteres y JWT_SECRET distinto de JWT_REFRESH_SECRET.
# Nunca reutilices los secretos de desarrollo en producción.
JWT_SECRET=
JWT_REFRESH_SECRET=

# Base de Datos (SQLite Local por defecto)
DATABASE_URL=file:./dev.db

# Configuración Opcional para Turso LibSQL (Cloud / Edge)
# Si se definen, la API se conecta automáticamente a Turso en lugar del archivo SQLite local.
# TURSO_DATABASE_URL=libsql://tu-db.turso.io
# TURSO_AUTH_TOKEN=tu-turso-auth-token

# Proxy inverso (Cloudflare, Coolify, Traefik, Nginx)
# Habilitar solo cuando la API esté detrás de un reverse proxy de confianza para rate limiting real por IP
TRUST_PROXY=false

# Bootstrap del Administrador Inicial en Producción (Opcional)
# Si la base de datos está vacía en NODE_ENV=production, creará este usuario admin (contraseña de 8 a 72 caracteres)
# ADMIN_EMAIL=admin@tudominio.com
# ADMIN_PASSWORD=cambia-esta-contraseña-12345

# Orígenes permitidos por CORS separados por comas (por defecto "*")
# CORS_ORIGINS=https://app.tudominio.com,https://admin.tudominio.com

# Documentación /docs y /openapi.json (por defecto activa salvo en producción)
# ENABLE_DOCS=false

# Rate limiting por IP (peticiones por ventana): login y refresh por minuto, registro por hora
# LOGIN_RATE_LIMIT_MAX=10
# REFRESH_RATE_LIMIT_MAX=30
# REGISTER_RATE_LIMIT_MAX=5

# Segundos en los que reutilizar un refresh token recién rotado se trata como refresh concurrente
# (fuera de esa ventana se considera robo del token y se revoca la sesión)
# REFRESH_REUSE_GRACE_SECONDS=10

# Nivel de log de Pino (trace, debug, info, warn, error, fatal, silent)
# LOG_LEVEL=info
```

## Prisma 7: el modelo de datos

Prisma 7 trae un fichero de configuración nuevo, `prisma.config.ts`, que usa la CLI (`migrate`, `db push`…). Ahora lee la misma variable que la aplicación, y eso arregló uno de los fallos más traicioneros de la primera ronda (te lo cuento en Docker):

```typescript
import dotenv from "dotenv";
import { defineConfig } from "prisma/config";

dotenv.config();

const url = process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./dev.db";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: {
    url,
  },
});
```

Este es el esquema completo:

```prisma
datasource db {
  provider = "sqlite"
}

generator client {
  provider = "prisma-client"
  output   = "../src/generated/client"
}

model User {
  id            String    @id @default(uuid())
  name          String
  email         String    @unique
  password      String
  role          String    @default("user") // "admin" | "user"
  isBlocked     Boolean   @default(false)
  blockedReason String?
  deletedAt     DateTime? // Soft delete timestamp
  createdAt     DateTime  @default(now())

  // Relaciones
  tasks         Task[]
  refreshTokens RefreshToken[]
  sessions      Session[]
  auditLogs     AuditLog[]
}

model Session {
  id        String   @id @default(uuid())
  userId    String
  userAgent String?
  ipAddress String?
  isActive  Boolean  @default(true)
  expiresAt DateTime
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

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

  @@index([userId])
}

model RefreshToken {
  id        String    @id @default(uuid())
  tokenHash String    @unique // SHA-256 of the signed refresh JWT (never stored in plaintext)
  userId    String
  sessionId String?
  usedAt    DateTime? // Set when rotated; distinguishes concurrent refreshes from token reuse
  expiresAt DateTime
  createdAt DateTime  @default(now())

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

  @@index([userId])
  @@index([sessionId])
}

model Task {
  id          String    @id @default(uuid())
  userId      String
  title       String
  description String    @default("")
  completed   Boolean   @default(false)
  deletedAt   DateTime? // Soft delete timestamp
  createdAt   DateTime  @default(now())

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

  @@index([userId, deletedAt])
}

model AuditLog {
  id        String   @id @default(uuid())
  userId    String?
  action    String // e.g. "CREATE", "UPDATE", "DELETE", "SOFT_DELETE", "LOGIN", "BLOCK"
  entity    String // e.g. "Task", "User", "Session"
  entityId  String?
  details   String? // JSON string or summary of changes
  ipAddress String?
  userAgent String?
  createdAt DateTime @default(now())

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

  @@index([createdAt])
  @@index([entity, action])
}
```

- `**User**`: la cuenta. `role` es `"admin"` o `"user"`; `isBlocked` y `blockedReason` permiten suspenderla, y `deletedAt` marca el borrado lógico.
- **`Session`**: **una fila por cada login**, con dispositivo, IP, si está activa y cuándo caduca. Es la pieza clave de toda la autenticación.
- `**RefreshToken**`: los refresh tokens emitidos, ligados a su sesión. Se guarda `tokenHash`, el SHA-256 del token, nunca el token; y `usedAt` marca cuándo se rotó, que es lo que distingue un refresco concurrente de un robo.
- **`Task`**: el recurso de ejemplo, siempre con su dueño y con borrado lógico.
- `**AuditLog**`: quién hizo qué, sobre qué entidad, desde qué IP y con qué navegador. Si se borra el usuario, el registro se queda (`onDelete: SetNull`).

El generador usa el proveedor nuevo `prisma-client`, que escribe el cliente como código TypeScript en `src/generated/client`. Por eso hay que ejecutar `prisma generate` después de instalar, y por eso esa carpeta no está en Git.

### Migraciones versionadas

La plantilla ya no usa `db push`: el esquema vive en `prisma/migrations/` como SQL en Git. Hay dos migraciones. `0_init` crea todo, y la segunda, la de la PR #10, guarda los refresh tokens como hash. Como un hash no se puede calcular en SQL a partir de un token en claro, descarta los que había (los usuarios tendrán que iniciar sesión una vez):

```sql
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_RefreshToken" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "tokenHash" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "sessionId" TEXT,
    "usedAt" DATETIME,
    "expiresAt" DATETIME NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT "RefreshToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Existing refresh tokens were stored in plaintext and cannot be hashed in SQL:
-- they are discarded, so clients must sign in again once after this migration.
DROP TABLE "RefreshToken";
ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken";
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
CREATE INDEX "RefreshToken_sessionId_idx" ON "RefreshToken"("sessionId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

```

### El cliente: SQLite local o Turso

```typescript
function resolveDatabaseUrl(): string {
  if (env.TURSO_DATABASE_URL) {
    return env.TURSO_DATABASE_URL;
  }
  if (env.DATABASE_URL) {
    // If DATABASE_URL starts with file:, normalize path if relative
    if (env.DATABASE_URL.startsWith("file:")) {
      const rawPath = env.DATABASE_URL.replace(/^file:/, "");
      const resolvedPath = path.isAbsolute(rawPath)
        ? rawPath
        : path.resolve(process.cwd(), rawPath);
      return `file:${resolvedPath}`;
    }
    return env.DATABASE_URL;
  }
  return `file:${path.resolve(process.cwd(), "dev.db")}`;
}

const libsqlUrl = resolveDatabaseUrl();
const adapter = new PrismaLibSql({
  url: libsqlUrl,
  authToken: env.TURSO_AUTH_TOKEN,
});

// Password hashes are omitted from every query by default; opt in explicitly with `omit: { password: false }`.
export const prisma = new PrismaClient({ adapter, omit: { user: { password: true } } });
```

Si hay `TURSO_DATABASE_URL`, se conecta a Turso con su token. Si no, usa `DATABASE_URL` y convierte las rutas relativas en absolutas. Una línea importante de `db.ts`: el cliente se crea con `omit: { user: { password: true } }`. El hash de la contraseña **nunca sale de ninguna consulta** salvo que la pida explícitamente (solo el login lo hace), así que un `include` descuidado no puede filtrarlo. Lo comprobé buscando hashes bcrypt en las respuestas de `/api/users` y de `/api/tasks?include=user`: ninguno.

### El seeder, y el primer administrador

```typescript
export async function seedDatabase() {
  const usersCount = await prisma.user.count();

  // In production, optionally bootstrap the primary administrator if environment credentials are provided
  if (env.NODE_ENV === "production") {
    if (usersCount === 0 && env.ADMIN_EMAIL && env.ADMIN_PASSWORD) {
      logger.info(`👑 Bootstrapping initial production administrator: ${env.ADMIN_EMAIL}...`);
      const hashedPassword = await hashPassword(env.ADMIN_PASSWORD);
      await prisma.user.create({
        data: {
          name: "System Admin",
          email: env.ADMIN_EMAIL.toLowerCase(),
          password: hashedPassword,
          role: "admin",
        },
      });
      logger.info("✔ Production administrator created successfully.");
    } else {
      logger.info("🔒 Production mode active: Demo seeder disabled.");
    }
    return;
  }
  // … fuera de producción: crea admin@example.com y ana@example.com con sus tareas
}
```

Solo actúa si la base de datos está vacía, así que se puede llamar en cada arranque. Fuera de producción crea las cuentas de demostración. En producción **nunca** las crea, y ahí estaba el hueco de la vez anterior: sin seeder no había forma de crear el primer usuario, porque `POST /api/users` exige estar autenticado. La PR #9 lo cerró con `ADMIN_EMAIL` y `ADMIN_PASSWORD`: si la base está vacía, se crea ese administrador. Ya no se vuelve a crear otro, aunque cambies el email en un reinicio (lo probé, sale en la sección de Docker). Una recomendación mía: quita `ADMIN_PASSWORD` del entorno tras el primer arranque.

## El punto de entrada: index.ts

Aquí se monta todo. Primero, el middleware global, que se ejecuta **en este orden** en cada petición:

```typescript
app.use("*", requestIdMiddleware);

// 2. HTTP Security: Injects HSTS, XSS Protection, CSP, No-Sniff, and Frameguard headers
app.use("*", secureHeaders());

// 3. HTTP access logger with response time calculation (routed through the structured Pino logger)
app.use(
  "*",
  logger((message, ...rest) => appLogger.info([message, ...rest].join(" "))),
);

// 4. Cross-Origin Resource Sharing (CORS) configuration (origins configurable via CORS_ORIGINS)
app.use(
  "*",
  cors({
    origin: env.CORS_ORIGINS.includes("*") ? "*" : env.CORS_ORIGINS,
    allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
  }),
);

// 5. Formatted readable JSON output
app.use("*", prettyJSON());
```

1. `**requestIdMiddleware**`: pone un identificador a cada petición (respeta el `X-Request-Id` de un proxy solo si tiene un formato seguro) y, al terminar, añade `X-Response-Time` y `Server-Timing`.
2. `**secureHeaders()**`: HSTS, `nosniff`, `X-Frame-Options`, `Referrer-Policy`… con una línea.
3. **`logger()`**: una línea por petición y otra por respuesta, con el tiempo, pasando por Pino.
4. `**cors()**`: por defecto abierto a cualquier origen; con `CORS_ORIGINS=https://app.tudominio.com` lo limitas a tu frontend sin tocar código.
5. `**prettyJSON()**`: añade `?pretty` a cualquier URL y el JSON sale indentado.
6. **`bodyLimit()`**: rechaza con un 413 cualquier cuerpo de más de 100 KB antes de leerlo entero. Evita que alguien te llene la memoria con un JSON gigante (lo probé con 150 KB: 413).

```typescript
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;

export async function requestIdMiddleware(c: Context<AppEnv>, next: Next) {
  const start = performance.now();
  c.set("startTime", start);

  // Reuse a well-formed request ID from upstream load balancers or generate a fresh UUID v4
  const incomingId = c.req.header("X-Request-Id");
  const reqId = incomingId && REQUEST_ID_PATTERN.test(incomingId) ? incomingId : randomUUID();

  // Store in context for application access and echo in response headers
  c.set("requestId", reqId);
  c.header("X-Request-Id", reqId);

  await next();

  // Calculate elapsed server duration and inject standard timing headers
  const elapsedMs = (performance.now() - start).toFixed(2);
  c.header("X-Response-Time", `${elapsedMs}ms`);
  c.header("Server-Timing", `total;dur=${elapsedMs}`);
}
```

`Server-Timing` es una cabecera estándar que las herramientas de desarrollo del navegador enseñan en la pestaña de red, en la sección de tiempos. Sin instalar nada, ves cuánto tardó el servidor.

### Documentación y health check

```typescript
if (env.ENABLE_DOCS ?? env.NODE_ENV !== "production") {
  // OpenAPI 3.0 specification generated from the route definitions and Zod schemas
  app.doc("/openapi.json", {
    openapi: "3.0.3",
    info: {
      title: "Hono REST API - Gestión de Usuarios y Tareas",
      version: API_VERSION,
      description:
        "API REST con Hono, Prisma 7 (SQLite), TypeScript, Zod, autenticación JWT con refresh tokens rotativos, sesiones con estado y rate limiting.",
    },
    servers: [{ url: `http://localhost:${env.PORT}`, description: "Servidor local" }],
    tags: [
      { name: "Auth" },
      { name: "Sessions" },
      { name: "Users" },
      { name: "Tasks" },
      { name: "Audit" },
      { name: "System" },
    ],
  });

  // Interactive web console at /docs powered by Scalar
  app.get(
    "/docs",
    apiReference({
      pageTitle: "API Reference | Hono + Prisma",
      spec: {
        url: "/openapi.json",
      },
    }),
  );
}
```

La documentación (`/docs` y `/openapi.json`) está activa fuera de producción y apagada dentro, salvo que pongas `ENABLE_DOCS=true`: en el contenedor de producción las dos rutas dieron 404\. Es una mejora de la PR #11 frente a la primera versión, que la servía siempre.

```typescript
app.openapi(healthRoute, async (c) => {
  try {
    const startTime = Date.now();
    await prisma.$queryRaw`SELECT 1`;
    const dbLatencyMs = Date.now() - startTime;

    return c.json(
      {
        status: "healthy" as const,
        timestamp: new Date().toISOString(),
        uptimeSeconds: Math.floor(process.uptime()),
        database: {
          status: "connected" as const,
          latencyMs: dbLatencyMs,
        },
      },
      200,
    );
  } catch (error) {
    // Details are logged server-side only; the public endpoint must not leak internals
    appLogger.error({ err: error }, "Healthcheck database probe failed");
    return c.json(
      {
        status: "unhealthy" as const,
        timestamp: new Date().toISOString(),
        database: {
          status: "disconnected" as const,
        },
      },
      503,
    );
  }
});
```

El health check hace un `SELECT 1` y devuelve la latencia de la base de datos. Si SQLite no responde, devuelve un **503** sin detalles internos (el error va solo al log), que es lo que necesitan Docker, Coolify o cualquier balanceador para dejar de mandar tráfico a un contenedor roto.

### Montar las rutas, y el tipo AppType

```typescript
// Apply authentication middleware to protected route paths
// Both the exact prefix and its sub-paths are protected
for (const prefix of ["/api/sessions", "/api/users", "/api/tasks", "/api/audit-logs"]) {
  app.use(prefix, authMiddleware);
  app.use(`${prefix}/*`, authMiddleware);
}

// Public authentication routes and protected resource modules chained cleanly for Hono RPC
const routes = app
  .route("/api/auth", authRoutes)
  .route("/api/sessions", sessionRoutes)
  .route("/api/users", userRoutes)
  .route("/api/tasks", taskRoutes)
  .route("/api/audit-logs", auditRoutes);

/**
 * Export Type-Safe RPC Application Type for client consumption (hc<AppType>)
 */
export type AppType = typeof routes;
```

Las rutas de autenticación son públicas; todo lo demás pasa antes por `authMiddleware`. El bucle protege el prefijo exacto **y** sus subrutas (sin token, todas dan 401, lo comprobé en cinco rutas). Las rutas se encadenan para exportar `AppType`, que permite usar el **cliente RPC de Hono** con tipos de extremo a extremo. En la primera ronda esto no funcionaba (el cliente salía como `unknown`); ahora sí, y te lo demuestro más abajo.

### Errores, arranque y cierre ordenado

```typescript
app.notFound((c) => {
  return errorResponse(c, `Route not found: ${c.req.method} ${c.req.path}`, 404);
});

app.onError((err, c) => {
  // Expected HTTP errors (malformed JSON, invalid filters, body limits, ...) keep their status code
  if (err instanceof HTTPException) {
    return errorResponse(c, err.message || "Request could not be processed.", err.status);
  }

  // Map known database constraint errors (e.g. races between existence checks and writes)
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === "P2002") {
      return errorResponse(c, "Conflict: A record with the same unique value already exists.", 409);
    }
    if (err.code === "P2025") {
      return errorResponse(c, "Resource not found.", 404);
    }
  }

  appLogger.error({ err, requestId: c.get("requestId") }, "Unhandled request error");
  return errorResponse(
    c,
    "Internal server error.",
    500,
    env.NODE_ENV === "development" ? err.message : undefined,
  );
});
```

Los errores esperados de Hono (JSON mal formado, un filtro inválido) mantienen su código de estado; los conflictos de la base de datos (`P2002`, `P2025`) se traducen a 409 y 404; y cualquier otra excepción acaba en un 500 en JSON con el mensaje real **solo en desarrollo**. Y el final del fichero:

```typescript
await seedDatabase();

let server: ReturnType<typeof serve> | undefined;

if (process.env.NODE_ENV !== "test") {
  // Start recurring cleanup job to purge expired sessions every hour
  startCleanupJob(60 * 60 * 1000);

  appLogger.info(`🚀 Hono server listening on http://localhost:${env.PORT}`);
  appLogger.info(
    `📖 Interactive API documentation available at: http://localhost:${env.PORT}/docs`,
  );

  server = serve({
    fetch: app.fetch,
    port: env.PORT,
  });

  /**
   * -------------------------------------------------------------
   * Graceful Process Shutdown
   * -------------------------------------------------------------
   * Intercepts OS signals (SIGINT, SIGTERM) to:
   * 1. Reject incoming requests.
   * 2. Allow in-flight requests to complete execution.
   * 3. Safely disconnect Prisma and close SQLite connection without data corruption.
   */
  let isShuttingDown = false;

  const gracefulShutdown = async (signal: string) => {
    if (isShuttingDown) return;
    isShuttingDown = true;

    appLogger.info(`\n🛑 Received ${signal}. Initiating graceful shutdown...`);

    try {
      server?.close(async () => {
        appLogger.info("✔ HTTP listener closed.");
        try {
          await prisma.$disconnect();
          appLogger.info("✔ SQLite connection safely closed.");
          process.exit(0);
        } catch (dbErr) {
          appLogger.error({ err: dbErr }, "❌ Error during database disconnect");
          process.exit(1);
        }
      });

      // Forced exit timeout fallback after 10 seconds
      setTimeout(() => {
        appLogger.error("⚠️ Forcefully terminating after shutdown timeout limit.");
        process.exit(1);
      }, 10000).unref();
    } catch (err) {
      appLogger.error({ err }, "❌ Error during server shutdown");
      process.exit(1);
    }
  };

  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
}
```

Con `NODE_ENV=test` no se abre el puerto ni arranca la limpieza periódica: las pruebas de Vitest usan la `app` exportada directamente. En los demás casos, al recibir SIGTERM (lo que manda Docker o Coolify al parar), deja de aceptar conexiones, termina las peticiones en curso, cierra Prisma y sale con código 0\. Si algo se atasca, a los 10 segundos sale igualmente. `docker stop` tardó **0,27 s**.

## El sobre de respuesta y los logs

Todas las rutas responden con el mismo formato, gracias a dos funciones de `src/lib/response.ts`:

```typescript
export function successResponse<T, S extends 200 | 201 = 200>(
  c: Context<AppEnv>,
  data: T,
  options?: {
    message?: string;
    pagination?: PaginationMeta;
    status?: S;
  },
) {
  const status = (options?.status ?? 200) as S;
  return c.json(
    {
      success: true as const,
      message: options?.message,
      data,
      pagination: options?.pagination,
      meta: buildMeta(c),
    },
    status,
  );
}
```

El cliente siempre recibe `success`, `data`, la paginación si la hay y un `meta` con el `requestId`, la hora, los milisegundos que tardó el servidor y la versión de la API. Da igual el endpoint: el frontend lo trata todo con el mismo código. Los errores de validación de Zod también salen en ese formato, agrupados por campo. Para los logs de la aplicación se usa **Pino**:

```typescript
const isProduction = process.env.NODE_ENV === "production";

export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  transport: isProduction
    ? undefined
    : {
        target: "pino-pretty",
        options: {
          colorize: true,
          translateTime: "HH:MM:ss Z",
          ignore: "pid,hostname",
        },
      },
});
```

En desarrollo, líneas de colores legibles; en producción, **una línea JSON por evento**, que es lo que entienden Loki, Datadog o CloudWatch (y los logs de Coolify se leen mejor). `LOG_LEVEL` permite subir o bajar el detalle sin tocar código. Ojo con una consecuencia: `pino-pretty` es una dependencia de desarrollo, así que si ejecutas la imagen de producción con `NODE_ENV=development` no arranca (me pasó al probar).

## Autenticación: tokens y sesiones con estado

Esta es la parte de la plantilla de la que estoy más orgulloso, así que me voy a detener.

Los JWT tienen un problema conocido: **son válidos hasta que caducan**. El servidor los firma y luego solo comprueba la firma, sin preguntar a nadie. Es rápido y escala, pero si le roban el móvil a un usuario, o si tienes que echar a alguien, no puedes invalidar un token ya emitido: toca esperar a que caduque.

La solución de la plantilla es un híbrido: **el JWT dice quién eres, y una fila de la tabla `Session` dice si todavía puedes entrar**. El token lleva el `sessionId` dentro, y en cada petición el middleware comprueba que esa sesión siga activa.

![Diagrama de secuencia entre cliente, API y SQLite: login, petición normal, revocación, token revocado que recibe 401 y renovación con rotación, con 409 dentro de la ventana de gracia y 401 con sesión cerrada al reutilizar un refresh token](https://blog.devutilites.com/content/images/2026/09/starter-sesion-v2.png)

El access token sigue teniendo una firma válida, pero la sesión ya no está activa: 401 en la siguiente petición. Diagrama: devutilites.

### El login

La ruta se declara con `createRoute`: método, ruta, esquema de entrada y **todos los códigos de estado que puede devolver**, y el compilador rechaza un manejador que devuelva uno no declarado.

```typescript
const loginRoute = createRoute({
  method: "post",
  path: "/login",
  tags: ["Auth"],
  summary: "Inicio de sesión (Login)",
  description:
    "Autentica credenciales y emite Access Token (15 min) y Refresh Token (7 días). Protegido por rate limit por IP y bloqueo por cuenta tras 5 intentos fallidos (15 min).",
  request: jsonBody(loginSchema),
  responses: {
    200: jsonResponse(loginResponseSchema, "Login exitoso"),
    ...errorResponses({
      400: "Error de validación o JSON mal formado",
      401: "Credenciales inválidas",
      403: "Cuenta suspendida",
      429: "Rate limit excedido o cuenta bloqueada temporalmente",
    }),
  },
});
```

```typescript
    const lockedForSeconds = loginLockout.retryAfterSeconds(email);
    if (lockedForSeconds > 0) {
      c.header("Retry-After", lockedForSeconds.toString());
      return errorResponse(
        c,
        "Too many failed login attempts for this account. Please try again later.",
        429,
      );
    }

    const user = await prisma.user.findUnique({
      where: { email },
      omit: { password: false },
    });

    // Constant-time bcrypt comparison (always executed to prevent timing side-channel leaks)
    const isValidPassword = await comparePassword(
      password,
      user?.password ?? (await getDummyHash()),
    );

    if (!user || !isValidPassword || user.deletedAt) {
      loginLockout.recordFailure(email);
      await recordAudit(c, {
        userId: user?.id ?? null,
        action: "LOGIN_FAILED",
        entity: "Session",
        details: { email },
      });
      return errorResponse(c, "Invalid credentials (incorrect email or password)", 401);
    }

    loginLockout.reset(email);
    // … si la cuenta está suspendida: 403; si no, crea la sesión y devuelve los tokens
```

Lo primero es el bloqueo por cuenta (cinco contraseñas malas, quince minutos). Después busca el usuario **y compara la contraseña siempre**, exista o no la cuenta: si el email no existe, compara contra un hash de relleno del mismo coste. Así el tiempo de respuesta no revela qué emails están registrados. Solo cuando la contraseña es correcta se mira si la cuenta está suspendida. Esto, que en la primera ronda estaba al revés, lo comprobé así:

```bash
== Pistas en el login
cuenta bloqueada + clave MAL ....... 401 Invalid credentials (incorrect email or password)
email inexistente + clave MAL ...... 401 Invalid credentials (incorrect email or password)
cuenta bloqueada + clave BIEN ...... 403 Account has been suspended. Please contact support.
mediana de 9 intentos: email existente 358 ms · email inexistente 353 ms
```

Si todo va bien, `createSessionAndTokens` crea la sesión y los dos tokens en una sola transacción:

```typescript
export async function createSessionAndTokens(
  user: TokenSubject,
  userAgent?: string,
  ipAddress?: string,
) {
  const sessionId: string = randomUUID();
  const { accessToken, refreshToken, refreshExpiresAt } = await signTokenPair(user, sessionId);

  await prisma.$transaction([
    prisma.session.create({
      data: {
        id: sessionId,
        userId: user.id,
        userAgent: userAgent || "Unknown Client",
        ipAddress: ipAddress || "unknown",
        isActive: true,
        expiresAt: refreshExpiresAt,
      },
    }),
    prisma.refreshToken.create({
      data: {
        tokenHash: hashToken(refreshToken),
        userId: user.id,
        sessionId,
        expiresAt: refreshExpiresAt,
      },
    }),
  ]);

  return {
    accessToken, refreshToken, sessionId /* … */
  };
}
```

```typescript
async function signTokenPair(user: TokenSubject, sessionId: string) {
  const nowSec = Math.floor(Date.now() / 1000);
  const refreshExp = nowSec + SESSION_TTL_SECONDS;

  const accessToken = await sign(
    {
      userId: user.id,
      sessionId,
      email: user.email,
      role: user.role as Role,
      iat: nowSec,
      exp: nowSec + ACCESS_TOKEN_TTL_SECONDS,
    },
    env.JWT_SECRET,
    "HS256",
  );

  const refreshToken = await sign(
    {
      userId: user.id,
      sessionId,
      iat: nowSec,
      jti: randomUUID(),
      exp: refreshExp,
    },
    env.JWT_REFRESH_SECRET,
    "HS256",
  );

  return { accessToken, refreshToken, refreshExpiresAt: new Date(refreshExp * 1000) };
```

Se emiten dos tokens firmados con **secretos distintos**. El **access token** (15 minutos) va en cada petición en `Authorization: Bearer`. El **refresh token** (7 días) solo sirve para pedir un par nuevo y, además de firmarse, **se guarda en la base de datos** (su hash SHA-256), así que se puede anular. Lleva un `jti` aleatorio para que dos tokens emitidos en el mismo segundo nunca sean idénticos.

Las contraseñas usan **bcryptjs**, la versión en JavaScript puro de bcrypt, con **12 rondas** (eran 10). Es algo más lenta que la nativa, pero no hay que compilar nada al instalar, ni en Alpine, ni en Windows, ni en el CI. El precio de las 12 rondas se nota: cada login tarda unos 370 ms, y por eso la suite de pruebas pasó de 4 a unos 38 segundos.

```typescript
const SALT_ROUNDS = 12;

let dummyHash: Promise<string> | undefined;

/**
 * Returns a real bcrypt hash (same cost factor as stored passwords) of a random secret.
 * Compared against when a login email does not exist, so both paths take the same time.
 */
export function getDummyHash(): Promise<string> {
  dummyHash ??= hashPassword(randomUUID());
  return dummyHash;
}

/**
 * Hashes a plaintext password using bcrypt with an automatically generated salt.
 * @param plainPassword Raw password entered by the user
 * @returns Resolves to the secure bcrypt hash string
 */
export async function hashPassword(plainPassword: string): Promise<string> {
  return bcrypt.hash(plainPassword, SALT_ROUNDS);
}

/**
 * Compares a plaintext password against a stored bcrypt hash in constant time
 * to prevent timing-attack vulnerabilities.
 * @param plainPassword Raw password candidate
 * @param hash Stored bcrypt hash from the database
 * @returns True if password matches hash, false otherwise
 */
export async function comparePassword(plainPassword: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plainPassword, hash);
}
```

### El middleware que revoca al instante

```typescript
export async function authMiddleware(c: Context<AppEnv>, next: Next) {
  const authHeader = c.req.header("Authorization");

  // Verify Bearer schema structure
  if (!authHeader?.startsWith("Bearer ")) {
    return errorResponse(
      c,
      "Unauthorized: Missing or malformed Bearer Token in Authorization header.",
      401,
    );
  }

  const token = authHeader.slice("Bearer ".length).trim();

  let payload: JwtPayload;
  try {
    // Decode and cryptographically verify the JWT signature
    payload = (await verify(token, env.JWT_SECRET, "HS256")) as unknown as JwtPayload;
  } catch {
    return errorResponse(c, "Invalid or expired authentication token.", 401);
  }

  // Every access token must be bound to a session so it can be revoked in real time
  if (!payload.userId || !payload.sessionId) {
    return errorResponse(c, "Invalid or expired authentication token.", 401);
  }

  const [user, session] = await Promise.all([
    prisma.user.findUnique({
      where: { id: payload.userId },
      select: { id: true, role: true, isBlocked: true, blockedReason: true, deletedAt: true },
    }),
    prisma.session.findUnique({
      where: { id: payload.sessionId },
      select: { userId: true, isActive: true, expiresAt: true },
    }),
  ]);

  // 1. Verify user existence and real-time suspension state
  if (!user || user.deletedAt) {
    return errorResponse(
      c,
      "Unauthorized: Account associated with this token no longer exists.",
      401,
    );
  }

  if (user.isBlocked) {
    return errorResponse(
      c,
      `Access denied: Account has been suspended. Reason: ${user.blockedReason || "Policy violation"}.`,
      403,
    );
  }

  // 2. Stateful session check: Verify that the session is active and belongs to the user
  if (!session?.isActive || session.userId !== user.id || session.expiresAt < new Date()) {
    return errorResponse(c, "Session has been revoked or expired. Please sign in again.", 401);
  }

  // Attach identity to context; the role comes from the database so role changes apply immediately
  c.set("user", { ...payload, role: user.role as Role });
  await next();
}

/**
 * Restricts a route to administrators. Must run after authMiddleware.
 */
export async function requireAdmin(c: Context<AppEnv>, next: Next) {
  if (c.get("user").role !== "admin") {
    return errorResponse(c, "Forbidden: This action requires administrator privileges.", 403);
  }
  await next();
}
```

Tres comprobaciones, en este orden:

1. **La firma** del JWT con `verify()` de `hono/jwt`, con el algoritmo fijado a HS256, y un `sessionId` obligatorio. Fijar el algoritmo evita los ataques que cambian el algoritmo en la cabecera del token (probé un token con `alg: none`: 401). Exigir el `sessionId` cierra un hueco de la primera ronda, donde un token sin él se saltaba la comprobación de sesión.
2. **El usuario** existe, no está borrado y no está bloqueado. Si un administrador lo suspende, su siguiente petición recibe un 403, aunque el token sea válido. Además, **el rol sale de la base de datos**, no del token: si degradas a un administrador, pierde el poder en la siguiente petición y no dentro de 15 minutos.
3. **La sesión** existe, es del mismo usuario, está activa y no ha caducado. Si se cerró desde otro dispositivo o con un logout, la siguiente petición recibe un 401.

El precio son **dos consultas por petición** (en paralelo). Con SQLite en el mismo proceso son microsegundos, y a cambio tienes algo que los JWT "puros" no dan: **revocación inmediata**. Las pruebas de extremo a extremo lo demuestran:

```bash
echo "🧪 4. TIRAR LA SESIÓN ESPECÍFICA DE ANA (DELETE /api/sessions/:id)"
echo "=========================================================="
REVOKE_RES=$(curl -s -X DELETE "$BASE_URL/api/sessions/$USER_SESSION_ID" \
  -H "Authorization: Bearer $USER_TOKEN")
echo "$REVOKE_RES"

echo -e "\nIntentando hacer una petición con el token de la sesión recién tirada:"
REVOKED_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE_URL/api/tasks" \
  -H "Authorization: Bearer $USER_TOKEN")
if [ "$REVOKED_STATUS" -eq 401 ]; then
  echo "✔ Correcto: El token fue rechazado inmediatamente (HTTP 401 - Sesión tirada en base de datos)"
else
  echo "❌ Error: La sesión tirada debió dar 401 pero dio $REVOKED_STATUS"
  exit 1
fi
```

```bash
$ npm run test:e2e        # bash test-api.sh, con la API arrancada
✔ Healthcheck funcionando correctamente
✔ Tokens generados para Admin y Ana (Session ID de Ana: 908852b7-60c7-43fa-95c5-0421fc3e22f2)
✔ Ana puede ver sus sesiones y dispositivos registrados en SQLite
✔ Correcto: El token fue rechazado inmediatamente (HTTP 401 - Sesión tirada en base de datos)
✔ Correcto: Ana quedó bloqueada en tiempo real (HTTP 403)
✔ Correcto: Login rechazado para cuenta bloqueada
✔ Correcto: Ana vuelve a acceder con normalidad tras ser desbloqueada
🎉 PRUEBAS DE STATEFUL SESSIONS Y BLOQUEO DE USUARIOS EXITOSAS
```

### Rotación de refresh tokens y detección de robo

Cada vez que usas un refresh token, se marca como usado y te dan otro, así que un refresh token solo sirve una vez. Si llega uno con la firma correcta **que ya se usó**, alguien lo está reutilizando: o lo robaron y el usuario legítimo ya lo había gastado, o al revés. Lo seguro es **cerrar la sesión entera**. La PR #10 movió toda esta lógica a `services/sessions.ts` y la hizo atómica:

```typescript
  const tokenHash = hashToken(refreshToken);
  const next = await signTokenPair(user, sessionId);

  const rotated = await 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;

    await tx.refreshToken.create({
      data: {
        tokenHash: hashToken(next.refreshToken),
        userId: user.id,
        sessionId,
        expiresAt: next.refreshExpiresAt,
      },
    });
    return true;
  });

  if (rotated) {
    return {
      status: "rotated",
      tokens: {
        accessToken: next.accessToken,
        refreshToken: next.refreshToken,
        expiresIn: ACCESS_TOKEN_TTL_SECONDS,
        sessionId,
      },
    };
  }

  const stored = await prisma.refreshToken.findUnique({ where: { tokenHash } });
  const graceMs = env.REFRESH_REUSE_GRACE_SECONDS * 1000;
  if (stored?.usedAt && now.getTime() - stored.usedAt.getTime() <= graceMs) {
    return { status: "concurrent" };
  }

  // Token Reuse Detection: a validly signed but already-rotated/revoked token was replayed
  await revokeSession(sessionId);
  return { status: "reused" };
}
```

Fíjate en el `updateMany` con `usedAt: null` dentro de una transacción: es lo que "reclama" el token de forma atómica, de modo que dos peticiones simultáneas no pueden rotar el mismo token. Y hay un matiz de diseño que me gusta y que conviene entender: la **ventana de gracia** (`REFRESH_REUSE_GRACE_SECONDS`, 10 s por defecto). Una SPA con dos pestañas puede refrescar a la vez, y no queremos que eso se interprete como un robo. Dentro de la ventana, el token reutilizado recibe un **409**; pasada la ventana, un **401** y la sesión se cierra. Lo comprobé con los dos casos y con dos refrescos realmente simultáneos:

```bash
== Reutilización del refresh token (REFRESH_REUSE_GRACE_SECONDS=10)
refresh 1 -> Tokens renewed successfully (Token Rotation)
reutilizar el viejo al instante -> 409  "…already rotated by a concurrent request…"
  ¿sigue viva la sesión? access nuevo -> 200
(espero 11 segundos)
reutilizar el viejo pasada la ventana -> 401  "Security alert: Refresh token has already been used or revoked. Session terminated."
  access nuevo tras la alarma -> 401
  refresh nuevo (el legítimo) tras la alarma -> 401
auditoría: {"action":"TOKEN_REUSE_DETECTED","entity":"Session","userId":"user-2"}
dos refresh a la vez con el mismo token -> 200 y 409
```

💡

La ventana tiene una consecuencia que no es un fallo pero hay que saber: si alguien te roba un refresh token y lo usa ****dentro de los 10 segundos** siguientes a tu propio refresco, solo recibe un 409 y no se dispara la alarma. Para un robo con reutilización posterior (lo normal), sí salta. Si tu riesgo es distinto, `REFRESH_REUSE_GRACE_SECONDS=0` la desactiva.

Y dos pruebas de Vitest lo cubren:

```typescript
  it("POST /api/auth/refresh reusing an already rotated token should terminate session", async () => {
    // Simulate that the rotation happened long ago (outside the concurrent-refresh grace period)
    await prisma.refreshToken.update({
      where: { tokenHash: hashToken(refreshToken) },
      data: { usedAt: new Date(Date.now() - 60 * 60 * 1000) },
    });

    // Re-submit the old (already rotated) refresh token
    const res = await app.request("/api/auth/refresh", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        refreshToken: refreshToken,
      }),
    });

    expect(res.status).toBe(401);
    const data = await res.json();
    expect(data.success).toBe(false);
    expect(data.message).toContain("Security alert");
  });
```

```typescript
  it("concurrent refreshes with the same token do not kill the session", async () => {
    const { refreshToken } = await createTestUser();
    const send = () =>
      app.request("/api/auth/refresh", {
        method: "POST",
        headers: jsonHeaders,
        body: JSON.stringify({ refreshToken }),
      });

    const responses = await Promise.all([send(), send()]);
    const statuses = responses.map((r) => r.status).sort();
    expect(statuses).toEqual([200, 409]);

    // The winning token pair must still be valid
    const winnerResponse = responses.find((r) => r.status === 200);
    if (!winnerResponse) throw new Error("No refresh request succeeded");
    const winner = await winnerResponse.json();
    const me = await app.request("/api/sessions/me", { headers: bearer(winner.accessToken) });
    expect(me.status).toBe(200);
    const next = await app.request("/api/auth/refresh", {
      method: "POST",
      headers: jsonHeaders,
      body: JSON.stringify({ refreshToken: winner.refreshToken }),
    });
    expect(next.status).toBe(200);
  });
```

### Logout, sesiones y bloqueo

```typescript
    // 1. If called with a Bearer token, terminate the current session
    if (authHeader?.startsWith("Bearer ")) {
      try {
        const payload = (await verify(
          authHeader.slice("Bearer ".length).trim(),
          env.JWT_SECRET,
          "HS256",
        )) as unknown as RefreshTokenPayload;
        if (payload.sessionId) revoked.set(payload.sessionId, payload.userId);
      } catch {
        // An expired/invalid access token must not prevent logout via refresh token
      }
    }

    // 2. If a refresh token was provided, revoke the session it belongs to
    if (refreshToken) {
      const stored = await prisma.refreshToken.findUnique({
        where: { tokenHash: hashToken(refreshToken) },
      });
      if (stored?.sessionId) revoked.set(stored.sessionId, stored.userId);
      else if (stored) await prisma.refreshToken.delete({ where: { id: stored.id } });
    }

    for (const [sessionId, userId] of revoked) {
```

```typescript
export async function revokeSession(sessionId: string) {
  const [sessions] = await prisma.$transaction([
    prisma.session.updateMany({
      where: { id: sessionId, isActive: true },
      data: { isActive: false },
    }),
    prisma.refreshToken.deleteMany({ where: { sessionId } }),
  ]);
  return sessions.count;
}
```

`POST /api/auth/logout` acepta el access token, el refresh token o los dos, y desactiva las sesiones correspondientes. `GET /api/sessions/me` lista mis sesiones con dispositivo e IP y marca la actual (`isCurrent`): con eso se hace la típica pantalla de "dispositivos conectados", con un botón para cerrar cada uno (`DELETE /api/sessions/:id`). Un usuario solo cierra las suyas; un administrador, cualquiera.

Y bloquear una cuenta la deja fuera **en la siguiente petición**, dentro de una transacción para que el bloqueo y el cierre de sesiones no puedan quedar a medias:

```typescript
    const update = prisma.user.update({
      where: { id },
      data: {
        isBlocked,
        blockedReason: isBlocked ? reason || "Suspended by administrator" : null,
      },
    });

    // Terminate all active sessions immediately upon suspension (atomically with the block)
    const [updatedUser, revokedSessions] = isBlocked
      ? await prisma.$transaction([update, ...userSessionRevocationOps(id)])
      : [await update, { count: 0 }];
    const revokedSessionsCount = revokedSessions.count;
```

Dos protecciones que se añadieron después: un administrador no puede bloquearse a sí mismo, y **no se puede borrar al último administrador activo**. Cambiar la contraseña, además, cierra todas las demás sesiones de la cuenta. Lo probé:

```typescript
async function isLastActiveAdmin(user: { id: string; role: string }) {
  if (user.role !== "admin") return false;
  const otherActiveAdmins = await prisma.user.count({
    where: { role: "admin", isBlocked: false, deletedAt: null, id: { not: user.id } },
  });
  return otherActiveAdmins === 0;
}
```

```typescript
    // A password change invalidates every other session (keep the caller's own session alive)
    const revokedSessionsCount = password
      ? await revokeUserSessions(id, {
          exceptSessionId: currentUser.userId === id ? currentUser.sessionId : undefined,
        })
      : 0;
```

```bash
== Cambiar la contraseña
Ana con dos sesiones (móvil y portátil); PUT password desde el portátil -> 200
  sesión del portátil (la que cambió) ... 200
  sesión del móvil ...................... 401  (y su refresh token también)
login con la clave vieja -> 401 · con la nueva -> 200
el admin cambia la clave de Ana -> todas las sesiones de Ana caen (401)
== Último administrador
el admin se borra a sí mismo ... 409 "Cannot delete the last active administrator."
el admin se bloquea a sí mismo . 400 "Administrators cannot suspend their own account."
```

## Recursos: tareas, usuarios y auditoría

Las tareas son el ejemplo de cómo construir un recurso "de verdad". Cada ruta es un `createRoute` con su esquema de entrada, sus respuestas por código de estado y la seguridad Bearer. Este es el de crear:

```typescript
const createTaskRoute = createRoute({
  method: "post",
  path: "/",
  tags: ["Tasks"],
  summary: "Crear tarea",
  description: "Crea una tarea asignada automáticamente al usuario del token. Genera un AuditLog.",
  security: secured,
  request: jsonBody(createTaskSchema),
  responses: {
    201: jsonResponse(successSchema(taskSchema), "Tarea creada"),
    ...errorResponses({ 400: "Error de validación", ...authErrors }),
  },
});
```

```typescript
export const createTaskSchema = z
  .object({
    title: z
      .string()
      .min(3, { message: "El título debe tener al menos 3 caracteres" })
      .max(100, { message: "El título no puede exceder 100 caracteres" })
      .openapi({ example: "Diseñar interfaz frontend" }),
    description: z
      .string()
      .max(2000, { message: "La descripción no puede exceder 2000 caracteres" })
      .optional()
      .default(""),
    completed: z.boolean().optional().default(false),
  })
  .openapi("CreateTaskRequest");
```

```typescript
export const taskQuerySchema = z.object({
  ...listParams,
  completed: z.enum(["true", "false"]).optional(),
  sortBy: z.enum(["createdAt", "title"]).default("createdAt"),
});
```

### El listado: dueño, filtros, orden y relaciones

```typescript
    // Apply generic filter[...] parameters first so they can never override the ownership scope
    const where: TaskWhereInput = parseFilters(c.req.query(), {
      allowedFields: {
        title: "string",
        description: "string",
        completed: "boolean",
        createdAt: "date",
      },
    });

    // Strict ownership filter: Always scope to the token's authenticated userId
    where.userId = currentUser.userId;

    // Soft delete filter: By default, exclude soft-deleted tasks unless explicitly requested
    if (includeDeleted !== "true") {
      where.deletedAt = null;
    }

    if (completed !== undefined) {
      where.completed = completed === "true";
    }

    if (search) {
      where.OR = [{ title: { contains: search } }, { description: { contains: search } }];
    }

    // Apply sorting (prioritize ?sort=-field if provided, fallback to sortBy & order)
    const orderBy = sort
      ? parseSorting(sort, { allowedFields: ["createdAt", "title", "completed"] })
      : { [sortBy]: order };

    const include = parseIncludes(c.req.query("include"), TASK_INCLUDES);

    const [total, tasks] = await Promise.all([
      prisma.task.count({ where }),
      prisma.task.findMany({
        where,
        skip,
        take: limit,
        orderBy,
        include,
      }),
    ]);

    return successResponse(c, tasks, { pagination: buildPagination(total, page, limit) });

```

Hay mucho en pocas líneas:

- **El filtro por dueño va siempre** (`userId: currentUser.userId`), y el `userId` sale del token, nunca de la petición. Se aplica **después** de los filtros dinámicos para que ninguno pueda pisarlo.
- **Borrado lógico**: las tareas borradas no salen, salvo que pidas `?includeDeleted=true`.
- **Filtros dinámicos** del estilo `?filter[completed]=true` o `?filter[title][contains]=informe`, con campos **tipados** y una lista blanca: un valor mal formado da un 400 claro en lugar de un 500.
- **Orden** con `?sort=-createdAt,title` (el guion es descendente), también con lista blanca.
- **Relaciones** con `?include=user`, que añade el autor sin su contraseña.
- `count` y `findMany` **en paralelo** con `Promise.all`.

Las funciones de `src/lib/query.ts` que traducen la URL a una consulta de Prisma:

```typescript
export function parseFilters(
  query: Record<string, string | undefined>,
  options: FilterOptions,
): Record<string, unknown> {
  const where: Record<string, unknown> = {};

  for (const [key, val] of Object.entries(query)) {
    if (!val) continue;

    // Pattern: filter[field] or filter[field][operator]
    const match = key.match(/^filter\[([a-zA-Z0-9_]+)\](?:\[([a-zA-Z0-9_]+)\])?$/);
    if (!match) continue;

    const [, field, operator = "eq"] = match;
    if (!Object.hasOwn(options.allowedFields, field)) continue;

    const type = options.allowedFields[field];
    if (!OPERATORS[type].includes(operator)) {
      invalidFilter(`operator '${operator}' is not supported for '${field}'`);
    }

    if (operator === "eq") {
      where[field] = coerceValue(field, type, val);
    } else if (operator === "in") {
      where[field] = { in: val.split(",").map((s) => coerceValue(field, type, s.trim())) };
    } else {
      where[field] = { [operator]: operator === "contains" ? val : coerceValue(field, type, val) };
    }
  }

  return where;
}
```

```typescript
export function parseSorting(
  sortParam: string | undefined,
  options: SortOptions,
): Array<Record<string, "asc" | "desc">> {
  const defaultSort = options.defaultSort || "createdAt";
  const defaultOrder = options.defaultOrder || "desc";

  const orderBy: Array<Record<string, "asc" | "desc">> = [];
  const seen = new Set<string>();

  for (const part of (sortParam ?? "").split(",").map((p) => p.trim())) {
    const isDesc = part.startsWith("-");
    const field = isDesc ? part.slice(1) : part;

    if (options.allowedFields.includes(field) && !seen.has(field)) {
      seen.add(field);
      orderBy.push({ [field]: isDesc ? "desc" : "asc" });
    }
  }

  return orderBy.length > 0 ? orderBy : [{ [defaultSort]: defaultOrder }];
  // … si no hay campos válidos, orden por defecto
}
```

La lista blanca es lo importante. Sin ella, `?filter[userId]=otro` se saltaría la propiedad de las tareas, y `?sort=password` dejaría ordenar por el hash. Con ella, un campo que no está en la lista se ignora sin más (lo probé: `filter[userId]=user-1` devolvió solo las tareas de Ana). Lo mismo para las relaciones:

```typescript
export function parseIncludes<T extends Record<string, unknown>>(
  includeParam: string | undefined,
  allowedMap: T,
): Partial<T> | undefined {
  if (!includeParam || typeof includeParam !== "string") {
    return undefined;
  }

  const requested = includeParam
    .split(",")
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);

  if (requested.length === 0) {
    return undefined;
  }

  const result: Record<string, unknown> = {};
  let hasValidRelation = false;

  for (const rel of requested) {
    if (Object.hasOwn(allowedMap, rel)) {
      result[rel] = allowedMap[rel];
      hasValidRelation = true;
    }
  }

  return hasValidRelation ? (result as Partial<T>) : undefined;
}
```

### Propiedad estricta y borrado lógico

Ver, editar, borrar y restaurar siguen el mismo patrón, ahora en una función: primero se busca (404 si no existe o está borrada) y después se comprueba el dueño (403 si no es tuya).

```typescript
async function findOwnedTask(
  c: Context<AppEnv>,
  id: string,
  options: { action: string; allowDeleted?: boolean },
) {
  const task = await prisma.task.findUnique({ where: { id } });

  if (!task || (task.deletedAt && !options.allowDeleted)) {
    return { error: errorResponse(c, `Task with ID '${id}' not found.`, 404) };
  }

  if (task.userId !== c.get("user").userId) {
    return {
      error: errorResponse(
        c,
        `Access denied: You do not have permission to ${options.action} this task.`,
        403,
      ),
    };
  }

  return { task };
}
```

Al crear, el dueño sale del token y queda registrado en la auditoría. `DELETE` hace **borrado lógico** por defecto, o definitivo con `?permanent=true`, y `POST /api/tasks/:id/restore` recupera una tarea borrada:

```typescript
  .openapi(createTaskRoute, async (c) => {
    const { title, description, completed } = c.req.valid("json");
    const currentUser = c.get("user");

    const newTask = await prisma.task.create({
      data: {
        userId: currentUser.userId, // Secure assignment from verified token
        title,
        description,
        completed,
      },
    });

    await recordAudit(c, {
      userId: currentUser.userId,
      action: "CREATE",
      entity: "Task",
      entityId: newTask.id,
      details: { title: newTask.title },
    });    return successResponse(c, newTask, { status: 201 });
  })
```

```typescript
    const softDeleted = await prisma.task.update({
      where: { id },
      data: { deletedAt: new Date() },
    });

    // … recordAudit(SOFT_DELETE) y successResponse(c, softDeleted)
```

```bash
== Tareas ajenas (Ana contra las del admin)
GET task-1 ......................... 403
PUT task-1 (título válido) ......... 403
DELETE task-1 ...................... 403
?filter[userId]=user-1 ............. 200  → solo ["Design Frontend UI"] (el filtro se ignora)
?sort=password ..................... 200  (campo fuera de la lista blanca: se ignora)
?include=user ...................... 200  → user: email, id, name, role (sin hash)
sin token en las 5 rutas protegidas  401
POST de 150 KB ..................... 413
JSON mal formado ................... 400
JWT con alg=none ................... 401
refresh token usado como Bearer .... 401
```

### Usuarios

Este es el arreglo más importante de la PR #8, y los siguientes lo completaron. En la primera versión, las rutas de usuarios comprobaban que hubiera un token válido, pero no **de quién** era: cualquier cuenta normal podía cambiar la contraseña del administrador con un `PUT` y entrar como él. Ahora listar y crear son solo para administradores (con el middleware `requireAdmin` en la definición de la ruta), y ver, editar o borrar, para uno mismo o un administrador:

```typescript
const listRoute = createRoute({
  method: "get",
  path: "/",
  tags: ["Users"],
  summary: "Listar usuarios (Solo Admin)",
  description:
    "Lista paginada con búsqueda, orden y filtros dinámicos tipados `filter[campo]` sobre `name`, `email`, `role`, `isBlocked` y `createdAt`. Los usuarios eliminados solo aparecen con `includeDeleted=true`.",
  security: secured,
  middleware: [requireAdmin] as const,
```

```typescript
export async function requireAdmin(c: Context<AppEnv>, next: Next) {
  if (c.get("user").role !== "admin") {
    return errorResponse(c, "Forbidden: This action requires administrator privileges.", 403);
  }
  await next();
}
```

```typescript
  .openapi(updateRoute, async (c) => {
    const { id } = c.req.valid("param");
    const currentUser = c.get("user");

    if (currentUser.role !== "admin" && currentUser.userId !== id) {
      return errorResponse(
        c,
        "Forbidden: You do not have permission to modify another user's profile.",
        403,
      );
    }
```

La PR #8 protegió el listado, `PUT` y `DELETE`, pero se dejó `GET /api/users/:id`. Y como ese endpoint acepta `?include=sessions,tasks`, en mi segunda ronda un usuario normal veía el email del administrador, sus sesiones (con IP y dispositivo) y sus tareas. La PR #10 le puso la misma comprobación:

```typescript
  .openapi(getRoute, async (c) => {
    const { id } = c.req.valid("param");
    const currentUser = c.get("user");

    if (currentUser.role !== "admin" && currentUser.userId !== id) {
      return errorResponse(
        c,
        "Forbidden: You do not have permission to view this user profile.",
        403,
      );
    }
    // … findUnique con parseIncludes(c.req.query("include"), USER_INCLUDES)
```

```bash
== GET /api/users/:id (el fallo que quedaba tras la PR #8)
Ana -> /api/users/user-1?include=sessions,tasks ..... 403  "Forbidden: You do not have permission to view this user profile."
Ana -> /api/users/user-1 (sin include) .............. 403
Ana -> su propio perfil con ?include=sessions,tasks . 200
Admin -> perfil de Ana ?include=sessions ............ 200
```

```bash
== Permisos (Ana, rol "user", contra el administrador)
PUT contraseña del admin ............ 403   (y el login del admin con esa clave: false)
DELETE admin ........................ 403
DELETE admin ?permanent=true ........ 403
GET /api/users ...................... 403
POST /api/users (crear cuenta) ...... 403
PATCH bloquear al admin ............. 403
POST revocar sesiones del admin ..... 403
DELETE la sesión del admin .......... 403
GET /api/audit-logs ................. 403
PUT su propio nombre ................ 200
PUT su propio rol a "admin" ......... 400   (rol después: user)
register con "role":"admin" ......... 201   (rol recibido: user)
Admin GET /api/users ................ 200
```

Fíjate en las líneas del rol: Ana no puede subirse el rol a `admin` ni registrándose con `"role":"admin"` (el esquema de registro ni siquiera lo acepta y la cuenta sale con rol `user`), y el esquema de edición solo admite `name`, `email` y `password`. Y hay una prueba de Vitest que impide que vuelva a pasar lo del principio:

```typescript
  it("Standard user cannot list all users (GET /api/users should return 403)", async () => {
    // 1. Login as standard user (Ana)
    const loginRes = await app.request("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: "ana@example.com",
        password: "password123",
      }),
    });
    const { accessToken: userToken } = await loginRes.json();

    // 2. Attempt to list users
    const listRes = await app.request("/api/users", {
      method: "GET",
      headers: { Authorization: `Bearer ${userToken}` },
    });
    expect(listRes.status).toBe(403);

    // 3. Attempt to inspect admin account (with or without includes)
    const getOtherRes = await app.request("/api/users/user-1?include=sessions,tasks", {
      headers: { Authorization: `Bearer ${userToken}` },
    });
    expect(getOtherRes.status).toBe(403);

    // 4. Attempt to modify admin account
    const putRes = await app.request("/api/users/user-1", {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${userToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ name: "Hacked Admin" }),
    });
    expect(putRes.status).toBe(403);
  });
```

### Auditoría

Las operaciones que cambian datos (crear, editar, borrar, bloquear, entrar, fallar un login, detectar un token reutilizado) llaman a `recordAudit()`:

```typescript
export async function recordAudit(c: Context, options: LogAuditOptions): Promise<void> {
  try {
    const ipAddress = getClientIp(c) ?? null;

    const userAgent = c.req.header("user-agent") || null;
    const details =
      typeof options.details === "object"
        ? JSON.stringify(options.details)
        : options.details || null;
    // … prisma.auditLog.create({ data: { userId, action, entity, entityId, details, ipAddress, userAgent } })
  } catch (error) {
    logger.error({ err: error }, "Failed to persist audit log entry");
  }
}
```

Nunca lanza excepciones: si falla el registro, se apunta en el log y la operación del usuario sigue adelante. Un administrador los consulta en `GET /api/audit-logs?entity=Task&action=SOFT_DELETE`, paginados y con el usuario que lo hizo. La IP de cada entrada sale de `getClientIp()`, la misma función que usa el rate limit.

| Método y ruta                                                                   | Qué hace                                                                                               | Quién                                           |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| GET /healthz · /docs · /openapi.json                                            | Salud con latencia de la BD, consola Scalar y especificación (las dos últimas, apagadas en producción) | Público                                         |
| POST /api/auth/register                                                         | Crea una cuenta con rol user (cualquier role enviado se ignora)                                        | Público, 5 por hora y IP                        |
| POST /api/auth/login                                                            | Comprueba la contraseña, crea la sesión y devuelve los dos tokens                                      | Público, 10 por minuto y IP; bloqueo por cuenta |
| POST /api/auth/refresh                                                          | Par nuevo con rotación; si el token ya se usó, cierra la sesión                                        | Refresh token                                   |
| POST /api/auth/logout                                                           | Desactiva la sesión y borra sus refresh tokens                                                         | Access o refresh                                |
| GET /api/sessions/me · DELETE /api/sessions/:id · POST /api/sessions/revoke-all | Ver y cerrar sesiones                                                                                  | Las mías (admin: cualquiera)                    |
| GET/POST/PUT/DELETE /api/tasks · POST /api/tasks/:id/restore                    | CRUD con filtros, orden, ?include=user, borrado lógico y restauración                                  | Solo el dueño                                   |
| GET /api/users · POST /api/users                                                | Listado con filtros, orden e ?include=tasks,sessions; alta con cualquier rol                           | Solo admin                                      |
| GET / PUT / DELETE /api/users/:id                                               | Ver, editar o borrar (lógico, o ?permanent=true)                                                       | Uno mismo o admin                               |
| PATCH /api/users/:id/block                                                      | Suspender o reactivar y cerrar todas sus sesiones                                                      | Solo admin                                      |
| POST /api/users/:id/revoke-sessions                                             | Cerrar todas las sesiones de una cuenta                                                                | Uno mismo o admin                               |
| GET /api/audit-logs                                                             | Registro de auditoría paginado, por entidad y acción                                                   | Solo admin                                      |

## Rate limiting y la IP real

Hay dos defensas distintas contra la fuerza bruta, y conviene no mezclarlas. La primera es un límite **por IP** en `/login` (10 por minuto), `/refresh` (30 por minuto) y `/register` (5 por hora), configurable con `LOGIN_RATE_LIMIT_MAX` y compañía:

```typescript
// Per-IP rate limiting on sensitive endpoints
router.use("/login", rateLimiter(60_000, env.LOGIN_RATE_LIMIT_MAX));
router.use("/refresh", rateLimiter(60_000, env.REFRESH_RATE_LIMIT_MAX));
router.use("/register", rateLimiter(60 * 60_000, env.REGISTER_RATE_LIMIT_MAX));

// Per-account lockout: 5 failed passwords lock the account for 15 minutes
const loginLockout = createLoginLockout();
```

```typescript
export function rateLimiter(windowMs: number = 60_000, maxRequests: number = 10) {
  const ipStore = new Map<string, RateLimitRecord>();

  // Periodically clean up expired IP records to prevent memory leak
  const interval = setInterval(() => {
    const now = Date.now();
    for (const [ip, record] of ipStore.entries()) {
      if (now > record.resetAt) {
        ipStore.delete(ip);
      }
    }
  }, windowMs);
  interval.unref?.();

  return async (c: Context, next: Next) => {
    // Proxy headers are only honoured when TRUST_PROXY is enabled (see getClientIp)
    const ip = getClientIp(c) || "127.0.0.1";

    const now = Date.now();
    const clientRecord = ipStore.get(ip);

    // Initial request or window expired
    if (!clientRecord || now > clientRecord.resetAt) {
      ipStore.set(ip, {
        count: 1,
        resetAt: now + windowMs,
      });
      c.header("X-RateLimit-Limit", maxRequests.toString());
      c.header("X-RateLimit-Remaining", (maxRequests - 1).toString());
      await next();
      return;
    }

    // Rate limit exceeded
    if (clientRecord.count >= maxRequests) {
      const retryAfterSec = Math.ceil((clientRecord.resetAt - now) / 1000);
      c.header("Retry-After", retryAfterSec.toString());
      c.header("X-RateLimit-Limit", maxRequests.toString());
      c.header("X-RateLimit-Remaining", "0");

      return c.json(
        {
          success: false,
          message: "Too many requests. Please slow down and try again later.",
          retryAfterSeconds: retryAfterSec,
        },
        429,
      );
    }

    // Increment request count within active window
```

Un `Map` en memoria con un contador y una hora de reinicio por IP, y un `setInterval` que borra las entradas caducadas. Al pasarse, **429** con `Retry-After`. Como vive en la memoria del proceso, el límite es por instancia: con varias réplicas necesitarías Redis. La segunda defensa es el **bloqueo por cuenta**, que frena al atacante que va cambiando de IP:

```typescript
export function createLoginLockout(maxFailures = 5, lockoutMs = 15 * 60_000) {
  const failures = new Map<string, RateLimitRecord>();

  const interval = setInterval(() => {
    const now = Date.now();
    for (const [key, record] of failures.entries()) {
      if (now > record.resetAt) failures.delete(key);
    }
  }, lockoutMs);
  interval.unref?.();

  return {
    /** Seconds until the account unlocks, or 0 if login attempts are allowed */
    retryAfterSeconds(key: string): number {
      const record = failures.get(key);
      if (!record || Date.now() > record.resetAt || record.count < maxFailures) return 0;
      return Math.ceil((record.resetAt - Date.now()) / 1000);
    },
    recordFailure(key: string) {
      const now = Date.now();
      const record = failures.get(key);
      if (!record || now > record.resetAt) {
        failures.set(key, { count: 1, resetAt: now + lockoutMs });
      } else {
        record.count++;
      }
    },
    reset(key: string) {
    // … reset(key) borra el contador
  };
}
```

La parte delicada del límite por IP es **de dónde sale la IP**. En la primera versión se tomaba el primer valor de `X-Forwarded-For`, y bastaba con cambiar esa cabecera para que el límite no saltara nunca. Ahora todo pasa por `getClientIp()`:

```typescript
export function getClientIp(c: Context): string | undefined {
  if (env.TRUST_PROXY) {
    const forwarded =
      c.req.header("cf-connecting-ip")?.trim() ||
      c.req.header("x-forwarded-for")?.split(",")[0].trim() ||
      c.req.header("x-real-ip")?.trim();
    if (forwarded) return forwarded;
  }

  try {
    return getConnInfo(c).remote.address;
  } catch { /* … */ }
}
```

Sin `TRUST_PROXY`, se usa la IP de la conexión TCP y las cabeceras no valen nada. Lo repetí con 40 intentos (cada uno con un email distinto para no activar el bloqueo por cuenta):

```bash
== Rate limit de login (10/min por IP), 40 intentos con un email distinto en cada uno
TRUST_PROXY sin definir, sin cabeceras ............ 10 × 401 · 30 × 429
TRUST_PROXY sin definir, rotando X-Forwarded-For .. 10 × 401 · 30 × 429   ← ya no se salta
TRUST_PROXY sin definir, rotando cf-connecting-ip . 10 × 401 · 30 × 429   ← ya no se salta
TRUST_PROXY=true, rotando X-Forwarded-For ......... 40 × 401             ← se salta
TRUST_PROXY=true, rotando cf-connecting-ip ........ 40 × 401             ← se salta
TRUST_PROXY=true, rotando x-real-ip ............... 40 × 401             ← se salta
```

Sin `TRUST_PROXY` la plantilla está bien. El problema es el otro lado: si `TRUST_PROXY=true`, lo que diga el cliente se cree. La pregunta útil es qué hace de verdad tu proxy con esas cabeceras, así que en lugar de suponerlo levanté un **Traefik v3.7 real** (el mismo que usa Coolify) delante de la API y le mandé cabeceras falsas:

```bash
# Lo que recibe la API cuando el cliente envía cabeceras falsas
# (X-Forwarded-For: 6.6.6.6, 7.7.7.7 · cf-connecting-ip: 8.8.8.8 · X-Real-IP: 9.9.9.9)

Traefik v3.7 con la configuración por defecto de Coolify:
{"xff":"10.0.5.1","cf":"8.8.8.8","xreal":"10.0.5.1"}
   → XFF y X-Real-IP los reescribe Traefik; cf-connecting-ip pasa tal cual

Traefik con forwardedHeaders.trustedIPs (lo que se hace detrás de Cloudflare):
{"xff":"6.6.6.6, 7.7.7.7, 10.0.5.1","cf":"8.8.8.8", …}
   → el cliente escribe el principio de X-Forwarded-For
```

Es un resultado con matices, y por eso no me quedo con la versión corta de "X-Forwarded-For se puede falsificar". Con la configuración por defecto de Coolify, Traefik **reescribe** `X-Forwarded-For` y `X-Real-IP` con la IP real, así que falsificarlas no sirve. Pero deja pasar `cf-connecting-ip` intacta, y `getClientIp()` la mira **primero**. Y cuando Traefik confía en quien tiene delante (`trustedIPs`, lo que se configura detrás de Cloudflare para ver la IP real), `X-Forwarded-For` llega con lo que escribió el cliente **al principio**, y `getClientIp()` toma justo el primer valor. Con la API detrás de ese Traefik:

```bash
== API con TRUST_PROXY=true detrás de Traefik real, 40 intentos rotando la cabecera
Traefik por defecto, X-Forwarded-For ............ 10 × 401 · 30 × 429   ← aguanta
Traefik por defecto, cf-connecting-ip ........... 40 × 401             ← se salta
Traefik con trustedIPs, X-Forwarded-For ......... 40 × 401             ← se salta

== Con clientIp() de más abajo (último valor de X-Forwarded-For), las tres
Traefik por defecto, X-Forwarded-For ............ 10 × 401 · 30 × 429
Traefik por defecto, cf-connecting-ip ........... 10 × 401 · 30 × 429
Traefik con trustedIPs, X-Forwarded-For ......... 10 × 401 · 30 × 429
IP guardada en la sesión de un login normal ..... 10.0.5.1 (la del cliente según Traefik)
```

La regla que arregla las tres cosas es la de siempre: **solo se cree la parte que escribió tu propio proxy**, que es el **último** valor de `X-Forwarded-For` (cada proxy añade al final la dirección de la que recibió la petición). Este `getClientIp()` es el que probé; los tres ataques de arriba dan 429, y el `tsc`, Biome y las 25 pruebas de `security` y `unit` siguen en verde:

```typescript
export function getClientIp(c: Context): string | undefined {
  if (env.TRUST_PROXY) {
    // Each proxy appends the address it received the request from, so only the LAST value
    // was written by our own proxy; everything before it is whatever the client sent.
    const forwarded = c.req.header("x-forwarded-for")?.split(",").at(-1)?.trim();
    if (forwarded) return forwarded;
  }

  try {
    return getConnInfo(c).remote.address;
  } catch {
    // Non-socket environments (e.g. app.request() in unit tests)
    return undefined;
  }
}
```

💡

Si tu API está detrás de ****Cloudflare y de Traefik a la vez**, hay dos saltos y el último valor es la IP de Cloudflare, no la del usuario. Entonces lo correcto es leer `cf-connecting-ip`, pero ****solo** si el servidor no es accesible más que desde Cloudflare (si alguien llega directo a tu IP, la inventa). Es una decisión de topología, no de código, y por eso la plantilla debería dejarla elegir con una variable en lugar de suponerla.

Otra cara del mismo asunto: **sin** `TRUST_PROXY` y con un proxy delante, todas las peticiones llegan desde la IP del proxy y comparten un único contador. Por eso el `.env.example` recomienda activarlo cuando hay un proxy de confianza. Y la prueba que vigila el caso seguro:

```typescript
  it("ignores X-Forwarded-For when TRUST_PROXY is disabled", async () => {
    const { email, password } = await createTestUser();
    const res = await app.request("/api/auth/login", {
      method: "POST",
      headers: { ...jsonHeaders, "X-Forwarded-For": "6.6.6.6" },
      body: JSON.stringify({ email, password }),
    });
    const { sessionId } = await res.json();
    const session = await prisma.session.findUniqueOrThrow({ where: { id: sessionId } });
    expect(session.ipAddress).not.toBe("6.6.6.6");

    const audit = await prisma.auditLog.findFirst({
      where: { action: "LOGIN", entityId: sessionId },
    });
    expect(audit?.ipAddress).not.toBe("6.6.6.6");
  });
```

### El bloqueo por cuenta también es un arma

El bloqueo por cuenta protege, pero tiene el reverso de siempre: cualquiera que conozca el email de tu administrador (por ejemplo el de `ADMIN_EMAIL`) puede bloquearle el login 15 minutos con cinco peticiones, y repetirlo:

```bash
== Bloqueo por cuenta (5 fallos = 15 min)
admin: 5 contraseñas malas y ahora la CORRECTA -> 429  Retry-After: 899
   "Too many failed login attempts for this account. Please try again later."
otro usuario (ana) sigue entrando ................. 200
la sesión que el admin ya tenía abierta ........... 200 (sigue funcionando)
```

Lo bueno es que las sesiones ya abiertas siguen funcionando, y que el contador vive en memoria (un reinicio lo limpia). No lo llamaría un fallo, es una decisión de diseño con contrapartida, y para una API interna es razonable. Si te preocupa, las salidas habituales son contar por pareja (email, IP) en lugar de solo por email, o retrasar cada intento fallido en lugar de bloquear del todo. No las he probado.

## Tareas en segundo plano

Con sesiones en la base de datos, las tablas `Session` y `RefreshToken` crecen con cada login. Una tarea periódica las limpia:

```typescript
    const [sessionsRes, tokensRes] = await prisma.$transaction([
      // 1. Purge sessions that are either naturally expired or marked inactive for >24 hours
      //    (refresh tokens of deleted sessions are removed by the ON DELETE CASCADE relation)
      prisma.session.deleteMany({
        where: {
          OR: [
            { expiresAt: { lt: now } },
            {
              isActive: false,
              updatedAt: { lt: new Date(now.getTime() - DAY_MS) },
            },
          ],
        },
      }),
      // 2. Purge expired tokens and tokens rotated more than 24 hours ago. A replay of a purged
      //    token is still detected as reuse because it no longer matches any stored hash.
      prisma.refreshToken.deleteMany({
        where: {
          OR: [{ expiresAt: { lt: now } }, { usedAt: { lt: new Date(now.getTime() - DAY_MS) } }],
        },
      }),
    ]);
```

```typescript
export function startCleanupJob(intervalMs: number = 60 * 60 * 1000): NodeJS.Timeout {
  // Run an initial non-blocking cycle on startup
  void cleanupExpiredSessions();

  // Schedule recurring timer
  const timer = setInterval(() => {
    void cleanupExpiredSessions();
  }, intervalMs);

  // Unref timer so it does not keep the Node process alive during shutdown
  timer.unref();

  return timer;
}
```

Se ejecuta al arrancar y luego cada hora; el `unref()` evita que el temporizador impida cerrar el proceso. Borra las sesiones caducadas y las inactivas desde hace más de 24 horas (ese margen permite ver en `/api/sessions/me` las que se acaban de cerrar), y los tokens caducados o rotados hace más de un día. Un token purgado que alguien reutilice sigue detectándose como robo, porque ya no coincide con ningún hash guardado.

## Documentación: OpenAPI generado desde las rutas

Esto es lo que más cambió con la PR #11\. La primera versión traía la especificación OpenAPI escrita a mano en `src/docs/openapi.ts`, y estaba desincronizada: faltaban las rutas de sesiones, el bloqueo y `revoke-sessions`. Ahora ese fichero no existe. Cada ruta se declara con `createRoute`, y `createRouter()` hace que los errores de validación de Zod salgan en el mismo formato que todos los demás:

```typescript
export const createRouter = () =>
  new OpenAPIHono<AppEnv>({
    defaultHook: (result, c) => {
      if (!result.success) {
        return c.json(
          {
            success: false as const,
            message:
              result.target === "json"
                ? "Validation error in request payload"
                : `Invalid request ${result.target} parameters`,
            errors: formatIssues(result.error.issues),
          },
          400,
        );
      }
    },
  });
```

`/docs` sirve **Scalar**, una consola más moderna que Swagger UI: lista los endpoints, enseña los esquemas y deja lanzar peticiones reales pegando el token. `/openapi.json` tiene ahora las **22 operaciones** de la API, y no hay que acordarse de actualizarlo, porque hay una prueba que falla si añades una ruta sin documentar:

```typescript
  it("documents every registered route (no drift between code and spec)", async () => {
    const spec = await getSpec();
    const documented = new Set(
      Object.entries(spec.paths).flatMap(([path, ops]) =>
        Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`),
      ),
    );

    const undocumented = app.routes
      .filter((r) => r.method !== "ALL")
      .map((r) => `${r.method} ${r.path.replace(/:(\w+)/g, "{$1}")}`)
      .filter((route) => {
        const path = route.split(" ")[1];
        return (path.startsWith("/api") || path === "/healthz") && !documented.has(route);
      });

    expect(undocumented).toEqual([]);
  });
```

Esa misma prueba valida el documento con `swagger-parser` y comprueba que las respuestas reales cumplen los esquemas publicados.

## Probar la API

### Vitest, sin levantar el servidor

Las 60 pruebas de `tests/` usan `app.request()` de Hono, que ejecuta la aplicación en memoria sin abrir ningún puerto. Cada ejecución crea su **propia base de datos SQLite temporal**, aplica las migraciones y la borra al terminar, así que nunca toca tu `dev.db`:

```typescript
// Every test run uses its own throwaway SQLite database (never the developer's dev.db)
const testDatabaseUrl = `file:${path.join(tmpdir(), `hono-api-test-${process.pid}-${Date.now()}.db`)}`;
process.env.TEST_DATABASE_URL = testDatabaseUrl;

export default defineConfig({
```

```typescript
import { execSync } from "node:child_process";
import { rmSync } from "node:fs";

/**
 * Applies all Prisma migrations to the throwaway test database before the suite runs,
 * and deletes the database file afterwards.
 */
export default function setup() {
  const databaseUrl = process.env.TEST_DATABASE_URL;
  if (!databaseUrl) throw new Error("TEST_DATABASE_URL was not set by vitest.config.ts");

  execSync("npx prisma migrate deploy", {
    stdio: "ignore",
    env: { ...process.env, DATABASE_URL: databaseUrl, TURSO_DATABASE_URL: "" },
  });

  return () => {
    const file = databaseUrl.replace(/^file:/, "");
    for (const suffix of ["", "-journal", "-wal", "-shm"]) {
      rmSync(`${file}${suffix}`, { force: true });
    }
  };
}
```

Los ficheros son `auth`, `security`, `users`, `tasks`, `unit`, `health`, `openapi` y `rpc`. Cubren permisos, rotación y reutilización de tokens, bloqueo por cuenta, hashes, filtros, la propiedad de las tareas, el borrado lógico, la IP del cliente y que la especificación no se desincronice. El CI ejecuta en cada push y pull request la auditoría de dependencias, la comprobación de que las migraciones coinciden con el esquema, los tipos, el build, Biome y Vitest con cobertura:

```yaml
name: CI & Typecheck

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build-and-verify:
    runs-on: ubuntu-latest

    steps:
      - name: 📥 Clonar repositorio
        uses: actions/checkout@v4

      - name: ⚙️ Configurar Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: 📦 Instalar dependencias
        run: npm ci

      - name: 🛠️ Generar Prisma Client
        run: npx prisma generate

      - name: 🛡️ Auditoría de dependencias de producción
        run: npm audit --omit=dev --audit-level=high

      - name: 🗄️ Verificar que las migraciones coinciden con el schema
        run: npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code

      - name: 🔎 Verificación de tipos (código y tests, incluye el cliente RPC tipado)
        run: npm run typecheck

      - name: 🏗️ Compilación (Build)
        run: npm run build

      - name: ⚡ Linting & Formatting Check (Biome)
        run: npm run lint

      # Los tests crean y migran su propia base de datos SQLite temporal (ver vitest.config.ts)
      - name: 🧪 Ejecutar Tests Automatizados con cobertura (Vitest)
        run: npm run test:coverage
```

### Un cliente RPC con tipos de verdad

El README anuncia un cliente con tipos de extremo a extremo con `hc<AppType>`. En la primera ronda lo probé y salía como `unknown`: no había autocompletado ni comprobación. El motivo es que los tipos de Hono solo se acumulan si las rutas se **encadenan**, y los routers las declaraban en sentencias separadas. La PR #11 encadenó cada router, y una prueba que compila un cliente vigila que no vuelva a romperse:

```typescript
const client = (token?: string) =>
  hc<AppType>("http://localhost", {
    fetch: app.request,
    headers: token ? { Authorization: `Bearer ${token}` } : undefined,
  });
```

Yo lo he comprobado con un cliente fuera de los tests, contra la ruta nueva de notas que añado más abajo. El compilador rechaza un campo mal escrito, que es justo lo que se promete:

```typescript
import { hc } from "hono/client";
import type { AppType } from "../src/index.js";

const client = hc<AppType>("http://localhost:3011");

const login = await client.api.auth.login.$post({
  json: { email: "ana@example.com", password: "password123" },
});
if (!login.ok) throw new Error("login failed");
const { accessToken } = await login.json(); // string, con tipos

const api = hc<AppType>("http://localhost:3011", {
  headers: { Authorization: `Bearer ${accessToken}` },
});
const res = await api.api.notes.$post({ json: { title: "Desde el cliente RPC", pinned: true } });
if (res.status === 201) console.log((await res.json()).data.title);
const bad = await api.api.notes.$post({ json: { titel: "typo" } }); // ← error de compilación
```

```bash
$ npx tsc --noEmit -p client/tsconfig.json
client/rpc.ts(17,49): error TS2561: Object literal may only specify known properties,
but 'titel' does not exist in type '{ title: string; content?: string | undefined; pinned?: boolean | undefined; }'.
Did you mean to write 'title'?
```

### test-api.sh, Bruno y curl

Además de Vitest, `npm run test:e2e` lanza `test-api.sh`, una batería en bash y curl contra el servidor arrancado que recorre sesiones, revocación y bloqueo (la salida la has visto más arriba). Y para probar a mano está [Bruno](https://www.usebruno.com/?ref=blog.devutilites.com), un cliente de API de código abierto, como Postman, pero sin nube ni cuenta: cada petición es un fichero `.bru` en el repositorio.

```text
meta {
  name: Login Admin
  type: http
  seq: 1
}

post {
  url: {{baseUrl}}/api/auth/login
  body: json
  auth: none
}

headers {
  Content-Type: application/json
}

body:json {
  {
    "email": "admin@example.com",
    "password": "password123"
  }
}

script:post-response {
  if (res.status === 200 && res.body.accessToken) {
    bru.setVar("token", res.body.accessToken);
    bru.setVar("refreshToken", res.body.refreshToken);
    if (res.body.sessionId) {
      bru.setVar("sessionId", res.body.sessionId);
    }
  }
}

docs {
  Inicia sesión como administrador (usuario demo del seeder, solo en desarrollo).
  El token, el refreshToken y el sessionId se guardan como variables de runtime de Bruno
  (en memoria): nunca se escriben en el repositorio y se pierden al cerrar Bruno.
}
```

El login guarda el token, el refresh token **y el `sessionId`** en variables de Bruno (así *Revoke Session* ya no sale sin ID), y la colección añade la cabecera `Authorization` a todas las peticiones. El puerto es 3011 en todas partes y `Local.bru` ya no guarda tokens:

```text
vars:pre-request {
  baseUrl: http://localhost:3011
}

headers {
  Authorization: Bearer {{token}}
}
```

Si prefieres la terminal, el flujo completo con curl:

```bash
B=http://localhost:3011

# Login: guarda los dos tokens
R=$(curl -s -X POST $B/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"password123"}')
ACCESS=$(echo "$R" | jq -r .accessToken)
REFRESH=$(echo "$R" | jq -r .refreshToken)

# Tus tareas: paginación, filtros dinámicos, orden y relaciones
# (-g evita que curl interprete los corchetes)
curl -g -s "$B/api/tasks?page=1&limit=5&filter[completed]=false&sort=-createdAt&include=user" \
  -H "Authorization: Bearer $ACCESS"

# Crear una tarea (el userId sale del token, nunca del cuerpo)
curl -s -X POST $B/api/tasks -H "Authorization: Bearer $ACCESS" \
  -H 'Content-Type: application/json' -d '{"title":"Probar la plantilla"}'

# Tus sesiones abiertas (dispositivo, IP y cuál es la actual)
curl -s $B/api/sessions/me -H "Authorization: Bearer $ACCESS"

# Renovar el par de tokens cuando caduque el access (15 min)
curl -s -X POST $B/api/auth/refresh -H 'Content-Type: application/json' \
  -d "{\"refreshToken\":\"$REFRESH\"}"
```

### Reglas para asistentes de IA

La plantilla trae `.cursorrules`, `.github/copilot-instructions.md` y siete *skills* en `.agents/skills/`: crear endpoints, generar una API desde SQL, Docker, Prisma, OpenAPI, Bruno y seguridad. Así, cuando le pides a Cursor, Copilot o Claude que añada algo, sigue las convenciones del proyecto en lugar de inventarse las suyas:

```markdown
## Core Rules & Architecture
1. **Declare routes once with `createRoute`** (`@hono/zod-openapi`):
   - Request validation, handler types and the OpenAPI spec all come from the same `createRoute({ request, responses })`. Never write OpenAPI by hand.
   - Import `z` from `@hono/zod-openapi` in `src/schemas/`; request schemas in `schemas/index.ts`, response schemas in `schemas/responses.ts`.
   - Use `createRouter()` from `src/lib/openapi.ts` (its default hook returns the standard 400 envelope) and helpers `jsonBody`, `jsonResponse`, `errorResponses`, `secured`.
   - Declare every status code the handler can return; the compiler rejects undeclared ones.

2. **Chain the routers**:
```

Me parece de lo más útil de la plantilla, con un matiz: las reglas se copian tal cual, así que un error en ellas se multiplica. La primera vez encontré uno (una skill que importaba una función que no existe). Ya está corregido y las reglas describen el código actual, incluida la norma de obtener la IP solo con `getClientIp()`.

## Docker

La plantilla trae un Dockerfile en dos etapas (compilar con todo; ejecutar con lo mínimo, con el usuario sin privilegios `node` y con un `HEALTHCHECK`), un `docker-entrypoint.sh` que aplica las migraciones antes de arrancar y un `docker-compose.yml` con un volumen:

```dockerfile
# ==========================================
# Etapa 1: Dependencias y Compilación
# ==========================================
FROM node:20-alpine AS builder

WORKDIR /app

# Instalar dependencias del sistema requeridas por Prisma / SQLite
RUN apk add --no-cache openssl

COPY package*.json ./
COPY prisma.config.ts ./
COPY prisma ./prisma/

RUN npm ci

COPY tsconfig.json ./
COPY src ./src

# Generar cliente de Prisma y compilar TypeScript
RUN npx prisma generate
RUN npm run build

# ==========================================
# Etapa 2: Imagen Final Ligera para Producción
# ==========================================
FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3011

RUN apk add --no-cache openssl

# Crear directorio de datos persistente con permisos para el usuario node
RUN mkdir -p /app/data && chown -R node:node /app

COPY --chown=node:node package*.json ./
COPY --chown=node:node prisma.config.ts ./
COPY --chown=node:node prisma ./prisma/
COPY --chown=node:node docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh

# Instalar dependencias de producción (deshabilitando prepare/husky en entorno CI/Docker)
USER node
ENV HUSKY=0
RUN npm ci --omit=dev --ignore-scripts

# Copiar el código compilado y el cliente generado de Prisma
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/src/generated ./src/generated

EXPOSE 3011

# Healthcheck profundo (proceso + latencia SQLite)
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/index.js"]
```

```bash
#!/bin/sh
set -e

# Asegurar que el directorio de la base de datos existe si es una ruta local
if [ -n "$DATABASE_URL" ]; then
  DB_DIR=$(echo "$DATABASE_URL" | sed 's|^file:||' | xargs dirname)
  if [ -n "$DB_DIR" ] && [ "$DB_DIR" != "." ] && [ ! -d "$DB_DIR" ]; then
    mkdir -p "$DB_DIR"
  fi
fi

# Aplicar migraciones versionadas (nunca "db push" en producción)
echo "🚀 Aplicando migraciones de base de datos..."
if ! OUTPUT=$(npx prisma migrate deploy 2>&1); then
  echo "$OUTPUT"
  # P3005: base de datos existente creada con "db push" (versiones anteriores de la plantilla).
  # Se marca la migración inicial como aplicada (baseline) y se aplican las siguientes.
  if echo "$OUTPUT" | grep -q "P3005"; then
    echo "🧱 Base de datos existente sin historial de migraciones: aplicando baseline 0_init..."
    npx prisma migrate resolve --applied 0_init
    npx prisma migrate deploy
  else
    exit 1
  fi
else
  echo "$OUTPUT"
fi

# Ejecutar el proceso principal
echo "⚡ Iniciando servidor Hono..."
exec "$@"
```

```yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: hono_rest_api
    restart: unless-stopped
    ports:
      - "3011:3011"
    environment:
      - PORT=3011
      - NODE_ENV=production
      # Los secretos NUNCA se versionan: se leen del entorno o de un archivo .env local (ignorado por git).
      # Genera valores seguros con: openssl rand -base64 48
      - JWT_SECRET=${JWT_SECRET:?Define JWT_SECRET en el entorno o en .env}
      - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:?Define JWT_REFRESH_SECRET en el entorno o en .env}
      - DATABASE_URL=file:/app/data/prod.db
      - TRUST_PROXY=${TRUST_PROXY:-false}
      - CORS_ORIGINS=${CORS_ORIGINS:-*}
      - ADMIN_EMAIL=${ADMIN_EMAIL:-}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
      # Opcionales: vacío = valor por defecto de la aplicación
      - ENABLE_DOCS=${ENABLE_DOCS:-}
      - LOG_LEVEL=${LOG_LEVEL:-}
      - LOGIN_RATE_LIMIT_MAX=${LOGIN_RATE_LIMIT_MAX:-}
      - REFRESH_RATE_LIMIT_MAX=${REFRESH_RATE_LIMIT_MAX:-}
      - REGISTER_RATE_LIMIT_MAX=${REGISTER_RATE_LIMIT_MAX:-}
      - REFRESH_REUSE_GRACE_SECONDS=${REFRESH_REUSE_GRACE_SECONDS:-}
      # Turso/libSQL remoto (si se define, sustituye al SQLite local)
      - TURSO_DATABASE_URL=${TURSO_DATABASE_URL:-}
      - TURSO_AUTH_TOKEN=${TURSO_AUTH_TOKEN:-}
    volumes:
      # Volumen persistente para almacenar el archivo SQLite de producción
      - sqlite_data:/app/data

volumes:
  sqlite_data:
```

Aquí hubo el mayor cambio respecto a mi segunda ronda. Entonces **la imagen no arrancaba**, por tres fallos encadenados: `npm ci --omit=dev` ejecutaba `husky` (que no está en producción) y fallaba; el entrypoint usaba `prisma db push --skip-generate`, una opción que no existe en Prisma 7; y `prisma.config.ts` seguía apuntando a `dev.db`, de modo que las tablas se creaban en un fichero y la API abría otro vacío (`/healthz` daba 200 y el login, 500). La PR #9 los arregló: `--ignore-scripts` y `HUSKY=0`, `migrate deploy` en el entrypoint y una configuración de Prisma que lee `DATABASE_URL`. Lo he vuelto a comprobar de principio a fin:

```bash
$ docker build -t starter .        # sin cambios: la PR #9 arregló los tres fallos
Successfully built · imagen de 602 MB (node:20-alpine)

$ docker run -d --name api -p 3011:3011 -v api-data:/app/data \
    -e JWT_SECRET=$(openssl rand -hex 32) -e JWT_REFRESH_SECRET=$(openssl rand -hex 32) \
    -e DATABASE_URL=file:/app/data/prod.db \
    -e ADMIN_EMAIL=admin@midominio.com -e ADMIN_PASSWORD=una-clave-larga-123 starter

🚀 Aplicando migraciones de base de datos...
Datasource "db": SQLite database "prod.db" at "file:/app/data/prod.db"
Applying migration `0_init`
Applying migration `20260929000000_hash_refresh_tokens_and_indexes`
All migrations have been successfully applied.
⚡ Iniciando servidor Hono...
{"level":30,"msg":"👑 Bootstrapping initial production administrator: admin@midominio.com..."}
{"level":30,"msg":"✔ Production administrator created successfully."}
{"level":30,"msg":"🚀 Hono server listening on http://localhost:3011"}
```

```bash
estado de Docker (HEALTHCHECK) ..................... healthy
GET /healthz ....................................... {"status":"healthy","database":{"status":"connected","latencyMs":1}}
login del admin de demostración (admin@example.com) ... 401
login del admin del bootstrap ...................... 200
POST /api/tasks .................................... 201
GET /docs y /openapi.json en producción ............ 404 404
ficheros: /app/data/prod.db (90 KB) · no hay /app/dev.db
tablas: AuditLog, RefreshToken, Session, Task, User, _prisma_migrations
docker stop ........................................ 0,27 s
  {"msg":"\n🛑 Received SIGTERM. Initiating graceful shutdown..."}
  {"msg":"✔ HTTP listener closed."}
  {"msg":"✔ SQLite connection safely closed."}

== Recrear el contenedor con el mismo volumen (y otro ADMIN_EMAIL distinto)
No pending migrations to apply.
🔒 Production mode active: Demo seeder disabled.       ← no crea un segundo admin
login del admin original ........................... 200 · su tarea sigue ahí: ["desde produccion"]
login del "otro" admin ............................. 401

== Secretos débiles con NODE_ENV=production (JWT_SECRET=corto, el mismo en los dos)
"JWT_SECRET must contain at least 32 characters in production (generate one with: openssl rand -base64 48)"
"JWT_REFRESH_SECRET must differ from JWT_SECRET in production"
== Sin secretos: "JWT_SECRET is required (generate one with: openssl rand -base64 48)"
```

Los tres fallos han desaparecido y aparece algo que antes no tenía: el health check de Docker (`HEALTHCHECK`) llega a *healthy*, y las tablas se crean en el mismo fichero que abre la API. También hay una ruta de **actualización** pensada para quien desplegó la versión anterior, con la base creada por `db push` (no hay historial de migraciones y Prisma responde `P3005`). El entrypoint lo detecta, marca `0_init` como aplicada y aplica el resto. La probé con una base de la versión antigua, con un usuario y una tarea dentro:

```bash
# Base creada con la versión ANTERIOR de la plantilla (prisma db push, tabla RefreshToken con la columna "token"),
# con un usuario y una tarea dentro. Arranco la imagen nueva sobre ese fichero:

🚀 Aplicando migraciones de base de datos...
Error: P3005
The database schema is not empty. Read more about how to baseline an existing production database
🧱 Base de datos existente sin historial de migraciones: aplicando baseline 0_init...
Migration 0_init marked as applied.
Applying migration `20260929000000_hash_refresh_tokens_and_indexes`

usuarios: vieja@x.com
tareas: Tarea antigua
RefreshToken columnas: id,tokenHash,userId,sessionId,usedAt,expiresAt,createdAt
migraciones: 0_init | 20260929000000_hash_refresh_tokens_and_indexes
```

La base de datos sobrevive, la tabla de tokens pasa a usar hashes (y los usuarios tendrán que iniciar sesión otra vez, como avisa la migración). Dos detalles que sí quedan por pulir en Docker, ambos con arreglo probado:

- **Sin `DATABASE_URL`, la base de datos queda fuera del volumen.** El `docker-compose.yml` la define, pero el Dockerfile no, y con Coolify y el build pack Dockerfile no pasa por el compose. Entonces `env.ts` usa su valor por defecto, `file:./dev.db`, es decir `/app/dev.db`: la API arranca, funciona, y pierdes todo al redesplegar.
- **Con una URL de PostgreSQL, el entrypoint crea una carpeta con la contraseña en el nombre**, porque hace `mkdir -p` del "directorio" de cualquier `DATABASE_URL`, y no solo de las rutas `file:`. No es grave (no se filtra a ningún sitio), pero es feo.

```bash
$ docker run -d -e JWT_SECRET=… -e JWT_REFRESH_SECRET=… -e ADMIN_EMAIL=… -e ADMIN_PASSWORD=… starter   # SIN DATABASE_URL
$ docker exec … ls -la /app/*.db /app/data
-rw-r--r-- 1 node node 90112 /app/dev.db          ← aquí está la base de datos
/app/data:  (vacío)                               ← el volumen, sin nada
```

```bash
$ DATABASE_URL="postgres://app:secreto@db:5432/app"; (las líneas del mkdir del entrypoint)
DB_DIR=postgres://app:secreto@db:5432
$ find . -type d
./postgres:
./postgres:/app:secreto@db:5432          ← una carpeta con la contraseña en el nombre
```

```diff
# Dockerfile: la imagen no sabe dónde está el volumen. Añade el valor por defecto
ENV NODE_ENV=production
ENV PORT=3011
+ENV DATABASE_URL=file:/app/data/prod.db

# docker-entrypoint.sh: solo crear carpetas para rutas de fichero, no para URLs de PostgreSQL
-if [ -n "$DATABASE_URL" ]; then
-  DB_DIR=$(echo "$DATABASE_URL" | sed 's|^file:||' | xargs dirname)
-  if [ -n "$DB_DIR" ] && [ "$DB_DIR" != "." ] && [ ! -d "$DB_DIR" ]; then
-    mkdir -p "$DB_DIR"
-  fi
-fi
+case "$DATABASE_URL" in
+  file:*)
+    DB_DIR=$(echo "$DATABASE_URL" | sed 's|^file:||' | xargs dirname)
+    if [ -n "$DB_DIR" ] && [ "$DB_DIR" != "." ] && [ ! -d "$DB_DIR" ]; then
+      mkdir -p "$DB_DIR"
+    fi ;;
+esac
```

## Desplegarla en Coolify

Yo despliego todo con Coolify en un VPS (la guía completa está en [Coolify a fondo](https://blog.devutilites.com/coolify-paas-autoalojado/)). Con la imagen funcionando, estos son los pasos:

1. Crea tu repositorio desde la plantilla y conéctalo en Coolify: **New Resource → Private Repository (GitHub App)** o **Public Repository**.
2. Build pack: **Dockerfile**. Puerto expuesto: **3011**.
3. En **Storages**, un volumen persistente con destino `/app/data`. Ahí vivirá `prod.db`.
4. En **Environment Variables**, las de abajo, con secretos generados de verdad. **`DATABASE_URL` no es opcional** (ver la sección anterior).
5. En **Health Check**, la ruta `/healthz` (el Dockerfile ya trae su propio `HEALTHCHECK`).
6. Pon tu dominio (Coolify pide el certificado solo) y despliega.
7. Entra con `ADMIN_EMAIL` / `ADMIN_PASSWORD` y, después, quita la contraseña del entorno.

```bash
# Variables del recurso en Coolify (Environment Variables)
NODE_ENV=production
PORT=3011
JWT_SECRET=<openssl rand -base64 48>
JWT_REFRESH_SECRET=<otro openssl rand -base64 48>
DATABASE_URL=file:/app/data/prod.db      # imprescindible con el build pack Dockerfile (ver abajo)
ADMIN_EMAIL=admin@tudominio.com          # solo se usa si la base de datos está vacía
ADMIN_PASSWORD=<8 a 72 caracteres>       # bórrala de aquí tras el primer arranque
CORS_ORIGINS=https://app.tudominio.com
TRUST_PROXY=true                         # Traefik va delante (mira "El rate limit y la IP real")
```

💡

Con SQLite, ****una sola réplica**: SQLite es un fichero en un volumen y no admite varios contenedores escribiendo a la vez, y el rate limiter vive en la memoria del proceso. Si necesitas varias réplicas, usa Turso o pasa a PostgreSQL (siguiente sección) y mueve el rate limit a Redis. Para una API normal, un contenedor da para mucho: en mi servidor, unas 320 peticiones autenticadas por segundo.

Y no olvides el volumen en tus copias de seguridad: con SQLite, **toda** la base de datos es `prod.db`. `TRUST_PROXY=true` es correcto detrás del Traefik de Coolify **si** aplicas el arreglo de `getClientIp()` que te enseñé antes; sin él, un atacante que mande `cf-connecting-ip` se salta el límite de intentos.

## Cómo extenderla: un recurso nuevo paso a paso

Para comprobar que la estructura aguanta crecer, he añadido sobre `main` un recurso completo, **notas personales**, siguiendo las convenciones actuales: dueño estricto, borrado lógico, filtros dinámicos, sobre de respuesta, auditoría, migración, documentación generada y una prueba. Seis pasos.

### 1\. El modelo y su migración

```prisma
model Note {
  id        String    @id @default(uuid())
  userId    String
  title     String
  content   String    @default("")
  pinned    Boolean   @default(false)
  deletedAt DateTime? // Soft delete timestamp
  createdAt DateTime  @default(now())

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

  @@index([userId, deletedAt])
}
```

```prisma
model User {
  // … campos y relaciones que ya tenía …
  auditLogs     AuditLog[]
  notes         Note[]        // ← nueva relación
}
```

```bash
# Cada cambio del modelo: una migración nueva que se guarda en Git
npm run db:migrate -- --name notas
npm run db:generate        # Prisma 7 ya no regenera el cliente por su cuenta

# En producción (lo hace docker-entrypoint.sh) solo se aplican las pendientes
npm run db:deploy
```

Prisma genera el SQL de la migración (es el fichero que se guarda en Git):

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

-- CreateIndex
CREATE INDEX "Note_userId_deletedAt_idx" ON "Note"("userId", "deletedAt");
```

### 2\. Los esquemas Zod

Los de entrada, en `schemas/index.ts`, y el de respuesta, en `schemas/responses.ts`. Los dos se convierten en documentación sin más:

```typescript
export const createNoteSchema = z
  .object({
    title: z.string().trim().min(1).max(100).openapi({ example: "Ideas para el blog" }),
    content: z.string().max(10_000).optional().default(""),
    pinned: z.boolean().optional().default(false),
  })
  .openapi("CreateNoteRequest");

export const updateNoteSchema = z
  .object({
    title: z.string().trim().min(1).max(100).optional(),
    content: z.string().max(10_000).optional(),
    pinned: z.boolean().optional(),
  })
  .refine((data) => Object.keys(data).length > 0, {
    message: "Debes proporcionar al menos un campo para actualizar (title, content o pinned)",
  })
  .openapi("UpdateNoteRequest");

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

```typescript
export const noteSchema = z
  .object({
    id: z.string(),
    userId: z.string(),
    title: z.string(),
    content: z.string(),
    pinned: z.boolean(),
    deletedAt: nullableDateTime,
    createdAt: dateTime,
  })
  .openapi("Note");
```

### 3\. Las rutas

Un fichero nuevo, `src/routes/notes.ts`. Se declaran las rutas con `createRoute` y se exporta **un único router encadenado**, que es lo que mantiene los tipos del cliente RPC. La comprobación de dueño va en una función para no repetirla:

```typescript
/**
 * @file notes.ts
 * @description Personal notes: strict ownership, soft delete, dynamic filters and audit trail.
 */

import { createRoute, z } from "@hono/zod-openapi";
import type { Context } from "hono";
import { prisma } from "../db.js";
import type { NoteWhereInput } from "../generated/client/models.js";
import { recordAudit } from "../lib/audit.js";
import { createRouter, errorResponses, jsonBody, jsonResponse, secured } from "../lib/openapi.js";
import { parseFilters, parseSorting } from "../lib/query.js";
import { buildPagination, errorResponse, successResponse } from "../lib/response.js";
import {
  createNoteSchema,
  idParamSchema,
  noteQuerySchema,
  updateNoteSchema,
} from "../schemas/index.js";
import { noteSchema, successSchema } from "../schemas/responses.js";
import type { AppEnv } from "../types/index.js";

const router = createRouter();

const authErrors = { 401: "Token ausente, inválido o sesión revocada" } as const;

/** Loads a note and enforces ownership. Returns either the note or an error response. */
async function findOwnedNote(c: Context<AppEnv>, id: string, action: string) {
  const note = await prisma.note.findUnique({ where: { id } });
  if (!note || note.deletedAt) {
    return { error: errorResponse(c, `Note with ID '${id}' not found.`, 404) };
  }
  if (note.userId !== c.get("user").userId) {
    return {
      error: errorResponse(
        c,
        `Access denied: You do not have permission to ${action} this note.`,
        403,
      ),
    };
  }
  return { note };
}

const listRoute = createRoute({
  method: "get",
  path: "/",
  tags: ["Notes"],
  summary: "Listar mis notas",
  description:
    "Lista paginada de las notas del usuario. Filtros: `filter[title][contains]`, `filter[pinned]`.",
  security: secured,
  request: { query: noteQuerySchema },
  responses: {
    200: jsonResponse(successSchema(z.array(noteSchema)), "Lista paginada de notas"),
    ...errorResponses({ 400: "Parámetros o filtros inválidos", ...authErrors }),
  },
});

const createNoteRoute = createRoute({
  method: "post",
  path: "/",
  tags: ["Notes"],
  summary: "Crear nota",
  security: secured,
  request: jsonBody(createNoteSchema),
  responses: {
    201: jsonResponse(successSchema(noteSchema), "Nota creada"),
    ...errorResponses({ 400: "Error de validación", ...authErrors }),
  },
});

const updateRoute = createRoute({
  method: "put",
  path: "/{id}",
  tags: ["Notes"],
  summary: "Actualizar nota",
  security: secured,
  request: { params: idParamSchema, ...jsonBody(updateNoteSchema) },
  responses: {
    200: jsonResponse(successSchema(noteSchema), "Nota actualizada"),
    ...errorResponses({
      400: "Error de validación",
      ...authErrors,
      403: "La nota pertenece a otro usuario",
      404: "Nota no encontrada",
    }),
  },
});

const deleteRoute = createRoute({
  method: "delete",
  path: "/{id}",
  tags: ["Notes"],
  summary: "Eliminar nota (borrado lógico)",
  security: secured,
  request: { params: idParamSchema },
  responses: {
    200: jsonResponse(successSchema(noteSchema), "Nota eliminada"),
    ...errorResponses({
      ...authErrors,
      403: "La nota pertenece a otro usuario",
      404: "Nota no encontrada",
    }),
  },
});

export const noteRoutes = router
  .openapi(listRoute, async (c) => {
    const { page, limit, search, pinned, sortBy, order, sort } = c.req.valid("query");

    // Filtros dinámicos primero: nunca pueden sobrescribir el dueño
    const where: NoteWhereInput = parseFilters(c.req.query(), {
      allowedFields: { title: "string", pinned: "boolean", createdAt: "date" },
    });
    where.userId = c.get("user").userId;
    where.deletedAt = null;
    if (pinned !== undefined) where.pinned = pinned === "true";
    if (search) where.OR = [{ title: { contains: search } }, { content: { contains: search } }];

    const orderBy = sort
      ? parseSorting(sort, { allowedFields: ["createdAt", "title", "pinned"] })
      : { [sortBy]: order };

    const [total, notes] = await Promise.all([
      prisma.note.count({ where }),
      prisma.note.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy }),
    ]);

    return successResponse(c, notes, { pagination: buildPagination(total, page, limit) });
  })
  .openapi(createNoteRoute, async (c) => {
    const { title, content, pinned } = c.req.valid("json");
    const userId = c.get("user").userId; // del token, nunca del cuerpo

    const note = await prisma.note.create({ data: { userId, title, content, pinned } });

    await recordAudit(c, { userId, action: "CREATE", entity: "Note", entityId: note.id });
    return successResponse(c, note, { status: 201, message: "Note created." });
  })
  .openapi(updateRoute, async (c) => {
    const { id } = c.req.valid("param");
    const { error } = await findOwnedNote(c, id, "edit");
    if (error) return error;

    const changes = c.req.valid("json");
    const note = await prisma.note.update({ where: { id }, data: changes });

    await recordAudit(c, {
      userId: c.get("user").userId,
      action: "UPDATE",
      entity: "Note",
      entityId: id,
      details: { changedFields: Object.keys(changes) },
    });
    return successResponse(c, note, { message: "Note updated." });
  })
  .openapi(deleteRoute, async (c) => {
    const { id } = c.req.valid("param");
    const { error } = await findOwnedNote(c, id, "delete");
    if (error) return error;

    const note = await prisma.note.update({ where: { id }, data: { deletedAt: new Date() } });

    await recordAudit(c, {
      userId: c.get("user").userId,
      action: "SOFT_DELETE",
      entity: "Note",
      entityId: id,
    });
    return successResponse(c, note, { message: "Note deleted." });
  });
```

### 4\. Montarlas en index.ts

```typescript
for (const prefix of [
  "/api/sessions",
  "/api/users",
  "/api/tasks",
  "/api/notes",
  "/api/audit-logs",
]) {
  app.use(prefix, authMiddleware);
  app.use(`${prefix}/*`, authMiddleware);
}

// Public authentication routes and protected resource modules chained cleanly for Hono RPC
const routes = app
  .route("/api/auth", authRoutes)
  .route("/api/sessions", sessionRoutes)
  .route("/api/users", userRoutes)
  .route("/api/tasks", taskRoutes)
  .route("/api/notes", noteRoutes)
  .route("/api/audit-logs", auditRoutes);
```

El prefijo tiene que estar en la lista de rutas protegidas (así queda protegido con `authMiddleware` antes de montar el router). Además hay que añadir la etiqueta `Notes` a la lista de `tags` de la documentación.

### 5\. Bruno y una prueba

```text
meta {
  name: Create Note
  type: http
  seq: 1
}

post {
  url: {{baseUrl}}/api/notes
  body: json
  auth: none
}

headers {
  Content-Type: application/json
}

body:json {
  {
    "title": "Ideas para el blog",
    "pinned": true
  }
}
```

```typescript
describe("Notes", () => {
  it("creates, lists, filters and soft-deletes a note", async () => {
    const { accessToken } = await createTestUser();
    const created = await post(accessToken, { title: "Ideas para el blog", pinned: true });
    expect(created.status).toBe(201);
    const { data } = await created.json();

    await post(accessToken, { title: "Lista de la compra" });
    const pinned = await app.request("/api/notes?filter[pinned]=true", {
      headers: bearer(accessToken),
    });
    expect((await pinned.json()).data.map((n: { title: string }) => n.title)).toEqual([
      "Ideas para el blog",
    ]);

    const del = await app.request(`/api/notes/${data.id}`, {
      method: "DELETE",
      headers: bearer(accessToken),
    });
    expect(del.status).toBe(200);
    const after = await app.request("/api/notes", { headers: bearer(accessToken) });
    expect((await after.json()).pagination.total).toBe(1);
  });
  // … y una prueba de propiedad y otra de autenticación
});
```

### 6\. Comprobar

```bash
$ npx prisma migrate dev --name notas
Applying migration `20260929230324_notas`
$ npx prisma generate
$ npx biome check --write . && npm run typecheck      # sin errores
$ npx vitest run
 Test Files  9 passed (9)
      Tests  63 passed (63)     ← las 60 de antes y 3 de notas
```

```bash
== Notas (sobre main + mis cambios)
POST /api/notes ........................ 201  pinned=true  meta.durationMs=number
GET ?filter[pinned]=true ............... ["Ideas para el blog"]  (total 1)
admin PUT la nota de Ana ............... 403
Ana DELETE su nota ..................... 200
tras borrarla quedan ................... ["Lista de la compra"]
auditoría de Note ...................... ["SOFT_DELETE","CREATE","CREATE"]
sin token .............................. 401
OpenAPI: /api/notes → get, post · el test "documents every registered route" sigue en verde
```

Fíjate en que no toqué ninguna especificación OpenAPI: la ruta apareció sola en `/openapi.json`, y la prueba de "sin deriva" habría fallado si no. Y el cliente RPC ya conoce `api.notes` (la prueba con `titel` de más arriba).

Dos avisos de uso: `prisma migrate dev` es para tu máquina; en producción usa siempre `migrate deploy` (el entrypoint ya lo hace). Y no uses `db push` con datos reales, porque no deja historial y, con algunos cambios (renombrar un campo, por ejemplo), puede borrar datos.

## Pasar a PostgreSQL

Lo he probado sobre `main` con `postgres:17-alpine`. Es más trabajo que antes por una razón buena: las migraciones son SQL de SQLite, así que hay que **regenerarlas** para PostgreSQL. En el esquema:

```prisma
datasource db {
  provider = "postgresql"   // antes: "sqlite"
}
```

Y en `db.ts`, el adaptador de PostgreSQL en lugar de `resolveDatabaseUrl()` y libSQL (con lo que pierdes Turso):

```typescript
import { PrismaPg } from "@prisma/adapter-pg";

const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });

export const prisma = new PrismaClient({ adapter, omit: { user: { password: true } } });
```

Para ejecutar las 60 pruebas contra PostgreSQL, la base de pruebas pasa a ser la de PostgreSQL (`global-setup.ts` ya hace `migrate deploy` sobre la que le des):

```diff
// vitest.config.ts: la base de pruebas es la de PostgreSQL en lugar de un fichero temporal
-const testDatabaseUrl = `file:${path.join(tmpdir(), `hono-api-test-${process.pid}-${Date.now()}.db`)}`;
+const testDatabaseUrl = process.env.TEST_PG_URL as string;
```

```bash
$ npm uninstall @prisma/adapter-libsql @libsql/client
$ npm install @prisma/adapter-pg pg
$ rm -r prisma/migrations && npx prisma migrate dev --name init     # SQL nuevo, de PostgreSQL
Applying migration `20260929230510_init`
Your database is now in sync with your schema.
$ npx prisma generate && npx tsc --noEmit         # sin errores (código y tests)
$ TEST_PG_URL=postgres://app:app@db:5432/app npx vitest run
 Test Files  8 passed (8)
      Tests  60 passed (60)

$ psql -U app -d app -c '\dt'
 public | AuditLog           | table | app
 public | RefreshToken       | table | app
 public | Session            | table | app
 public | Task               | table | app
 public | User               | table | app
 public | _prisma_migrations | table | app
```

Las 60 pruebas pasan sin tocar ni una ruta: Prisma abstrae la diferencia. Bueno, **casi**: en SQLite, `contains` no distingue mayúsculas (para letras ASCII) y en PostgreSQL sí, y eso se ve en la API real:

```bash
PostgreSQL  ?search=learn  ->  []
PostgreSQL  ?search=Learn  ->  ["Learn Hono Framework"]
```

Si quieres el mismo comportamiento, añade `mode: "insensitive"` a las búsquedas cuando hagas el cambio (solo existe en PostgreSQL y MongoDB):

```typescript
where.OR = [
  { title: { contains: search, mode: "insensitive" } },        // solo PostgreSQL / MongoDB
  { description: { contains: search, mode: "insensitive" } },
];
```

Y un compose con la base de datos al lado, para Coolify o para tu máquina. Recuerda el arreglo del entrypoint de la sección de Docker: sin él, la URL de PostgreSQL acaba como nombre de una carpeta.

```yaml
services:
  api:
    build: .
    environment:
      - NODE_ENV=production
      - JWT_SECRET=${JWT_SECRET}
      - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
      - DATABASE_URL=postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      - ADMIN_EMAIL=${ADMIN_EMAIL}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      retries: 10

volumes:
  pgdata:
```

## Rendimiento y consumo

Medido en mi servidor, con 4 vCPU compartidas con otros servicios en producción (carga media de 1,6 durante la prueba), dentro del contenedor de producción. Son cifras orientativas, no un benchmark de laboratorio:

| Medida                                | Resultado                             | Cómo                                                                       |
| ------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
| npm test (60 pruebas de Vitest)       | \~38 s                                | Sin levantar servidor, con app.request(); cada login de prueba paga bcrypt |
| Arranque hasta el primer /healthz 200 | 760–840 ms                            | Proceso nuevo, base de datos ya migrada, dentro del contenedor             |
| Memoria en reposo                     | \~64 MiB                              | docker stats del contenedor; \~149 MiB tras la prueba de carga             |
| Login (bcrypt, 12 rondas)             | 365–410 ms                            | El primero, en frío, \~480 ms. Con 10 rondas eran \~120 ms                 |
| GET /healthz                          | \~1.750 peticiones/s · 11 ms de media | autocannon, 20 conexiones, 8 s                                             |
| GET /api/tasks autenticado            | \~324 peticiones/s · 61 ms de media   | Igual; 4 consultas a SQLite por petición                                   |
| Imagen Docker                         | 602 MB                                | node:20-alpine con la CLI de Prisma                                        |
| docker stop (cierre ordenado)         | 0,27 s                                | SIGTERM → cierre del servidor y de Prisma                                  |

Lo que más cuesta es la seguridad, y está bien que sea así. El login tarda unos 380 ms porque bcrypt es lento **a propósito** (la PR #10 subió de 10 a 12 rondas, y cada ronda duplica el coste), para que probar contraseñas salga caro. Cada petición autenticada hace cuatro consultas: usuario, sesión, `count` y `findMany`. Aun así, más de 300 peticiones por segundo con 20 clientes a la vez y 64 MiB de memoria en reposo es muchísimo margen para una API normal. Las cifras son algo menores que las de la primera ronda (430 peticiones por segundo): el enrutador de `zod-openapi` valida y serializa más, y lo cambiaría por la documentación y los tipos.

## Lo que arreglaron las PRs #8 a #11 (verificado)

La primera ronda de pruebas encontró fallos, algunos serios, y la segunda sacó más. Se han ido corrigiendo en cuatro PRs: la **\[#8\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8)** (permisos, tokens, seeder), la **\[#9\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9)** (Docker y producción), la **\[#10\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10)** (auditoría completa) y la **\[#11\](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11)** (Zod 4 y OpenAPI). No me he fiado de las descripciones: he repetido cada ataque contra `main` en contenedores limpios. Este es el resultado:

| Arreglo                                                                                                                                           | PR                                                                                                                                                                                                                                                                                                                  | Cómo lo comprobé                                                                                      | Resultado                                                                    |
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Usuarios: listar solo admin; ver, editar y borrar solo uno mismo o un admin; crear cuentas con rol solo admin                                     | [#8](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com) · [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com) | Ana intenta cambiar la contraseña del admin, borrarlo, listar, crear, bloquear, revocar sus sesiones… | ✅ 403 en todo; Ana sí edita su perfil                                        |
| GET /api/users/:id?include=sessions,tasks ya no enseña a cualquiera el perfil, las sesiones y las tareas del admin                                | [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                                                                                                                               | Ana pide el perfil del admin con include                                                              | ✅ 403                                                                        |
| Reutilizar un refresh token viejo cierra la sesión (con ventana de gracia de 10 s para refrescos concurrentes)                                    | [#8](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                         | Refresh, reutilizar el viejo al instante y pasados 11 s, y dos refrescos simultáneos                  | ✅ 409, luego 401 y sesión cerrada; el refresco concurrente no mata la sesión |
| Refresh tokens guardados como hash SHA-256, con migración                                                                                         | [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                                                                                                                               | Prueba de Vitest y base antigua creada con db push                                                    | ✅ y la actualización conserva los datos                                      |
| La imagen Docker se construye y arranca: sin husky, sin \--skip-generate, migraciones en el entrypoint, DATABASE\_URL respetada                   | [#9](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                         | Construir, arrancar, hacer login, parar, recrear con el mismo volumen                                 | ✅ y HEALTHCHECK en *healthy*                                                 |
| El seeder no crea usuarios de demostración en producción; el primer admin sale de ADMIN\_EMAIL / ADMIN\_PASSWORD                                  | [#9](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                         | Contenedor con NODE\_ENV=production, y reinicio con otro ADMIN\_EMAIL                                 | ✅ un solo admin; el de demostración da 401                                   |
| Secretos: sin valor por defecto; en producción, 32 caracteres o más y distintos                                                                   | [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com)                                                                                                                                                                                                               | Arrancar con secretos cortos, iguales o ausentes                                                      | ✅ la API no arranca y dice por qué                                           |
| Rate limit: las cabeceras solo cuentan con TRUST\_PROXY=true; lo demás, la IP de la conexión                                                      | [#8](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8?ref=blog.devutilites.com) · [#9](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)   | 40 intentos rotando tres cabeceras distintas                                                          | ✅ 429 sin TRUST\_PROXY (con TRUST\_PROXY=true queda algo: ver pendiente)     |
| Login: mismo mensaje y mismo tiempo exista o no la cuenta; el bloqueo solo se revela con la contraseña correcta; bloqueo por cuenta tras 5 fallos | [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                                                                                                                               | Cuenta bloqueada con clave mala, email inexistente y tiempos                                          | ✅ 358 ms frente a 353 ms                                                     |
| Cambiar la contraseña cierra las demás sesiones; no se puede borrar ni bloquear al último admin                                                   | [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com)                                                                                                                                                                                                               | Dos sesiones de Ana, cambio de clave desde una                                                        | ✅ la otra cae con 401                                                        |
| El cliente RPC hc<AppType> tiene tipos                                                                                                            | [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com)                                                                                                                                                                                                               | Un cliente con un campo mal escrito                                                                   | ✅ el compilador lo rechaza; hay una prueba que lo vigila                     |
| OpenAPI generado desde las rutas (22 de 22 operaciones) y Bruno con sessionId, puerto 3011 y sin tokens guardados                                 | [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com)                                                                                                                                                                                                               | Comparar /openapi.json con las rutas registradas                                                      | ✅ y una prueba impide que se desincronicen                                   |
| Pruebas: de 16 a 60, en 8 ficheros, con una base de datos temporal por ejecución                                                                  | [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com) · [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com)                                                                                                       | Node 20, Node 22 y PostgreSQL                                                                         | ✅ 60/60 en los tres                                                          |

El más importante sigue siendo el primero. Antes, esto funcionaba: Ana, con rol `user`, cambiaba la contraseña del administrador y entraba como él. Ahora recibe un 403, y hay pruebas de Vitest que impiden que vuelva a pasar. De los ocho puntos abiertos en mi segunda ronda (Docker, la fuga de `GET /api/users/:id`, la IP del rate limit, el primer administrador, las pistas del login, el cliente RPC, Bruno y OpenAPI), siete están resueltos del todo y uno a medias.

## Lo que sigue pendiente

Es poco, y nada tan grave como lo de antes. Esto es lo que salió de esta tercera ronda. Las que marco como *probado* las apliqué en una copia de `main`.

| Qué                                                                                                                                                                         | Gravedad | Arreglo                                           |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------- |
| Con TRUST\_PROXY=true, cf-connecting-ip (y X-Real-IP) se creen tal cual, y el **primer** valor de X-Forwarded-For también: se saltan el límite si el proxy no los reescribe | Media    | Usar el último valor de X-Forwarded-For (probado) |
| Con el build pack Dockerfile (Coolify) y sin DATABASE\_URL, la base de datos queda en /app/dev.db, fuera del volumen: se pierde al redesplegar                              | Media    | ENV DATABASE\_URL en el Dockerfile (probado)      |
| Cinco contraseñas malas de cualquiera bloquean el login de esa cuenta 15 minutos, también para su dueño                                                                     | Media    | Decisión de diseño; ver abajo                     |
| El entrypoint hace mkdir -p de una URL de PostgreSQL (crea una carpeta con la contraseña en el nombre)                                                                      | Baja     | Un case (probado)                                 |
| README, CI y Dockerfile dicen Node 20; 13 paquetes piden Node 22 (avisos EBADENGINE)                                                                                        | Baja     | Subir a Node 22 o rebajar dependencias            |

### 1\. La IP con TRUST\_PROXY=true

Lo tienes explicado arriba, con las salidas de Traefik real. En resumen: con `TRUST_PROXY=true` la plantilla se cree `cf-connecting-ip` y el primer valor de `X-Forwarded-For`, y eso solo es seguro si tu proxy los reescribe. Con la configuración por defecto de Coolify, `cf-connecting-ip` sigue siendo falsificable. El arreglo *probado* es leer el último valor de `X-Forwarded-For`.

### 2\. DATABASE\_URL en el Dockerfile

Una línea (`ENV DATABASE_URL=file:/app/data/prod.db`), y un despliegue en Coolify sin esa variable deja de perder los datos. *Probado* el problema; el arreglo es trivial.

### 3\. El bloqueo por cuenta

Es una decisión con contrapartida, no un fallo. Te lo cuento para que la tomes con los ojos abiertos: quien conoce un email puede impedir que su dueño entre 15 minutos. Las sesiones abiertas no se ven afectadas.

### 4\. Detalles

- **Entrypoint con PostgreSQL:** crea una carpeta con la URL como nombre. Arreglo *probado* en la sección de Docker.
- **Node:** el README, el CI y el Dockerfile dicen Node 20, y trece paquetes piden Node 22\. Funciona, pero conviene alinearlo (Node 22 en los tres sitios).
- **La ventana de gracia de 10 segundos:** documentada arriba; no es un fallo, pero conviene saberlo.

## Conclusión

Me hice esta plantilla para no volver a escribir lo mismo en cada proyecto, y para eso funciona. Lo mejor que tiene sigue siendo la arquitectura de sesiones con estado: la comodidad de los JWT y, a la vez, poder echar a alguien **en la siguiente petición**, ya sea para cerrar el móvil perdido, bloquear una cuenta comprometida o cerrar la sesión en todos los dispositivos. Ahora además detecta los refresh tokens robados sin romper los refrescos concurrentes, guarda solo hashes, se documenta sola y arranca en Docker a la primera. La estructura aguanta crecer (lo comprobé añadiendo un recurso entero, con su migración y su prueba) y pasar a PostgreSQL cuesta unos pocos cambios.

También me ha confirmado algo que conviene recordar: **llamar a algo "production-ready" no lo hace listo para producción; probarlo, sí**. La primera ronda sacó un fallo de permisos serio; la segunda, que Docker no arrancaba y que una ruta se había quedado sin proteger; la tercera, que lo grave se había corregido y que lo que queda son decisiones de despliegue (una variable que falta, qué cabecera creer). Cada ronda pasó por una PR y cada arreglo tiene ahora una prueba. Pasar de 16 a 60 pruebas es la mejor prueba de que el ejercicio ha servido.

Si la usas, me encantará saberlo. Y si encuentras algo más, abre un *issue* o un *pull request*: el repositorio trae plantillas para los dos.

- **Plantilla:** [github.com/Collanteslu/hono-prisma-production-starter](https://github.com/Collanteslu/hono-prisma-production-starter?ref=blog.devutilites.com)
- **Crear tu copia:** [Use this template](https://github.com/Collanteslu/hono-prisma-production-starter/generate?ref=blog.devutilites.com)
- **Los arreglos:** [PR #8](https://github.com/Collanteslu/hono-prisma-production-starter/pull/8?ref=blog.devutilites.com) · [#9](https://github.com/Collanteslu/hono-prisma-production-starter/pull/9?ref=blog.devutilites.com) · [#10](https://github.com/Collanteslu/hono-prisma-production-starter/pull/10?ref=blog.devutilites.com) · [#11](https://github.com/Collanteslu/hono-prisma-production-starter/pull/11?ref=blog.devutilites.com)
- **Mis proyectos:** [github.com/Collanteslu](https://github.com/Collanteslu?ref=blog.devutilites.com)
- **Para entender Hono a fondo:** [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/)
- **Para elegir framework:** [Hono, Fastify, Express o NestJS 12: comparativa con pruebas reales](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/)
- **Para desplegarla:** [Coolify a fondo](https://blog.devutilites.com/coolify-paas-autoalojado/)
- **La serie completa:** [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/), [Express a fondo](https://blog.devutilites.com/express-a-fondo/) y [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/)