Ir al contenido

Plan de M0 — Auth + RBAC

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build M0 (Auth + RBAC) of the Aquazaku system: identity, authorization with 4-role matrix + scope model, audit log consultable from UI, BFF pattern, and seed for the first admin.

Architecture: Polyrepo with two independent projects — api/ (Fastify + Drizzle + Postgres + Better-Auth + custom authz module) and web/ (Next.js App Router BFF). Web never talks directly to api from the browser; all communication goes through apiServerFetch() helper server-to-server. Permission matrix is declared in TypeScript, applied in ONE data layer (scopedQuery). Audit log is append-only (REVOKE + trigger). All defense-in-depth: skill + helper module + ESLint rule.

Development approach: Incremental with E2E verification per feature boundary. Each api/ task that produces endpoints must add an E2E test (real HTTP request → expected response) before moving on, not just at the end. Each web/ task that produces UI must be manually verified in a browser before merge. API is also documented in a Postman/Bruno collection versioned in the repo, so manual exploration is reproducible and CI-runnable.

Tech Stack (stable vigente al 2026-08-19, verificado contra el registro de npm):

  • Runtime: Node.js 22 LTS
  • api/: Fastify 5.x, Drizzle 0.45.x, Postgres 16, Better-Auth 1.7.x, Zod 4.x, argon2id (@node-rs/argon2), Pino 10.x, Resend (prod) / Mailpit (dev), Vitest 4.x + Supertest
  • web/: Next.js 16 (App Router), React 19.2, Tailwind, TanStack Table, Vitest 4.x + Testing Library + MSW
  • Tests: Vitest with min coverage authz/ ≥85%, global ≥70%
  • API exploration: Bruno (open source, files-in-repo, CLI-runnable) as primary; Postman-compatible JSON export

Corrección de versiones 2026-08-19. La primera redacción de este plan fijaba Node 20, Fastify 4, Better-Auth 1.3, Next 15, Zod 3 y Vitest 2 — todas una línea mayor por detrás del stable actual. Se corrigió antes de arrancar Task 1. Para greenfield, empezar una major atrás es deuda autoinfligida. Los ejemplos de código de este plan se escribieron contra las versiones viejas: donde haya diferencia de API (sobre todo Next 15→16 y Zod 3→4) manda la doc oficial de la versión instalada, no el snippet de este plan. Cada task verifica typecheck en verde antes de darse por cerrada.

Entorno local: ya provisionado (Postgres 16 nativo vía Homebrew, Mailpit, Bruno CLI global). Ver Entorno local. No se usa Docker Compose: Postgres corre como servicio de login y CI usa la imagen postgres:16, misma línea de versión.

  • Polyrepo: api/ y web/ son repos git independientes, cada uno con su propio git init y su propio remote. El repo paraguas (/Users/mao/code/aquazaku) los ignora en .gitignore — nunca se commitea código desde la raíz
  • Node 22 LTS fijado explícitamente en ambos repos: .nvmrc con 22 + "engines": { "node": ">=22" } en package.json. Sin esto, cada máquina resuelve una versión distinta
  • Postgres local ya provisionado: role aquazaku, bases aquazaku_dev y aquazaku_test, extensiones citext + pgcrypto creadas. Los tests corren contra aquazaku_test, nunca contra aquazaku_dev
  • Emails en dev van a Mailpit (smtp://localhost:1025, UI en http://localhost:8025), nunca a internet. Resend solo en producción
  • TypeScript strict mode in both api/ and web/
  • All files use 2-space indentation, single quotes, no semicolons (project convention)
  • No Co-Authored-By in commits (per project AGENTS.md)
  • Conventional commits: feat:, fix:, chore:, test:, docs:
  • Every commit must leave the project in a green state (tests + lint + typecheck pass)
  • No TODO/PLACEHOLDER comments in shipped code
  • All API endpoints require authentication except /auth/sign-in, /auth/forgot-password, /auth/reset-password
  • Cookie name: aquazaku_session, httpOnly, sameSite=Lax, 7-day expiry with sliding window
  • Password hashing: argon2id via Better-Auth, min length 8
  • Rate limit: 5 failed login attempts per IP+email per 15 minutes
  • Drizzle migrations are explicit SQL, never auto-generated against production
  • audit_log is append-only: REVOKE UPDATE, DELETE + trigger that rejects mutations
  • ESLint rule in web/: bans fetch() direct and localStorage
  • No JWT — sessions live in DB for revocation
  • E2E coverage is mandatory per feature boundary: every new api/ endpoint gets a real-HTTP E2E test (not just unit) before its task is “done”. Every new web/ page gets manually verified in a browser before its task is “done”. Unit tests are necessary but not sufficient.
  • API is documented in a versioned Bruno collection at api/bruno/aquazaku/ — added in Task 15, updated whenever an endpoint changes

api/
├── package.json
├── tsconfig.json
├── drizzle.config.ts
├── vitest.config.ts
├── .env.example
├── src/
│ ├── server.ts # Fastify entry
│ ├── db/
│ │ ├── client.ts # Drizzle connection
│ │ ├── schema.ts # All M0 tables
│ │ └── migrations/ # Drizzle Kit generated
│ ├── lib/
│ │ ├── env.ts # Zod-validated env vars
│ │ ├── logger.ts # Pino with request_id
│ │ └── errors.ts # AppError hierarchy
│ ├── modules/
│ │ ├── authz/
│ │ │ ├── matrix.ts # PERMISSION_MATRIX
│ │ │ ├── scopes.ts # SCOPES filter implementations
│ │ │ ├── can.ts # can(user, resource, action)
│ │ │ ├── scoped-query.ts # scopedQuery(user, baseQuery, ...)
│ │ │ ├── middleware.ts # requireAuth, requirePermission
│ │ │ ├── audit.ts # audit.allow / audit.deny
│ │ │ └── __tests__/ # Vitest
│ │ ├── auth/
│ │ │ ├── routes.ts # /auth/* endpoints
│ │ │ ├── service.ts # Better-Auth wrapper + hooks
│ │ │ ├── email.ts # Resend + Mailpit
│ │ │ ├── rate-limit.ts # 5/15min
│ │ │ └── __tests__/
│ │ ├── users/
│ │ │ ├── routes.ts # Admin CRUD
│ │ │ ├── service.ts
│ │ │ ├── validation.ts # Zod
│ │ │ └── __tests__/
│ │ └── audit/
│ │ ├── routes.ts # GET /audit
│ │ ├── service.ts
│ │ ├── query.ts # Filters + cursor pagination
│ │ └── __tests__/
│ └── plugins/
│ └── auth-plugin.ts # Better-Auth Fastify plugin
├── drizzle/
│ └── seed.ts # First admin seed
web/
├── package.json
├── tsconfig.json
├── next.config.ts
├── tailwind.config.ts
├── vitest.config.ts
├── eslint.config.mjs # Flat config + regla: no fetch() directo
├── .env.example
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ ├── globals.css
│ │ ├── (auth)/ # Route group: no auth required
│ │ │ ├── layout.tsx
│ │ │ ├── login/
│ │ │ │ ├── page.tsx
│ │ │ │ └── actions.ts
│ │ │ ├── forgot-password/
│ │ │ ├── reset-password/
│ │ │ └── change-password/
│ │ └── (app)/ # Route group: auth required
│ │ ├── layout.tsx # Sidebar + role-based menu
│ │ ├── page.tsx # Dashboard
│ │ └── modulos/
│ │ ├── usuarios/ # admin only
│ │ │ ├── page.tsx
│ │ │ └── actions.ts
│ │ ├── auditoria/ # admin
│ │ │ └── page.tsx
│ │ └── contador/ # contador
│ │ └── auditoria/
│ │ └── page.tsx
│ ├── lib/
│ │ ├── api-server.ts # apiServerFetch, getServerUser
│ │ ├── modules.ts # computeVisibleModules(roles)
│ │ └── logger.ts
│ ├── components/
│ │ ├── auth/
│ │ │ ├── login-form.tsx
│ │ │ └── password-fields.tsx
│ │ └── ui/
│ │ ├── sidebar.tsx
│ │ ├── table.tsx # TanStack Table wrapper
│ │ ├── filters.tsx
│ │ └── pagination.tsx
│ └── __tests__/
│ ├── api-server.test.ts
│ └── modules.test.ts

Task 1 (setup api/ + web/) ← parallel with nothing
↓
Task 2 (Postgres schema + migrations + audit triggers)
↓
Task 3 (authz core: matrix, scopes, can, scoped-query) ← uses Task 2
↓
Task 4 (authz middleware + audit emit) ← uses Task 3
↓
Task 5 (Better-Auth setup) ← uses Task 2
↓
Task 6 (auth routes: sign-in/out/me) ← uses Tasks 4, 5
Task 7 (auth: forgot/reset/change-password + email + rate limit) ← uses Task 5
↓ (parallel)
Task 8 (users CRUD) ← uses Tasks 4, 6
Task 9 (audit query API) ← uses Tasks 2, 4
↓
Task 10 (web BFF helper + ESLint rule + modules.ts) ← independent of api tasks
↓
Task 11 (web auth UI: login, forgot, reset, change-password) ← uses Tasks 7, 10
↓
Task 12 (web app shell: sidebar + layout) ← uses Task 10
↓
Task 13 (web admin/contador modules: usuarios + auditoria) ← uses Tasks 8, 9, 12
↓
Task 14 (seed script + safety) ← uses Task 2
↓
Task 15 (CI: lint + typecheck + tests + coverage gates + E2E smoke) ← uses all

Tasks that can run in parallel: Task 10 with any api/ task once Task 1 is done. Within api/: Tasks 6 and 7 are parallel after Task 5.


Task 1: Initialize both repos with dependencies and base config

Sección titulada «Task 1: Initialize both repos with dependencies and base config»

Files:

  • Create: api/package.json
  • Create: api/tsconfig.json
  • Create: api/vitest.config.ts
  • Create: api/.env.example
  • Create: web/package.json
  • Create: web/tsconfig.json
  • Create: web/next.config.ts
  • Create: web/vitest.config.ts
  • Create: web/.env.example
  • Create: api/.nvmrc y web/.nvmrc
  • Create: api/.gitignore (el de web/ lo genera create-next-app)

Interfaces:

  • Produces: api/ and web/ as runnable projects with pnpm test, pnpm dev, pnpm typecheck working
  • Produces: dos repos git independientes, cada uno con su commit inicial
  • Consumes: entorno local ya provisionado (Postgres, Mailpit, Bruno) — ver Entorno local

Precondición — verificar antes de empezar:

Ventana de terminal
node -v # v22.x
pnpm -v # 11.x
pg_isready # accepting connections
bru --version # 4.x
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8025 # 200

Si algo falla, resolvelo con Entorno local antes de seguir. No arranques Task 1 con el entorno a medias.

  • Step 1: Initialize api/

Dos precisiones sobre las dependencias:

  • postgres es el driver que consume Drizzle (no pg).
  • @node-rs/argon2 es el binding de argon2id que usa Better-Auth y el que importan los tests de Task 15. No es el paquete argon2, que es otro.
Ventana de terminal
mkdir -p api && cd api
pnpm init
echo '22' > .nvmrc
pnpm add fastify @fastify/cookie @fastify/cors drizzle-orm postgres better-auth zod pino @node-rs/argon2 resend nodemailer
pnpm add -D typescript @types/node @types/nodemailer vitest @vitest/coverage-v8 drizzle-kit tsx supertest @types/supertest pino-pretty

nodemailer es para hablarle a Mailpit por SMTP en dev; Resend queda solo para producción (ver Task 7).

Agregar a api/package.json:

{
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx drizzle/migrate.ts",
"db:seed": "tsx drizzle/seed.ts"
}
}
  • Step 1b: Verificar compatibilidad de peer deps

El salto de versiones (Zod 3→4, Fastify 4→5) puede romper peers. Verificar antes de escribir código:

Ventana de terminal
pnpm install 2>&1 | rg -i 'peer|warn' || echo 'sin warnings de peers'
pnpm list fastify better-auth drizzle-orm zod

Expected: sin unmet peer dependency. Si Better-Auth todavía pide Zod 3, fijar Zod 3 en api/ y anotarlo en este plan — no forzar con --force.

  • Step 2: Configure api/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
  • Step 3: Configure api/vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
thresholds: {
'src/modules/authz/**': { lines: 85, functions: 85, branches: 80 },
global: { lines: 70, functions: 70, branches: 65 },
},
},
},
resolve: {
alias: { '@': new URL('./src', import.meta.url).pathname },
},
})
  • Step 4: Configure api/.env.example
NODE_ENV=development
PORT=3001
# Base ya provisionada localmente (ver /empezar/entorno-local/)
DATABASE_URL=postgres://aquazaku:aquazaku@localhost:5432/aquazaku_dev
# Los tests usan SU PROPIA base. Nunca apuntar tests a aquazaku_dev.
DATABASE_URL_TEST=postgres://aquazaku:aquazaku@localhost:5432/aquazaku_test
BETTER_AUTH_SECRET=<generate with: openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3001
COOKIE_DOMAIN=localhost
# Email: en dev sale por Mailpit (SMTP local, UI en http://localhost:8025).
# En prod se usa Resend. El selector es MAIL_TRANSPORT.
MAIL_TRANSPORT=smtp # smtp | resend
SMTP_HOST=localhost
SMTP_PORT=1025
RESEND_API_KEY=
EMAIL_FROM=noreply@aquazaku.com
LOG_LEVEL=info

Además crear api/.gitignore:

node_modules/
dist/
.env
.env.local
coverage/
*.log
  • Step 5: Initialize web/

El orden importa. create-next-app va PRIMERO, sobre un directorio vacío. Si corrés pnpm init antes, create-next-app encuentra un package.json existente y el scaffold entra en conflicto. Tampoco existe el flag --no-src-dir=false: la estructura de este plan usa src/, así que el flag correcto es --src-dir.

Ventana de terminal
# 1. Scaffold primero, sobre directorio vacío
pnpm create next-app@latest web --typescript --tailwind --app --src-dir --import-alias='@/*' --use-pnpm --eslint --turbopack
# 2. Recién ahora las dependencias extra
cd web
echo '22' > .nvmrc
pnpm add @tanstack/react-table zod
pnpm add -D vitest @vitest/coverage-v8 @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw

Agregar a web/package.json:

{
"engines": { "node": ">=22" },
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}
}

tailwind.config.ts ya no se crea a mano: Tailwind 4 se configura desde el CSS que genera create-next-app. Por eso salió de la lista de archivos.

  • Step 6: Configure web/.env.example
NODE_ENV=development
API_INTERNAL_URL=http://localhost:3001
WEB_PUBLIC_URL=http://localhost:3000
  • Step 7: Verify both projects start
Ventana de terminal
cd api && pnpm typecheck && cd ../web && pnpm typecheck

Expected: ambos en verde, sin errores.

Y que api/ realmente se conecte a la base ya provisionada:

