chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
function positiveInteger(
config: ConfigService,
key: string,
fallback: number,
): number {
const value = Number(config.get<string>(key) ?? fallback);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${key} must be a positive integer`);
}
return value;
}
function requiredSecret(config: ConfigService, key: string): string {
const value = config.get<string>(key);
if (!value || value.length < 64) {
throw new Error(`${key} must contain at least 64 characters`);
}
return value;
}
function cookieName(config: ConfigService, key: string, fallback: string): string {
const value = config.get<string>(key) ?? fallback;
if (!/^[A-Za-z0-9_-]{1,80}$/.test(value)) {
throw new Error(`${key} must be a valid cookie name`);
}
return value;
}
@Injectable()
export class AuthConfigService {
readonly accessTokenSecret: string;
readonly refreshTokenPepper: string;
readonly accessTokenTtlSeconds: number;
readonly refreshTokenTtlSeconds: number;
readonly maxLoginAttempts: number;
readonly lockoutSeconds: number;
readonly webOrigin: string;
readonly accessCookieName: string;
readonly refreshCookieName: string;
readonly csrfCookieName: string;
constructor(config: ConfigService) {
this.accessTokenSecret = requiredSecret(config, 'JWT_ACCESS_SECRET');
this.refreshTokenPepper = requiredSecret(config, 'REFRESH_TOKEN_PEPPER');
if (this.accessTokenSecret === this.refreshTokenPepper) {
throw new Error('JWT_ACCESS_SECRET and REFRESH_TOKEN_PEPPER must differ');
}
this.accessTokenTtlSeconds = positiveInteger(
config,
'ACCESS_TOKEN_TTL_SECONDS',
900,
);
this.refreshTokenTtlSeconds = positiveInteger(
config,
'REFRESH_TOKEN_TTL_SECONDS',
604800,
);
this.maxLoginAttempts = positiveInteger(
config,
'AUTH_MAX_LOGIN_ATTEMPTS',
5,
);
this.lockoutSeconds = positiveInteger(
config,
'AUTH_LOCKOUT_SECONDS',
900,
);
const webOrigin = config.get<string>('WEB_ORIGIN');
if (!webOrigin) throw new Error('Missing required environment variable: WEB_ORIGIN');
const parsedOrigin = new URL(webOrigin).origin;
if (parsedOrigin !== webOrigin || !webOrigin.startsWith('https://')) {
throw new Error('WEB_ORIGIN must be an HTTPS origin without a path');
}
this.webOrigin = parsedOrigin;
this.accessCookieName = cookieName(
config,
'ACCESS_COOKIE_NAME',
'dhv2_access',
);
this.refreshCookieName = cookieName(
config,
'REFRESH_COOKIE_NAME',
'dhv2_refresh',
);
this.csrfCookieName = cookieName(config, 'CSRF_COOKIE_NAME', 'dhv2_csrf');
}
}
@@ -0,0 +1,65 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Response } from 'express';
import type { RequestWithContext } from '../http/request-context';
interface ErrorBody {
code?: unknown;
message?: unknown;
}
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<RequestWithContext>();
const response = context.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const rawBody =
exception instanceof HttpException ? exception.getResponse() : undefined;
const body =
typeof rawBody === 'object' && rawBody !== null
? (rawBody as ErrorBody)
: undefined;
let code = typeof body?.code === 'string' ? body.code : undefined;
let message = typeof body?.message === 'string' ? body.message : undefined;
if (status === HttpStatus.BAD_REQUEST && !code) {
code = 'VALIDATION_ERROR';
message = 'Datos inválidos';
}
if (status === HttpStatus.UNAUTHORIZED && !code) {
code = 'UNAUTHORIZED';
message = 'Autenticación requerida';
}
if (status === HttpStatus.FORBIDDEN && !code) {
code = 'FORBIDDEN';
message = 'Acceso denegado';
}
if (status === HttpStatus.TOO_MANY_REQUESTS && !code) {
code = 'RATE_LIMITED';
message = 'Demasiadas solicitudes';
}
if (status >= 500) {
code = 'INTERNAL_ERROR';
message = 'Error interno';
}
response.status(status).json({
statusCode: status,
code: code ?? 'REQUEST_ERROR',
message: message ?? 'La solicitud no pudo procesarse',
requestId: request.requestId,
});
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { Request } from 'express';
export type AuthTransport = 'cookie' | 'bearer';
export interface AuthPrincipal {
userId: string;
username: string;
sessionId: string;
firstName: string;
lastName: string;
email: string | null;
mustChangePassword: boolean;
roles: string[];
permissions: string[];
transport: AuthTransport;
}
export interface RequestWithContext extends Request {
requestId: string;
auth?: AuthPrincipal;
}
@@ -0,0 +1,17 @@
import { randomUUID } from 'node:crypto';
import type { NextFunction, Response } from 'express';
import type { RequestWithContext } from './request-context';
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
export function requestIdMiddleware(
request: RequestWithContext,
response: Response,
next: NextFunction,
): void {
const supplied = request.header('x-request-id');
request.requestId =
supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
response.setHeader('X-Request-ID', request.requestId);
next();
}