Mi plantilla Hono + Prisma 7 para APIs en producción: cómo está hecha, cómo usarla y qué aprendí probándola

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.

Portada: Mi plantilla Hono + Prisma 7 para APIs en producción: cómo está hecha, cómo usarla y qué aprendí probándola

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
Crear tu copiahttps://github.com/Collanteslu/hono-prisma-production-starter/generate (lo mismo que el botón Use this template)
Descargar en ZIPmain.zip
LicenciaMIT: úsala, cámbiala y véndela; solo mantén el aviso de copyright
Integración continuaGitHub Actions: auditoría de dependencias, migraciones, tipos, build, Biome y 60 pruebas de Vitest en cada push y pull request
RequisitosNode.js 22 o 24 (ver la nota de abajo) y npm. Nada más: la base de datos es un fichero
Mis otros proyectosgithub.com/Collanteslu

Cuatro formas de conseguirla

# 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.

# 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
$ 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: [email protected], User: [email protected])
[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, [email protected] y [email protected], 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:

# 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:

$ 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

PiezaVersión probadaPara qué
Hono + @hono/node-server4.13.10 · 2.1.1Router, middleware, contexto tipado, JWT, cabeceras de seguridad y límite de tamaño integrados
Zod 4 + @hono/zod-openapi4.6.5 · 1.6.3Una sola definición por ruta valida la petición, tipa el manejador y genera el OpenAPI
Prisma ORM (cliente y CLI)7.10.0Esquema, cliente tipado y migraciones, con el generador nuevo prisma-client
@prisma/adapter-libsql + @libsql/client7.10.0 · 0.18.0Driver adapter: SQLite local o Turso, sin motor en Rust
bcryptjs3.0.3Hash de contraseñas (12 rondas) en JavaScript puro
Pino (+ pino-pretty)10.3.1Logs estructurados: JSON en producción, legibles en desarrollo
Scalar0.12.7Documentación interactiva en /docs
Vitest · Biome · husky5.0.2 · 2.5.14 · 9.1.7Pruebas, lint y formato, y comprobación antes de cada commit
TypeScript + tsx7.0.2 · 4.23.15Tipado estricto; el compilador nativo tarda ~1 segundo
Brunocolecció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. Y si dudas entre Hono, Fastify, Express o NestJS, en la comparativa con pruebas reales 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

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:

{
  "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
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:

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:

# 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)
# [email protected]
# 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):

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:

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

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

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

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 [email protected] y [email protected] 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:

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

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.

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

// 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

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:

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:

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:

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
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.

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",
    }),
  },
});
    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í:

== 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:

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 /* … */
  };
}
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.

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

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:

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
$ 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:

  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:

== 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:

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

    // 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) {
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:

    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é:

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;
}
    // 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;
== 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:

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

    // 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:

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

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

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:

  .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 });
  })
    const softDeleted = await prisma.task.update({
      where: { id },
      data: { deletedAt: new Date() },
    });

    // … recordAudit(SOFT_DELETE) y successResponse(c, softDeleted)
== 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:

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,
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();
}
  .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:

  .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)