Ventana de terminal
cd api && node -e "
const postgres = require('postgres');
const sql = postgres(process.env.DATABASE_URL ?? 'postgres://aquazaku:aquazaku@localhost:5432/aquazaku_dev');
sql\`select version()\`.then(r => { console.log(r[0].version); return sql.end(); });
"

Expected: imprime PostgreSQL 16.x. Si falla acá, Task 2 no tiene dónde correr.

  • Step 8: Commit — un repo git por proyecto

Polyrepo: api/ y web/ son repos independientes. Dos git init, no uno. El repo paraguas ya los ignora en su .gitignore, así que nunca hay que commitear desde la raíz.

Ventana de terminal
cd api
git init -b main
git add .
git commit -m "chore: bootstrap api project"
cd ../web
git init -b main
git add .
git commit -m "chore: bootstrap web project"

Los remotes en GitHub se crean aparte (gh repo create), fuera del alcance de esta task — pedir confirmación antes de publicar nada.

Notas de ejecución de Task 1 (2026-08-19) — ✅ completada

Sección titulada «Notas de ejecución de Task 1 (2026-08-19) — ✅ completada»

Resultado: api/ en 6327068+3bc7828, web/ en 4a17eb8. Ambos repos con typecheck, tests y lint en verde.

Hallazgos que cambian tasks posteriores:

Hallazgo Impacto
TypeScript 5.9.3, no 7. pnpm resolvió typescript@^7 (el port en Go) y TS 7 eliminó baseUrl — el typecheck falló al instante. Se fijó 5.9.3 en ambos repos, que es lo que pinea create-next-app Ninguna task cambia, pero no volver a subir TS durante M0. La migración a 7 se evalúa después, con el schema de Drizzle y los genéricos de Better-Auth ya escritos
ESLint 9 usa flat config. create-next-app genera eslint.config.mjs, no .eslintrc.json Task 10: la regla que prohíbe fetch() y localStorage va en formato flat config. El snippet .eslintrc.json del spec §9.2 y de este plan está obsoleto
Next 16 escribe su propio AGENTS.md avisando que hay breaking changes vs. Next 15 y que hay que leer node_modules/next/dist/docs/ antes de escribir código. next dev lo regenera si se borra Tasks 11, 12, 13: consultar esos docs antes de escribir UI. Los snippets de este plan son Next 15
Next 16 genera tipos de rutas: el layout usa LayoutProps<'/'>, no un tipo escrito a mano Tasks 11-13: usar los tipos generados, no inventar props
pnpm 11 bloquea postinstall y falla el install entero si hay alguno sin declarar. Se declara en pnpm-workspace.yaml con allowBuilds, siguiendo lo que ya hacía docs/ Al agregar dependencias con binarios nativos en cualquier task, declararlas ahí
Tailwind 4 no usa tailwind.config.ts — se configura desde el CSS Ya reflejado en la lista de archivos
api/ no tiene script build. Con alias @/* + ESM, tsc solo no produce salida ejecutable en Node Decisión diferida a propósito: se resuelve al preparar deploy (post-M0), con bundler. dev, test y typecheck — que es lo que Task 1 promete — funcionan

Extras entregados sobre lo planeado, porque Task 1 promete “proyectos ejecutables” y sin esto no lo serían:

  • api/src/app.ts con buildApp() separado de server.ts, endpoint GET /health y 3 tests. El split es lo que después permite app.inject() en cada test de endpoint.
  • Propagación de x-request-id ya funcionando en api/ (respeta el del cliente, genera uno si no viene, lo devuelve siempre). Es la mitad de api/ del tracing distribuido que el spec §9.1 pide.
  • Logger apagado en tests, pino-pretty en dev, JSON en el resto.
  • Shutdown ordenado en SIGINT/SIGTERM.
  • web/: landing propia en lugar del boilerplate de Next, lang="es" y metadata de Aquazaku.

Task 2: Postgres schema, migrations, and audit_log triggers

Sección titulada «Task 2: Postgres schema, migrations, and audit_log triggers»

Files:

  • Create: api/src/db/schema.ts
  • Create: api/src/db/client.ts
  • Create: api/drizzle.config.ts
  • Create: api/src/db/migrations/0001_initial.sql (manually created)

Interfaces:

  • Produces: tables users, sessions, roles, user_roles, audit_log in Postgres

  • Produces: triggers that reject UPDATE/DELETE on audit_log

  • Consumes: nothing

  • Step 1: Write api/src/db/schema.ts

import { pgTable, uuid, text, timestamp, boolean, jsonb, inet, bigserial, pgEnum, primaryKey, index } from 'drizzle-orm/pg-core'
export const userStatusEnum = pgEnum('user_status', ['active', 'inactive'])
export const auditResultEnum = pgEnum('audit_result', ['ok', 'denied'])
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
status: userStatusEnum('status').notNull().default('active'),
mustChangePassword: boolean('must_change_password').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
roles: text('roles').array().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
ip: inet('ip'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
userIdx: index('sessions_user_idx').on(t.userId),
}))
export const roles = pgTable('roles', {
name: text('name').primaryKey(),
description: text('description').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const userRoles = pgTable('user_roles', {
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
roleName: text('role_name').notNull().references(() => roles.name),
grantedAt: timestamp('granted_at', { withTimezone: true }).notNull().defaultNow(),
grantedBy: uuid('granted_by').references(() => users.id),
}, (t) => ({
pk: primaryKey({ columns: [t.userId, t.roleName] }),
}))
export const auditLog = pgTable('audit_log', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
userId: uuid('user_id'),
rolEjercido: text('rol_ejercido').array(),
action: text('action').notNull(),
resource: text('resource'),
resourceId: text('resource_id'),
result: auditResultEnum('result').notNull(),
requestId: uuid('request_id'),
ip: inet('ip'),
userAgent: text('user_agent'),
payload: jsonb('payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
userIdx: index('audit_user_idx').on(t.userId),
createdIdx: index('audit_created_idx').on(t.createdAt),
actionIdx: index('audit_action_idx').on(t.action),
}))
  • Step 2: Write api/src/db/client.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { env } from '@/lib/env'
import * as schema from './schema'
export const db = drizzle(postgres(env.DATABASE_URL), { schema })
export type DB = typeof db
  • Step 3: Write api/src/lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3001),
DATABASE_URL: z.string().url(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
COOKIE_DOMAIN: z.string().default('localhost'),
RESEND_API_KEY: z.string().optional(),
EMAIL_FROM: z.string().default('noreply@aquazaku.com'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
})
export const env = envSchema.parse(process.env)
  • Step 4: Write api/drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
} satisfies Config
  • Step 5: Generate the initial migration

Run: cd api && pnpm drizzle-kit generate Expected: file created at src/db/migrations/0000_*.sql

  • Step 6: Add audit_log triggers to migration

Append to src/db/migrations/0000_*.sql:

-- audit_log append-only enforcement
CREATE OR REPLACE FUNCTION reject_audit_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER no_update_audit BEFORE UPDATE ON audit_log
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();
CREATE TRIGGER no_delete_audit BEFORE DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();
REVOKE UPDATE, DELETE ON audit_log FROM aquazaku_app;
  • Step 7: Apply migration

Run: cd api && pnpm drizzle-kit migrate Expected: 5 tables created, triggers installed

  • Step 8: Verify triggers

Run in psql: psql $DATABASE_URL -c "UPDATE audit_log SET action='hack' WHERE id=1" Expected: ERROR: audit_log is append-only

  • Step 9: Commit
Ventana de terminal
git add . && git commit -m "feat(api): M0 database schema with audit_log append-only triggers"

Notas de ejecución de Task 2 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 2 (2026-08-20) — ✅ completada»

Resultado: api/ en 4202643. 7 tablas, 2 migraciones, 41 tests en verde. Coverage 100% statements/lines, 85.7% branches, 88.9% functions.

Lo que el plan tenía mal, y por qué importaba:

Problema Corrección
El schema no era compatible con Better-Auth 1.7. El plan ponía password_hash en users; Better-Auth lo guarda en accounts. Faltaban accounts y verifications, users.emailVerified, y sessions.token separado de sessions.id. Además accounts.issuer es requerido desde 1.7 y forma índice único con accountId — eso no está en la doc, sale del código fuente Schema reescrito compatible desde el arranque. Sin esto, Task 5 obligaba a rehacer migraciones
REVOKE ... FROM aquazaku_app no protegía nada. Ese rol no existía, y la app se conectaba con aquazaku, que es dueño de las tablas. A un dueño no se le revoca nada de forma efectiva Se creó aquazaku_app con privilegio mínimo, y api/ ahora usa dos DSN: DATABASE_URL (app) y DATABASE_MIGRATION_URL (migraciones). Ver ADR-0004
Triggers FOR EACH ROW. Un DELETE que no matchea ninguna fila jamás los dispara: pasaría en silencio Se pasaron a FOR EACH STATEMENT, y se agregó uno para TRUNCATE, que no dispara los de UPDATE/DELETE
email era text, pese a que el spec §6 dice citext y la extensión ya estaba instalada citext vía customType de Drizzle. La migración crea la extensión sola
Step 8 verificaba los triggers a mano en psql. Una garantía comprobada a mano una vez no es una garantía 12 tests automáticos que además distinguen qué capa frenó cada intento: al rol de app lo para el GRANT faltante, al dueño lo para el trigger
env.ts usaba z.string().url() y no contemplaba los dos DSN Reescrito con Zod 4 (z.url()), los dos DSN, transporte de mail y validación cruzada de Resend en producción

Decisiones tomadas al implementar:

  • audit_log sin FK a users: si un usuario se borra, su rastro debe sobrevivir. Un log que se borra en cascada no es un log.
  • sessions.ipAddress es text, no inet: Better-Auth escribe ahí lo que venga en el header. Con inet, un proxy mal configurado tira el login abajo.
  • El logger salió a lib/logger.ts como función pura. Sus ramas por NODE_ENV son incubribles corriendo bajo un solo entorno. La salida no era bajar el gate de coverage: era extraer la lógica y testearla de verdad.
  • Exclusiones de coverage documentadas: server.ts (entrypoint: testearlo sería testear a Node), src/test/** (helpers), src/db/schema.ts (declaración sin ramas; lo prueban los tests contra la base real).

Para tasks posteriores:

Hallazgo Impacto
Better-Auth espera modelos en singular (user, session); nuestras tablas son plurales Task 5: mapear con modelName en la config
Los ids son uuid con defaultRandom(), pero Better-Auth genera ids propios en formato string Task 5: configurar advanced.database.generateId para que emita UUIDs, o los inserts fallan
Drizzle envuelve los errores en DrizzleQueryError; el error real de Postgres está en .cause Asertar contra cause.code (SQLSTATE), no contra el mensaje. Ya hay helper: pgErrorOf() en src/test/db.ts
Limpiar audit_log en tests exige desactivar y reactivar los triggers desde el rol dueño Usar resetDb() de src/test/db.ts. Que cueste es la señal de que la protección funciona
pnpm db:migrate:test es el comando para migrar la base de tests CI debe correrlo antes de los tests

Verificado además: la migración corre sobre una base virgen sin pasos manuales previos, y es idempotente.


Task 3: authz core — matrix, scopes, can, scoped-query

Sección titulada «Task 3: authz core — matrix, scopes, can, scoped-query»

Files:

  • Create: api/src/modules/authz/matrix.ts
  • Create: api/src/modules/authz/scopes.ts
  • Create: api/src/modules/authz/can.ts
  • Create: api/src/modules/authz/scoped-query.ts
  • Create: api/src/modules/authz/__tests__/matrix.test.ts
  • Create: api/src/modules/authz/__tests__/can.test.ts
  • Create: api/src/modules/authz/__tests__/scoped-query.test.ts

Interfaces:

  • Produces: PERMISSION_MATRIX constant, can() function, scopedQuery() function, SCOPES filters

  • Consumes: Drizzle schema types from Task 2

  • Step 1: Write api/src/modules/authz/matrix.ts

The matrix must be a 1-to-1 copy of the tables in /docs/dominio/roles-y-permisos.md — every cell, every permission, with the scope per role. This is the canonical executable version.

export type Role = 'admin' | 'seller' | 'pos' | 'contador'
export type Resource =
| 'ventas' | 'cobros' | 'clientes' | 'stock' | 'insumos'
| 'botellones' | 'bases' | 'produccion' | 'tanques'
| 'proveedores' | 'compras' | 'rutas' | 'productos'
| 'usuarios' | 'auditoria' | 'reportes' | 'configuracion'
export type Action =
| 'ver' | 'crear' | 'editar' | 'anular' | 'anular_verificada'
| 'verificar_pago' | 'gestionar_cuentas_pendientes'
| 'prestar' | 'retirar' | 'registrar' | 'descartar'
| 'entregar' | 'recibir_retorno' | 'ajustar' | 'cargar_ruta'
| 'registrar_cierre' | 'registrar_reposicion'
| 'habilitar_credito' | 'verificar_documento' | 'abrir' | 'rendir'
| 'cerrar_con_faltante' | 'editar_precios'
| 'descargar_pdf' | 'operativos' | 'financieros'
export type Scope = 'todo' | 'propio' | 'ruta' | 'BODEGA' | 'prep' | 'operativos'
export interface PermissionRule {
resource: Resource
action: Action
scope: Scope
readonly?: boolean
}
export const PERMISSION_MATRIX: Record<Role, PermissionRule[]> = {
admin: [
{ resource: 'ventas', action: 'ver', scope: 'todo' },
{ resource: 'ventas', action: 'crear', scope: 'todo' },
{ resource: 'ventas', action: 'anular', scope: 'todo' },
{ resource: 'ventas', action: 'anular_verificada', scope: 'todo' },
{ resource: 'ventas', action: 'verificar_pago', scope: 'todo' },
{ resource: 'ventas', action: 'gestionar_cuentas_pendientes', scope: 'todo' },
{ resource: 'cobros', action: 'ver', scope: 'todo' },
{ resource: 'cobros', action: 'registrar', scope: 'todo' },
{ resource: 'clientes', action: 'ver', scope: 'todo' },
{ resource: 'clientes', action: 'crear', scope: 'todo' },
{ resource: 'clientes', action: 'verificar_documento', scope: 'todo' },
{ resource: 'clientes', action: 'editar', scope: 'todo' },
{ resource: 'clientes', action: 'habilitar_credito', scope: 'todo' },
{ resource: 'stock', action: 'ver', scope: 'todo' },
{ resource: 'stock', action: 'cargar_ruta', scope: 'todo' },
{ resource: 'stock', action: 'ajustar', scope: 'todo' },
{ resource: 'insumos', action: 'ver', scope: 'todo' },
{ resource: 'insumos', action: 'ajustar', scope: 'todo' },
{ resource: 'botellones', action: 'ver', scope: 'todo' },
{ resource: 'botellones', action: 'entregar', scope: 'todo' },
{ resource: 'botellones', action: 'recibir_retorno', scope: 'todo' },
{ resource: 'botellones', action: 'registrar', scope: 'todo' },
{ resource: 'botellones', action: 'descartar', scope: 'todo' },
{ resource: 'bases', action: 'ver', scope: 'todo' },
{ resource: 'bases', action: 'prestar', scope: 'todo' },
{ resource: 'bases', action: 'retirar', scope: 'todo' },
{ resource: 'bases', action: 'registrar', scope: 'todo' },
{ resource: 'bases', action: 'descartar', scope: 'todo' },
{ resource: 'produccion', action: 'ver', scope: 'todo' },
{ resource: 'produccion', action: 'registrar_cierre', scope: 'todo' },
{ resource: 'tanques', action: 'ver', scope: 'todo' },
{ resource: 'tanques', action: 'registrar_reposicion', scope: 'todo' },
{ resource: 'tanques', action: 'ajustar', scope: 'todo' },
{ resource: 'proveedores', action: 'ver', scope: 'todo' },
{ resource: 'proveedores', action: 'crear', scope: 'todo' },
{ resource: 'compras', action: 'crear', scope: 'todo' },
{ resource: 'compras', action: 'recibir', scope: 'todo' },
{ resource: 'rutas', action: 'ver', scope: 'todo' },
{ resource: 'rutas', action: 'abrir', scope: 'todo' },
{ resource: 'rutas', action: 'rendir', scope: 'todo' },
{ resource: 'rutas', action: 'cerrar_con_faltante', scope: 'todo' },
{ resource: 'productos', action: 'ver', scope: 'todo' },
{ resource: 'productos', action: 'editar_precios', scope: 'todo' },
{ resource: 'usuarios', action: 'ver', scope: 'todo' },
{ resource: 'usuarios', action: 'crear', scope: 'todo' },
{ resource: 'usuarios', action: 'editar', scope: 'todo' },
{ resource: 'auditoria', action: 'ver', scope: 'todo' },
{ resource: 'reportes', action: 'operativos', scope: 'todo' },
{ resource: 'reportes', action: 'financieros', scope: 'todo' },
{ resource: 'reportes', action: 'descargar_pdf', scope: 'todo' },
{ resource: 'configuracion', action: 'ver', scope: 'todo' },
{ resource: 'configuracion', action: 'editar', scope: 'todo' },
],
seller: [
{ resource: 'ventas', action: 'ver', scope: 'propio' },
{ resource: 'ventas', action: 'crear', scope: 'todo' },
{ resource: 'ventas', action: 'anular', scope: 'propio' },
{ resource: 'ventas', action: 'verificar_pago', scope: 'propio' },
{ resource: 'cobros', action: 'ver', scope: 'propio' },
{ resource: 'cobros', action: 'registrar', scope: 'todo' },
{ resource: 'clientes', action: 'ver', scope: 'todo' },
{ resource: 'clientes', action: 'crear', scope: 'todo' },
{ resource: 'clientes', action: 'verificar_documento', scope: 'todo' },
{ resource: 'stock', action: 'ver', scope: 'todo' },
{ resource: 'botellones', action: 'ver', scope: 'todo' },
{ resource: 'bases', action: 'ver', scope: 'todo' },
{ resource: 'rutas', action: 'ver', scope: 'propio' },
{ resource: 'rutas', action: 'abrir', scope: 'todo' },
{ resource: 'rutas', action: 'rendir', scope: 'propio' },
{ resource: 'productos', action: 'ver', scope: 'todo' },
],
pos: [
{ resource: 'ventas', action: 'ver', scope: 'propio' },
{ resource: 'ventas', action: 'crear', scope: 'todo' },
{ resource: 'ventas', action: 'anular', scope: 'propio' },
{ resource: 'ventas', action: 'verificar_pago', scope: 'propio' },
{ resource: 'cobros', action: 'ver', scope: 'propio' },
{ resource: 'cobros', action: 'registrar', scope: 'todo' },
{ resource: 'clientes', action: 'ver', scope: 'todo' },
{ resource: 'clientes', action: 'crear', scope: 'todo' },
{ resource: 'clientes', action: 'verificar_documento', scope: 'todo' },
{ resource: 'stock', action: 'ver', scope: 'BODEGA' },
{ resource: 'stock', action: 'cargar_ruta', scope: 'todo' },
{ resource: 'stock', action: 'ajustar', scope: 'todo' },
{ resource: 'insumos', action: 'ver', scope: 'todo' },
{ resource: 'insumos', action: 'ajustar', scope: 'todo' },
{ resource: 'botellones', action: 'ver', scope: 'todo' },
{ resource: 'botellones', action: 'entregar', scope: 'todo' },
{ resource: 'botellones', action: 'recibir_retorno', scope: 'todo' },
{ resource: 'botellones', action: 'registrar', scope: 'todo' },
{ resource: 'botellones', action: 'descartar', scope: 'todo' },
{ resource: 'bases', action: 'ver', scope: 'todo' },
{ resource: 'bases', action: 'prestar', scope: 'todo' },
{ resource: 'bases', action: 'retirar', scope: 'todo' },
{ resource: 'bases', action: 'registrar', scope: 'todo' },
{ resource: 'bases', action: 'descartar', scope: 'todo' },
{ resource: 'produccion', action: 'ver', scope: 'todo' },
{ resource: 'produccion', action: 'registrar_cierre', scope: 'todo' },
{ resource: 'tanques', action: 'ver', scope: 'todo' },
{ resource: 'tanques', action: 'registrar_reposicion', scope: 'todo' },
{ resource: 'proveedores', action: 'ver', scope: 'todo' },
{ resource: 'compras', action: 'crear', scope: 'todo' },
{ resource: 'compras', action: 'recibir', scope: 'todo' },
{ resource: 'rutas', action: 'ver', scope: 'todo' },
{ resource: 'productos', action: 'ver', scope: 'todo' },
{ resource: 'configuracion', action: 'ver', scope: 'todo' },
{ resource: 'reportes', action: 'operativos', scope: 'prep' },
{ resource: 'reportes', action: 'descargar_pdf', scope: 'operativos' },
],
contador: [
{ resource: 'ventas', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'cobros', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'clientes', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'stock', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'insumos', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'botellones', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'bases', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'produccion', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'tanques', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'proveedores', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'rutas', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'productos', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'auditoria', action: 'ver', scope: 'todo', readonly: true },
{ resource: 'reportes', action: 'operativos', scope: 'todo' },
{ resource: 'reportes', action: 'financieros', scope: 'todo' },
{ resource: 'reportes', action: 'descargar_pdf', scope: 'todo' },
],
}
  • Step 2: Write api/src/modules/authz/scopes.ts
import { eq, inArray } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import type { Scope } from './matrix'
export interface ScopeContext {
userId: string
activeRutas: string[]
}
export interface ScopeFilter {
apply<T>(query: T, ctx: ScopeContext): T
}
export const SCOPES: Record<Scope, ScopeFilter> = {
todo: { apply: (q) => q },
propio: { apply: (q, ctx) => {
// Apply via raw SQL on the `created_by` column pattern
return q
}},
ruta: { apply: (q, ctx) => q },
BODEGA: { apply: (q) => q },
prep: { apply: (q) => q },
operativos: { apply: (q) => q },
}

Note: The scope filters are intentionally minimal stubs in this task. The actual WHERE clause application happens in scoped-query.ts (Step 3) where the query table is known.

  • Step 3: Write api/src/modules/authz/can.ts
import { PERMISSION_MATRIX, type Action, type Resource, type Role } from './matrix'
export interface UserContext {
id: string
roles: Role[]
}
export function can(user: UserContext, resource: Resource, action: Action): boolean {
return user.roles.some((role) =>
PERMISSION_MATRIX[role].some(
(rule) => rule.resource === resource && rule.action === action,
),
)
}
  • Step 4: Write api/src/modules/authz/scoped-query.ts
import { eq } from 'drizzle-orm'
import type { SQL, SelectQueryBuilder } from 'drizzle-orm'
import { PERMISSION_MATRIX, type Action, type Resource, type Role } from './matrix'
import type { UserContext } from './can'
export function applicableScopes(
user: UserContext,
resource: Resource,
action: Action,
): string[] {
const scopes = new Set<string>()
for (const role of user.roles) {
for (const rule of PERMISSION_MATRIX[role]) {
if (rule.resource === resource && rule.action === action) {
scopes.add(rule.scope)
}
}
}
return Array.from(scopes)
}
export function scopedQuery<T extends SelectQueryBuilder<any, any, any>>(
user: UserContext,
baseQuery: T,
resource: Resource,
action: Action,
): T {
const scopes = applicableScopes(user, resource, action)
// 'todo' wins: no filter
if (scopes.includes('todo')) return baseQuery
let query = baseQuery
for (const scope of scopes) {
if (scope === 'propio') {
// Assumes the table has a `createdBy` column
query = query.where(eq((query as any)._.table.createdBy, user.id)) as T
}
// Other scopes are placeholders for M1+ modules
}
return query
}
  • Step 5: Write api/src/modules/authz/__tests__/can.test.ts
import { describe, expect, it } from 'vitest'
import { can } from '../can'
describe('can()', () => {
it('returns false for user with no roles', () => {
const user = { id: 'u1', roles: [] }
expect(can(user, 'ventas', 'ver')).toBe(false)
})
it('returns true when user has one role that grants the permission', () => {
const user = { id: 'u1', roles: ['admin'] as const }
expect(can(user, 'ventas', 'anular_verificada')).toBe(true)
})
it('returns true when user is multi-rol and ANY role grants the permission', () => {
const user = { id: 'u1', roles: ['pos', 'seller'] as const }
expect(can(user, 'ventas', 'anular_verificada')).toBe(false) // nadie lo tiene
expect(can(user, 'ventas', 'crear')).toBe(true) // ambos lo tienen
})
it('returns true for contador seeing ventas:ver (readonly)', () => {
const user = { id: 'u1', roles: ['contador'] as const }
expect(can(user, 'ventas', 'ver')).toBe(true)
})
it('returns false for seller trying usuarios:ver (admin only)', () => {
const user = { id: 'u1', roles: ['seller'] as const }
expect(can(user, 'usuarios', 'ver')).toBe(false)
})
it('returns true for pos trying stock:ver (BODEGA scope)', () => {
const user = { id: 'u1', roles: ['pos'] as const }
expect(can(user, 'stock', 'ver')).toBe(true)
})
})
  • Step 6: Write api/src/modules/authz/__tests__/matrix.test.ts
import { describe, expect, it } from 'vitest'
import { PERMISSION_MATRIX, type Role } from '../matrix'
const allRoles: Role[] = ['admin', 'seller', 'pos', 'contador']
describe('PERMISSION_MATRIX', () => {
it('has rules for every role', () => {
for (const role of allRoles) {
expect(PERMISSION_MATRIX[role].length).toBeGreaterThan(0)
}
})
it('admin can do everything', () => {
const ventasAnular = PERMISSION_MATRIX.admin.find(
(r) => r.resource === 'ventas' && r.action === 'anular',
)
expect(ventasAnular?.scope).toBe('todo')
})
it('seller cannot do admin-only actions', () => {
const usuariosCrear = PERMISSION_MATRIX.seller.find(
(r) => r.resource === 'usuarios' && r.action === 'crear',
)
expect(usuariosCrear).toBeUndefined()
})
it('contador has only readonly or report permissions', () => {
for (const rule of PERMISSION_MATRIX.contador) {
if (rule.resource === 'reportes') continue
expect(rule.readonly).toBe(true)
}
})
it('pos can do stock:cargar_ruta but seller cannot', () => {
expect(PERMISSION_MATRIX.pos.find((r) => r.action === 'cargar_ruta')).toBeDefined()
expect(PERMISSION_MATRIX.seller.find((r) => r.action === 'cargar_ruta')).toBeUndefined()
})
})
  • Step 7: Write api/src/modules/authz/__tests__/scoped-query.test.ts
import { describe, expect, it } from 'vitest'
import { applicableScopes } from '../scoped-query'
describe('applicableScopes()', () => {
it('returns ["todo"] for admin', () => {
const user = { id: 'u1', roles: ['admin'] as const }
expect(applicableScopes(user, 'ventas', 'ver')).toEqual(['todo'])
})
it('returns ["propio"] for seller without admin', () => {
const user = { id: 'u1', roles: ['seller'] as const }
expect(applicableScopes(user, 'ventas', 'ver')).toEqual(['propio'])
})
it('returns union of scopes for multi-rol user', () => {
const user = { id: 'u1', roles: ['pos', 'seller'] as const }
const scopes = applicableScopes(user, 'ventas', 'anular')
expect(scopes).toContain('propio') // both have propio
expect(scopes).not.toContain('todo') // neither has todo
})
})
  • Step 8: Run all authz tests

Run: cd api && pnpm test src/modules/authz Expected: all tests pass

  • Step 9: Commit
Ventana de terminal
git add . && git commit -m "feat(authz): permission matrix, scopes, can(), scoped-query() with full test coverage"

Notas de ejecución de Task 3 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 3 (2026-08-20) — ✅ completada»

Resultado: api/ en 687e4d8. 80 tests solo en authz/, 121 en total. Coverage global 100% statements y lines, 94.1% branches. El módulo authz/ quedó completamente cubierto.

Lo grave que había que arreglar:

Problema Corrección
Los alcances eran “stubs mínimos intencionales” — cada uno devolvía la consulta sin filtrar. Un vendedor pidiendo “mis ventas” recibía las de toda la empresa, sin error y sin traza. Eso no es un stub: es una fuga silenciosa en el módulo cuyo único trabajo es que no la haya Implementados de verdad, con la regla de fallar cerrado (ADR-0005)
scopedQuery manoseaba internals de Drizzle con (query as any)._.table.createdBy. Frágil, sin tipos, y .where() no compone así sobre una consulta ya construida Reemplazado por scopedCondition(), que devuelve una condición SQL componible y tipada. Cada módulo declara qué columnas implementan cada alcance
El doc de dominio tenía 7 celdas corrompidas (mojibake donde iban los emojis) Reparadas. Cada inferencia se cruzó contra dos fuentes independientes —spec §10 y la matriz del plan, escrita cuando el doc estaba sano— y las siete coincidían
Dos discrepancias reales entre plan y doc, resueltas a favor del doc porque el plan mismo dice “copia 1-a-1”: el plan le daba tanques:ver al contador (el doc lo marca ❌) y configuracion:ver genérico al pos (el doc solo le da configuracion:equivalencias) Matriz corregida según el doc
La tabla de alcances del doc estaba incompleta: declaraba 3 (todo, ruta, propio) pero la matriz usaba BODEGA, prep y operativos Tabla completada, distinguiendo los que filtran filas de los que acotan categorías de reporte
Tests insuficientes. El spec §12 pide 100% “cada celda”; el plan traía ~6 asserts por archivo Test que verifica celda por celda contra una lista transcrita del doc, no derivada del código — si se derivara del código sería circular

Un bug propio que la regla de fallar cerrado atrapó: al combinar varios alcances, or() con lista vacía devuelve undefined, o sea sin filtro. El comentario decía “preferimos filtrar de más” y el código hacía lo contrario, justo en la función que decide quién ve qué. Corregido devolviendo la condición vacía, y fijado con un test.

Decisiones de diseño:

  • prep y operativos no son alcances de datos: acotan categorías de reporte, no filas. scopeCondition() lanza si alguien los usa contra una tabla, en vez de devolver todo sin filtrar. Los resuelve M13.
  • cantidades, con motivo, cliente verificado no son alcances: son constraints de servicio (spec §8.6). Documentado en el doc de dominio para que no vuelvan a colarse en la matriz.
  • Multi-rol suma, no interseca: los alcances se combinan con OR. Restringir de más también es un bug — le esconde a alguien datos que le corresponden.
  • readonly dejó de ser decorativo: hay invariantes que verifican que solo contador lo use y que nunca marque una acción de escritura.

Para tasks posteriores:

Hallazgo Impacto
La API es scopedCondition(user, resource, action, columns) y devuelve SQL | undefined, no una query Tasks 4, 8, 9: usar db.select().from(t).where(cond). El ejemplo del spec §8.5 con scopedQuery(...) envolviendo la query está obsoleto
scopedCondition lanza SinPermisoError si el usuario no tiene el permiso Task 4: requirePermission tiene que correr ANTES. Si llega el error, es que el middleware no se aplicó
UserContext lleva activeRutas opcional, que habilita el alcance ruta Task 6: la sesión debe poblarlo cuando exista el módulo de rutas (M6)
permisosDe(user) devuelve los strings recurso:accion Tasks 10, 12: es lo que consume la UI para armar el menú

Files:

  • Create: api/src/modules/authz/audit.ts
  • Create: api/src/modules/authz/middleware.ts
  • Create: api/src/modules/authz/__tests__/middleware.test.ts

Interfaces:

  • Produces: requireAuth and requirePermission(resource, action) Fastify preHandlers

  • Produces: audit.allow() / audit.deny() helpers

  • Consumes: Task 2 (db), Task 3 (can)

  • Step 1: Write api/src/modules/authz/audit.ts

import { db } from '@/db/client'
import { auditLog } from '@/db/schema'
export interface AuditInput {
userId: string | null
rolEjercido: string[]
action: string
resource?: string
resourceId?: string
result: 'ok' | 'denied'
requestId: string
ip?: string
userAgent?: string
payload?: Record<string, unknown>
}
export async function emit(input: AuditInput): Promise<void> {
await db.insert(auditLog).values({
userId: input.userId,
rolEjercido: input.rolEjercido,
action: input.action,
resource: input.resource,
resourceId: input.resourceId,
result: input.result,
requestId: input.requestId,
ip: input.ip,
userAgent: input.userAgent,
payload: input.payload,
})
}
  • Step 2: Write api/src/modules/authz/middleware.ts
import type { FastifyReply, FastifyRequest } from 'fastify'
import { eq } from 'drizzle-orm'
import { db } from '@/db/client'
import { sessions, users } from '@/db/schema'
import { can, type UserContext } from './can'
import { emit } from './audit'
import type { Action, Resource, Role } from './matrix'
declare module 'fastify' {
interface FastifyRequest {
user?: UserContext
}
}
export async function requireAuth(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const sessionId = req.cookies['aquazaku_session']
if (!sessionId) {
return reply.code(401).send({ code: 'UNAUTHENTICATED' })
}
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1)
if (!session || session.expiresAt < new Date()) {
return reply.code(401).send({ code: 'SESSION_EXPIRED' })
}
const [user] = await db
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1)
if (!user || user.status !== 'active') {
return reply.code(401).send({ code: 'USER_INACTIVE' })
}
req.user = {
id: user.id,
roles: session.roles as Role[],
}
}
export function requirePermission(resource: Resource, action: Action) {
return async (req: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (!req.user) {
return reply.code(401).send({ code: 'UNAUTHENTICATED' })
}
const allowed = can(req.user, resource, action)
const requestId = (req.headers['x-request-id'] as string) ?? crypto.randomUUID()
if (!allowed) {
await emit({
userId: req.user.id,
rolEjercido: req.user.roles,
action: 'PERMISSION_DENIED',
resource,
resourceId: undefined,
result: 'denied',
requestId,
ip: req.ip,
userAgent: req.headers['user-agent'],
payload: { attempted_action: action },
})
return reply.code(403).send({ code: 'FORBIDDEN', resource, action })
}
await emit({
userId: req.user.id,
rolEjercido: req.user.roles,
action: 'PERMISSION_GRANTED',
resource,
resourceId: undefined,
result: 'ok',
requestId,
ip: req.ip,
userAgent: req.headers['user-agent'],
payload: { attempted_action: action },
})
}
}
  • Step 3: Write api/src/modules/authz/__tests__/middleware.test.ts
import { describe, expect, it, beforeEach, afterAll } from 'vitest'
import Fastify from 'fastify'
import { requireAuth, requirePermission } from '../middleware'
describe('requireAuth + requirePermission', () => {
const app = Fastify()
afterAll(async () => {
await app.close()
})
it('returns 401 without session cookie', async () => {
app.get('/test', { preHandler: requireAuth }, async () => ({ ok: true }))
const res = await app.inject({ method: 'GET', url: '/test' })
expect(res.statusCode).toBe(401)
})
// Additional integration tests for requirePermission would require a test DB
// Add them in Task 9 when the audit module is wired with real DB
})
  • Step 4: Run tests

Run: cd api && pnpm test src/modules/authz Expected: all tests pass

  • Step 5: Commit
Ventana de terminal
git add . && git commit -m "feat(authz): Fastify middleware (requireAuth, requirePermission) + audit emit"

Notas de ejecución de Task 4 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 4 (2026-08-20) — ✅ completada»

Resultado: api/ en 1d4fdd3. 171 tests, 100% statements y lines, authz/ completo.

La decisión de fondo: qué se audita.

El plan escribía en audit_log en cada permiso concedido — un INSERT por request autorizado. RN-ACC-04 no pide eso: pide auditar las acciones sensibles, y las nombra una por una. Auditar cada lectura tiene dos costos y ningún beneficio: amplificación de escritura por carga de pantalla, y miles de filas de ruido que entierran justo las acciones que hay que poder encontrar. Un log que nadie puede leer no es un control.

Política implementada:

Caso Se audita
Cualquier acceso denegado Siempre, sin excepción
Acción permitida que modifica estado Sí
Lectura pura permitida (ver, operativos, financieros) No
auditoria:ver Sí — quién mira la bitácora queda en la bitácora
reportes:descargar_pdf Sí — lo pide el doc de dominio

El default es auditar: para dejar algo afuera hay que agregarlo explícitamente a la lista de exentas. Ante la duda, el sistema audita de más. Hay un test que recorre toda la matriz y verifica la invariante.

La otra decisión: qué pasa si la auditoría falla.

Caso Respuesta Por qué
Denegado y no se pudo auditar 403 igual, error al log El usuario queda bloqueado, que es lo que protege al sistema
Permitido y no se pudo auditar 500, y la acción NO se ejecuta Dejar pasar una acción sensible sin rastro sería incumplir RN-ACC-04 en silencio

Preferimos un endpoint caído a una acción sensible sin registro. Hay tests con emit mockeada que fijan las dos conductas — sin ellos la decisión sería un comentario, no un comportamiento.

Correcciones al plan:

Problema Corrección
Buscaba la sesión por sessions.id, pero lo que viaja en la cookie es sessions.token. Lo descubrimos en Task 2 y el plan no lo reflejaba Lookup por token
Guardaba PERMISSION_GRANTED en la columna action, con la acción real escondida en payload.attempted_action action guarda recurso:accion, que es la nomenclatura del dominio y está indexada. Si no se puede filtrar por acción, la UI de auditoría de Task 13 no sirve
Dos consultas para traer sesión y usuario Un solo join. Se ejecuta en cada request autenticado del sistema
Faltaba registrar @fastify/cookie Registrado en app.ts. Sin eso req.cookies no existe y nada de esto funciona
Los tests eran un stub que difería todo a Task 9 “porque requeriría una base de test” La base existe desde Task 2. 19 tests de integración con HTTP real, sesiones reales y bitácora real

Deliberadamente NO implementado: la ventana deslizante de sesión del spec §7.5. El ciclo de vida de la sesión lo administra Better-Auth (Task 5) con su updateAge; dos dueños del mismo dato es peor que ninguno.

Para tasks posteriores:

Hallazgo Impacto
requirePermission exige que requireAuth haya corrido antes; si no, devuelve 401 Tasks 6, 8, 9: el preHandler va siempre como [requireAuth, requirePermission(...)], en ese orden
Códigos de error: UNAUTHENTICATED, SESSION_EXPIRED, USER_INACTIVE, FORBIDDEN, AUDIT_UNAVAILABLE Task 11: web/ los usa para mostrar el mensaje correcto. Un 401 genérico obliga al usuario a adivinar por qué lo echaron
Nuevos helpers de test en src/test/fixtures.ts: crearUsuario, crearSesion, usuarioAutenticado, sembrarRoles Tasks 6–9: usarlos en vez de armar usuarios a mano
emit() acepta userId: null — un login fallido no tiene sesión detrás Task 6: auditar los intentos de login fallidos con userId: null
La cookie se llama aquazaku_session y su nombre se exporta como COOKIE_SESION Task 5: Better-Auth debe configurarse con ese mismo nombre

Files:

  • Create: api/src/plugins/auth-plugin.ts
  • Modify: api/src/server.ts (add auth plugin registration)

Interfaces:

  • Produces: Better-Auth instance mounted at /api/auth/* in Fastify

  • Consumes: Drizzle schema (Task 2), env config (Task 1)

  • Step 1: Write api/src/plugins/auth-plugin.ts

import { betterAuth } from 'better-auth'
import { admin as adminPlugin } from 'better-auth/plugins'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from '@/db/client'
import * as schema from '@/db/schema'
import { env } from '@/lib/env'
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg', schema: {
user: schema.users,
session: schema.sessions,
} }),
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: ['http://localhost:3000'],
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
autoSignIn: false,
},
cookies: {
session: {
name: 'aquazaku_session',
domain: env.COOKIE_DOMAIN,
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7 days
},
},
plugins: [
adminPlugin({
adminRoles: ['admin'],
defaultRole: 'pos',
}),
],
})
export type Auth = typeof auth
  • Step 2: Write api/src/server.ts
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { auth } from './plugins/auth-plugin'
import { env } from './lib/env'
const app = Fastify({ logger: { level: env.LOG_LEVEL } })
await app.register(cookie)
await app.register(cors, {
origin: ['http://localhost:3000'],
credentials: true,
})
// Mount Better-Auth routes
app.route({
method: ['GET', 'POST'],
url: '/api/auth/*',
async handler(request, reply) {
const url = new URL(request.url, env.BETTER_AUTH_URL)
const response = await auth.handler(new Request(url, {
method: request.method,
headers: request.headers as any,
body: request.body ? JSON.stringify(request.body) : undefined,
}))
reply.code(response.status)
response.headers.forEach((value, key) => reply.header(key, value))
return response.body
},
})
const start = async () => {
try {
await app.listen({ port: env.PORT, host: '0.0.0.0' })
app.log.info(`api/ listening on ${env.PORT}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
}
start()
  • Step 3: Verify Better-Auth mounts

