feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -0,0 +1,166 @@
import type { FastifyInstance, FastifyReply } 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<void> {
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'] },
},
},
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'] },
},
},
{ type: 'object', properties: { user: { type: 'null' } } },
],
},
},
};
app.post('/backoffice/auth/login', { schema: loginSchema }, async (request, reply) => {
const input = credentialsSchema.parse(request.body);
try {
const result = await login.execute(input);
setCookie(reply, result.token, true);
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, true);
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 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,
});
}