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.
Global Constraints
Sección titulada «Global Constraints»- Polyrepo:
api/yweb/son repos git independientes, cada uno con su propiogit inity 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:
.nvmrccon22+"engines": { "node": ">=22" }enpackage.json. Sin esto, cada máquina resuelve una versión distinta - Postgres local ya provisionado: role
aquazaku, basesaquazaku_devyaquazaku_test, extensionescitext+pgcryptocreadas. Los tests corren contraaquazaku_test, nunca contraaquazaku_dev - Emails en dev van a Mailpit (
smtp://localhost:1025, UI enhttp://localhost:8025), nunca a internet. Resend solo en producción - TypeScript strict mode in both
api/andweb/ - All files use 2-space indentation, single quotes, no semicolons (project convention)
- No
Co-Authored-Byin 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_logis append-only: REVOKE UPDATE, DELETE + trigger that rejects mutations- ESLint rule in web/: bans
fetch()direct andlocalStorage - 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
File Structure
Sección titulada «File Structure»api/ repo (Fastify backend)
Sección titulada «api/ repo (Fastify backend)»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 seedweb/ repo (Next.js BFF)
Sección titulada «web/ repo (Next.js BFF)»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.tsTask Dependency Graph
Sección titulada «Task Dependency Graph»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, 5Task 7 (auth: forgot/reset/change-password + email + rate limit) ← uses Task 5 ↓ (parallel)Task 8 (users CRUD) ← uses Tasks 4, 6Task 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 allTasks 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/.nvmrcyweb/.nvmrc - Create:
api/.gitignore(el deweb/lo generacreate-next-app)
Interfaces:
- Produces:
api/andweb/as runnable projects withpnpm test,pnpm dev,pnpm typecheckworking - 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:
node -v # v22.xpnpm -v # 11.xpg_isready # accepting connectionsbru --version # 4.xcurl -s -o /dev/null -w '%{http_code}\n' http://localhost:8025 # 200Si 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:
postgreses el driver que consume Drizzle (nopg).@node-rs/argon2es el binding de argon2id que usa Better-Auth y el que importan los tests de Task 15. No es el paqueteargon2, que es otro.
mkdir -p api && cd apipnpm initecho '22' > .nvmrcpnpm add fastify @fastify/cookie @fastify/cors drizzle-orm postgres better-auth zod pino @node-rs/argon2 resend nodemailerpnpm add -D typescript @types/node @types/nodemailer vitest @vitest/coverage-v8 drizzle-kit tsx supertest @types/supertest pino-prettynodemailer 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:
pnpm install 2>&1 | rg -i 'peer|warn' || echo 'sin warnings de peers'pnpm list fastify better-auth drizzle-orm zodExpected: 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=developmentPORT=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:3001COOKIE_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 | resendSMTP_HOST=localhostSMTP_PORT=1025RESEND_API_KEY=EMAIL_FROM=noreply@aquazaku.com
LOG_LEVEL=infoAdemás crear api/.gitignore:
node_modules/dist/.env.env.localcoverage/*.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.
# 1. Scaffold primero, sobre directorio vacíopnpm create next-app@latest web --typescript --tailwind --app --src-dir --import-alias='@/*' --use-pnpm --eslint --turbopack
# 2. Recién ahora las dependencias extracd webecho '22' > .nvmrcpnpm add @tanstack/react-table zodpnpm add -D vitest @vitest/coverage-v8 @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event mswAgregar 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=developmentAPI_INTERNAL_URL=http://localhost:3001WEB_PUBLIC_URL=http://localhost:3000- Step 7: Verify both projects start
cd api && pnpm typecheck && cd ../web && pnpm typecheckExpected: ambos en verde, sin errores.
Y que api/ realmente se conecte a la base ya provisionada:
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.
cd apigit init -b maingit add .git commit -m "chore: bootstrap api project"
cd ../webgit init -b maingit 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.tsconbuildApp()separado deserver.ts, endpointGET /healthy 3 tests. El split es lo que después permiteapp.inject()en cada test de endpoint.- Propagación de
x-request-idya funcionando enapi/(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-prettyen 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_login 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 enforcementCREATE 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
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_logsin FK ausers: si un usuario se borra, su rastro debe sobrevivir. Un log que se borra en cascada no es un log.sessions.ipAddressestext, noinet: Better-Auth escribe ahí lo que venga en el header. Coninet, un proxy mal configurado tira el login abajo.- El logger salió a
lib/logger.tscomo función pura. Sus ramas porNODE_ENVson 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_MATRIXconstant,can()function,scopedQuery()function,SCOPESfilters -
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
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:
prepyoperativosno 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 verificadono 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. readonlydejó de ser decorativo: hay invariantes que verifican que solocontadorlo 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ú |
Task 4: authz middleware and audit emit
Sección titulada «Task 4: authz middleware and audit emit»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:
requireAuthandrequirePermission(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
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 |
Task 5: Better-Auth setup with admin plugin
Sección titulada «Task 5: Better-Auth setup with admin plugin»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 routesapp.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
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:
- Better-Auth firma sus cookies. El valor es
token.firma, yrequireAuthcomparaba contrasessions.tokencrudo: nunca matcheaba. El arreglo no fue partir el string sino sacar la duplicación de raíz —requireAuthahora delega enauth.api.getSession, que verifica firma, vencimiento y ventana deslizante. Better-Auth es el dueño de la identidad; nosotros solo agregamos el chequeo destatus, que él no conoce. - 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-outyGET /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í vivensign-in/emailysign-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/medesde la constanteAUTH_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
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
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
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
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
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
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
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()andlocalStorage -
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
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/mereal de Task 6):
redirect('/dashboard')apunta a una ruta que nadie crea. El snippet deloginActionredirige ahí, pero el dashboard quedó en/— Task 12 lo puso en(app)/page.tsxy(app)es un route group, así que no agrega segmento. O se apunta el redirect a/, o se crea el segmento/dashboard.Falta forzar el primer cambio de contraseña (spec §7.2).
/auth/medevuelvemustChangePassword, ya tipado enServerUser, pero el guard de(app)/layout.tsxtodavía no lo mira: hoy un usuario de primer login entra derecho al dashboard. El redirect a/change-passwordva en el guard, no en cada página, y se puede recién cuando esta task cree esa pantalla.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 propagax-request-idni respeta el patrón BFF. VaapiServerFetch(). Ojo que el sign-in es el único caso que necesita leer elset-cookiede 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 generadoLayoutProps<...>, como en el root layout y en(app)/layout.tsx. Correrpnpm next typegendespués de crear los archivos y mirarLayoutRoutesen.next/types/routes.d.tsdice 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
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:
- Start api/ (
pnpm dev) and web/ (pnpm dev) in two terminals - Open
http://localhost:3000/loginin Chrome - Sign in with the seeded admin (
admin@aquazaku.com/ password from.env) - Expected: redirects to
/dashboard(or whatever default page Task 12 sets up) - Open DevTools → Application → Cookies → confirm
aquazaku_sessionis httpOnly - Click “Cerrar sesión” → should redirect to
/login - Try
/forgot-password, enter email, check Mailpit (http://localhost:8025) for the email - Copy the reset link, open in browser, set new password, verify you can log in with it
- Try
/change-passwordafter a fresh login withmust_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):
git add docs/verification/m0-task11-auth-ui.pnggit 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:
- La pantalla quedaba muda.
loginActiondevolví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 conuseActionState, que es cómo React 19 hace llegar ese valor a la pantalla. Hay un test que lo cubre. - Armaba la cookie de sesión a mano, con una regex y atributos hardcodeados. Se reemplazó por
forwardSetCookies, que reemite lo que pusoapi/tal cual — ver notas de la task del helper BFF.
Decisiones de diseño:
forgot-passwordresponde 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
redirectTodel correo apunta aweb/, no aapi/. Better-Auth por defecto manda el link al backend; eso haría al browser hablar directo conapi/, 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-passwordvive 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_INACTIVEsí 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.tsxyweb/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.tsxresuelve a/— la misma ruta que ya ocupaba la landing provisoria creada en Task 1. Dospageen 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
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:
- Start api/ + web/. Run
pnpm db:seedif not already done - Sign in as admin → sidebar shows: Usuarios, Auditoría
- Open DevTools → Application → Local Storage → confirm empty (proves no tokens leaked there)
- Open DevTools → Network → sign in → confirm NO request to
localhost:3001(proves BFF: browser never talks to api/ directly) - Sign out, then directly seed a
contadoruser via SQL (or temporarily modify the seed) - Sign in as contador → sidebar shows: Auditoría (only)
- 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
/logincon sesión nula, unión de módulos en multi-rol, menú vacío paraposyseller, 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/usuariosshows the list -
Navigate to
/modulos/auditoria→ shows audit log -
Step 6: Commit
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:
- Sign in as admin → navigate to
/modulos/usuarios - Expected: table with the seeded admin user, columns Email / Nombre / Estado
- Test pagination if there are multiple users (seed more if needed)
- Click into a user → no edit page exists yet (M0 doesn’t have it), but the row should render
- Navigate to
/modulos/auditoria - Expected: table with audit log entries from previous E2E tests, filters work
- Try filter by
action=USER_CREATED→ should filter correctly - Sign out, sign in as contador →
/contador/auditoriaworks, looks identical, no actions available (read-only) - Try
/modulos/usuariosas contador → should redirect to/contador/auditoriaor 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:
- 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 comoformcon Server Action y no como link — cerrar sesión cambia estado del servidor, y unGETdesde un link queda expuesto a que un prefetch lo dispare solo. - Un 403 mostraba el error crudo de Next. Ahora hay
error.tsxen(app)que distingue “no tenés permiso” de “se cayó api/”. Para saberlo mira eldigest: en producción Next borramessageystackantes de mandar el error al browser, y el digest es lo único que sobrevive.ApiErrorlo 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_ADMINse muestra tal cual lo mandaapi/. 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:seedthat 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
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.comSEED_ADMIN_NAME=Admin Inicial# Generar con: openssl rand -base64 24SEED_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-cion: [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-cion: [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
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/):
Exploración de la API con Bruno
Sección titulada «Exploración de la API con Bruno»
- Instalar Bruno (open source, CLI available)
- Abrir la carpeta
api/bruno/aquazaku/como colección- En la UI de Bruno, configurar el environment
localcon tus secrets:
adminEmail: el email del admin seedeadoadminPassword: su contraseñaUso básico
Sección titulada «Uso básico»
- Ejecutar
Auth/Login.bruprimero — captura el session cookie automáticamente- Las requests subsiguientes usan
{{sessionId}}automáticamente- Cada request tiene tests inline que verifican el status code esperado
Postman compatible
Sección titulada «Postman compatible»Bruno permite importar a Postman (y viceversa). Si necesitás Postman:
File > Export > Postman Collectiondesde 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
- Start api/ (
pnpm dev) - Run
pnpm db:seedwithSEED_ADMIN_PASSWORDset - Open Bruno, set env vars, run
Auth/Login.bru→ should set sessionId - Run
Auth/Me.bru→ should return user with roles - Run
Users/ListUsers.bru→ should show at least the seeded admin - 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):
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:
git add src/content/docs/backend/exploracion-api.mdgit 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:
cd api && bru run bruno/aquazaku --env localNotas 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ó:
params:queryanidado dentro del bloqueget. 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.- El test del logout esperaba
SESSION_EXPIREDy llegabaUNAUTHENTICATED. La aserción estaba mal, no el sistema: Bruno respeta la cookie de borrado, así que deja de mandar cookie. Que déUNAUTHENTICATEDconfirma, 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.
Self-Review
Sección titulada «Self-Review»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:
Roletype defined inauthz/matrix.ts, exported, used consistently incan.ts,scoped-query.ts,middleware.tsResource,Action,Scopetypes same in all authz filesUserContextdefined incan.ts, consumed byscoped-query.tsandmiddleware.tsPermissionRuledefined inmatrix.ts, used inPERMISSION_MATRIXApiErrordefined inweb/src/lib/errors.ts, thrown byapiServerFetch- 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.
Execution Handoff
Sección titulada «Execution Handoff»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:
pnpm typechecken verde- Tests de la task en verde
- Para tasks que producen endpoints: test E2E con HTTP real, no solo unit
- Para tasks que producen UI: verificación manual en browser
- 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.
