Sesión 4 · chuleta
En el orden de las slides. Copia con el botón de cada bloque; si la slide dice «teclea», esas líneas van a mano.
📋 S4·01 · receta «Cambiar de rama» hacia origin/s4-start (terminal)
Sección titulada «📋 S4·01 · receta «Cambiar de rama» hacia origin/s4-start (terminal)»git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s4 origin/s4-start # checkpoint: mi-s4-cp origin/s4-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed📋 S4·02 · instalar argon2 (terminal)
Sección titulada «📋 S4·02 · instalar argon2 (terminal)»npm install argon2 # si npm 11 avisa de "allow-scripts", es inofensivo📋 S4·03 · password.ts completo (src/services/password.ts)
Sección titulada «📋 S4·03 · password.ts completo (src/services/password.ts)»import argon2 from 'argon2';
// Argon2id: recomendación de OWASP (bcrypt, solo legado).// argon2.hash genera la sal automáticamente.export const hashPassword = (plain: string) => argon2.hash(plain, { type: argon2.argon2id });
export const verifyPassword = (plain: string, hash: string) => argon2.verify(hash, plain);📋 S4·04 · seed-manual.ts completo (src/seed-manual.ts)
Sección titulada «📋 S4·04 · seed-manual.ts completo (src/seed-manual.ts)»// src/seed-manual.ts (fichero completo: sustituye el tuyo)// Seed de AcademIA. Idempotente: se puede ejecutar varias veces seguidas.// Ejecutar: npx tsx src/seed-manual.tsimport { prisma } from './db.js';import { hashPassword } from './services/password.js';
// Sesión 4: contraseñas hasheadas de verdad. Todos los usuarios de prueba// usan la misma para poder hacer login en clase.export const SEED_PASSWORD = 'Passw0rd!';const passwordHash = await hashPassword(SEED_PASSWORD);
async function upsertUser(email: string, name: string, role: 'STUDENT' | 'INSTRUCTOR' | 'ADMIN') { return prisma.user.upsert({ where: { email }, update: { password: passwordHash, role }, // re-ejecutar el seed actualiza los hashes antiguos create: { email, name, password: passwordHash, role }, });}
async function main() { const marta = await upsertUser('marta@academia.dev', 'Marta', 'INSTRUCTOR'); await upsertUser('pablo@academia.dev', 'Pablo', 'INSTRUCTOR'); // segundo instructor: para probar ownership await upsertUser('admin@academia.dev', 'Admin', 'ADMIN'); const students = await Promise.all([ upsertUser('luis@academia.dev', 'Luis', 'STUDENT'), upsertUser('sara@academia.dev', 'Sara', 'STUDENT'), upsertUser('omar@academia.dev', 'Omar', 'STUDENT'), ]);
const [backend, databases] = await Promise.all( ['Backend', 'Bases de datos'].map((name) => prisma.category.upsert({ where: { name }, update: {}, create: { name } }), ), );
// Course no tiene campo único aparte del id: reseteamos los cursos de Marta await prisma.enrollment.deleteMany({ where: { course: { instructorId: marta.id } } }); await prisma.course.deleteMany({ where: { instructorId: marta.id } });
const ts = await prisma.course.create({ data: { title: 'TypeScript desde cero', price: 49.99, published: true, level: 'BEGINNER', instructorId: marta.id, categoryId: backend.id }, }); const prismaAdv = await prisma.course.create({ data: { title: 'Prisma avanzado', price: 79.99, published: true, level: 'ADVANCED', instructorId: marta.id, categoryId: databases.id }, }); await prisma.course.create({ data: { title: 'SQL para backend', price: 39.99, published: false, level: 'INTERMEDIATE', instructorId: marta.id, categoryId: databases.id }, });
// Inscripciones cubriendo los 3 estados (cada par usuario-curso es único) await prisma.enrollment.createMany({ data: [ { userId: students[0].id, courseId: ts.id, status: 'ACTIVE' }, { userId: students[1].id, courseId: ts.id, status: 'COMPLETED', grade: 9 }, { userId: students[2].id, courseId: prismaAdv.id, status: 'CANCELLED' }, ], });
console.log('Seed OK:', await prisma.course.count(), 'cursos,', await prisma.enrollment.count(), 'inscripciones');}
main().finally(() => prisma.$disconnect());📋 S4·05 · ejecutar el seed (terminal)
Sección titulada «📋 S4·05 · ejecutar el seed (terminal)»npm run seed📋 S4·06 · generar el secreto (terminal)
Sección titulada «📋 S4·06 · generar el secreto (terminal)»# 1. Genera tu secretonode -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"📋 S4·07 · líneas de JWT_SECRET (.env y .env.example)
Sección titulada «📋 S4·07 · líneas de JWT_SECRET (.env y .env.example)»# 2. .env (no se sube a git): añade el valor realJWT_SECRET="<pega aquí el valor generado>"
# 3. .env.example (sí se sube): añade la línea, sin valorJWT_SECRET=📋 S4·08 · config.ts completo (src/config.ts)
Sección titulada «📋 S4·08 · config.ts completo (src/config.ts)»import 'dotenv/config';import { z } from 'zod';
// Si falta una variable o es insegura, la app no arranca (mejor que un 500 a medianoche).const envSchema = z.object({ DATABASE_URL: z.string().min(1), JWT_SECRET: z.string().min(32, 'JWT_SECRET debe tener al menos 32 caracteres'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),});
export const config = envSchema.parse(process.env);📋 S4·09 · db.ts completo (src/db.ts)
Sección titulada «📋 S4·09 · db.ts completo (src/db.ts)»// src/db.ts (fichero completo: ya no importa 'dotenv/config', usa config)import { PrismaPg } from '@prisma/adapter-pg';import { PrismaClient } from '../generated/prisma/client.js';import { config } from './config.js';
// Una sola instancia para toda la aplicación (no una por request).const adapter = new PrismaPg({ connectionString: config.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });📋 S4·10 · auth.schema.ts completo (src/schemas/auth.schema.ts)
Sección titulada «📋 S4·10 · auth.schema.ts completo (src/schemas/auth.schema.ts)»import { z } from 'zod';
// Limpia y DESPUÉS valida: " Ana@X.com " → "ana@x.com" (al revés, 400)const email = z.string().trim().toLowerCase().pipe(z.email());
export const registerSchema = z.object({ email, password: z.string().min(8).max(128), // tope para no hashear megas de texto name: z.string().min(2).max(80),});
export const loginSchema = z.object({ email, password: z.string().min(1).max(128),});
export type RegisterInput = z.infer<typeof registerSchema>;export type LoginInput = z.infer<typeof loginSchema>;📋 S4·11 · instalar jsonwebtoken y sus tipos (terminal)
Sección titulada «📋 S4·11 · instalar jsonwebtoken y sus tipos (terminal)»npm install jsonwebtoken && npm install -D @types/jsonwebtoken📋 S4·12 · auth.service.ts, esqueleto con imports y DUMMY_HASH (src/services/auth.service.ts)
Sección titulada «📋 S4·12 · auth.service.ts, esqueleto con imports y DUMMY_HASH (src/services/auth.service.ts)»import jwt from 'jsonwebtoken';import { config } from '../config.js';import { prisma } from '../db.js';import { ConflictError, UnauthorizedError } from '../errors/app-error.js';import type { LoginInput, RegisterInput } from '../schemas/auth.schema.js';import { hashPassword, verifyPassword } from './password.js';
const ACCESS_TOKEN_TTL = '15m';
// Hash de una contraseña que nadie tiene. Si el email no existe verificamos// contra él igualmente, para que login tarde lo mismo exista o no el usuario// (si no, el tiempo de respuesta delata qué emails están registrados).const DUMMY_HASH = await hashPassword('dummy-password-for-timing');
export const authService = { // register (2/3) y login (3/3): los tecleas tú};📋 S4·13 · register (dentro de authService, en auth.service.ts)
Sección titulada «📋 S4·13 · register (dentro de authService, en auth.service.ts)» // dentro de export const authService = { ... } register: async (input: RegisterInput) => { const existing = await prisma.user .findUnique({ where: { email: input.email } }); // ⚠ Esto SÍ revela si un email está registrado. if (existing) throw new ConflictError('Email already registered');
return prisma.user.create({ data: { email: input.email, name: input.name, password: await hashPassword(input.password), role: 'STUDENT' }, select: { id: true, email: true, name: true, role: true, createdAt: true }, // nunca el hash }); },📋 S4·14 · login (dentro de authService, después de register)
Sección titulada «📋 S4·14 · login (dentro de authService, después de register)» // dentro de authService, después de register login: async (input: LoginInput) => { const user = await prisma.user.findUnique({ where: { email: input.email } }); // Email inexistente: verificamos contra DUMMY_HASH → tarda lo mismo const valid = await verifyPassword(input.password, user?.password ?? DUMMY_HASH); if (!user || !valid) throw new UnauthorizedError('Invalid credentials');
const token = jwt.sign( { sub: String(user.id), role: user.role }, // sub: string (RFC 7519) config.JWT_SECRET, { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_TTL }, ); return { token }; },📋 S4·15 · auth.controller.ts completo (src/controllers/auth.controller.ts)
Sección titulada «📋 S4·15 · auth.controller.ts completo (src/controllers/auth.controller.ts)»import type { Request, Response } from 'express';import { authService } from '../services/auth.service.js';
export const authController = { register: async (req: Request, res: Response) => { res.status(201).json(await authService.register(req.body)); }, login: async (req: Request, res: Response) => { res.status(200).json(await authService.login(req.body)); },};📋 S4·16 · auth.routes.ts completo (src/routes/auth.routes.ts)
Sección titulada «📋 S4·16 · auth.routes.ts completo (src/routes/auth.routes.ts)»import { Router } from 'express';import { authController } from '../controllers/auth.controller.js';import { validateBody } from '../middlewares/validate.js';import { loginSchema, registerSchema } from '../schemas/auth.schema.js';
export const authRouter = Router();
authRouter.post('/register', validateBody(registerSchema), authController.register);authRouter.post('/login', validateBody(loginSchema), authController.login);📋 S4·17 · app.ts completo (src/app.ts)
Sección titulada «📋 S4·17 · app.ts completo (src/app.ts)»// src/app.ts (fichero completo: añade el import de authRouter y lo monta antes de los demás)import express from 'express';import { errorHandler } from './middlewares/error-handler.js';import { authRouter } from './routes/auth.routes.js';import { coursesRouter } from './routes/courses.routes.js';import { usersRouter } from './routes/users.routes.js';
// La app se exporta sin arrancar: server.ts hace listen() y los tests// (sesión 6) la usan directamente con Supertest.export const app = express();
app.use(express.json());
app.use('/api/v1/auth', authRouter);app.use('/api/v1/courses', coursesRouter);app.use('/api/v1/users', usersRouter);
app.use(errorHandler); // siempre el último📋 S4·18 · login de Marta con curl (terminal)
Sección titulada «📋 S4·18 · login de Marta con curl (terminal)»export API=http://localhost:3000/api/v1 # export: si no, los checks no la ven
curl -X POST $API/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"marta@academia.dev","password":"Passw0rd!"}'# → {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}📋 S4·19 · guardar el token de Luis en una variable (terminal)
Sección titulada «📋 S4·19 · guardar el token de Luis en una variable (terminal)»# bash / zsh / Git Bash: pega el token SIN las comillas del JSONTOKEN_LUIS=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...echo $TOKEN_LUIS
# o directamente desde el login (bash / zsh / Git Bash)TOKEN_LUIS=$(curl -s -X POST $API/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"luis@academia.dev","password":"Passw0rd!"}' \ | node -pe "JSON.parse(require('fs').readFileSync(0)).token")📋 S4·20 · s4-require-auth.ia.ts, el código de IA que auditas (solo para leer)
Sección titulada «📋 S4·20 · s4-require-auth.ia.ts, el código de IA que auditas (solo para leer)»// ─────────────────────────────────────────────────────────────────────────// Generado por un asistente de IA · Sesión 4// Prompt: "Middleware de Express que compruebe el JWT del header Authorization// y deje el usuario en req.user. Que no rompa si algo falla."// Tu tarea: audítalo como si fuera una pull request de un compañero.// Cada fallo: qué permite a un atacante y cómo lo arreglas.// ─────────────────────────────────────────────────────────────────────────import type { NextFunction, Request, Response } from 'express';import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'secret';
export function requireAuth(req: Request, res: Response, next: NextFunction) { try { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token' });
const payload = jwt.decode(token) as { sub: string; role: string }; (req as any).user = { id: payload.sub, role: req.headers['x-user-role'] ?? payload.role, }; next(); } catch (err) { console.error(err); next(); // no bloqueamos al usuario por un error inesperado }}
export function signToken(user: { id: number; role: string }) { return jwt.sign({ sub: user.id, role: user.role, password: undefined }, SECRET);}📋 S4·21 · contrato de require-auth.ts (src/middlewares/require-auth.ts)
Sección titulada «📋 S4·21 · contrato de require-auth.ts (src/middlewares/require-auth.ts)»// src/middlewares/require-auth.ts: contrato (lo usan las secciones siguientes)import type { NextFunction, Request, Response } from 'express';export type AuthPayload = { sub: string; role: 'STUDENT' | 'INSTRUCTOR' | 'ADMIN' };declare global { // amplía los tipos de Express en toda la app: namespace Express { // req.user existe y está tipado, sin "as any" interface Request { user?: AuthPayload } }}export function requireAuth(req: Request, _res: Response, next: NextFunction) { /* vuestra versión: verify, algoritmo fijado, 401 si algo falla, y valida el payload con Zod: un cast deja pasar cualquier cosa */}// ! = "aquí req.user existe": solo se usa en rutas detrás de requireAuthexport const currentUserId = (req: Request) => Number(req.user!.sub);📋 S4·23 · require-role.ts completo (src/middlewares/require-role.ts)
Sección titulada «📋 S4·23 · require-role.ts completo (src/middlewares/require-role.ts)»import type { NextFunction, Request, Response } from 'express';import { ForbiddenError, UnauthorizedError } from '../errors/app-error.js';import type { AuthPayload } from './require-auth.js';
// Siempre DESPUÉS de requireAuth: no hay rol sin saber quién eresexport const requireRole = (...roles: AuthPayload['role'][]) => (req: Request, _res: Response, next: NextFunction) => { if (!req.user) throw new UnauthorizedError(); // 401: ¿quién eres? if (!roles.includes(req.user.role)) // 403: sabemos quién eres y no puedes throw new ForbiddenError('Insufficient permissions'); next(); };📋 S4·24 · courses.routes.ts completo (src/routes/courses.routes.ts)
Sección titulada «📋 S4·24 · courses.routes.ts completo (src/routes/courses.routes.ts)»// src/routes/courses.routes.ts (fichero completo)import { Router } from 'express';import { coursesController } from '../controllers/courses.controller.js';import { enrollmentsController } from '../controllers/enrollments.controller.js';import { requireAuth } from '../middlewares/require-auth.js';import { requireRole } from '../middlewares/require-role.js';import { validateBody } from '../middlewares/validate.js';import { createCourseSchema, updateCourseSchema } from '../schemas/course.schema.js';import { createEnrollmentSchema } from '../schemas/enrollment.schema.js';
export const coursesRouter = Router();
// Lectura públicacoursesRouter.get('/', coursesController.list);coursesRouter.get('/:id', coursesController.get);
// Escritura: autenticación → rol → validación → controlador (el ownership, en el servicio)coursesRouter.post('/', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), validateBody(createCourseSchema), coursesController.create);coursesRouter.patch('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), validateBody(updateCourseSchema), coursesController.update);coursesRouter.delete('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), coursesController.remove);
coursesRouter.post('/:id/enrollments', requireAuth, requireRole('STUDENT'), validateBody(createEnrollmentSchema), enrollmentsController.create);📋 S4·25 · curl sin token (401) y como STUDENT (403) (terminal)
Sección titulada «📋 S4·25 · curl sin token (401) y como STUDENT (403) (terminal)»# Sin token → 401curl -X POST $API/courses \ -H "Content-Type: application/json" \ -d '{"title":"Sin token","price":10}'
# Con token de un STUDENT (luis@, en $TOKEN_LUIS: 📋 S4·19) → 403curl -X POST $API/courses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN_LUIS" \ -d '{"title":"Alumno","price":10}'📋 S4·26 · tipo Actor (arriba de src/services/courses.service.ts)
Sección titulada «📋 S4·26 · tipo Actor (arriba de src/services/courses.service.ts)»// src/services/courses.service.ts: añade arribaimport type { AuthPayload } from '../middlewares/require-auth.js';export type Actor = { id: number; role: AuthPayload['role'] };📋 S4·27 · imports, actorOf y update (fragmento de src/controllers/courses.controller.ts)
Sección titulada «📋 S4·27 · imports, actorOf y update (fragmento de src/controllers/courses.controller.ts)»// src/controllers/courses.controller.ts (fragmento): imports y actorOfimport { currentUserId } from '../middlewares/require-auth.js';import { coursesService, type Actor } from '../services/courses.service.js';
const actorOf = (req: Request): Actor => ({ id: currentUserId(req), role: req.user!.role });
// dentro de coursesController, update pasa el actor al servicio: update: async (req: Request, res: Response) => { const { id } = idParamSchema.parse(req.params); res.status(200).json(await coursesService.updateCourse(id, actorOf(req), req.body)); },📋 S4·28 · receta «Cambiar de rama» hacia origin/s4-checkpoint (terminal)
Sección titulada «📋 S4·28 · receta «Cambiar de rama» hacia origin/s4-checkpoint (terminal)»git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s4-cp origin/s4-checkpoint # nombre nuevo: mi-s4 ya existenpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seednpm run seed && npm run dev # deja la API arrancadanpx tsx checks/s4.ts # en otra terminal → 12 ok, 7 fallos = la práctica📋 S4·29 · verificar la práctica (terminal)
Sección titulada «📋 S4·29 · verificar la práctica (terminal)»npx tsx checks/s4.tsnpm run typecheck