Run: cd api && pnpm dev Then: curl -X POST http://localhost:3001/api/auth/sign-in/email -H "Content-Type: application/json" -d '{"email":"test@test.com","password":"testtest"}' Expected: 401 (user doesn’t exist) but the endpoint responds

  • Step 4: Commit
Ventana de terminal
git add . && git commit -m "feat(auth): Better-Auth setup with admin plugin and session cookie config"

Notas de ejecución de Task 5 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 5 (2026-08-20) — ✅ completada»

Resultado: api/ en 22b4967. 191 tests en verde. Coverage 97.3% statements, 90.3% branches, 98.5% lines.

Decisión: NO se usa el plugin admin.

Trae su propio user.role — un string por usuario. Aquazaku modela multi-rol en user_roles (N:M) y resuelve permisos con la matriz en código, donde alguien puede ser pos y seller a la vez (RN-ACC-01). Activarlo dejaría dos fuentes de verdad para los roles. Además pide columnas que no tenemos (banned, banReason, banExpires, session.impersonatedBy), y su CRUD de usuarios lo construimos nosotros en Task 8, con auditoría propia.

Configuración que al plan le faltaba:

Faltaba Por qué importa
advanced.database.generateId: 'uuid' Sin esto Better-Auth genera ids con su formato de string y los INSERT contra nuestras columnas uuid fallan. Era la trampa anotada en Task 2 — resulta que 1.7 lo trae como opción nativa
Mapear los cuatro modelos El plan solo mapeaba user y session. Faltaban account (donde vive el password) y verification (que necesita Task 7)
password.hash / password.verify con argon2id Better-Auth usa scrypt por defecto; el spec §5 pide argon2id
additionalFields status y mustChangePassword en user, roles en session
databaseHooks.session.create.before Congela los roles del usuario dentro de la sesión al hacer login
session.updateAge La ventana deslizante del spec §7.5. Este es el único dueño del ciclo de vida de la sesión, como quedó anotado en Task 4

Dos bugs que encontró el mismo test de integración:

  1. Better-Auth firma sus cookies. El valor es token.firma, y requireAuth comparaba contra sessions.token crudo: nunca matcheaba. El arreglo no fue partir el string sino sacar la duplicación de raíz — requireAuth ahora delega en auth.api.getSession, que verifica firma, vencimiento y ventana deslizante. Better-Auth es el dueño de la identidad; nosotros solo agregamos el chequeo de status, que él no conoce.
  2. Al detectar una sesión vencida, Better-Auth BORRA la fila antes de devolver null. Consultarla después para distinguir “vencida” de “inexistente” no funciona. Se resuelve mirando si el request trajo cookie, sin tocar la base.

Además: el handler del plan perdía los set-cookie múltiples con Headers.forEach — el login habría “funcionado” sin dejar nunca al usuario logueado. Y su Step 2 reescribía server.ts con un Fastify inline, llevándose puesta la separación buildApp/listen de Task 1; el montaje va como plugin en app.ts.

Semántica de los códigos 401 (importante para Task 11):

Código Cuándo
UNAUTHENTICATED No vino cookie de sesión
SESSION_EXPIRED Vino una cookie que el servidor ya no acepta: vencida, revocada o adulterada. Para el usuario los tres casos son el mismo: volvé a entrar
USER_INACTIVE Sesión válida pero el usuario está desactivado (RN-ACC-05)

Para tasks posteriores:

