Spec de M0 — Auth + RBAC
Fecha: 2026-08-19 Estado: ✅ Implementado (20-ago-2026) Módulo: M0 del Roadmap Aquazaku Autores: Mao (product owner) + AI (asistente de diseño)
1. Contexto
Sección titulada «1. Contexto»M0 es el cimiento del sistema Aquazaku. Gates todos los demás módulos (M1+). Sin auth y RBAC sólidos, no se puede construir nada que dependa de identidad o permisos.
El sistema modela 4 roles (admin, seller, pos, contador) con multi-rol por usuario (RN-ACC-01), un sistema de alcances por recurso (todo, propio, ruta, BODEGA, read-only) y auditoría inmutable de toda acción sensible (RN-ACC-04).
Las reglas de negocio que este módulo implementa están definidas en /docs/dominio/roles-y-permisos.md (RN-ACC-01 a 05). Este spec describe cómo se implementan, no redefine el qué.
2. Objetivos
Sección titulada «2. Objetivos»- Identidad de usuarios (email + password, sesión httpOnly)
- Autorización declarativa (matriz 4 roles × permisos × scopes, en código)
- Aplicación de alcance en UNA capa de datos (RN-ACC-03)
- Auditoría automática de cada check (allow/deny) y de acciones sensibles (RN-ACC-04)
- UI consultable de la auditoría (admin + contador)
- Defense in depth: UI oculta + API prohíbe (RN-ACC-02)
3. Fuera de alcance (M0)
Sección titulada «3. Fuera de alcance (M0)»- Tablas de productos, stock, ventas, clientes → M1+
- State machine completa de ventas (pendiente → verificado) → M2
- Módulo
/admin/cuentas-pendientes(gestión de pendientes de pago) → M2 - Job nocturno de ventas vencidas → M2
- Exportación PDF de auditoría → M13
- Analítica / alertas automáticas → M13
- App mobile del seller → post-MVP
- OAuth / SSO / 2FA → post-MVP
- Internacionalización → post-MVP
4. Decisiones arquitectónicas
Sección titulada «4. Decisiones arquitectónicas»| Decisión | ADR |
|---|---|
| Custom auth + custom authz (no Supabase, no Auth0) | ADR-0001-stack-m0 |
| Better-Auth como librería de auth (Lucia v3 está deprecada desde marzo 2025) | ADR-0001-stack-m0 |
Polyrepo: api/ y web/ en repos git separados |
Estrategia ya decidida |
| BFF-native desde M0: web/ hace de proxy server-to-server hacia api/ | ADR-0002-bff-pattern |
| Sesiones via cookies httpOnly (no JWT stateless) — mejor revocación para auditoría | ADR-0001-stack-m0 |
| Multi-rol sin switch: todos los roles asignados activos simultáneamente | ADR-0003-roles-permisos-matriz |
| Matriz de permisos en TypeScript (no en DB) — regla de negocio pura | ADR-0003-roles-permisos-matriz |
| Defense in depth: skill del proyecto + helper module + ESLint rule | ADR-0002-bff-pattern |
5. Stack
Sección titulada «5. Stack»api/ (Fastify backend)
Sección titulada «api/ (Fastify backend)»| Capa | Elección |
|---|---|
| Runtime | Node.js 22 LTS |
| Framework HTTP | Fastify 5.x |
| ORM | Drizzle 0.45.x |
| DB | Postgres 16 |
| Auth | Better-Auth 1.7.x |
| Validación | Zod 4.x |
| Resend (prod), Mailpit (dev) | |
| Logging | Pino 10.x |
| Tests | Vitest 4.x + Supertest |
| Hashing password | argon2id (@node-rs/argon2, vía Better-Auth) |
web/ (Next.js frontend)
Sección titulada «web/ (Next.js frontend)»| Capa | Elección |
|---|---|
| Framework | Next.js 16.x (App Router) |
| UI | React 19.2 |
| Estilos | Tailwind CSS |
| Componentes | Headless UI + custom |
| Tablas / filtros | TanStack Table |
| Validación | Zod 4.x |
| Tests | Vitest 4.x + Testing Library + MSW |
Nota de versiones (corrección 2026-08-19). La primera redacción de este spec fijaba Node 20, Fastify 4, Better-Auth 1.3 y Next 15. Al validar contra el registro de npm antes de implementar, todas estaban una línea mayor por detrás del stable actual. Para un proyecto greenfield eso es deuda técnica autoinfligida el día uno, así que se sube al stable vigente. El criterio queda fijado: se actualiza deliberadamente entre módulos, nunca en medio de uno.
Setup de la máquina en Entorno local.
6. Modelo de datos
Sección titulada «6. Modelo de datos»Solo las tablas de M0. Las de M1+ se agregan en sus respectivos módulos.
Corregido al implementar (20-ago-2026). La primera versión de este spec ponía
password_hashenusersy listaba cinco tablas. Better-Auth 1.7 exige otra cosa, y se supo leyendo su código fuente, no su documentación: el hash de la contraseña vive enaccounts, hacen faltaaccountsyverifications,users.emailVerifiedes obligatorio,sessions.tokenva aparte desessions.id, yaccounts.issueres requerido desde 1.7 con índice único junto aaccountId.Lo de abajo es el schema que existe. Detalle en las notas de ejecución de la Task 2 del plan.
Tablas que administra Better-Auth
Sección titulada «Tablas que administra Better-Auth»| Tabla | Columnas propias de Aquazaku |
|---|---|
users |
status (active/inactive), must_change_password · email es citext |
sessions |
roles text[] — todos los activos, congelados al iniciar sesión |
accounts |
— acá vive password, el hash argon2id |
verifications |
— tokens de un solo uso para recuperar la contraseña |
Los nombres de tabla son plurales (convención del resto del schema); Better-Auth
los espera en singular y se mapean con modelName en su configuración.
Tablas propias
Sección titulada «Tablas propias»| Tabla | Para qué |
|---|---|
roles |
Catálogo cerrado de los cuatro |
user_roles |
N:M, con granted_by — quién otorgó cada rol |
audit_log |
Bitácora append-only |
audit_log
Sección titulada «audit_log»id bigserial PKuser_id uuid ← SIN foreign key, a propósitorol_ejercido text[] ← todos los roles activos al ejecutar la acciónaction text ← formato `recurso:accion`, indexadoresource textresource_id textresult enum(ok, denied)request_id text ← el mismo x-request-id que viajó desde web/ip textuser_agent textpayload jsonbcreated_at timestamptzSin foreign key a users porque si un usuario se borra, su rastro tiene que
sobrevivir. Un log que se borra en cascada no es un log.
Inmutable con dos capas independientes —
ADR-0004: triggers FOR EACH STATEMENT
que rechazan UPDATE, DELETE y TRUNCATE, más un rol de aplicación que sobre
esta tabla solo tiene SELECT e INSERT. El REVOKE ... FROM app_user que
proponía la primera versión de este spec no protegía nada: ese rol no
existía y la aplicación se conectaba como dueña de las tablas.
Multi-rol: un usuario tiene N roles en user_roles. sessions.roles se
llena al iniciar sesión con todos. NO existe active_role ni switch
(RN-ACC-01). Al cambiar los roles de alguien, las sesiones abiertas se
actualizan (RN-ACC-07).
7. Flujos de autenticación
Sección titulada «7. Flujos de autenticación»7.1 Login normal
Sección titulada «7.1 Login normal»Browser → web/login (RSC renderiza form) → form submit → Server Action → Server Action llama api/auth/sign-in vía apiServerFetch() → Better-Auth valida password, chequea status=active → crea session con roles[] del usuario → devuelve Set-Cookie: aquazaku_session → Server Action propaga Set-Cookie al browser response → 302 redirect a /dashboard7.2 Primer login (must_change_password=true)
Sección titulada «7.2 Primer login (must_change_password=true)»Después del login normal, web/ chequea user.must_change_password:
- Si
true→ redirect forzado a/change-password(no se puede skipear) - Form → Server Action →
api/auth/change-password→ actualiza hash + flag → redirect a/dashboard
7.3 Forgot password
Sección titulada «7.3 Forgot password»/login→ “¿Olvidaste tu contraseña?” →/forgot-password- Form con email → Server Action →
api/auth/forgot-password - api/ genera token (1h expiry), guarda, envía email via Resend
- Email contiene link:
https://web.aquazaku.com/reset-password?token=xxx - User clickea → form con nueva password → Server Action →
api/auth/reset-password - api/ valida token, actualiza password, borra token usado
- Redirect a
/logincon toast
7.4 Logout
Sección titulada «7.4 Logout»- Click “Cerrar sesión” → Server Action
api/auth/sign-out→ borra session en DB- Web limpia cookie en response
- Redirect a
/login
7.5 Expiración / desactivación
Sección titulada «7.5 Expiración / desactivación»- Cookie expira en 7 días (refresh on use → sliding window)
- api/ chequea
user.status === 'active'en cada request - Sesión expirada o user inactivo → 401 con código específico
- Web detecta 401 → redirect a
/login?toast=sesion-expiradao?toast=cuenta-desactivada
8. Módulo authz/ — RBAC
Sección titulada «8. Módulo authz/ — RBAC»8.1 Estructura
Sección titulada «8.1 Estructura»api/src/modules/authz/├── matrix.ts # PERMISSION_MATRIX: Record<Role, PermissionRule[]>├── scopes.ts # SCOPES: Record<Scope, ScopeFilter>├── can.ts # can(user, resource, action): boolean├── scoped-query.ts # scopedQuery(user, baseQuery, resource, action)├── middleware.ts # requireAuth, requirePermission(resource, action)└── audit.ts # audit.allow / audit.deny8.2 Sistema de scopes
Sección titulada «8.2 Sistema de scopes»| Scope | Filtro aplicado |
|---|---|
todo |
Sin filtro |
propio |
WHERE created_by = $user.id |
ruta |
WHERE ruta_id IN $user.activeRutas |
BODEGA |
WHERE ubicacion = 'BODEGA' |
read-only |
Sin filtro de datos (readonly se enforce en otra capa) |
8.3 can(user, resource, action)
Sección titulada «8.3 can(user, resource, action)»Devuelve true si cualquiera de los roles del usuario concede el permiso (unión, sin switch). Sin estado mutable, sin caché, función pura testeable.
8.4 scopedQuery(user, baseQuery, resource, action)
Sección titulada «8.4 scopedQuery(user, baseQuery, resource, action)»La UNA capa que aplica el filtro de alcance (RN-ACC-03). Si el usuario tiene el scope todo desde cualquier rol, no aplica filtro. Si tiene propio y BODEGA (multi-rol), aplica la unión (registros que cumplan cualquiera).
8.5 Middleware Fastify
Sección titulada «8.5 Middleware Fastify»fastify.get('/api/ventas', { preHandler: [requireAuth, requirePermission('ventas', 'ver')],}, async (req) => { const query = scopedQuery(req.user, db.select().from(ventas), 'ventas', 'ver') return query})requirePermission registra en audit_log cada allow y deny.
8.6 Constraints de negocio (NO son authz)
Sección titulada «8.6 Constraints de negocio (NO son authz)»mismo_dia, con_motivo, cliente_verificado, cantidades — viven en la capa de servicio del módulo correspondiente (ej: ventas/service.ts). authz/ solo decide SI PUEDO; el servicio decide SI APLICA.
9. Patrón BFF (Backend-For-Frontend)
Sección titulada «9. Patrón BFF (Backend-For-Frontend)»Web es un proxy server-to-server hacia api/. El browser nunca habla directo a api/. Ver ADR-0002-bff-pattern para la justificación completa.
9.1 Helper único
Sección titulada «9.1 Helper único»export async function apiServerFetch<T>(path: string, init: RequestInit = {}): Promise<T>export async function getServerUser(): Promise<User | null>Reglas codificadas en el helper:
- SIEMPRE reenvía cookies del browser (
cookies()→Cookieheader) - SIEMPRE propaga
x-request-idpara tracing distribuido - SIEMPRE
cache: 'no-store'por default (datos de sesión no se cachean) - Logging estructurado en cada error
9.2 ESLint rule
Sección titulada «9.2 ESLint rule»Regla custom que falla el build si alguien usa fetch() directo o localStorage:
{ "no-restricted-syntax": [ "error", { "selector": "CallExpression[callee.name='fetch']", "message": "Use apiServerFetch() from @/lib/api-server instead of fetch() directly. See /docs/frontend/bff-pattern.md" }, { "selector": "MemberExpression[object.name='localStorage']", "message": "No tokens in localStorage. Use httpOnly cookies only." } ]}9.3 Skill del proyecto
Sección titulada «9.3 Skill del proyecto»Un skill que el LLM (asistente de código) consulta antes de escribir código BFF. Contiene: patrón correcto, anti-patrones, reglas de caching, convenciones de logging, estructura del helper.
Estado: creado en .claude/skills/aquazaku-bff/SKILL.md del repo paraguas (/Users/mao/code/aquazaku). Vive ahí y no en web/ porque el repo paraguas es el directorio de trabajo de las sesiones de asistente y aloja lo transversal; así el skill carga aunque se esté editando api/ o web/. No duplica contenido: apunta a /frontend/bff-pattern, que es la fuente de verdad.
10. Matriz de permisos — versión final
Sección titulada «10. Matriz de permisos — versión final»Las celdas marcadas ⚠️ en /docs/dominio/roles-y-permisos.md quedan así:
| Permiso | admin |
seller |
pos |
contador |
|---|---|---|---|---|
ventas:anular |
✅ todo | 🟡 propio + status=pendiente | 🟡 propio + status=pendiente | ❌ |
ventas:anular_verificada |
✅ todo (motivo obligatorio) | ❌ | ❌ | ❌ |
ventas:verificar_pago |
✅ todo | 🟡 propio | 🟡 propio | ❌ |
ventas:gestionar_cuentas_pendientes |
✅ todo | ❌ | ❌ | ❌ |
stock:cargar_ruta |
✅ todo | ❌ | ✅ todo | ❌ |
bases:prestar |
✅ todo | ❌ | ✅ con cliente verificado | ❌ |
compras:recibir |
✅ todo | ❌ | ✅ si compra=pendiente y proveedor=activo | ❌ |
auditoria:ver |
✅ todo | ❌ | ❌ | ✅ todo (readonly) |
El resto de la matriz (las celdas que no estaban ⚠️) se transcribe 1-a-1 desde /docs/dominio/roles-y-permisos.md al PERMISSION_MATRIX en código.
11. State machine de ventas (forward-looking)
Sección titulada «11. State machine de ventas (forward-looking)»Documentada para que M2 la implemente, no es parte de M0:
registrada ├─ pago total → pendiente_verificacion_pago (+ payment_method) └─ pago parcial → pago_parcial_verificado (+ saldo_pendiente) ↓ (cliente paga saldo) pago_verificado ↓ (admin anula con motivo) anulada_con_devolucion
(>7 días sin verificar) pendiente_verificacion_pago → vencida (+ alerta al admin)Constraints adicionales que M2 deberá implementar:
payment_method: enum(efectivo, transferencia, cuenta_bajo_monto)verified_by: quién confirmó el pago (puede ser el mismo registrador o admin)verified_at: timestampsaldo_pendiente: calculado automáticamente- Vencimiento: job nocturno marca
vencidadespués de 7 días
12. Estrategia de testing
Sección titulada «12. Estrategia de testing»| Componente | Coverage mínimo | Tipo de test |
|---|---|---|
authz/matrix.ts |
100% | Unit (cada celda) |
authz/can.ts |
100% | Unit (multi-rol, sin roles, etc.) |
authz/scoped-query.ts |
100% | Unit (cada scope, combinaciones) |
authz/middleware.ts |
95% | Unit + integration |
auth/* |
80% | Integration (Supertest) |
audit/* |
90% | Unit + integration |
web/lib/api-server.ts |
90% | Unit |
web/lib/modules.ts |
90% | Unit |
| Global | 70% | — |
| authz/ (consolidado) | 85% | — |
TDD-first: tests se escriben antes que la implementación. CI falla si coverage baja del threshold.
Tests críticos end-to-end:
- Login flow completo
- Forgot-password flow completo
- Must-change-password flow completo
- 401 cuando sesión expira
- 403 cuando rol no tiene permiso (seller intenta
usuarios:*) - Audit log se escribe en cada allow/deny
- Admin desactiva user → próximo request del user → 401 USER_INACTIVE
- User multi-rol ve módulos de ambos roles en sidebar
13. Seed y deploy
Sección titulada «13. Seed y deploy»13.1 Seed script (api/drizzle/seed.ts)
Sección titulada «13.1 Seed script (api/drizzle/seed.ts)»Crea idempotentemente:
- Las 4 filas en
roles(admin, seller, pos, contador) - El primer usuario admin desde env vars:
SEED_ADMIN_EMAIL=admin@aquazaku.comSEED_ADMIN_NAME="Admin Inicial"SEED_ADMIN_PASSWORD=<password fuerte>
- Asigna rol
adminal usuario creado
Seguridad:
- En
NODE_ENV=production, requiereSEED_CONFIRM=yespara correr - Si ya existe algún usuario con rol
admin, abort con mensaje claro - En dev corre sin confirmación
13.2 Primer deploy
Sección titulada «13.2 Primer deploy»- Aplicar migrations Drizzle (
pnpm db:migrate) - Configurar env vars con passwords fuertes
- Correr seed:
SEED_CONFIRM=yes pnpm db:seed - Login con el admin inicial, cambiar contraseña
- Crear resto de usuarios desde
/admin/usuarios
14. Archivos a crear
Sección titulada «14. Archivos a crear»Ver estructura completa en sección 4 del diseño presentado al usuario (pre-spec). Resumen:
api/ (~25 archivos): server.ts, db/, modules/auth/, modules/users/, modules/audit/, modules/authz/, lib/
web/ (~20 archivos): app/(auth)/, app/(app)/, lib/api-server.ts, lib/modules.ts, components/, eslint.config.mjs con regla custom
docs/ (actualizaciones):
/arquitectura/modulos.md→ M0 pasa de “por arrancar” a ✅/dominio/roles-y-permisos.md→ actualizar tabla con la matriz resuelta/frontend/bff-pattern.md(nuevo) → cómo se usa el helper BFF/decisiones/0001-stack-m0.md(nuevo)/decisiones/0002-bff-pattern.md(nuevo)/decisiones/0003-roles-permisos-matriz.md(nuevo)
Skill del proyecto: aquazaku-bff en .claude/skills/ del repo paraguas, con las reglas duras para el LLM. Ya creado.
15. Criterios de éxito (Definition of Done de M0)
Sección titulada «15. Criterios de éxito (Definition of Done de M0)»- Un admin puede crear usuarios y asignar roles desde la UI
- Un usuario con 1 rol ve solo los módulos de ese rol
- Un usuario multi-rol ve los módulos de todos sus roles (sin switch)
- Cualquier intento de acción sin permiso devuelve 403 y queda en audit_log
- Cualquier acción sensible (anular venta, ajustar stock, prestar base) queda registrada con
user_id,rol_ejercido[],request_id, timestamp -
audit_loges append-only: triggers rechazan UPDATE y DELETE - La UI de auditoría en
/admin/auditoriay/contador/auditoriamuestra el log con filtros y paginación - Forgot-password funciona end-to-end (email llega, link funciona, password se cambia)
- Primer login fuerza cambio de contraseña
- Admin puede desactivar un usuario y este pierde acceso en el siguiente request
- ESLint rule falla el build si alguien intenta
fetch()directo en web/ - Coverage:
authz/≥ 85%, global ≥ 70% - CI pasa (lint + typecheck + tests + coverage)
- Seed funciona en dev sin confirmación; en prod requiere
SEED_CONFIRM=yes
16. Preguntas abiertas / riesgos
Sección titulada «16. Preguntas abiertas / riesgos»Estado al cerrar M0 (20-ago-2026).
| # | Pregunta | Cómo quedó |
|---|---|---|
| 1 | ¿La matriz resuelta necesita revisión formal con Aquazaku? | Sigue abierta — se confirma al inicio de M2. La matriz está implementada y un test la verifica celda por celda contra el documento de dominio, así que confirmarla es cambiar celdas, no reescribir el módulo |
| 2 | ¿El seller tendrá acceso web? |
Cerrada. Puede entrar por web, pero no ve ningún módulo en M0: la matriz no le da ninguno de los tres que existen. Su superficie sigue siendo la app móvil (post-MVP) |
| 3 | ¿Refresh tokens o ventana deslizante? | Cerrada. Ventana deslizante, administrada por Better-Auth (expiresIn 7 días, updateAge 1 día). El middleware de authz solo valida: un único dueño del ciclo de vida de la sesión |
| 4 | ¿Qué pasa si Resend cae? | Cerrada para M0. El transporte lanza y la Server Action muestra un mensaje; la contraseña del usuario no cambia. Cola de reintentos sigue siendo post-MVP |
| 5 | ¿El rate limit necesita Redis? | Cerrada para M0. En memoria por proceso, como preveía el spec. El día que haya más de una instancia el contador por proceso deja de servir — está anotado en el código, no es un descuido |
Riesgos que aparecieron implementando
Sección titulada «Riesgos que aparecieron implementando»| Riesgo | Estado |
|---|---|
Los bugs de costura entre api/ y web/ no los ve ninguna suite unitaria |
Mitigado parcialmente. La colección de Bruno en CI atraviesa un socket real, pero solo del lado de api/. El seam web/ → api/ sigue verificándose a mano en el browser |
api/ no tiene linter real: lint es un alias de tsc --noEmit |
Abierto. web/ sí tiene ESLint con la regla anti-fetch(). Decidir si api/ suma uno |
api/ no tiene script build |
Diferido a propósito. Con alias @/* y ESM, tsc solo no produce salida ejecutable en Node. Se resuelve al preparar el deploy, con bundler |
| Los roles se congelan en la sesión al iniciar sesión | Resuelto — RN-ACC-07: al cambiar roles se actualizan las sesiones abiertas |
Resultado: M0 implementado en las 15 tasks del plan, cada una con sus notas de ejecución. Lo que este spec no anticipó está documentado ahí, task por task.
