import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { FastifySchema } from 'fastify'; import type pg from 'pg'; import { z } from 'zod'; import type { Authenticate } from '../../../shared/auth.js'; import { AppError } from '../../../shared/errors.js'; import { errorSchema } from '../../../shared/swagger.js'; import { BackofficeLogin } from '../application/login.js'; import { BackofficeLogout } from '../application/logout.js'; import { InMemoryBackofficeLoginRateLimiter } from '../infrastructure/backoffice-login-rate-limiter.js'; import type { BackofficeLoginRateLimiter } from '../domain/ports.js'; import { Argon2BackofficePasswordHasher } from '../infrastructure/backoffice-password-hasher.js'; import { PgBackofficeUserRepository } from '../infrastructure/pg-backoffice-user-repository.js'; import { PgBackofficeSessionRepository } from '../infrastructure/pg-backoffice-session-repository.js'; import { generateBackofficeToken, hashBackofficeToken, } from '../infrastructure/backoffice-session-token.js'; import { BACKOFFICE_SESSION_TTL_MS } from '../domain/backoffice-session.js'; import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js'; export const BACKOFFICE_SESSION_COOKIE_NAME = 'backoffice_session'; export interface BackofficeRoutesDeps { pool: pg.Pool; hasher?: import('../domain/ports.js').BackofficePasswordHasher; rateLimiter?: BackofficeLoginRateLimiter; /** Session authenticator injected by the composition root. */ authenticate?: Authenticate; } const credentialsSchema = z.object({ email: z.email(), password: z.string().min(8).max(128), }); export async function registerBackofficeRoutes( app: FastifyInstance, deps: BackofficeRoutesDeps, ): Promise { const hasher = deps.hasher ?? new Argon2BackofficePasswordHasher(); const users = new PgBackofficeUserRepository(deps.pool); const sessions = new PgBackofficeSessionRepository(deps.pool); const rateLimiter = deps.rateLimiter ?? new InMemoryBackofficeLoginRateLimiter(); const login = new BackofficeLogin({ users, sessions, hasher, rateLimiter, generateToken: generateBackofficeToken, hashToken: hashBackofficeToken, }); const logout = new BackofficeLogout(sessions, hashBackofficeToken); const loginSchema: FastifySchema = { tags: ['Backoffice'], summary: 'Backoffice login', description: 'Inicia sesión en el backoffice y devuelve una cookie `backoffice_session` (válida 8h).', body: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', format: 'email' }, password: { type: 'string' }, }, }, response: { 200: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] }, }, }, 401: errorSchema, 429: errorSchema, }, }; const logoutSchema: FastifySchema = { tags: ['Backoffice'], summary: 'Backoffice logout', response: { 204: { type: 'null' } }, }; const meSchema: FastifySchema = { tags: ['Backoffice'], summary: 'Current backoffice user', description: 'Devuelve el usuario backoffice autenticado o `{user: null}` si no hay sesión.', response: { 200: { oneOf: [ { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] }, }, }, { type: 'object', properties: { user: { type: 'null' } } }, ], }, }, }; app.post('/backoffice/auth/login', { schema: loginSchema }, async (request, reply) => { const parseResult = credentialsSchema.safeParse(request.body); if (!parseResult.success) { throw new AppError( 400, 'VALIDATION_ERROR', 'Invalid request payload', parseResult.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message, code: issue.code, })), ); } const input = parseResult.data; try { const result = await login.execute(input); setCookie(reply, result.token, isSecureRequest(request)); return reply .code(200) .send({ id: result.user.id, email: result.user.email, role: result.user.role }); } catch (error) { if (error instanceof BackofficeRateLimitedError) { void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000))); throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts'); } if (error instanceof BackofficeInvalidCredentialsError) { throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials'); } throw error; } }); app.post('/backoffice/auth/logout', { schema: logoutSchema }, async (request, reply) => { const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME]; await logout.execute(token); clearCookie(reply, isSecureRequest(request)); return reply.code(204).send(); }); app.get('/backoffice/auth/me', { schema: meSchema }, async (request, reply) => { try { const user = await deps.authenticate!(request); return reply.send({ id: user.id, email: user.email, role: user.role }); } catch (error) { if (error instanceof AppError && error.statusCode === 401) { return reply.send({ user: null }); } throw error; } }); } function isSecureRequest(request: FastifyRequest): boolean { const forwardedProto = request.headers['x-forwarded-proto']; const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; return request.protocol === 'https' || proto === 'https'; } function setCookie(reply: FastifyReply, token: string, secure: boolean): void { void reply.setCookie(BACKOFFICE_SESSION_COOKIE_NAME, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: Math.floor(BACKOFFICE_SESSION_TTL_MS / 1000), }); } function clearCookie(reply: FastifyReply, secure: boolean): void { void reply.clearCookie(BACKOFFICE_SESSION_COOKIE_NAME, { path: '/', httpOnly: true, sameSite: 'lax', secure, }); }