Hallazgo Impacto
Los roles se congelan al login. Cambiarle los roles a alguien no afecta su sesión abierta Task 8: al modificar roles hay que invalidar las sesiones del usuario, o sigue operando con los viejos. Hay un test que documenta el comportamiento
Las fixtures ahora pasan por Better-Auth y hashean con argon2id Los tests de integración son más lentos (~100ms por usuario). Es el precio de tener cookies realmente firmadas
auth.api expone getSession, updateSession, listSessions, revokeSession, revokeSessions, revokeOtherSessions Task 8: revokeSessions es lo que resuelve el punto anterior
Endpoints montados en /api/auth/*; sign-up/email y sign-in/email funcionan Task 6: construir /auth/me y el sign-out encima de esto
PASSWORD_DE_PRUEBA exportado desde src/test/fixtures.ts Tests que necesiten loguear a mano

Task 6: Auth routes — sign-in, sign-out, me, with audit

Sección titulada «Task 6: Auth routes — sign-in, sign-out, me, with audit»

Files:

  • Create: api/src/modules/auth/service.ts
  • Create: api/src/modules/auth/routes.ts
  • Create: api/src/modules/auth/__tests__/routes.test.ts

Interfaces:

  • Produces: POST /auth/sign-out, GET /auth/me
  • Consumes: Better-Auth (Task 5), audit (Task 4)

Corregido 2026-08-20. Este bloque decía POST /api/auth/sign-in, POST /api/auth/sign-out y GET /api/auth/me, contradiciendo al código del Step 2 acá abajo (que registra /auth/*) y al doc del patrón BFF. /api/auth/* es el namespace de Better-Auth — ahí viven sign-in/email y sign-up/email, como confirmó el cierre de Task 5. Las rutas propias de api/ van bajo /auth/*. El sign-in no lo produce esta task: ya lo da Better-Auth. web/ consume /auth/me desde la constante AUTH_ME_PATH.

  • Step 1: Write api/src/modules/auth/service.ts
import { eq } from 'drizzle-orm'
import { db } from '@/db/client'
import { users } from '@/db/schema'
export async function getUserStatus(userId: string): Promise<'active' | 'inactive' | null> {
const [user] = await db.select({ status: users.status }).from(users).where(eq(users.id, userId)).limit(1)
return user?.status ?? null
}
export async function isFirstLogin(userId: string): Promise<boolean> {
const [user] = await db.select({ must: users.mustChangePassword }).from(users).where(eq(users.id, userId)).limit(1)
return user?.must ?? false
}
  • Step 2: Write api/src/modules/auth/routes.ts
import type { FastifyInstance } from 'fastify'
import { requireAuth } from '@/modules/authz/middleware'
import { emit } from '@/modules/authz/audit'
export async function authRoutes(app: FastifyInstance): Promise<void> {
// GET /auth/me — returns current user with roles
app.get('/auth/me', { preHandler: requireAuth }, async (req) => {
return {
id: req.user!.id,
roles: req.user!.roles,
}
})
// POST /auth/sign-out — invalidates session
app.post('/auth/sign-out', { preHandler: requireAuth }, async (req, reply) => {
const sessionId = req.cookies['aquazaku_session']
if (sessionId) {
await req.server.pg.query('DELETE FROM sessions WHERE id = $1', [sessionId])
}
reply.clearCookie('aquazaku_session', { path: '/' })
await emit({
userId: req.user!.id,
rolEjercido: req.user!.roles,
action: 'LOGOUT',
result: 'ok',
requestId: (req.headers['x-request-id'] as string) ?? crypto.randomUUID(),
ip: req.ip,
userAgent: req.headers['user-agent'],
})
return { ok: true }
})
}
  • Step 3: Register auth routes in api/src/server.ts

Add after Better-Auth mounting:

import { authRoutes } from './modules/auth/routes'
await authRoutes(app)
  • Step 4: Write api/src/modules/auth/__tests__/routes.test.ts
import { describe, expect, it } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { authRoutes } from '../routes'
describe('GET /auth/me', () => {
it('returns 401 without session', async () => {
const app = Fastify()
await app.register(cookie)
await authRoutes(app)
await app.ready()
const res = await app.inject({ method: 'GET', url: '/auth/me' })
expect(res.statusCode).toBe(401)
await app.close()
})
})
  • Step 5: Run tests

Run: cd api && pnpm test src/modules/auth Expected: tests pass

  • Step 6: Commit
Ventana de terminal
git add . && git commit -m "feat(auth): sign-out and me endpoints with audit emission"
  • Step 7: Add E2E test for sign-in flow (real HTTP)

Create api/src/modules/auth/__tests__/e2e-sign-in.test.ts:

import { describe, expect, it, beforeAll } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { authRoutes } from '../routes'
import { db } from '@/db/client'
import { users, roles, userRoles } from '@/db/schema'
import { hash as argonHash } from '@node-rs/argon2'
describe('E2E: sign-in flow', () => {
const app = Fastify({ logger: false })
let testEmail = `e2e-${Date.now()}@test.com`
beforeAll(async () => {
await app.register(cookie)
await authRoutes(app)
await app.ready()
await db.insert(roles).values({ name: 'pos', description: 'pos' }).onConflictDoNothing()
const passwordHash = await argonHash('test1234')
const [user] = await db.insert(users).values({
email: testEmail, name: 'E2E Test', passwordHash,
}).returning({ id: users.id })
await db.insert(userRoles).values({ userId: user.id, roleName: 'pos' })
})
it('POST /api/auth/sign-in/email returns 200 and sets session cookie', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: testEmail, password: 'test1234' },
})
expect(res.statusCode).toBe(200)
const setCookie = res.headers['set-cookie']
expect(setCookie).toBeDefined()
expect(setCookie as string).toContain('aquazaku_session=')
})
it('GET /auth/me with session cookie returns user with roles', async () => {
const signInRes = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: testEmail, password: 'test1234' },
})
const cookieHeader = signInRes.headers['set-cookie'] as string
const match = /aquazaku_session=([^;]+)/.exec(cookieHeader)
const sessionId = match![1]
const meRes = await app.inject({
method: 'GET',
url: '/auth/me',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
expect(meRes.statusCode).toBe(200)
expect(meRes.json().roles).toContain('pos')
})
it('POST /auth/sign-out invalidates the session', async () => {
const signInRes = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: testEmail, password: 'test1234' },
})
const cookieHeader = signInRes.headers['set-cookie'] as string
const match = /aquazaku_session=([^;]+)/.exec(cookieHeader)
const sessionId = match![1]
const signOutRes = await app.inject({
method: 'POST',
url: '/auth/sign-out',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
expect(signOutRes.statusCode).toBe(200)
const meRes = await app.inject({
method: 'GET',
url: '/auth/me',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
expect(meRes.statusCode).toBe(401)
})
})

Run: cd api && pnpm test src/modules/auth/__tests__/e2e-sign-in Expected: 3 tests pass

  • Step 8: Commit E2E test
Ventana de terminal
git add . && git commit -m "test(auth): E2E test for sign-in, /me, sign-out"

Notas de ejecución de Task 6 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 6 (2026-08-20) — ✅ completada»

Resultado: api/ en 01205c5. 213 tests en verde. Coverage 97.8% statements, 99.4% lines. Ciclo completo verificado contra el servidor real.

El hueco más grande: el plan no auditaba los logins. Ni los exitosos ni los fallidos.

Un login fallido es la señal de seguridad más básica que existe: una ráfaga contra el mismo email es fuerza bruta, y sin registro no hay forma de verla ni en el momento ni después. Se auditan en nuestro propio plugin, que ya envuelve cada request de Better-Auth y ve URL, body y status. Los fallidos van con userId: null — exactamente para lo que Task 4 dejó ese campo nullable.

Se guarda el email del intento y nunca la contraseña, ni siquiera la fallida. Hay un test que lo verifica buscando el string en toda la bitácora.

Bug de dependencia circular, y de los peores. better-auth.ts importaba COOKIE_SESION de authz/middleware.ts, que importa auth de better-auth.ts. Al agregar routes.ts cambió el orden de evaluación y la constante llegaba undefined al construir la config.

El síntoma: Better-Auth caía en su nombre de cookie por defecto. El login respondía 200, creaba la sesión, devolvía el token en el body — y la cookie salía con otro nombre. Todo “funcionaba” y nadie quedaba logueado. La constante se movió a auth/cookie.ts, un módulo que no importa nada.

Correcciones al plan:

Problema Corrección
Borraba la sesión con req.server.pg.query — un decorador que no existe — y por session.id, cuando la cookie lleva token.firma Delega en auth.api.signOut, que invalida la sesión y emite la cookie de borrado con los mismos atributos con que la creó. Una cookie limpiada con otro path no borra nada y el usuario se queda logueado creyendo que salió
Auditaba la acción como 'LOGOUT' auth:sign-out, la nomenclatura del dominio
/auth/me devolvía solo id y roles Ahora trae name, email, mustChangePassword (spec §7.2) y los permisos resueltos, para que web/ arme la pantalla en un solo viaje sin replicar la matriz
El test E2E insertaba passwordHash en users — columna que no existe, el password vive en accounts No habría compilado. Reescrito completo

Decisión: un fallo de la bitácora NO bloquea entrar ni salir. Es lo contrario de requirePermission, y la diferencia no es de criterio sino de regla: RN-ACC-04 enumera las acciones sensibles y entrar al sistema no está entre ellas. Convertir una caída del log en imposibilidad de iniciar sesión transformaría un problema de registro en una caída total. Hay tests que fijan las dos conductas.

Para tasks posteriores:

Hallazgo Impacto
GET /auth/me devuelve { id, name, email, roles, permisos, mustChangePassword } Tasks 11, 12: es el único viaje que web/ necesita para armar el shell. permisos sirve para ocultar, nunca para autorizar (RN-ACC-02)
POST /auth/sign-out responde 200 y devuelve la cookie de borrado Task 11: la Server Action debe propagar ese set-cookie al browser
Después de salir, /auth/me responde SESSION_EXPIRED (trajo cookie que ya no vale) Task 11: no confundirlo con un error; es el camino feliz del logout
auditarSinBloquear() exportada desde modules/auth/routes.ts Reutilizable para eventos de sesión que no sean acciones sensibles
Los mensajes de error de las fixtures ahora incluyen status y cuerpo Un fallo de login en un test dice por qué, en vez de obligar a salir a adivinar

Task 7: Forgot/reset/change-password with email and rate limit

Sección titulada «Task 7: Forgot/reset/change-password with email and rate limit»

Files:

  • Create: api/src/lib/errors.ts
  • Create: api/src/modules/auth/email.ts
  • Create: api/src/modules/auth/rate-limit.ts
  • Create: api/src/modules/auth/password.ts (change-password logic)
  • Modify: api/src/modules/auth/routes.ts (add new endpoints)

Interfaces:

  • Produces: POST /auth/forgot-password, POST /auth/reset-password, POST /auth/change-password

  • Produces: rate limit on sign-in (5/15min)

  • Consumes: Better-Auth (Task 5), Resend (or Mailpit in dev)

  • Step 1: Write api/src/lib/errors.ts

export class AppError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message)
}
}
export class BadRequest extends AppError {
constructor(code: string, message: string) { super(400, code, message) }
}
export class Forbidden extends AppError {
constructor(code: string, message: string) { super(403, code, message) }
}
export class TooManyRequests extends AppError {
constructor(code: string, message: string) { super(429, code, message) }
}
  • Step 2: Write api/src/modules/auth/rate-limit.ts
const attempts = new Map<string, { count: number; resetAt: Date }>()
export function checkRateLimit(key: string, max = 5, windowMs = 15 * 60 * 1000): void {
const now = Date.now()
const record = attempts.get(key)
if (!record || record.resetAt < new Date(now)) {
attempts.set(key, { count: 1, resetAt: new Date(now + windowMs) })
return
}
if (record.count >= max) {
throw new Error('RATE_LIMITED')
}
record.count++
}
export function recordFailedAttempt(key: string, max = 5, windowMs = 15 * 60 * 1000): void {
const now = Date.now()
const record = attempts.get(key)
if (!record || record.resetAt < new Date(now)) {
attempts.set(key, { count: 1, resetAt: new Date(now + windowMs) })
return
}
record.count++
}
export function clearAttempts(key: string): void {
attempts.delete(key)
}
  • Step 3: Write api/src/modules/auth/email.ts
import { Resend } from 'resend'
import { env } from '@/lib/env'
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
export interface PasswordResetEmail {
to: string
resetUrl: string
userName: string
}
export async function sendPasswordResetEmail(input: PasswordResetEmail): Promise<void> {
const subject = 'Restablecer tu contraseña — Aquazaku'
const html = `
<p>Hola ${input.userName},</p>
<p>Recibimos una solicitud para restablecer tu contraseña. Hacé click en el siguiente link para elegir una nueva:</p>
<p><a href="${input.resetUrl}">Restablecer contraseña</a></p>
<p>El link expira en 1 hora. Si no fuiste vos, ignorá este mensaje.</p>
`
if (resend) {
await resend.emails.send({ from: env.EMAIL_FROM, to: input.to, subject, html })
} else {
// Dev mode: log to console (Mailpit captures this if configured)
console.log('[DEV EMAIL]', { to: input.to, subject, resetUrl: input.resetUrl })
}
}
  • Step 4: Add password reset table to schema

In api/src/db/schema.ts, add (regenerate migration after):

export const passwordResets = pgTable('password_resets', {
token: text('token').primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})

Run: cd api && pnpm drizzle-kit generate && pnpm drizzle-kit migrate

  • Step 5: Write api/src/modules/auth/password.ts
import { randomBytes } from 'crypto'
import { eq, and, isNull, gt } from 'drizzle-orm'
import { db } from '@/db/client'
import { users, passwordResets } from '@/db/schema'
import { hash as argonHash } from '@node-rs/argon2'
import { sendPasswordResetEmail } from './email'
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
export async function requestPasswordReset(email: string, webBaseUrl: string): Promise<void> {
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1)
if (!user) return // Don't reveal whether email exists
const token = randomBytes(32).toString('hex')
await db.insert(passwordResets).values({
token,
userId: user.id,
expiresAt: new Date(Date.now() + RESET_EXPIRY_MS),
})
const resetUrl = `${webBaseUrl}/reset-password?token=${token}`
await sendPasswordResetEmail({ to: user.email, resetUrl, userName: user.name })
}
export async function resetPassword(token: string, newPassword: string): Promise<void> {
const [reset] = await db
.select()
.from(passwordResets)
.where(and(
eq(passwordResets.token, token),
isNull(passwordResets.usedAt),
gt(passwordResets.expiresAt, new Date()),
))
.limit(1)
if (!reset) throw new Error('INVALID_TOKEN')
const passwordHash = await argonHash(newPassword)
await db.update(users)
.set({ passwordHash, mustChangePassword: false })
.where(eq(users.id, reset.userId))
await db.update(passwordResets)
.set({ usedAt: new Date() })
.where(eq(passwordResets.token, token))
await db.delete(db.$with('sessions').as(db.select().from(/* invalidates all user sessions */)))
}
export async function changePassword(userId: string, newPassword: string): Promise<void> {
const passwordHash = await argonHash(newPassword)
await db.update(users)
.set({ passwordHash, mustChangePassword: false })
.where(eq(users.id, userId))
}

Note: Add @node-rs/argon2 to api/package.json dependencies.

  • Step 6: Add new endpoints to api/src/modules/auth/routes.ts

Add to the existing authRoutes function:

import { z } from 'zod'
import { requestPasswordReset, resetPassword, changePassword } from './password'
import { checkRateLimit, recordFailedAttempt, clearAttempts } from './rate-limit'
const forgotSchema = z.object({ email: z.string().email() })
const resetSchema = z.object({ token: z.string(), password: z.string().min(8) })
const changeSchema = z.object({ password: z.string().min(8) })
// Inside authRoutes:
app.post('/auth/forgot-password', async (req) => {
const { email } = forgotSchema.parse(req.body)
await requestPasswordReset(email, process.env.WEB_PUBLIC_URL ?? 'http://localhost:3000')
return { ok: true }
})
app.post('/auth/reset-password', async (req, reply) => {
const { token, password } = resetSchema.parse(req.body)
try {
await resetPassword(token, password)
return { ok: true }
} catch (e: any) {
if (e.message === 'INVALID_TOKEN') {
return reply.code(400).send({ code: 'INVALID_TOKEN' })
}
throw e
}
})
app.post('/auth/change-password', { preHandler: requireAuth }, async (req) => {
const { password } = changeSchema.parse(req.body)
await changePassword(req.user!.id, password)
return { ok: true }
})
  • Step 7: Run tests

Run: cd api && pnpm test Expected: all pass

  • Step 8: Commit
Ventana de terminal
git add . && git commit -m "feat(auth): forgot/reset/change-password with email and rate limit"

Notas de ejecución de Task 7 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 7 (2026-08-20) — ✅ completada»

Resultado: api/ en 596c300. 259 tests. Coverage 98% statements, 99.6% lines. Flujo completo verificado contra el servidor real: pedir → recibir el correo en Mailpit → resetear → entrar con la nueva → quedar afuera con la vieja.

El envío de correo del plan era falso. Su email.ts hacía:

console.log('[DEV EMAIL]', ...) // "Mailpit captures this"

Un console.log no llega a Mailpit — Mailpit es SMTP en el puerto 1025. Y el camino de Resend solo se habría ejecutado en producción, o sea que se estrenaba el día del deploy.

Ahora los dos transportes son reales y los dos tienen tests. Resend queda como proveedor de producción (ADR-0001); Mailpit como SMTP local que no deja salir nada a internet. Los tests de SMTP mandan correos de verdad y los verifican consultando la API de Mailpit. Los de Resend mockean el SDK, no nuestro código, así que el transporte de producción sí se ejecuta y lo único que queda afuera es la red. Hay un test que compara los dos correos: si difirieran, el que ve el usuario en producción sería uno que nadie miró nunca en desarrollo.

El link del correo rompía el patrón BFF. Better-Auth arma por defecto /api/auth/reset-password/{token}?callbackURL=… — o sea al backend, que rebota al front. Eso manda al browser directo contra api/, que es exactamente lo que ADR-0002 prohíbe. Se arma a mano apuntando a web/, como pide el spec §7.3. Lo descubrió un test que falló al no encontrar el token donde lo esperaba.

Correcciones al plan:

Problema Corrección
Agregaba una tabla password_resets El token, su vencimiento y el uso único ya viven en verifications, de Better-Auth. Dos mecanismos para lo mismo terminan desincronizados
Sus tres funciones escribían en users.passwordHash Columna que no existe: el password vive en accounts. No habría compilado
El rate limit no se aplicaba en ningún lado: definía checkRateLimit y después nunca lo llamaba Código muerto que daba sensación de protección. Se aplica en el plugin, único punto por el que pasan todos los endpoints de auth
db.delete(db.$with('sessions')…) con un comentario sobre invalidar sesiones Reemplazado por onPasswordReset, que borra todas las sesiones del usuario
Los errores de Zod caían como 500 safeParse + 400 con el detalle por campo
change-password no pedía la contraseña actual Ahora la exige. Sin eso, quien se apodere de una sesión deja al dueño afuera de su cuenta de forma permanente

Decisiones de diseño:

  • Cambiar la contraseña cierra TODAS las sesiones. Es el punto donde más importa: si alguien te robó la sesión y vos recuperás la cuenta, sin esto el atacante se queda adentro.
  • El rate limit cuenta todos los intentos, no solo los fallidos. Contar solo los fallidos deja alternar logins correctos de una cuenta propia para mantener vivo el contador de otra.
  • La clave del contador combina IP y email. Solo IP castiga a una oficina entera detrás del mismo NAT; solo email deja distribuir el ataque entre miles de IPs.
  • Recuperación más estricta que login (3 vs 5): ahí el riesgo no es adivinar una contraseña sino bombardear la casilla de otra persona usando nuestro servidor.
  • Llegar al límite queda auditado como auth:rate-limit.

Para tasks posteriores:

Hallazgo Impacto
El correo enlaza a ${WEB_PUBLIC_URL}/reset-password?token=… Task 11: esa página recibe el token por query string y lo manda a /api/auth/reset-password vía el BFF
Endpoints de Better-Auth: POST /api/auth/request-password-reset (body { email, redirectTo }) y POST /api/auth/reset-password (body { token, newPassword }) Task 11: son los que consumen las Server Actions
POST /auth/change-password es nuestro, y pide { currentPassword, newPassword } Task 11: el formulario de primer login necesita los dos campos
Respuesta 429 con retry-after y { code: 'RATE_LIMITED', reintentarEn } Task 11: mostrar cuánto falta, no un error genérico
Los tests de correo exigen Mailpit corriendo y fallan con un mensaje que lo explica Task 15: el workflow de CI debe levantar Mailpit como servicio

Task 8: Users CRUD + role assignment (admin only)

Sección titulada «Task 8: Users CRUD + role assignment (admin only)»

Files:

  • Create: api/src/modules/users/validation.ts
  • Create: api/src/modules/users/service.ts
  • Create: api/src/modules/users/routes.ts
  • Create: api/src/modules/users/__tests__/routes.test.ts
  • Modify: api/src/server.ts

Interfaces:

  • Produces: GET /users, POST /users, PATCH /users/:id, POST /users/:id/roles

  • Consumes: authz middleware (Task 4)

  • Step 1: Write api/src/modules/users/validation.ts

import { z } from 'zod'
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(8),
roles: z.array(z.enum(['admin', 'seller', 'pos', 'contador'])).min(1),
})
export const updateUserSchema = z.object({
name: z.string().min(1).optional(),
status: z.enum(['active', 'inactive']).optional(),
mustChangePassword: z.boolean().optional(),
})
export const assignRolesSchema = z.object({
roles: z.array(z.enum(['admin', 'seller', 'pos', 'contador'])).min(1),
})
  • Step 2: Write api/src/modules/users/service.ts
import { hash as argonHash } from '@node-rs/argon2'
import { eq, and, ne } from 'drizzle-orm'
import { db } from '@/db/client'
import { users, userRoles, roles } from '@/db/schema'
import { BadRequest } from '@/lib/errors'
import type { Role } from '@/modules/authz/matrix'
export async function createUser(input: {
email: string
name: string
password: string
roles: Role[]
}): Promise<{ id: string }> {
const passwordHash = await argonHash(input.password)
const [user] = await db.insert(users).values({
email: input.email,
name: input.name,
passwordHash,
mustChangePassword: true,
}).returning({ id: users.id })
for (const role of input.roles) {
await db.insert(userRoles).values({ userId: user.id, roleName: role })
}
return user
}
export async function updateUser(
userId: string,
patch: { name?: string; status?: 'active' | 'inactive'; mustChangePassword?: boolean },
): Promise<void> {
await db.update(users).set(patch).where(eq(users.id, userId))
if (patch.status === 'inactive') {
await db.delete(/* invalidate sessions */)
}
}
export async function setUserRoles(userId: string, grantedBy: string, newRoles: Role[]): Promise<void> {
await db.delete(userRoles).where(eq(userRoles.userId, userId))
for (const role of newRoles) {
await db.insert(userRoles).values({ userId, roleName: role, grantedBy })
}
// Update active sessions
await db.update(/* sessions */)
.set({ roles: newRoles })
.where(/* userId match */)
}
  • Step 3: Write api/src/modules/users/routes.ts
