Ir al contenido

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)»
Ventana de terminal
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s4 origin/s4-start # checkpoint: mi-s4-cp origin/s4-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed
Ventana de 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)»
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.ts
import { 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());
Ventana de terminal
npm run seed

📋 S4·06 · generar el secreto (terminal)

Sección titulada «📋 S4·06 · generar el secreto (terminal)»
Ventana de terminal
# 1. Genera tu secreto
node -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)»
Ventana de terminal
# 2. .env (no se sube a git): añade el valor real
JWT_SECRET="<pega aquí el valor generado>"
# 3. .env.example (sí se sube): añade la línea, sin valor
JWT_SECRET=

📋 S4·08 · config.ts completo (src/config.ts)

Sección titulada «📋 S4·08 · config.ts completo (src/config.ts)»
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);
// 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)»
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)»
Ventana de 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)»
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)»
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)»
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);
// 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)»
Ventana de 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)»
Ventana de terminal
# bash / zsh / Git Bash: pega el token SIN las comillas del JSON
TOKEN_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 requireAuth
export 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)»
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 eres
export 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ública
coursesRouter.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)»
Ventana de terminal
# Sin token → 401
curl -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) → 403
curl -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 arriba
import 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 actorOf
import { 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)»
Ventana de terminal
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s4-cp origin/s4-checkpoint # nombre nuevo: mi-s4 ya existe
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed
npm run seed && npm run dev # deja la API arrancada
npx 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)»
Ventana de terminal
npx tsx checks/s4.ts
npm run typecheck