# bunHono Base de backend con **Bun + Hono + Prisma (PostgreSQL)**, en JavaScript plano (ESM). Replica el sistema de autenticación y RBAC de `apimotitools`, pero con RBAC puro, cookie httpOnly como único transporte del token y tres dependencias de runtime en vez de noventa. --- ## Puesta en marcha ```bash bun install cp .env.example .env # editar DATABASE_URL y SECRET_JWT_SEED bun run db:generate bun run db:migrate # primera vez: --name init bun run db:seed bun run dev ``` | Script | Qué hace | |---|---| | `bun run dev` | Servidor con recarga (`bun --watch`) | | `bun run start` | Servidor sin watch | | `bun run db:generate` | `prisma generate` | | `bun run db:migrate` | `prisma migrate dev` | | `bun run db:deploy` | `prisma migrate deploy` (producción) | | `bun run db:seed` | Permisos base, roles `super_admin` y `admin`, usuario inicial | | `bun run db:studio` | Prisma Studio | | `bun run lint` | ESLint | El seed es idempotente: se puede correr las veces que haga falta. --- ## Estructura ``` config/ env.js (valida process.env al arrancar), prisma.js (cliente singleton) prisma/ schema.prisma, seed.js routes/ admin/auth.routes.js, admin/rbac.routes.js controller/ admin/{auth,password,rbac}.controller.js middlewares/ authJwt.js, requirePermission.js helpers/ respond, jwt, password, cookies, validate, logger services/ user.service.js (motor de permisos), mail.service.js index.js app Hono: CORS, bodyLimit, rutas, onError, notFound ``` --- ## Autenticación El JWT viaja **solo** en una cookie `token` httpOnly. No se devuelve en el cuerpo de la respuesta y no se leen headers `xtoken` / `x-token` / `Authorization`. El frontend solo necesita `credentials: 'include'` (axios: `withCredentials: true`). - Vigencia del token: **15 días**, igual que el `maxAge` de la cookie. - `secure` y `sameSite` se derivan de `NODE_ENV` (`production` → `secure: true`, `SameSite=Strict`). - El token de reset de contraseña dura 1 hora, es de un solo uso y lleva el claim `typ: 'reset'`, así que no sirve para autenticarse. --- ## RBAC Tres tablas (`users`, `roles`, `permissions`) más dos tablas puente explícitas (`user_roles`, `role_permissions`). Un permiso es `resource:action` — ambas partes en minúsculas y limitadas a `[a-z0-9_-]`. La llave se deriva siempre en el servidor, nunca se acepta del cliente. `requirePermission(llaves)` acepta una llave o un arreglo (semántica OR) y compara contra un arreglo plano de **nombres de rol + llaves de permiso**, igual que apimotitools: `requirePermission(['admin', 'usuario:update'])` pasa si el usuario tiene el rol `admin` **o** el permiso `usuario:update`. - `super_admin` salta cualquier verificación. - ⚠️ `'admin'` solo puede resolver como **nombre de rol**: no cumple el formato `resource:action`, así que nunca puede ser una llave de permiso. El seed crea ese rol; sin él, toda la administración de RBAC queda accesible solo para `super_admin`. - Falla cerrado: cualquier error en la verificación termina en 403. - Los registros removidos se filtran en los tres niveles (usuario, rol, permiso). ### Borrado lógico Todo usa `removed: boolean`, nunca `DELETE`. Mongo permitía reutilizar el nombre de un rol removido gracias a un índice único parcial; Postgres con Prisma no puede expresar `UNIQUE ... WHERE removed = false`, así que `createRole` / `createPermission` **reviven**: si existe un registro removido con ese nombre o llave, se reactiva y se actualiza con los datos nuevos (a los roles se les reemplazan los permisos, para que no hereden concesiones viejas). Solo un registro activo devuelve 409. --- ## Endpoints Todos bajo `/api/auth`. Más `GET /api/health`. | Método | Ruta | Protección | |---|---|---| | POST | `/` y `/login` | pública — login | | POST | `/logout` | pública — funciona con token expirado o ausente | | POST | `/requestPasswordReset` | pública | | POST | `/resetPassword` | pública | | GET | `/renew` | `authJwt` | | GET | `/getUsers` | `authJwt` | | POST | `/new` | `authJwt` + `admin` \| `usuario:create` | | POST | `/updateUser` | `authJwt` + `admin` \| `usuario:update` | | POST · PUT · GET | `/permissions` · `/permissions/:id` · `/permissions` | `authJwt` + `admin` | | POST · PUT · GET | `/roles` · `/roles/:id` · `/roles` | `authJwt` + `admin` | | POST | `/roles/addPermission` | `authJwt` + `admin` | | POST | `/users/addRole` | `authJwt` + `admin` (+ guard `super_admin`) | | POST | `/users/removeRole` | `authJwt` + `admin` (+ guard `super_admin`) | | GET | `/users/byRole/:roleId` | `authJwt` + `admin` | `GET /permissions`, `/roles` y `/getUsers` aceptan `?includeRemoved=true`. `updateUser` identifica al usuario por `email` en el cuerpo, igual que apimotitools. A futuro debería ser `PUT /users/:id`. **Guard de `super_admin`:** solo un `super_admin` puede crear, editar o renombrar el rol `super_admin`, agregarle permisos, o asignárselo / quitárselo a un usuario. ### Formato de respuesta Uno solo, en todo el proyecto: ```json { "ok": true, "roles": [] } { "ok": false, "msg": "No tienes permisos: Acceso denegado" } ``` Códigos: **400** validación (con `errors` por campo), **401** sin token / token inválido o expirado / credenciales inválidas, **403** sin permisos, **404** no encontrado, **409** duplicado, **500** el resto. --- ## Diferencias con apimotitools Cambios que el frontend debe absorber: | apimotitools | bunHono | |---|---| | El login devuelve `token` en el cuerpo | **No lo devuelve** — solo cookie | | El login devuelve `permisos`, `rol`, `employee_name`, `efiuser` | Eliminados (RBAC puro) | | Login fallido → `200 {ok:false}` con tres mensajes distintos | **401** con un mensaje genérico | | Token inválido o expirado → `503` | **401** | | Error de validación → `200 {ok:false, errors}` | **400** | | `GET /getUsers` → arreglo pelado | **`{ok:true, users:[…]}`** | | Headers `xtoken` / `x-token` | Ignorados | | `roles`: arreglo plano de nombres de rol + llaves de permiso | Igual | Endpoints que ya no existen: `POST /getPermisos`, `GET /getAccess`, `GET /models` (todos ligados a los campos legacy `permisos` / `access`). Correcciones de seguridad respecto al original: - La identidad va en el contexto de Hono (`c.set('auth', …)`), no inyectada en el cuerpo del request. - El login no revela qué correos existen: mismo mensaje y mismo tiempo de respuesta (se ejecuta un verify de relleno cuando el correo no existe). - `requestPasswordReset` responde igual exista o no el correo, y el host del enlace se toma del `Origin` **solo si está en la lista blanca de CORS**; si no, usa `FRONTEND_URL`. En el original, un `Origin` falsificado producía un enlace de reset válido apuntando a un sitio de phishing. - La vigencia del token (15 días) coincide con la de la cookie; antes eran 40 días contra 15. --- ## Pruebas rápidas ```bash curl -s localhost:8083/api/health # login: 200 con Set-Cookie. El cuerpo NO debe traer "token" curl -i -c /tmp/c.txt -X POST localhost:8083/api/auth/ \ -H 'Content-Type: application/json' -H 'Origin: http://localhost:5174' \ -d '{"email":"admin@example.com","password":"..."}' curl -i -b /tmp/c.txt localhost:8083/api/auth/renew # 200 + nueva cookie curl -i -b /tmp/c.txt localhost:8083/api/auth/getUsers # 200 {ok:true, users:[…]} curl -i -b /tmp/c.txt localhost:8083/api/auth/roles # 200 {ok:true, roles:[…]} curl -i -X POST localhost:8083/api/auth/logout # 200, cookie borrada curl -i -b /tmp/c.txt localhost:8083/api/auth/getUsers # 401 ``` Casos negativos: ```bash curl -i localhost:8083/api/auth/renew # 401 (no 503) curl -i -H 'Cookie: token=basura' localhost:8083/api/auth/renew # 401 curl -i -H 'xtoken: ' localhost:8083/api/auth/renew # 401 (confirma cookie-only) curl -i localhost:8083/api/nope # 404 {ok:false} ``` --- ## Notas de implementación - `maxAge` en Hono va en **segundos** (en Express eran milisegundos), y `sameSite` va capitalizado (`'Strict'` / `'Lax'`). Ambos se calculan una sola vez en `helpers/cookies.js`. - `hono/jwt` no tiene `expiresIn`: el claim `exp` se calcula a mano en `helpers/jwt.js`. - El `path: '/'` explícito en la cookie es lo que hace que el logout realmente la borre. - El guard `globalThis` en `config/prisma.js` evita agotar el pool de conexiones cuando `bun --watch` recarga los módulos. - `Bun.password` usa el algoritmo bcrypt (`$2b$`), así que los hashes de apimotitools se pueden importar tal cual.