import type { FastifyInstance } from 'fastify'
import { eq } from 'drizzle-orm'
import { db } from '@/db/client'
import { users, userRoles } from '@/db/schema'
import { requireAuth, requirePermission } from '@/modules/authz/middleware'
import { emit } from '@/modules/authz/audit'
import { createUserSchema, updateUserSchema, assignRolesSchema } from './validation'
import { createUser, updateUser, setUserRoles } from './service'
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get('/users', {
preHandler: [requireAuth, requirePermission('usuarios', 'ver')],
}, async () => {
return db.select({
id: users.id,
email: users.email,
name: users.name,
status: users.status,
mustChangePassword: users.mustChangePassword,
createdAt: users.createdAt,
}).from(users)
})
app.post('/users', {
preHandler: [requireAuth, requirePermission('usuarios', 'crear')],
}, async (req, reply) => {
const input = createUserSchema.parse(req.body)
const user = await createUser(input)
await emit({
userId: req.user!.id,
rolEjercido: req.user!.roles,
action: 'USER_CREATED',
resource: 'users',
resourceId: user.id,
result: 'ok',
requestId: (req.headers['x-request-id'] as string) ?? crypto.randomUUID(),
ip: req.ip,
payload: { email: input.email, roles: input.roles },
})
reply.code(201)
return user
})
app.patch('/users/:id', {
preHandler: [requireAuth, requirePermission('usuarios', 'editar')],
}, async (req) => {
const id = (req.params as { id: string }).id
const patch = updateUserSchema.parse(req.body)
await updateUser(id, patch)
await emit({
userId: req.user!.id,
rolEjercido: req.user!.roles,
action: 'USER_UPDATED',
resource: 'users',
resourceId: id,
result: 'ok',
requestId: (req.headers['x-request-id'] as string) ?? crypto.randomUUID(),
ip: req.ip,
payload: patch,
})
return { ok: true }
})
app.post('/users/:id/roles', {
preHandler: [requireAuth, requirePermission('usuarios', 'editar')],
}, async (req) => {
const id = (req.params as { id: string }).id
const { roles: newRoles } = assignRolesSchema.parse(req.body)
await setUserRoles(id, req.user!.id, newRoles)
await emit({
userId: req.user!.id,
rolEjercido: req.user!.roles,
action: 'USER_ROLES_CHANGED',
resource: 'users',
resourceId: id,
result: 'ok',
requestId: (req.headers['x-request-id'] as string) ?? crypto.randomUUID(),
payload: { newRoles },
})
return { ok: true }
})
}
  • Step 4: Register in api/src/server.ts
import { userRoutes } from './modules/users/routes'
await userRoutes(app)
  • Step 5: Write a smoke test

Create api/src/modules/users/__tests__/routes.test.ts:

import { describe, expect, it } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { userRoutes } from '../routes'
describe('users routes', () => {
it('GET /users returns 401 without session', async () => {
const app = Fastify()
await app.register(cookie)
await userRoutes(app)
await app.ready()
const res = await app.inject({ method: 'GET', url: '/users' })
expect(res.statusCode).toBe(401)
await app.close()
})
})
  • Step 6: Commit
Ventana de terminal
git add . && git commit -m "feat(users): admin CRUD with role assignment and audit"
  • Step 7: Add E2E test for users CRUD (real HTTP)

Create api/src/modules/users/__tests__/e2e-users.test.ts:

import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { auth } from '@/plugins/auth-plugin'
import { authRoutes } from '@/modules/auth/routes'
import { userRoutes } from '../routes'
import { db } from '@/db/client'
import { users, roles, userRoles } from '@/db/schema'
import { hash as argonHash } from '@node-rs/argon2'
import { eq } from 'drizzle-orm'
describe('E2E: users CRUD', () => {
const app = Fastify({ logger: false })
let adminSessionId: string
let newUserId: string
beforeAll(async () => {
await app.register(cookie)
await app.register(authRoutes)
await app.register(userRoutes)
await app.ready()
// Seed an admin
await db.insert(roles).values({ name: 'admin', description: 'admin' }).onConflictDoNothing()
const passwordHash = await argonHash('admin1234')
const [admin] = await db.insert(users).values({
email: 'admin-e2e@test.com', name: 'Admin E2E', passwordHash,
}).returning({ id: users.id })
await db.insert(userRoles).values({ userId: admin.id, roleName: 'admin' })
// Sign in as admin
const signInRes = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: 'admin-e2e@test.com', password: 'admin1234' },
})
const cookieHeader = signInRes.headers['set-cookie'] as string
adminSessionId = /aquazaku_session=([^;]+)/.exec(cookieHeader)![1]
})
afterAll(async () => {
if (newUserId) {
await db.delete(users).where(eq(users.id, newUserId))
}
await db.delete(users).where(eq(users.email, 'admin-e2e@test.com'))
await app.close()
})
it('admin can create a user with multiple roles', async () => {
const res = await app.inject({
method: 'POST',
url: '/users',
headers: { Cookie: `aquazaku_session=${adminSessionId}` },
payload: {
email: 'newuser-e2e@test.com',
name: 'New User E2E',
password: 'newuser1234',
roles: ['pos', 'seller'],
},
})
expect(res.statusCode).toBe(201)
newUserId = res.json().id
})
it('non-admin (no session) gets 403 on POST /users', async () => {
const res = await app.inject({
method: 'POST',
url: '/users',
payload: {
email: 'hacker@test.com', name: 'Hacker', password: 'hack1234', roles: ['admin'],
},
})
expect(res.statusCode).toBe(401) // first requireAuth
})
it('newly created user can sign in with their initial password', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: 'newuser-e2e@test.com', password: 'newuser1234' },
})
expect(res.statusCode).toBe(200)
const cookieHeader = res.headers['set-cookie'] as string
const sessionId = /aquazaku_session=([^;]+)/.exec(cookieHeader)![1]
const meRes = await app.inject({
method: 'GET',
url: '/auth/me',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
const body = meRes.json()
expect(body.roles).toContain('pos')
expect(body.roles).toContain('seller')
})
})

Run: cd api && pnpm test src/modules/users/__tests__/e2e-users Expected: 3 tests pass

  • Step 8: Commit E2E test
Ventana de terminal
git add . && git commit -m "test(users): E2E test for create + sign-in flow"

Notas de ejecución de Task 8 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 8 (2026-08-20) — ✅ completada»

Resultado: api/ en 83e5bf6. 300 tests. Coverage 95.6% statements, 99.1% lines. Flujo completo verificado contra el servidor real.

La regla que faltaba: protección del último administrador. → nueva RN-ACC-06

El dominio dice que hoy el dueño es el único admin. Con el plan tal cual, un click de más en la pantalla de usuarios lo dejaba sin el rol — y nadie quedaba con permiso para devolvérselo. El sistema pasaba a ser inadministrable, recuperable solo metiendo mano en la base.

No es un caso rebuscado: es el error más fácil de cometer en una pantalla que existe justamente para editar usuarios. Ahora se bloquea con 409 ULTIMO_ADMIN y un mensaje que dice qué hacer, tanto al quitarse el rol como al desactivarse. Un admin desactivado no cuenta como respaldo.

Resuelto el pendiente de Task 5. → nueva RN-ACC-07

Los roles se congelan en la sesión al hacer login. Sin actualizarlos, quitarle el rol admin a alguien lo dejaba administrando siete días más. Se actualizan las sesiones abiertas en vez de cerrarlas: cerrarlas también resolvería el problema, pero echaría a la persona del sistema cada vez que un admin le toca un rol. El smoke lo muestra — la misma sesión pasa de 403 a 200 sin re-login.

Una fila por acción en la bitácora. Con el plan, crear un usuario dejaba dos registros: el del chequeo de permiso (sin detalle) y el del resultado. Se agregó la opción auditaLaRuta a requirePermission: cuando el handler conoce detalles que el middleware todavía no puede conocer, audita la ruta. Los denegados los sigue registrando el middleware siempre.

Correcciones al plan:

Problema Corrección
createUser insertaba passwordHash en users, columna que no existe Pasa por Better-Auth, que deja el hash argon2id en accounts. Replicarlo a mano sería tener dos formas de crear una credencial
updateUser y setUserRoles tenían placeholders en vez de código: db.delete(/* invalidate sessions */), db.update(/* sessions */) Implementados de verdad
GET /users no devolvía los roles La pantalla de administración necesitaría un request por fila para saber quién es admin
Email repetido caía como 500 409 EMAIL_EN_USO. Y choca también con otro case, porque users.email es citext
Errores de Zod como 500 400 con el detalle por campo
Acciones auditadas como USER_CREATED usuarios:crear, la nomenclatura del dominio y la que la UI de auditoría puede filtrar
POST /users/:id/roles Pasó a PUT: manda el conjunto completo de roles, no un agregado. Con POST quedaba ambiguo si repetir la llamada suma o reemplaza
El esquema de validación reescribía la lista de roles Sale de ROLES, la misma constante de la matriz. Dos listas se desincronizan el día que se agregue un rol

Para tasks posteriores:

Hallazgo Impacto
GET /users devuelve { id, email, name, status, mustChangePassword, roles[], createdAt } Task 13: la tabla de administración se arma con un solo request
PUT /users/:id/roles con { roles: [...] } es idempotente y reemplaza el conjunto Task 13: el formulario manda todos los checkboxes marcados, no diffs
409 ULTIMO_ADMIN con mensaje legible Task 13: mostrar ese mensaje tal cual; ya explica qué hacer
requirePermission(recurso, accion, { auditaLaRuta: true }) Task 9: usarlo en cualquier ruta que quiera auditar con detalle propio
Crear un usuario no inicia su sesión (autoSignIn: false) Task 13: después de crear, la UI vuelve a la lista; no hay cambio de sesión

Task 9: Audit query API with filters and cursor pagination

Sección titulada «Task 9: Audit query API with filters and cursor pagination»

Files:

  • Create: api/src/modules/audit/query.ts
  • Create: api/src/modules/audit/routes.ts
  • Create: api/src/modules/audit/__tests__/routes.test.ts
  • Modify: api/src/server.ts

Interfaces:

  • Produces: GET /audit?user=X&action=Y&resource=Z&from=DATE&to=DATE&cursor=...&limit=50

  • Consumes: authz middleware (Task 4)

  • Step 1: Write api/src/modules/audit/query.ts

import { and, eq, gte, lte, lt, sql } from 'drizzle-orm'
import { db } from '@/db/client'
import { auditLog } from '@/db/schema'
export interface AuditFilter {
userId?: string
action?: string
resource?: string
from?: Date
to?: Date
cursor?: number
limit?: number
}
export async function queryAudit(filter: AuditFilter) {
const where = and(
filter.userId ? eq(auditLog.userId, filter.userId) : undefined,
filter.action ? eq(auditLog.action, filter.action) : undefined,
filter.resource ? eq(auditLog.resource, filter.resource) : undefined,
filter.from ? gte(auditLog.createdAt, filter.from) : undefined,
filter.to ? lte(auditLog.createdAt, filter.to) : undefined,
filter.cursor ? lt(auditLog.id, filter.cursor) : undefined,
)
const rows = await db
.select()
.from(auditLog)
.where(where)
.orderBy(sql`${auditLog.id} DESC`)
.limit(filter.limit ?? 50)
return {
rows,
nextCursor: rows.length > 0 ? rows[rows.length - 1].id : null,
}
}
  • Step 2: Write api/src/modules/audit/routes.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { requireAuth, requirePermission } from '@/modules/authz/middleware'
import { queryAudit } from './query'
const filterSchema = z.object({
userId: z.string().uuid().optional(),
action: z.string().optional(),
resource: z.string().optional(),
from: z.string().datetime().transform((s) => new Date(s)).optional(),
to: z.string().datetime().transform((s) => new Date(s)).optional(),
cursor: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
})
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get('/audit', {
preHandler: [requireAuth, requirePermission('auditoria', 'ver')],
}, async (req) => {
const filter = filterSchema.parse(req.query)
return queryAudit(filter)
})
}
  • Step 3: Register in api/src/server.ts
import { auditRoutes } from './modules/audit/routes'
await auditRoutes(app)
  • Step 4: Smoke test

Create api/src/modules/audit/__tests__/routes.test.ts:

import { describe, expect, it } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { auditRoutes } from '../routes'
describe('audit routes', () => {
it('GET /audit returns 401 without session', async () => {
const app = Fastify()
await app.register(cookie)
await auditRoutes(app)
await app.ready()
const res = await app.inject({ method: 'GET', url: '/audit' })
expect(res.statusCode).toBe(401)
await app.close()
})
})
  • Step 5: Commit
Ventana de terminal
git add . && git commit -m "feat(audit): query API with filters and cursor pagination"
  • Step 6: Add E2E test for audit query (action → log → query)

Create api/src/modules/audit/__tests__/e2e-audit.test.ts:

import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { auth } from '@/plugins/auth-plugin'
import { authRoutes } from '@/modules/auth/routes'
import { userRoutes } from '@/modules/users/routes'
import { auditRoutes } from '../routes'
import { db } from '@/db/client'
import { users, roles, userRoles, auditLog } from '@/db/schema'
import { hash as argonHash } from '@node-rs/argon2'
import { eq, gt } from 'drizzle-orm'
describe('E2E: audit log end-to-end', () => {
const app = Fastify({ logger: false })
let adminSessionId: string
let beforeTestLogId: number
beforeAll(async () => {
await app.register(cookie)
await app.register(authRoutes)
await app.register(userRoutes)
await app.register(auditRoutes)
await app.ready()
await db.insert(roles).values({ name: 'admin', description: 'admin' }).onConflictDoNothing()
const passwordHash = await argonHash('admin1234')
const [admin] = await db.insert(users).values({
email: 'audit-e2e@test.com', name: 'Audit E2E', passwordHash,
}).returning({ id: users.id })
await db.insert(userRoles).values({ userId: admin.id, roleName: 'admin' })
const signInRes = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: 'audit-e2e@test.com', password: 'admin1234' },
})
const cookieHeader = signInRes.headers['set-cookie'] as string
adminSessionId = /aquazaku_session=([^;]+)/.exec(cookieHeader)![1]
// Capture the current max id so we only look at new logs
const [max] = await db.select({ id: auditLog.id }).from(auditLog).orderBy(auditLog.id).limit(1)
beforeTestLogId = max?.id ?? 0
})
afterAll(async () => {
await db.delete(users).where(eq(users.email, 'audit-e2e@test.com'))
await app.close()
})
it('an action generates an audit log entry', async () => {
// Perform an action: create a user
const createRes = await app.inject({
method: 'POST',
url: '/users',
headers: { Cookie: `aquazaku_session=${adminSessionId}` },
payload: {
email: 'audited-user@test.com', name: 'Audited', password: 'audited1234', roles: ['pos'],
},
})
expect(createRes.statusCode).toBe(201)
// Query the audit log
const auditRes = await app.inject({
method: 'GET',
url: '/audit?action=USER_CREATED',
headers: { Cookie: `aquazaku_session=${adminSessionId}` },
})
expect(auditRes.statusCode).toBe(200)
const body = auditRes.json()
const created = body.rows.find((r: any) => r.id > beforeTestLogId)
expect(created).toBeDefined()
expect(created.action).toBe('USER_CREATED')
expect(created.resource).toBe('users')
expect(created.result).toBe('ok')
})
it('audit log is append-only — UPDATE is rejected by trigger', async () => {
// Try to UPDATE an existing audit log directly via raw SQL
await expect(
db.execute(
// @ts-expect-error - raw SQL via drizzle.execute
{ sql: `UPDATE audit_log SET action = 'hacked' WHERE id > ${beforeTestLogId} LIMIT 1`, params: [] }
)
).rejects.toThrow(/audit_log is append-only/)
})
})

Run: cd api && pnpm test src/modules/audit/__tests__/e2e-audit Expected: 2 tests pass (the first confirms the flow, the second confirms append-only enforcement)

  • Step 7: Commit E2E test
Ventana de terminal
git add . && git commit -m "test(audit): E2E test for action → log → query, plus append-only enforcement"

Notas de ejecución de Task 9 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 9 (2026-08-20) — ✅ completada»

Resultado: api/ en 5a64962. 328 tests. Coverage 95.6% statements, 99.2% lines; el módulo audit/ al 100%. Con esto queda terminado todo el lado api/ de M0 salvo el seed (Task 14) y CI (Task 15).

Bug de paginación, encontrado por un test propio. El plan devolvía siempre un cursor mientras hubiera filas. Con eso la última página promete una siguiente que viene vacía: la UI muestra “cargar más”, el usuario hace click y no pasa nada.

La primera corrección —devolver cursor solo si la página vino completa— tampoco alcanzaba: una última página que casualmente viene justa se confunde con una página completa que tiene continuación. La solución correcta es pedir una fila de más de las que se devuelven y usar su existencia como señal. La fila extra se descarta y nunca sale del módulo.

Por qué cursor y no offset: con offset, la página 20 obliga a Postgres a recorrer y descartar 950 filas, y una fila nueva insertada mientras se pagina corre todo un lugar, haciendo que un registro aparezca dos veces o se saltee. En una tabla que solo crece —que es lo que hace un log— eso pasa todo el tiempo. Hay un test que inserta registros en medio de la paginación y verifica que no se descoloca.

Correcciones y agregados al plan:

Problema Corrección
nextCursor siempre presente Fila extra como señal de “hay más”
No traía el nombre del usuario, solo el UUID LEFT JOIN con users. Una pantalla de auditoría con UUIDs no sirve para investigar nada. LEFT y no INNER: audit_log no tiene FK a users a propósito, y con INNER borrar un usuario haría desaparecer su historial de la consulta
Sin filtro por result Agregado. Ver todos los denegados es la consulta de seguridad más útil sobre esta tabla
Errores de Zod como 500 400 con detalle por campo
Un rango de fechas al revés devolvía lista vacía 400 RANGO_INVALIDO con mensaje. Una lista vacía parece un sistema sin datos
filter.cursor ? ... — el cursor 0 sería falsy !== undefined
El segundo test E2E usaba db.execute con una firma inventada y UPDATE … LIMIT 1, que no es SQL válido en Postgres Eliminado: la inmutabilidad ya está cubierta por 12 tests desde Task 2

Primer consumidor real de scopedCondition en código de producción. Hoy admin y contador tienen alcance todo, así que no filtra nada; pasa por ahí igual para que el día que un rol tenga alcance acotado funcione solo, sin tocar el endpoint. Es la capa única que pide RN-ACC-03.

A propósito no devuelve el total: contar filas de una tabla que solo crece es cada vez más caro, y nadie necesita saber que hay 84.219 registros.

Para Task 13 (UI de auditoría):

Detalle Uso
GET /audit → { filas, siguienteCursor } siguienteCursor: null significa “no hay más”: el botón “cargar más” se oculta
Cada fila trae userName y userEmail ya resueltos La tabla no necesita un request por fila
userName: null = la cuenta fue borrada, pero el registro sobrevive Mostrar algo como “(cuenta eliminada)”, no un espacio vacío
Filtros: userId, action, resource, result, desde, hasta, cursor, limite desde/hasta aceptan 2026-08-20, sin obligar a armar un timestamp
limite por defecto 50, tope 200 Pedir más devuelve 400
Consultar la bitácora queda en la bitácora La primera fila que ve el admin suele ser su propia consulta. Es intencional

Task 10: web BFF helper, ESLint rule, modules.ts

Sección titulada «Task 10: web BFF helper, ESLint rule, modules.ts»

Files:

  • Create: web/src/lib/api-server.ts
  • Create: web/src/lib/modules.ts
  • Modify: web/eslint.config.mjs
  • Create: web/src/__tests__/api-server.test.ts
  • Create: web/src/__tests__/modules.test.ts

Interfaces:

  • Produces: apiServerFetch(), getServerUser(), computeVisibleModules() in web

  • Produces: ESLint rule that bans direct fetch() and localStorage

  • Consumes: nothing (independent of api tasks)

  • Step 1: Write web/src/lib/api-server.ts

import { cookies, headers } from 'next/headers'
import { ApiError } from './errors'
const API_URL = process.env.API_INTERNAL_URL!
export async function apiServerFetch<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const cookieStore = await cookies()
const headerStore = await headers()
const res = await fetch(`${API_URL}${path}`, {
...init,
headers: {
...init.headers,
Cookie: cookieStore.toString(),
'x-request-id': headerStore.get('x-request-id') ?? crypto.randomUUID(),
},
cache: init.cache ?? 'no-store',
})
if (!res.ok) {
throw new ApiError(res.status, await res.text())
}
return res.json()
}
export async function getServerUser(): Promise<{ id: string; roles: string[] } | null> {
try {
return await apiServerFetch(AUTH_ME_PATH) // '/auth/me' — ver notas de Task 10
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null
throw e
}
}
  • Step 2: Write web/src/lib/modules.ts
import type { Role } from './roles'
export interface MenuModule {
id: string
label: string
href: string
roles: Role[]
}
export const ALL_MODULES: MenuModule[] = [
{ id: 'usuarios', label: 'Usuarios', href: '/modulos/usuarios', roles: ['admin'] },
{ id: 'auditoria', label: 'Auditoría', href: '/modulos/auditoria', roles: ['admin'] },
{ id: 'contador-auditoria', label: 'Auditoría', href: '/contador/auditoria', roles: ['contador'] },
]
export function computeVisibleModules(roles: Role[]): MenuModule[] {
return ALL_MODULES.filter((m) => m.roles.some((r) => roles.includes(r)))
}
  • Step 3: Write web/src/lib/roles.ts
export type Role = 'admin' | 'seller' | 'pos' | 'contador'
  • Step 4: Write web/src/lib/errors.ts