== 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
== 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:

  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: "[email protected]",
        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():

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 rutaQué haceQuién
GET /healthz · /docs · /openapi.jsonSalud con latencia de la BD, consola Scalar y especificación (las dos últimas, apagadas en producción)Público
POST /api/auth/registerCrea una cuenta con rol user (cualquier role enviado se ignora)Público, 5 por hora y IP
POST /api/auth/loginComprueba la contraseña, crea la sesión y devuelve los dos tokensPúblico, 10 por minuto y IP; bloqueo por cuenta
POST /api/auth/refreshPar nuevo con rotación; si el token ya se usó, cierra la sesiónRefresh token
POST /api/auth/logoutDesactiva la sesión y borra sus refresh tokensAccess o refresh
GET /api/sessions/me · DELETE /api/sessions/:id · POST /api/sessions/revoke-allVer y cerrar sesionesLas mías (admin: cualquiera)
GET/POST/PUT/DELETE /api/tasks · POST /api/tasks/:id/restoreCRUD con filtros, orden, ?include=user, borrado lógico y restauraciónSolo el dueño
GET /api/users · POST /api/usersListado con filtros, orden e ?include=tasks,sessions; alta con cualquier rolSolo admin
GET / PUT / DELETE /api/users/:idVer, editar o borrar (lógico, o ?permanent=true)Uno mismo o admin
PATCH /api/users/:id/blockSuspender o reactivar y cerrar todas sus sesionesSolo admin
POST /api/users/:id/revoke-sessionsCerrar todas las sesiones de una cuentaUno mismo o admin
GET /api/audit-logsRegistro de auditoría paginado, por entidad y acciónSolo 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:

// 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();
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:

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():

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

== 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:

# 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:

== 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:

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:

  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:

== 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:

    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) } }],
        },
      }),
    ]);
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:

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:

  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:

// 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({
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:

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:

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:

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: "[email protected]", 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
$ 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, 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.

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": "[email protected]",
    "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:

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


headers {
  Authorization: Bearer {{token}}
}

Si prefieres la terminal, el flujo completo con curl:

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":"[email protected]","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:

## 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:

# ==========================================
# 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"]
#!/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 "$@"
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:

$ 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 [email protected] -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: [email protected]..."}
{"level":30,"msg":"✔ Production administrator created successfully."}
{"level":30,"msg":"🚀 Hono server listening on http://localhost:3011"}
estado de Docker (HEALTHCHECK) ..................... healthy
GET /healthz ....................................... {"status":"healthy","database":{"status":"connected","latencyMs":1}}
login del admin de demostración ([email protected]) ... 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:

# 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: [email protected]
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.
$ 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
$ 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
# 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). 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.
# 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)
[email protected]          # 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

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])
}
model User {
  // … campos y relaciones que ya tenía …
  auditLogs     AuditLog[]
  notes         Note[]        // ← nueva relación
}
# 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):

-- 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:

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"),
});
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:

/**
 * @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

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

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

$ 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
== 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:

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

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

// 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;
$ 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:

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

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.

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:

MedidaResultadoCómo
npm test (60 pruebas de Vitest)~38 sSin levantar servidor, con app.request(); cada login de prueba paga bcrypt
Arranque hasta el primer /healthz 200760–840 msProceso nuevo, base de datos ya migrada, dentro del contenedor
Memoria en reposo~64 MiBdocker stats del contenedor; ~149 MiB tras la prueba de carga
Login (bcrypt, 12 rondas)365–410 msEl primero, en frío, ~480 ms. Con 10 rondas eran ~120 ms
GET /healthz~1.750 peticiones/s · 11 ms de mediaautocannon, 20 conexiones, 8 s
GET /api/tasks autenticado~324 peticiones/s · 61 ms de mediaIgual; 4 consultas a SQLite por petición
Imagen Docker602 MBnode:20-alpine con la CLI de Prisma
docker stop (cierre ordenado)0,27 sSIGTERM → 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:

ArregloPRCó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 · #10 · #11Ana 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#10Ana 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 · #10Refresh, 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#10Prueba 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 · #10Construir, 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 · #10Contenedor 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#11Arrancar 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 · #9 · #1040 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#10Cuenta 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#10Dos sesiones de Ana, cambio de clave desde una✅ la otra cae con 401
El cliente RPC hc<AppType> tiene tipos#11Un 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#11Comparar /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 · #11Node 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éGravedadArreglo
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 reescribeMediaUsar 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 redesplegarMediaENV 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ñoMediaDecisió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)BajaUn case (probado)
README, CI y Dockerfile dicen Node 20; 13 paquetes piden Node 22 (avisos EBADENGINE)BajaSubir 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.