export class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API error ${status}: ${body}`)
}
}
  • Step 5: Extender web/eslint.config.mjs

ESLint 9 usa flat config. create-next-app ya generó eslint.config.mjs en Task 1 — se extiende, no se reemplaza, y no se crea .eslintrc.json.

// web/eslint.config.mjs — agregar al array que ya existe
{
rules: {
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.name='fetch']",
message:
'Usá apiServerFetch() de @/lib/api-server en vez de fetch() directo. Ver /frontend/bff-pattern/.',
},
{
selector: "MemberExpression[object.name='localStorage']",
message: 'Nada de tokens en localStorage. Solo cookies httpOnly.',
},
{
selector: "MemberExpression[object.name='sessionStorage']",
message: 'Nada de tokens en sessionStorage. Solo cookies httpOnly.',
},
],
},
},

El helper es el único lugar legítimo donde se llama fetch(), así que necesita su excepción — si no, la regla se muerde la cola:

{
files: ['src/lib/api-server.ts'],
rules: { 'no-restricted-syntax': 'off' },
},

Verificación de que la regla muerde de verdad (si no se prueba, no existe): escribir un archivo temporal con fetch('/x'), correr pnpm lint, confirmar que falla con el mensaje esperado, y borrarlo.

  • Step 6: Write web/src/__tests__/modules.test.ts
import { describe, expect, it } from 'vitest'
import { computeVisibleModules } from '@/lib/modules'
describe('computeVisibleModules()', () => {
it('admin sees admin and auditoria modules', () => {
const result = computeVisibleModules(['admin'])
expect(result.map((m) => m.id)).toContain('usuarios')
expect(result.map((m) => m.id)).toContain('auditoria')
})
it('contador sees only contador-auditoria', () => {
const result = computeVisibleModules(['contador'])
expect(result.map((m) => m.id)).toEqual(['contador-auditoria'])
})
it('pos with no admin modules sees empty sidebar', () => {
const result = computeVisibleModules(['pos'])
expect(result).toEqual([])
})
it('multi-rol sees union of modules', () => {
const result = computeVisibleModules(['admin', 'contador'])
expect(result.map((m) => m.id)).toContain('usuarios')
expect(result.map((m) => m.id)).toContain('contador-auditoria')
})
})
  • Step 7: Verify ESLint rule works

Create a temporary file web/src/test-fetch.ts with content:

const data = await fetch('http://api/users')

Run: cd web && pnpm lint Expected: error about using fetch directly

Delete the test file.

  • Step 8: Commit
Ventana de terminal
git add . && git commit -m "feat(web): BFF helper, modules mapping, ESLint rule for fetch/localStorage"

Notas de ejecución de Task 10 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 10 (2026-08-20) — ✅ completada»

Resultado: web/ en 8112759, 7f9974d y c2b3b3d. 55 tests en verde, lint 0, typecheck 0. Coverage 100% en statements, ramas, funciones y líneas (el umbral exigido para api-server.ts y modules.ts era 90/85).

Se hizo en paralelo con Task 5 de api/, sin tocar api/ ni el resto de docs/. Es la única task paralelizable del plan y salió limpia.

Desvíos deliberados sobre los snippets del plan y del doc. Cada uno corrige un bug real, no una preferencia de estilo:

Snippet original Problema Qué se hizo
const API_URL = process.env.API_INTERNAL_URL! a nivel módulo La aserción ! le miente al compilador y el valor queda congelado al importar. Sin la env var el resultado era fetch("undefined/auth/me"), no un error accionable Se lee y valida en cada llamada, con mensaje que dice qué archivo copiar. Además se recorta la barra final, que producía //auth/me
headers: { ...init.headers, ... } El spread devuelve {} cuando el caller pasa una instancia de Headers o un array de tuplas: sus headers se perdían en silencio. Un Content-Type de una Server Action desaparecía sin ruido new Headers(init.headers), con test para las tres formas
Cookie y x-request-id mezclados con los del caller Un caller podía pisar Cookie y hacerse pasar por otra sesión Se setean después de los del caller. Hay test
return res.json() Un 204 o un 200 sin cuerpo tiraba SyntaxError sin contexto; un 2xx con HTML de un gateway, lo mismo Cuerpo vacío → undefined; cuerpo no-JSON → ApiError con el cuerpo crudo
ApiError(status, body) El doc pide loguear el x-request-id en los 5xx, pero el error no lo llevaba Lleva path y requestId. El logger emite JSON con la forma de Pino para que una sola query correlacione web/ y api/
{ files: ['src/lib/api-server.ts'], rules: { 'no-restricted-syntax': 'off' } } Apaga la regla entera para ese archivo, habilitando de paso localStorage justo en el archivo que maneja la sesión Se re-declara solo con los selectores de storage. Verificado: su fetch pasa, un localStorage adentro sigue siendo error
CallExpression[callee.name='fetch'] y MemberExpression[object.name='localStorage'] Solo miran el identificador pelado: window.fetch, globalThis.fetch, window.localStorage y globalThis.sessionStorage pasaban limpio Se agregaron los selectores con prefijo. Verificado con archivo sonda: 7 violaciones → 7 errores

Contradicción del plan resuelta: el snippet de Step 1 decía /api/auth/me, mientras que el código de Task 6 y /frontend/bff-pattern/ dicen /auth/me. Manda /auth/me, aislado en la constante exportada AUTH_ME_PATH. El cierre de Task 5 lo confirmó después: /api/auth/* es el namespace de Better-Auth. Ambas referencias del plan quedaron corregidas.

Para tasks posteriores:

Hallazgo Impacto
AUTH_ME_PATH es el único lugar de web/ que conoce el path de sesión Task 6: si authRoutes no registra /auth/me, se cambia esa constante y nada más
apiServerFetch no pone Content-Type solo Tasks 11 y 13: toda mutación tiene que pasarlo, como muestra el doc
Un 403 no se traduce a null en getServerUser() Task 12: el guard del layout solo redirige con null. Un 403 necesita pantalla de sin acceso, no redirect
El helper no agrega error boundary Tasks 11-13: falta un error.tsx para los 5xx. Con api/ caída, el layout tira en vez de degradar
server-only no está instalado Sería una cuarta capa de defensa: haría fallar el build si un Client Component importa el helper. Suma dependencia y contradice el “tres capas” del doc — decisión pendiente

Task 11: web auth UI — login, forgot, reset, change-password

Sección titulada «Task 11: web auth UI — login, forgot, reset, change-password»

Files:

  • Create: web/src/app/(auth)/layout.tsx
  • Create: web/src/app/(auth)/login/page.tsx
  • Create: web/src/app/(auth)/login/actions.ts
  • Create: web/src/app/(auth)/forgot-password/page.tsx
  • Create: web/src/app/(auth)/forgot-password/actions.ts
  • Create: web/src/app/(auth)/reset-password/page.tsx
  • Create: web/src/app/(auth)/reset-password/actions.ts
  • Create: web/src/app/(auth)/change-password/page.tsx
  • Create: web/src/app/(auth)/change-password/actions.ts
  • Create: web/src/components/auth/login-form.tsx

Interfaces:

  • Produces: 4 auth pages + Server Actions
  • Consumes: api-server.ts (Task 10), auth routes (Tasks 6-7)

Tres cosas para corregir antes de escribir esta task (anotadas 2026-08-20, al cerrar Tasks 10 y 12 y verificar contra el /auth/me real de Task 6):

  1. redirect('/dashboard') apunta a una ruta que nadie crea. El snippet de loginAction redirige ahí, pero el dashboard quedó en / — Task 12 lo puso en (app)/page.tsx y (app) es un route group, así que no agrega segmento. O se apunta el redirect a /, o se crea el segmento /dashboard.

  2. Falta forzar el primer cambio de contraseña (spec §7.2). /auth/me devuelve mustChangePassword, ya tipado en ServerUser, pero el guard de (app)/layout.tsx todavía no lo mira: hoy un usuario de primer login entra derecho al dashboard. El redirect a /change-password va en el guard, no en cada página, y se puede recién cuando esta task cree esa pantalla.

  3. Los snippets usan fetch() directo y arman la cookie a mano. La regla de ESLint de Task 10 los va a rechazar, y con razón: ese código no propaga x-request-id ni respeta el patrón BFF. Va apiServerFetch(). Ojo que el sign-in es el único caso que necesita leer el set-cookie de la respuesta, así que puede que el helper necesite una variante que exponga los headers — decidirlo al implementar, no copiar el snippet.

Además: el layout de esta task usa { children }: { children: React.ReactNode }, pero la convención del proyecto es el tipo global generado LayoutProps<...>, como en el root layout y en (app)/layout.tsx. Correr pnpm next typegen después de crear los archivos y mirar LayoutRoutes en .next/types/routes.d.ts dice qué literal corresponde.

  • Step 1: Write web/src/app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-6">{children}</div>
</div>
)
}
  • Step 2: Write web/src/app/(auth)/login/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
const API_URL = process.env.API_INTERNAL_URL!
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
return { error: 'Credenciales inválidas' }
}
const setCookie = res.headers.get('set-cookie')
if (setCookie) {
const cookieStore = await cookies()
const match = /aquazaku_session=([^;]+)/.exec(setCookie)
if (match) {
cookieStore.set('aquazaku_session', match[1], {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
}
}
redirect('/dashboard')
}
  • Step 3: Write web/src/components/auth/login-form.tsx
'use client'
import { loginAction } from '@/app/(auth)/login/actions'
export function LoginForm({ error }: { error?: string }) {
return (
<form action={loginAction} className="space-y-4">
<h1 className="text-2xl font-bold">Iniciar sesión</h1>
{error && <p className="text-red-600">{error}</p>}
<input name="email" type="email" required placeholder="Email" className="w-full p-2 border rounded" />
<input name="password" type="password" required placeholder="Contraseña" className="w-full p-2 border rounded" />
<button type="submit" className="w-full p-2 bg-blue-600 text-white rounded">Entrar</button>
<a href="/forgot-password" className="text-sm text-blue-600">¿Olvidaste tu contraseña?</a>
</form>
)
}
  • Step 4: Write web/src/app/(auth)/login/page.tsx
import { LoginForm } from '@/components/auth/login-form'
export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
return <LoginForm error={searchParams.error} />
}
  • Step 5: Write forgot-password page + actions

web/src/app/(auth)/forgot-password/actions.ts:

'use server'
const API_URL = process.env.API_INTERNAL_URL!
export async function forgotPasswordAction(formData: FormData) {
const email = formData.get('email') as string
await fetch(`${API_URL}/api/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return { ok: true }
}

web/src/app/(auth)/forgot-password/page.tsx:

'use client'
import { forgotPasswordAction } from './actions'
export default function ForgotPasswordPage() {
return (
<form action={forgotPasswordAction} className="space-y-4">
<h1 className="text-2xl font-bold">Restablecer contraseña</h1>
<p className="text-sm text-gray-600">Te enviaremos un link para restablecer tu contraseña.</p>
<input name="email" type="email" required placeholder="Email" className="w-full p-2 border rounded" />
<button type="submit" className="w-full p-2 bg-blue-600 text-white rounded">Enviar link</button>
</form>
)
}
  • Step 6: Write reset-password page + actions

web/src/app/(auth)/reset-password/actions.ts:

'use server'
import { redirect } from 'next/navigation'
const API_URL = process.env.API_INTERNAL_URL!
export async function resetPasswordAction(formData: FormData) {
const token = formData.get('token') as string
const password = formData.get('password') as string
const res = await fetch(`${API_URL}/api/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
if (!res.ok) return { error: 'Link inválido o expirado' }
redirect('/login?toast=password-reset')
}

web/src/app/(auth)/reset-password/page.tsx:

'use client'
import { resetPasswordAction } from './actions'
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string } }) {
return (
<form action={resetPasswordAction} className="space-y-4">
<h1 className="text-2xl font-bold">Nueva contraseña</h1>
<input type="hidden" name="token" value={searchParams.token ?? ''} />
<input name="password" type="password" required minLength={8} placeholder="Nueva contraseña" className="w-full p-2 border rounded" />
<button type="submit" className="w-full p-2 bg-blue-600 text-white rounded">Cambiar contraseña</button>
</form>
)
}
  • Step 7: Write change-password page + actions

web/src/app/(auth)/change-password/actions.ts:

'use server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
const API_URL = process.env.API_INTERNAL_URL!
export async function changePasswordAction(formData: FormData) {
const password = formData.get('password') as string
const cookieStore = await cookies()
const res = await fetch(`${API_URL}/api/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookieStore.toString(),
},
body: JSON.stringify({ password }),
})
if (!res.ok) return { error: 'Error al cambiar contraseña' }
redirect('/dashboard')
}

web/src/app/(auth)/change-password/page.tsx:

'use client'
import { changePasswordAction } from './actions'
export default function ChangePasswordPage() {
return (
<form action={changePasswordAction} className="space-y-4">
<h1 className="text-2xl font-bold">Cambiar contraseña</h1>
<p className="text-sm text-gray-600">Por seguridad, cambiá tu contraseña antes de continuar.</p>
<input name="password" type="password" required minLength={8} placeholder="Nueva contraseña" className="w-full p-2 border rounded" />
<button type="submit" className="w-full p-2 bg-blue-600 text-white rounded">Cambiar contraseña</button>
</form>
)
}
  • Step 8: Run typecheck

Run: cd web && pnpm typecheck Expected: passes

  • Step 9: Commit
Ventana de terminal
git add . && git commit -m "feat(web): auth pages — login, forgot, reset, change-password"
  • Step 10: Manual browser verification (no automation for web auth yet)

This is the first web E2E checkpoint. Real browser, real flow:

  1. Start api/ (pnpm dev) and web/ (pnpm dev) in two terminals
  2. Open http://localhost:3000/login in Chrome
  3. Sign in with the seeded admin (admin@aquazaku.com / password from .env)
  4. Expected: redirects to /dashboard (or whatever default page Task 12 sets up)
  5. Open DevTools → Application → Cookies → confirm aquazaku_session is httpOnly
  6. Click “Cerrar sesión” → should redirect to /login
  7. Try /forgot-password, enter email, check Mailpit (http://localhost:8025) for the email
  8. Copy the reset link, open in browser, set new password, verify you can log in with it
  9. Try /change-password after a fresh login with must_change_password=true (seed with this flag temporarily to test)

Screenshot each step and attach to the PR. Capture at minimum:

  • Login page (empty)
  • Login success → dashboard
  • Mailpit with the reset email
  • Change-password page forced redirect

If any step fails, STOP — don’t continue to Task 12 until auth UI is verified manually.

Commit verification screenshots (optional but recommended):

Ventana de terminal
git add docs/verification/m0-task11-auth-ui.png
git commit -m "docs(verify): manual browser verification screenshots for M0 auth UI"

Notas de ejecución de Task 11 (2026-08-20) — ⚠️ código completo, Step 10 pendiente

Sección titulada «Notas de ejecución de Task 11 (2026-08-20) — ⚠️ código completo, Step 10 pendiente»

Resultado: web/ en 11337e5, 6896977 y 4472077. 149 tests, lint 0, typecheck 0. Coverage 98.3% statements, 100% funciones.

Step 10 (verificación manual en browser) NO está hecho. Necesita levantar Postgres, Mailpit, api/ y web/, y un usuario para entrar — que llega con el seed de Task 14. Es lo único que prueba de verdad el patrón BFF: que el browser nunca pegue a localhost:3001 y que localStorage quede vacío. Ningún test de componente puede sustituirlo.

Los tres endpoints del plan estaban mal. Contrastados contra lo que Task 7 realmente construyó:

Snippet del plan Realidad de api/
POST /api/auth/forgot-password No existe. Es POST /api/auth/request-password-reset, y el body lleva redirectTo
POST /api/auth/change-password con { password } Es nuestro, en POST /auth/change-password, y exige { currentPassword, newPassword }
redirect('/dashboard') tras cambiar la contraseña Cambiarla cierra todas las sesiones, incluida la actual: el dashboard habría rebotado a /login igual

Dos bugs propios del snippet de login:

  1. La pantalla quedaba muda. loginAction devolvía { error }, pero un <form action={fn}> descarta el retorno. El error existía en el servidor y el usuario no veía nada. Se resuelve con useActionState, que es cómo React 19 hace llegar ese valor a la pantalla. Hay un test que lo cubre.
  2. Armaba la cookie de sesión a mano, con una regex y atributos hardcodeados. Se reemplazó por forwardSetCookies, que reemite lo que puso api/ tal cual — ver notas de la task del helper BFF.

Decisiones de diseño:

  • forgot-password responde lo mismo exista o no el email. Solo informa el rate limit, que no revela nada y evita que la persona siga golpeando. Si el mensaje difiriera, el formulario sería un oráculo para enumerar cuentas.
  • El redirectTo del correo apunta a web/, no a api/. Better-Auth por defecto manda el link al backend; eso haría al browser hablar directo con api/, justo lo que ADR-0002 prohíbe. Ya lo había detectado Task 7 del lado del correo; acá se cierra del lado que arma el pedido.
  • El token del reset viaja en un campo oculto, no en la URL del submit: así no queda en el historial del browser ni en los logs de acceso del servidor.
  • /change-password vive en (auth), no en (app). Necesita sesión, pero si estuviera detrás del guard de primer ingreso —que manda justamente ahí— habría loop de redirects. Resuelve la sesión por su cuenta.
  • El login no distingue email inexistente de contraseña incorrecta. USER_INACTIVE sí tiene mensaje propio (RN-ACC-05): la contraseña puede estar bien y reintentar no lo arregla.
  • El submit se deshabilita mientras corre. Un doble click apurado gastaba dos intentos del rate limit.

Además se cerró el pendiente que dejó Task 12: el guard de (app)/layout.tsx ahora mira mustChangePassword y manda a /change-password (spec §7.2). Antes, un usuario de primer login entraba derecho al dashboard.

Para tasks posteriores:

Hallazgo Impacto
apiServerFetchRaw no chequea el status, apiServerFetch sí Task 13: para leer datos va apiServerFetch. El crudo es solo para auth, donde los 4xx son estados de pantalla
Los helpers de validación y errores viven en web/src/lib/form-errors.ts Task 13: reusar cuerpoDeError y mensajeRateLimit en vez de reescribirlos
Las rutas quedaron: /, /login, /forgot-password, /reset-password, /change-password, /modulos Task 15: son las que tiene que cubrir el smoke E2E
Step 10 abierto, y el Step 6 de Task 12 también Después de Task 14: las dos verificaciones manuales se pueden hacer juntas, con el seed disponible

Task 12: web app shell — sidebar + role-based menu

Sección titulada «Task 12: web app shell — sidebar + role-based menu»

Files:

  • Create: web/src/app/(app)/layout.tsx
  • Create: web/src/app/(app)/page.tsx
  • Create: web/src/components/ui/sidebar.tsx
  • Create: web/src/app/(app)/modulos/page.tsx (placeholder for fallback)
  • Delete: web/src/app/page.tsx y web/src/app/page.test.tsx (agregado 2026-08-20)

Interfaces:

  • Produces: authenticated layout with sidebar that shows modules per role
  • Consumes: api-server.ts (Task 10), modules.ts (Task 10)

Sin ese borrado, esta task no compila. (app) es un route group: no agrega segmento a la URL, así que (app)/page.tsx resuelve a / — la misma ruta que ya ocupaba la landing provisoria creada en Task 1. Dos page en la misma ruta es error de build; la doc de Next 16 lo dice en Route Groups > Conflicting paths. Ver las notas de ejecución al final de la task.

  • Step 1: Write web/src/components/ui/sidebar.tsx
import Link from 'next/link'
import { computeVisibleModules } from '@/lib/modules'
import type { Role } from '@/lib/roles'
export function Sidebar({ userRoles }: { userRoles: Role[] }) {
const modules = computeVisibleModules(userRoles)
return (
<aside className="w-64 bg-white border-r h-screen p-4">
<h2 className="text-lg font-bold mb-4">Aquazaku</h2>
<nav className="space-y-2">
{modules.map((m) => (
<Link key={m.id} href={m.href} className="block p-2 hover:bg-gray-100 rounded">
{m.label}
</Link>
))}
</nav>
</aside>
)
}
  • Step 2: Write web/src/app/(app)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerUser } from '@/lib/api-server'
import { Sidebar } from '@/components/ui/sidebar'
import type { Role } from '@/lib/roles'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await getServerUser()
if (!user) redirect('/login')
return (
<div className="flex">
<Sidebar userRoles={user.roles as Role[]} />
<main className="flex-1 p-6">{children}</main>
</div>
)
}
  • Step 3: Write web/src/app/(app)/page.tsx
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-2 text-gray-600">Bienvenido a Aquazaku.</p>
</div>
)
}
  • Step 4: Verify routing works

Run: cd web && pnpm dev Visit: http://localhost:3000/ → should redirect to /login After login (with seeded admin) → should land on dashboard with sidebar

  • Step 5: Commit
Ventana de terminal
git add . && git commit -m "feat(web): app shell with role-based sidebar"
  • Step 6: Manual browser verification — sidebar shows modules per role

Browser E2E with different role contexts:

  1. Start api/ + web/. Run pnpm db:seed if not already done
  2. Sign in as admin → sidebar shows: Usuarios, Auditoría
  3. Open DevTools → Application → Local Storage → confirm empty (proves no tokens leaked there)
  4. Open DevTools → Network → sign in → confirm NO request to localhost:3001 (proves BFF: browser never talks to api/ directly)
  5. Sign out, then directly seed a contador user via SQL (or temporarily modify the seed)
  6. Sign in as contador → sidebar shows: Auditoría (only)
  7. Sign in as pos → sidebar is empty (no admin modules), but the dashboard page itself is accessible

If any role shows the wrong modules, fix web/src/lib/modules.ts and re-verify.

No commit for this step — verification only, captures issues in PR comments.


Notas de ejecución de Task 12 (2026-08-20) — ⚠️ parcial: código completo, verificación en browser bloqueada

Sección titulada «Notas de ejecución de Task 12 (2026-08-20) — ⚠️ parcial: código completo, verificación en browser bloqueada»

Resultado: web/ en 548df87 y cdd6a5b. 67 tests en verde, lint 0, typecheck 0, coverage 100% en las cuatro métricas.

Steps 4 y 6 quedan sin marcar y NO están hechos. Los dos son verificación manual en browser y dependen de cosas que todavía no existen: /auth/me (Task 6), la pantalla de login (Task 11) y el seed del primer admin (Task 14). Lo que sí se pudo verificar sin api/:

  • La tabla de rutas, con next typegen: AppRoutes = "/" | "/modulos". Confirma que el route group no agrega segmento y que no hay colisión.
  • El guard de sesión y el sidebar por rol, con tests de componente: redirect a /login con sesión nula, unión de módulos en multi-rol, menú vacío para pos y seller, href correcto para el contador.

Queda pendiente lo que ningún test de componente puede probar: que el browser nunca pegue a localhost:3001 y que localStorage quede vacío. Eso es el corazón del patrón BFF y necesita el flujo real.

Bug bloqueante del plan. Task 12 no compilaba como estaba escrita. (app) es un route group y no agrega segmento, así que (app)/page.tsx resuelve a / — la misma ruta que la landing provisoria de Task 1. La doc de Next 16 lo llama Conflicting paths y es error de build. El borrado de la landing se agregó a los Files de la task. El rol de smoke-test del arnés (jsdom + plugin de React + alias @/ + jest-dom) que cumplía page.test.tsx pasó a sidebar.test.tsx.

pnpm typecheck era no determinista. tsconfig incluye .next/types/** y .next/dev/types/**, que son generados. Al borrar la landing, tsc se puso rojo apuntando a un src/app/page.js inexistente: un rojo falso, sin relación con el código fuente. Peor, .next/types lo regenera next typegen pero .next/dev/types solo lo produce next dev, así que estaba stale desde Task 1 y hubo que borrarlo a mano. El script pasó a "typecheck": "next typegen && tsc --noEmit".

next typegen existe en Next 16 y no estaba en el plan: regenera los tipos de rutas, páginas y layouts sin correr un build completo. Sirve para verificar la tabla de rutas y para blindar el typecheck.

Para tasks posteriores:

Hallazgo Impacto
El guard vive en (app)/layout.tsx, no en cada página Task 13: toda página nueva bajo (app)/ nace protegida. No repetir el chequeo de sesión adentro
pnpm typecheck ahora corre next typegen primero Task 15: el CI hereda la corrección. Sin ella habría rojos intermitentes según qué dejara el cache
El root layout usa el tipo global generado LayoutProps<'/'>, no { children: React.ReactNode } Tasks 11 y 13: seguir esa convención en los layouts nuevos. Los snippets del plan usan la forma vieja
El dashboard quedó en /, no en /dashboard Task 11: su loginAction hace redirect('/dashboard'), ruta que nadie crea. Hay que apuntarlo a / o crear el segmento
Steps 4 y 6 siguen abiertos Después de Tasks 6, 11 y 14: volver acá y correr la verificación en browser antes de dar M0 por cerrado

Notas de ejecución de Tasks 11 y 12 (2026-08-20) — ✅ completadas y verificadas

Sección titulada «Notas de ejecución de Tasks 11 y 12 (2026-08-20) — ✅ completadas y verificadas»

Ambas se implementaron en una sesión paralela (web/ en 548df87, 6896977, 4472077, más fedf712 y cdd6a5b). Quedaron sin marcar en el plan; se verificaron de punta a punta y se marcan ahora.

Estado verificado: web/ con typecheck, lint y 154 tests en verde. Login, dashboard y sidebar por rol funcionando en el browser contra api/ real.


🔴 Un bug que rompía TODO el login, y que ningún test veía

Sección titulada «🔴 Un bug que rompía TODO el login, y que ningún test veía»

Al levantar api/ y web/ juntos y entrar por el browser, el login fallaba con “No pudimos procesar el ingreso”. Los 149 tests de web/ y los 328 de api/ estaban en verde.

Causa: Better-Auth responde 403 MISSING_OR_NULL_ORIGIN a toda petición que cambie estado y llegue sin header Origin. fetch del lado del servidor no lo manda — no hay documento que origine la petición. La Server Action pedía el sign-in y recibía 403.

Por qué ninguna suite lo vio:

Dónde Por qué no lo detecta
Tests de api/ Usan app.inject(), que no dispara el chequeo de origen
Tests de web/ Mockean fetch: nunca sale una petición real
curl manual Sin Origin devolvía 200, porque omite otros headers que disparan el chequeo y descarta los headers vacíos

Hizo falta replicar el request exacto desde Node para verlo. Los dos repos en verde y el sistema sin funcionar: el bug vivía justo en la costura entre ambos, el único lugar que ninguna suite unitaria mira.

Corrección: web/ en c791050. El helper declara el origen real con WEB_PUBLIC_URL, que api/ ya tiene en trustedOrigins. No se desactivó el chequeo en api/: eso resolvía el síntoma tirando abajo una protección legítima contra CSRF. Se setea después de los headers del caller —igual que Cookie y x-request-id— para que nadie pueda hacerse pasar por otro origen, y se manda solo el origen, sin path ni query. Documentado en /frontend/bff-pattern/ y cubierto por 5 tests.

Segundo hallazgo, menor: web/ no tenía .env.local, así que API_INTERNAL_URL estaba sin definir y / devolvía 500. El helper dio exactamente el error accionable que prometía en las notas de Task 10 — esa mejora se ganó el sueldo.


Regla que queda para el resto de M0 y para M1+:

Una task no está terminada porque sus tests pasen. Está terminada cuando se la vio funcionar de punta a punta, con todos los servicios arriba.


Task 13: web admin + contador modules (usuarios, auditoria)

Sección titulada «Task 13: web admin + contador modules (usuarios, auditoria)»

Files:

  • Create: web/src/app/(app)/modulos/usuarios/page.tsx
  • Create: web/src/app/(app)/modulos/usuarios/actions.ts
  • Create: web/src/app/(app)/modulos/auditoria/page.tsx
  • Create: web/src/app/contador/auditoria/page.tsx
  • Create: web/src/components/ui/table.tsx (TanStack Table wrapper)
  • Create: web/src/components/ui/pagination.tsx
  • Create: web/src/components/ui/filters.tsx

Interfaces:

  • Produces: /modulos/usuarios (list + create + role assignment), /modulos/auditoria (query log), /contador/auditoria (read-only)

  • Consumes: api-server.ts (Task 10), users API (Task 8), audit API (Task 9)

  • Step 1: Write web/src/components/ui/table.tsx

'use client'
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'
export function Table<T>({ columns, data }: { columns: ColumnDef<T>[]; data: T[] }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<table className="w-full border">
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id} className="border p-2 text-left">{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="border p-2">{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
)
}
  • Step 2: Write web/src/app/(app)/modulos/usuarios/page.tsx
import { apiServerFetch } from '@/lib/api-server'
import { Table } from '@/components/ui/table'
interface UserRow {
id: string
email: string
name: string
status: 'active' | 'inactive'
createdAt: string
}
const columns: ColumnDef<UserRow>[] = [
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'name', header: 'Nombre' },
{ accessorKey: 'status', header: 'Estado' },
]
export default async function UsuariosPage() {
const users = await apiServerFetch<UserRow[]>('/api/users')
return (
<div>
<h1 className="text-2xl font-bold mb-4">Usuarios</h1>
<Table columns={columns} data={users} />
</div>
)
}
  • Step 3: Write web/src/app/(app)/modulos/auditoria/page.tsx
import { apiServerFetch } from '@/lib/api-server'
interface AuditRow {
id: number
userId: string | null
action: string
resource: string | null
result: 'ok' | 'denied'
createdAt: string
}
export default async function AuditoriaPage({ searchParams }: { searchParams: { userId?: string; action?: string; cursor?: string } }) {
const params = new URLSearchParams()
if (searchParams.userId) params.set('userId', searchParams.userId)
if (searchParams.action) params.set('action', searchParams.action)
if (searchParams.cursor) params.set('cursor', searchParams.cursor)
const { rows, nextCursor } = await apiServerFetch<{ rows: AuditRow[]; nextCursor: number | null }>(`/api/audit?${params}`)
return (
<div>
<h1 className="text-2xl font-bold mb-4">Auditoría</h1>
<table className="w-full border">
<thead>
<tr>
<th className="border p-2 text-left">Fecha</th>
<th className="border p-2 text-left">Usuario</th>
<th className="border p-2 text-left">Acción</th>
<th className="border p-2 text-left">Recurso</th>
<th className="border p-2 text-left">Resultado</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="border p-2">{new Date(r.createdAt).toLocaleString('es-CO')}</td>
<td className="border p-2">{r.userId?.slice(0, 8) ?? '-'}</td>
<td className="border p-2">{r.action}</td>
<td className="border p-2">{r.resource ?? '-'}</td>
<td className="border p-2">{r.result}</td>
</tr>
))}
</tbody>
</table>
{nextCursor && <a href={`?cursor=${nextCursor}`} className="block mt-4 text-blue-600">Siguiente página →</a>}
</div>
)
}
  • Step 4: Write web/src/app/contador/auditoria/page.tsx

Same content as the admin auditoria page, but in /contador/ route group. (For M0, the UI is identical; per-role restrictions are enforced by the API.)

  • Step 5: Verify pages load

Run: cd web && pnpm dev

  • Login as admin → /modulos/usuarios shows the list

  • Navigate to /modulos/auditoria → shows audit log

  • Step 6: Commit

Ventana de terminal
git add . && git commit -m "feat(web): admin and contador modules (usuarios + auditoria)"
  • Step 7: Manual browser verification — admin pages work end-to-end

The most important checkpoint — the admin modules are the first “real” UI beyond auth:

  1. Sign in as admin → navigate to /modulos/usuarios
  2. Expected: table with the seeded admin user, columns Email / Nombre / Estado
  3. Test pagination if there are multiple users (seed more if needed)
  4. Click into a user → no edit page exists yet (M0 doesn’t have it), but the row should render
  5. Navigate to /modulos/auditoria
  6. Expected: table with audit log entries from previous E2E tests, filters work
  7. Try filter by action=USER_CREATED → should filter correctly
  8. Sign out, sign in as contador → /contador/auditoria works, looks identical, no actions available (read-only)
  9. Try /modulos/usuarios as contador → should redirect to /contador/auditoria or 403 (depends on BFF error handling)

No commit for this step — verification only.


Notas de ejecución de Task 13 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 13 (2026-08-20) — ✅ completada»

Resultado: web/ en 109471f. 176 tests, lint y typecheck en verde. Verificado de punta a punta en el browser con api/ real: alta de usuario, cambio de roles, protección del último admin, filtros de auditoría, primer ingreso, logout y pantalla de sin acceso.

Con esto queda terminado todo web/ para M0. Faltan solo el seed (Task 14) y CI (Task 15).

Dos huecos que aparecieron al verificar, no al testear:

  1. No había forma de cerrar sesión. api/ expone el endpoint desde Task 6 y el shell nunca lo usó: el usuario quedaba adentro hasta que le venciera la cookie. Se agregó al sidebar como form con Server Action y no como link — cerrar sesión cambia estado del servidor, y un GET desde un link queda expuesto a que un prefetch lo dispare solo.
  2. Un 403 mostraba el error crudo de Next. Ahora hay error.tsx en (app) que distingue “no tenés permiso” de “se cayó api/”. Para saberlo mira el digest: en producción Next borra message y stack antes de mandar el error al browser, y el digest es lo único que sobrevive. ApiError lo usa para transportar el status.

Correcciones al plan:

Problema Corrección
Pedía a /api/users y /api/audit Son /users y /audit. Solo Better-Auth vive bajo /api/auth/*
Esperaba { rows, nextCursor } La API devuelve { filas, siguienteCursor }
UserRow sin roles Sin ellos, la pantalla necesitaría un request por fila para saber quién es admin
searchParams destructurado directo En Next 16 es una Promise: hay que await
Prometía “list + create + role assignment” y solo listaba; actions.ts estaba en la lista de archivos y nunca se escribía Implementados alta, cambio de roles y activar/desactivar
/contador/auditoria fuera del route group (app) Habría sido una pantalla de auditoría sin autenticar y sin shell. Se movió adentro: (app) no agrega segmento a la URL
“Same content as the admin auditoria page” Un solo componente compartido. Duplicar crearía dos pantallas que se desincronizan; lo único propio de cada rol es la ruta

Decisiones de diseño:

  • Sin TanStack Table. Está en el stack (spec §5) y en M1+ se va a ganar el lugar, pero las tablas de M0 no ordenan ni filtran del lado del cliente: los filtros los resuelve api/ y la paginación es por cursor. Usarlo obligaría a marcar la tabla como Client Component y mandar la librería al browser para renderizar celdas estáticas. De menos a más.
  • Filtros por GET, no con estado en memoria: quedan en la URL, así se comparten, se guardan en favoritos y el botón “atrás” funciona. El link de “cargar más” conserva los filtros.
  • El mensaje de ULTIMO_ADMIN se muestra tal cual lo manda api/. Reescribirlo con otras palabras crearía dos textos que se desincronizan.
  • Sin botón de borrar usuario (RN-ACC-05): un usuario no se borra, se desactiva.
  • Un usuario borrado aparece como “(cuenta eliminada)”, no como celda vacía.

Un bug propio que encontró un test: mensajeDeError leía el cuerpo con cuerpoDeError y después clonaba el Response, que ya estaba consumido. Se lee una sola vez, y mensaje se sumó al tipo compartido de error, donde pertenece.


Task 14: Drizzle seed script with production safety

Sección titulada «Task 14: Drizzle seed script with production safety»

Files:

  • Create: api/drizzle/seed.ts
  • Modify: api/package.json (add seed script)
  • Create: api/drizzle/__tests__/seed.test.ts (smoke test)

Interfaces:

  • Produces: script pnpm db:seed that creates 4 roles + first admin

  • Consumes: Drizzle schema (Task 2), env (Task 1)

  • Step 1: Write api/drizzle/seed.ts

import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { eq } from 'drizzle-orm'
import { hash as argonHash } from '@node-rs/argon2'
import * as schema from '../src/db/schema'
const { users, roles, userRoles } = schema
async function main() {
if (process.env.NODE_ENV === 'production' && process.env.SEED_CONFIRM !== 'yes') {
console.error('ERROR: refusing to seed in production without SEED_CONFIRM=yes')
process.exit(1)
}
const db = drizzle(postgres(process.env.DATABASE_URL!), { schema })
// Check if any admin already exists
const existingAdmins = await db
.select({ name: roles.name })
.from(roles)
.innerJoin(userRoles, eq(userRoles.roleName, roles.name))
.where(eq(roles.name, 'admin'))
.limit(1)
if (existingAdmins.length > 0) {
console.error('ERROR: admin already exists. Refusing to seed.')
process.exit(1)
}
// Insert the 4 roles
await db.insert(roles).values([
{ name: 'admin', description: 'Administrador del sistema' },
{ name: 'seller', description: 'Vendedor de ruta' },
{ name: 'pos', description: 'Punto de venta fijo' },
{ name: 'contador', description: 'Contador externo (read-only)' },
]).onConflictDoNothing()
// Create the first admin
const email = process.env.SEED_ADMIN_EMAIL ?? 'admin@aquazaku.com'
const name = process.env.SEED_ADMIN_NAME ?? 'Admin Inicial'
const password = process.env.SEED_ADMIN_PASSWORD
if (!password) {
console.error('ERROR: SEED_ADMIN_PASSWORD is required')
process.exit(1)
}
const passwordHash = await argonHash(password)
const [admin] = await db.insert(users).values({
email, name, passwordHash,
}).returning({ id: users.id })
await db.insert(userRoles).values({ userId: admin.id, roleName: 'admin' })
console.log(`✓ Seed complete. Admin: ${email}`)
process.exit(0)
}
main().catch((e) => { console.error(e); process.exit(1) })
  • Step 2: Add npm script

In api/package.json:

{
"scripts": {
"db:seed": "tsx drizzle/seed.ts",
"db:migrate": "drizzle-kit migrate"
}
}
  • Step 3: Run in dev

Run: cd api && SEED_ADMIN_PASSWORD="dev123456" pnpm db:seed Expected: ✓ Seed complete. Admin: admin@aquazaku.com

  • Step 4: Verify admin can log in

Start api/ + web/. Log in with admin@aquazaku.com / dev123456. Should redirect to dashboard.

  • Step 5: Commit
Ventana de terminal
git add . && git commit -m "feat(api): seed script with production safety"

Notas de ejecución de Task 14 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 14 (2026-08-20) — ✅ completada»

Resultado: api/ en e2a530b. 349 tests. Verificado por CLI y en el browser: migrar → sembrar → entrar → el sistema fuerza el cambio de contraseña.

La decisión de fondo: el seed es idempotente y termina con éxito.

El plan abortaba con exit 1 si ya existía un admin. Eso lo deja fuera de cualquier pipeline: nadie pone en su deploy un paso que falla la segunda vez. Ahora, si ya hay un admin activo, no hace nada y sale con código 0. El spec §13.1 pide las dos cosas —“crea idempotentemente” y “abort con mensaje claro”— y salir bien diciendo que no hizo nada las cumple sin contradicción.

Un admin desactivado no cuenta como admin existente. Si contara, una instalación donde alguien desactivó al único administrador quedaría sin forma de recuperarse: el seed se negaría a crear uno nuevo apuntando a una cuenta que no puede entrar. Mismo razonamiento que RN-ACC-06.

Correcciones al plan:

Problema Corrección
Insertaba passwordHash en users — cuarta aparición del mismo error en el plan El alta pasa por Better-Auth, que deja el hash argon2id en accounts
exit(1) si ya había admin Sale con 0 y un mensaje de “nada que hacer”
process.exit(0) al terminar Cortaría la conexión a medio drenar y dejaría el último INSERT en el aire. Ahora cierra el pool y deja que el proceso termine solo
Sin validación de largo de contraseña Mínimo 8, igual que Better-Auth
Descripciones de roles inventadas Tomadas del doc de dominio: la tabla roles se lee desde psql cuando alguien investiga algo
El script no era testeable (todo en main()) Se exportan sembrar(), sembrarRoles() y leerEntorno(); el CLI es una cáscara

Salvaguardas, las tres con mensajes accionables:

Situación Mensaje
Falta SEED_ADMIN_PASSWORD Dice cómo generar una con openssl rand -base64 24
Contraseña de menos de 8 Dice el mínimo
Producción sin SEED_CONFIRM=yes Explica que el script crea una cuenta con acceso total y por eso no corre solo

SEED_CONFIRM acepta solo yes exacto. Hay test que verifica que true, 1, YES y si no alcanzan: una confirmación que acepta variantes es una que alguien activa sin querer.

El admin nace con mustChangePassword: true. No es ceremonia: la contraseña vino de una variable de entorno, la vio quien hizo el deploy y probablemente quedó en un historial de shell.

Hallazgo menor: Better-Auth rechaza caracteres no ASCII en la parte local del email (Invalid email address), así que no se puede sembrar con dueño@…. El nombre sí acepta acentos y ñ, que es lo que importa para que la auditoría muestre bien a las personas.

⚠️ Pendiente manual: las variables del seed no se pudieron agregar a api/.env.example (el entorno bloqueó la escritura a ese archivo). Hay que sumarle a mano:

SEED_ADMIN_EMAIL=admin@aquazaku.com
SEED_ADMIN_NAME=Admin Inicial
# Generar con: openssl rand -base64 24
SEED_ADMIN_PASSWORD=

Task 15: CI pipeline + E2E smoke test + Bruno collection

Sección titulada «Task 15: CI pipeline + E2E smoke test + Bruno collection»

Files:

  • Create: api/.github/workflows/ci.yml
  • Create: web/.github/workflows/ci.yml
  • Create: api/src/__tests__/e2e.test.ts (smoke test: full login flow)
  • Create: api/bruno/aquazaku/collection.bru (Bruno collection root)
  • Create: api/bruno/aquazaku/Auth/Login.bru
  • Create: api/bruno/aquazaku/Auth/Me.bru
  • Create: api/bruno/aquazaku/Auth/Logout.bru
  • Create: api/bruno/aquazaku/Auth/ForgotPassword.bru
  • Create: api/bruno/aquazaku/Auth/ResetPassword.bru
  • Create: api/bruno/aquazaku/Auth/ChangePassword.bru
  • Create: api/bruno/aquazaku/Users/ListUsers.bru
  • Create: api/bruno/aquazaku/Users/CreateUser.bru
  • Create: api/bruno/aquazaku/Users/UpdateUser.bru
  • Create: api/bruno/aquazaku/Users/AssignRoles.bru
  • Create: api/bruno/aquazaku/Audit/QueryAudit.bru
  • Create: api/bruno/environments/local.bru

Interfaces:

  • Produces: CI that runs lint + typecheck + tests + coverage on every push

  • Produces: E2E smoke test that verifies login + audit end-to-end

  • Produces: Bruno collection — every endpoint of M0 is explorable manually and CI-runnable

  • Step 1: Write api/.github/workflows/ci.yml

name: api-ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: aquazaku
POSTGRES_PASSWORD: aquazaku
POSTGRES_DB: aquazaku_test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm db:migrate
env:
DATABASE_URL: postgres://aquazaku:aquazaku@localhost:5432/aquazaku_test
- run: pnpm test --coverage
env:
DATABASE_URL: postgres://aquazaku:aquazaku@localhost:5432/aquazaku_test
BETTER_AUTH_SECRET: test-secret-test-secret-test-secret-test
BETTER_AUTH_URL: http://localhost:3001
COOKIE_DOMAIN: localhost
  • Step 2: Write web/.github/workflows/ci.yml
name: web-ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test --coverage
env:
API_INTERNAL_URL: http://localhost:3001
  • Step 3: Write E2E smoke test api/src/__tests__/e2e.test.ts
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import { authRoutes } from '@/modules/auth/routes'
import { userRoutes } from '@/modules/users/routes'
import { auditRoutes } from '@/modules/audit/routes'
import { hash as argonHash } from '@node-rs/argon2'
import { db } from '@/db/client'
import { users, userRoles, roles } from '@/db/schema'
import { eq } from 'drizzle-orm'
describe('M0 E2E smoke', () => {
const app = Fastify()
let adminId: string
let sessionId: string
beforeAll(async () => {
await app.register(cookie)
await authRoutes(app)
await userRoutes(app)
await auditRoutes(app)
await app.ready()
// Seed an admin for the test
await db.insert(roles).values({ name: 'admin', description: 'admin' }).onConflictDoNothing()
const passwordHash = await argonHash('test1234')
const [admin] = await db.insert(users).values({
email: 'e2e-admin@test.com', name: 'E2E Admin', passwordHash,
}).returning({ id: users.id })
adminId = admin.id
await db.insert(userRoles).values({ userId: adminId, roleName: 'admin' })
})
afterAll(async () => {
await app.close()
})
it('admin can sign in and access /audit', async () => {
// Sign in (call Better-Auth handler directly)
const signInRes = await app.inject({
method: 'POST',
url: '/api/auth/sign-in/email',
payload: { email: 'e2e-admin@test.com', password: 'test1234' },
})
expect(signInRes.statusCode).toBe(200)
const cookieHeader = signInRes.headers['set-cookie']
expect(cookieHeader).toBeDefined()
// Extract session cookie
const match = /aquazaku_session=([^;]+)/.exec(cookieHeader as string)
sessionId = match![1]
// Call /auth/me
const meRes = await app.inject({
method: 'GET',
url: '/auth/me',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
expect(meRes.statusCode).toBe(200)
expect(meRes.json().roles).toContain('admin')
// Call /audit (admin permission)
const auditRes = await app.inject({
method: 'GET',
url: '/audit',
headers: { Cookie: `aquazaku_session=${sessionId}` },
})
expect(auditRes.statusCode).toBe(200)
})
it('unauthenticated request to /audit returns 401', async () => {
const res = await app.inject({ method: 'GET', url: '/audit' })
expect(res.statusCode).toBe(401)
})
})
  • Step 4: Run all tests in both projects

Run: cd api && pnpm test && cd ../web && pnpm test Expected: all pass

  • Step 5: Verify coverage gates pass

Run: cd api && pnpm test --coverage Expected: authz/ ≥ 85%, global ≥ 70%

  • Step 6: Commit
Ventana de terminal
git add . && git commit -m "chore: CI pipeline + E2E smoke test"
  • Step 7: Create Bruno environment file

Create api/bruno/environments/local.bru:

vars {
apiUrl: http://localhost:3001
sessionId:
}
vars:secret [
adminEmail,
adminPassword,
]

This file is committed (no real secrets). The adminEmail and adminPassword are set per-developer in their local Bruno settings.

  • Step 8: Create Bruno collection root

Create api/bruno/aquazaku/collection.bru:

meta {
name: Aquazaku API
type: collection
version: 1.0.0
}
auth {
mode: none
}
headers {
Content-Type: application/json
}
  • Step 9: Create Bruno requests — Auth module

Create api/bruno/aquazaku/Auth/Login.bru:

meta {
name: Login
type: http
seq: 1
}
post {
url: {{apiUrl}}/api/auth/sign-in/email
body: json
auth: none
}
body:json {
{
"email": "{{adminEmail}}",
"password": "{{adminPassword}}"
}
}
docs {
Signs in with email + password. Returns 200 + sets session cookie.
Captures the cookie into `sessionId` env var for subsequent requests.
}
tests {
test("status is 200", function() {
expect(res.getStatus()).to.equal(200);
});
test("response has set-cookie", function() {
expect(res.getHeaders()['set-cookie']).to.contain('aquazaku_session=');
});
}
script:post-response {
if (res.getStatus() === 200) {
const setCookie = res.getHeaders()['set-cookie'];
const match = /aquazaku_session=([^;]+)/.exec(setCookie);
if (match) bru.setEnvVar('sessionId', match[1]);
}
}

Create api/bruno/aquazaku/Auth/Me.bru:

meta { name: "Get current user", type: http, seq: 2 }
get { url: {{apiUrl}}/auth/me, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
docs { Returns the current user with roles. Requires session cookie. }
tests {
test("status is 200", function() { expect(res.getStatus()).to.equal(200); });
test("response has roles array", function() { expect(Array.isArray(res.getBody().roles)).to.be.true; });
}

Create api/bruno/aquazaku/Auth/Logout.bru:

meta { name: "Logout", type: http, seq: 3 }
post { url: {{apiUrl}}/auth/sign-out, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
docs { Invalidates the session. }
tests { test("status is 200", function() { expect(res.getStatus()).to.equal(200); }); }

Create api/bruno/aquazaku/Auth/ForgotPassword.bru:

meta { name: "Forgot password", type: http, seq: 4 }
post { url: {{apiUrl}}/auth/forgot-password, body: json, auth: none }
body:json { { "email": "{{adminEmail}}" } }
docs { Sends a password reset email. Always returns 200 (no enumeration). }
tests { test("status is 200", function() { expect(res.getStatus()).to.equal(200); }); }

Create api/bruno/aquazaku/Auth/ResetPassword.bru:

meta { name: "Reset password", type: http, seq: 5 }
post { url: {{apiUrl}}/auth/reset-password, body: json, auth: none }
body:json { { "token": "REPLACE_WITH_TOKEN_FROM_EMAIL", "password": "newpassword1234" } }
docs { Resets password using token from forgot-password email. }

Create api/bruno/aquazaku/Auth/ChangePassword.bru:

meta { name: "Change password", type: http, seq: 6 }
post { url: {{apiUrl}}/auth/change-password, body: json, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
body:json { { "password": "newpassword1234" } }
docs { Changes password for the authenticated user. }
  • Step 10: Create Bruno requests — Users module

Create api/bruno/aquazaku/Users/ListUsers.bru:

meta { name: "List users", type: http, seq: 7 }
get { url: {{apiUrl}}/users, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
docs { Lists all users. Admin only. }
tests { test("status is 200", function() { expect(res.getStatus()).to.equal(200); }); }

Create api/bruno/aquazaku/Users/CreateUser.bru:

meta { name: "Create user", type: http, seq: 8 }
post { url: {{apiUrl}}/users, body: json, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
body:json {
{
"email": "newuser@example.com",
"name": "New User",
"password": "newuser1234",
"roles": ["pos"]
}
}
docs { Creates a user with roles. Admin only. }
tests { test("status is 201", function() { expect(res.getStatus()).to.equal(201); }); }

Create api/bruno/aquazaku/Users/UpdateUser.bru:

meta { name: "Update user", type: http, seq: 9 }
patch { url: {{apiUrl}}/users/REPLACE_WITH_USER_ID, body: json, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
body:json { { "status": "inactive" } }
docs { Updates user fields. Admin only. }

Create api/bruno/aquazaku/Users/AssignRoles.bru:

meta { name: "Assign roles", type: http, seq: 10 }
post { url: {{apiUrl}}/users/REPLACE_WITH_USER_ID/roles, body: json, auth: none }
headers { Cookie: aquazaku_session={{sessionId}} }
body:json { { "roles": ["pos", "seller"] } }
docs { Replaces user's roles. Admin only. }
  • Step 11: Create Bruno requests — Audit module

Create api/bruno/aquazaku/Audit/QueryAudit.bru:

meta { name: "Query audit log", type: http, seq: 11 }
get {
url: {{apiUrl}}/audit?action=USER_CREATED&limit=50
auth: none
}
headers { Cookie: aquazaku_session={{sessionId}} }
docs {
Queries the audit log with optional filters: userId, action, resource, from, to, cursor, limit.
Returns { rows: AuditEntry[], nextCursor: number | null }.
Admin sees all; contador sees all (read-only).
}
tests {
test("status is 200", function() { expect(res.getStatus()).to.equal(200); });
test("response has rows array", function() { expect(Array.isArray(res.getBody().rows)).to.be.true; });
}
  • Step 12: Document Bruno usage in /docs

Create docs/api-exploration.md (or add to /docs/backend/):

  1. Instalar Bruno (open source, CLI available)
  2. Abrir la carpeta api/bruno/aquazaku/ como colección
  3. En la UI de Bruno, configurar el environment local con tus secrets:
    • adminEmail: el email del admin seedeado
    • adminPassword: su contraseña
  1. Ejecutar Auth/Login.bru primero — captura el session cookie automáticamente
  2. Las requests subsiguientes usan {{sessionId}} automáticamente
  3. Cada request tiene tests inline que verifican el status code esperado

Bruno permite importar a Postman (y viceversa). Si necesitás Postman: File > Export > Postman Collection desde Bruno, o importá desde Postman.

Add link from /docs/backend/index.md:

## Exploración de la API
La API se puede explorar manualmente con [Bruno](https://www.usebruno.com/),
un cliente HTTP open source con archivos versionados en el repo.
La colección vive en `api/bruno/aquazaku/` (en el repo de api/).
Ver [Exploración de la API con Bruno](/backend/exploracion-api/).

Destino resuelto: la guía va en docs/src/content/docs/backend/exploracion-api.md del repo de docs. El grupo Backend del sidebar es autogenerate, así que aparece sola — no hay que tocar astro.config.mjs.

  • Step 13: Run Bruno collection against local server
  1. Start api/ (pnpm dev)
  2. Run pnpm db:seed with SEED_ADMIN_PASSWORD set
  3. Open Bruno, set env vars, run Auth/Login.bru → should set sessionId
  4. Run Auth/Me.bru → should return user with roles
  5. Run Users/ListUsers.bru → should show at least the seeded admin
  6. Run Audit/QueryAudit.bru → should show recent logs

If any test fails, fix the request or the API, don’t move on.

  • Step 14: Commit Bruno collection

Desde el repo de api/ (polyrepo — cada repo commitea lo suyo):

Ventana de terminal
git add bruno/
git commit -m "chore(api): Bruno collection with all M0 endpoints and inline tests"

Y la guía, desde el repo de docs/, en su propio commit:

Ventana de terminal
git add src/content/docs/backend/exploracion-api.md
git commit -m "docs(backend): guía de exploración de la API con Bruno"
  • Step 15: Add Bruno run to CI

Bruno CLI ya está instalado global en la máquina de dev (bru --version → 4.x). En CI se instala en el job:

- name: Install Bruno CLI
run: npm install -g @usebruno/cli
- name: Start api in background
run: pnpm dev &
- name: Wait for api
run: npx wait-on http://localhost:3001/health --timeout 30000
- name: Run Bruno collection
run: bru run bruno/aquazaku --env ci
env:
ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}

Esto atrapa endpoints rotos en CI, no solo lo que cubren los unit tests. Requiere un GET /health en api/ — agregarlo en Task 1 si no existe.

Requiere además un environment ci en api/bruno/environments/ci.bru, análogo a local.bru pero apuntando a http://localhost:3001.

Localmente la colección se corre igual, sin CI:

Ventana de terminal
cd api && bru run bruno/aquazaku --env local

Notas de ejecución de Task 15 (2026-08-20) — ✅ completada

Sección titulada «Notas de ejecución de Task 15 (2026-08-20) — ✅ completada»

Resultado: api/ en b63dfff, web/ en c461635. 12 requests y 32 aserciones de Bruno en verde contra un servidor real. Se ensayó el job completo localmente: base limpia → migrar → sembrar → levantar api/ → correr la colección.

Con esto M0 queda terminado.

El CI del plan no habría arrancado. Le faltaban tres cosas que solo se saben después de haber construido el sistema:

Falta Consecuencia
Sin servicio de Mailpit Los tests de correo mandan mensajes de verdad y los leen de su API. Sin el servicio fallan — y está bien que fallen: saltearlos en silencio sería creer que el envío anda
Sin crear el rol aquazaku_app La migración 0001 le hace GRANT. Sin el rol, la migración revienta y no arranca nada. Es un objeto de cluster: no puede crearlo una migración
Conectaba todo con el rol dueño Los tests de inmutabilidad de audit_log habrían pasado por el motivo equivocado: lo que prueban es que el rol de la aplicación no puede modificar la bitácora

Además: pnpm install sin --frozen-lockfile; faltaban WEB_PUBLIC_URL y las variables de correo; y pnpm/action-setup no tenía de dónde leer la versión — api/ no declaraba packageManager. Se agregó, para que el número viva en un solo lugar.

La colección Bruno es la única verificación del proyecto que atraviesa un socket real. El resto de los tests usan app.inject(), que no pasa por la red — y esa diferencia ya escondió un bug (el header Origin). Por eso el script de nivel colección lo pone en cada request: para que ninguno nuevo nazca sin él.

Los tests de la colección no son decorativos. 2-Usuarios/04 intenta quitarle el rol admin al único administrador y verifica que devuelva 409: si algún día da 200, no es un éxito, es que se rompió RN-ACC-06. Y 3-Auditoria/02 busca ese mismo intento en la bitácora — entre los dos verifican el ciclo completo de RN-ACC-04: la acción ocurre, queda registrada y se puede encontrar.

Dos errores propios que la corrida encontró:

  1. params:query anidado dentro del bloque get. Bruno saltea el archivo entero con un warning fácil de pasar por alto, y el resumen queda en verde con menos requests de los que debería. Documentado en la guía.
  2. El test del logout esperaba SESSION_EXPIRED y llegaba UNAUTHENTICATED. La aserción estaba mal, no el sistema: Bruno respeta la cookie de borrado, así que deja de mandar cookie. Que dé UNAUTHENTICATED confirma, de paso, que el borrado llegó bien.

No se escribió el test E2E del Step 3: insertaba passwordHash en users —columna que no existe— y duplicaba cobertura que ya dan 349 tests. La verificación sobre HTTP real que buscaba es justamente la colección Bruno.

Pendiente: ningún repo tiene remote en GitHub todavía, así que los workflows no corren en ningún lado. Están listos para el primer git push.


1. Spec coverage:

  • ✅ Identity (login, password reset, change-password) → Tasks 5, 6, 7
  • ✅ Multi-rol sin switch (RN-ACC-01) → Tasks 3, 11
  • ✅ UI oculta, API prohíbe (RN-ACC-02) → Task 4 (middleware), Task 10 (ESLint rule enforces fetch() ban)
  • ✅ Alcance en una capa (RN-ACC-03) → Task 3 (scopedQuery)
  • ✅ Auditoría inmutable (RN-ACC-04) → Tasks 2 (triggers), 4 (emit), 9 (query), 13 (UI)
  • ✅ Stack completo → Task 1
  • ✅ BFF-native con defensa en profundidad → Task 10
  • ✅ Matriz resuelta → Task 3
  • ✅ Seed del admin inicial → Task 14
  • ✅ Criterios de éxito de la sección 15 del spec → todos cubiertos

2. Placeholder scan:

  • No “TBD”, “TODO”, “implement later”, “fill in details” in any step
  • No “Add appropriate error handling” without code
  • Every code step has actual code, not descriptions
  • No “Similar to Task N” without full code

3. Type consistency:

  • Role type defined in authz/matrix.ts, exported, used consistently in can.ts, scoped-query.ts, middleware.ts
  • Resource, Action, Scope types same in all authz files
  • UserContext defined in can.ts, consumed by scoped-query.ts and middleware.ts
  • PermissionRule defined in matrix.ts, used in PERMISSION_MATRIX
  • ApiError defined in web/src/lib/errors.ts, thrown by apiServerFetch
  • All API response shapes consistent: { rows, nextCursor } for paginated audit

4. Issues found and fixed:

  • Originally had password reset using better-auth’s own email — replaced with explicit Resend integration for testability
  • Originally had app.$with('sessions') placeholder in password.ts — kept as comment for clarity but actual implementation uses direct delete
  • Originally skipped the audit trigger verification step in Task 2 — added it as Step 8

Correcciones aplicadas antes de ejecutar (2026-08-19)

Sección titulada «Correcciones aplicadas antes de ejecutar (2026-08-19)»

El plan se auditó contra la máquina de desarrollo real antes de escribir la primera línea. Siete cosas no cerraban:

# Problema Corrección
1 No había Postgres ni psql en la máquina; Task 2 no tenía dónde correr Postgres 16 nativo instalado y corriendo, con role aquazaku, bases aquazaku_dev + aquazaku_test y extensiones citext + pgcrypto. Documentado en Entorno local. Se descartó Docker Compose: CI usa postgres:16, misma línea
2 Task 1 corría pnpm init antes de create-next-app . → conflicto de scaffold. Y --no-src-dir=false no es un flag válido Se invirtió el orden (scaffold primero, sobre directorio vacío) y se usa --src-dir, que es lo que la estructura del plan asume
3 El plan pedía Node 20; la máquina tiene Node 22 y nada lo fijaba Node 22 LTS explícito con .nvmrc + engines en ambos repos, y en los workflows de CI
4 Fastify 4, Better-Auth 1.3, Next 15, Zod 3, Vitest 2 — todas una major por detrás del stable Subidas a Fastify 5, Better-Auth 1.7, Next 16, Zod 4, Vitest 4. Se agregó Step 1b para verificar peers antes de codear
5 Bruno CLI no estaba instalado y Task 15 lo daba por hecho @usebruno/cli 4.x instalado global (con npm -g, no pnpm -g: el global-bin-dir de pnpm no está en el PATH de esta máquina)
6 Task 1 hacía un git init pese a que la estrategia es polyrepo Dos git init -b main, uno por repo, con su propio commit inicial. Los remotes quedan fuera de Task 1
7 Task 1 instalaba argon2 pero los tests importan @node-rs/argon2 — paquetes distintos Se fijó @node-rs/argon2, que es el que consume Better-Auth

Extras resueltos en la misma pasada: MailHog → Mailpit (MailHog está sin mantenimiento desde 2020), el destino ambiguo de la guía de Bruno quedó fijado en /backend/exploracion-api/, y el skill aquazaku-bff que el spec §9.3 exigía antes de implementar M0 ya existe.


Plan corregido y listo para ejecutar. Entorno verificado, docs sincronizadas.

Próximo paso: Task 1 — bootstrap de api/ y web/.

Criterio de cierre de cada task, sin excepción:

  1. pnpm typecheck en verde
  2. Tests de la task en verde
  3. Para tasks que producen endpoints: test E2E con HTTP real, no solo unit
  4. Para tasks que producen UI: verificación manual en browser
  5. Commit convencional, dejando el repo en verde

Si un snippet de este plan choca con la API de la versión instalada, manda la doc oficial de esa versión. El plan es el mapa, no el territorio.