import 'reflect-metadata'; import { Writable } from 'node:stream'; import { createInterface } from 'node:readline/promises'; import { DataSource } from 'typeorm'; import { PasswordService } from '../auth/services/password.service'; import { AuditAction, AuditEvent, AuditSource, PHASE_A_ENTITIES, Role, User, UserRole, UserStatus, } from '../database/entities'; function requiredEnvironmentVariable(key: string): string { const value = process.env[key]; if (!value) throw new Error(`Missing required environment variable: ${key}`); return value; } function databasePort(): number { const port = Number(process.env.DB_PORT ?? 5432); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('DB_PORT must be a valid TCP port'); } return port; } function createRuntimeDataSource(): DataSource { return new DataSource({ type: 'postgres', host: requiredEnvironmentVariable('DB_HOST'), port: databasePort(), database: requiredEnvironmentVariable('DB_NAME'), username: requiredEnvironmentVariable('DB_APP_USER'), password: requiredEnvironmentVariable('DB_APP_PASSWORD'), entities: PHASE_A_ENTITIES, synchronize: false, migrationsRun: false, logging: false, applicationName: 'dhv2-bootstrap-admin', connectTimeoutMS: 5000, }); } class MutedOutput extends Writable { muted = false; override _write( chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void, ): void { if (!this.muted) process.stdout.write(chunk); callback(); } } function validatedText( label: string, value: string, maximumLength: number, ): string { const trimmed = value.trim(); if (!trimmed) throw new Error(`${label} is required`); if (trimmed.length > maximumLength) { throw new Error(`${label} is too long`); } return trimmed; } async function main(): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error('Bootstrap admin must run in an interactive terminal'); } const dataSource = createRuntimeDataSource(); await dataSource.initialize(); try { const existing = (await dataSource.query(` SELECT COUNT(*)::integer AS count FROM user_roles user_role INNER JOIN roles role ON role.id = user_role.role_id WHERE role.code = 'admin' `)) as Array<{ count: number }>; if (Number(existing[0]?.count ?? 0) > 0) { throw new Error('An administrator already exists; bootstrap was cancelled'); } const output = new MutedOutput(); const prompt = createInterface({ input: process.stdin, output, terminal: true, }); let username = ''; let email = ''; let firstName = ''; let lastName = ''; let password = ''; let passwordConfirmation = ''; let mustChangeAnswer = ''; try { username = validatedText( 'Username', await prompt.question('Username: '), 80, ).toLowerCase(); if (!/^[a-z0-9._-]{3,80}$/.test(username)) { throw new Error( 'Username must contain 3-80 letters, numbers, dots, underscores or hyphens', ); } email = (await prompt.question('Email (optional): ')).trim().toLowerCase(); if ( email && (email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) ) { throw new Error('Email is invalid'); } firstName = validatedText( 'First name', await prompt.question('Nombre: '), 120, ); lastName = validatedText( 'Last name', await prompt.question('Apellido: '), 120, ); const askSecret = async (label: string): Promise => { output.muted = false; const pending = prompt.question(label); output.muted = true; const answer = await pending; output.muted = false; process.stdout.write('\n'); return answer; }; password = await askSecret('Contraseña (mínimo 12 caracteres): '); passwordConfirmation = await askSecret('Repetir contraseña: '); mustChangeAnswer = ( await prompt.question('¿Forzar cambio en el primer ingreso? [S/n]: ') ) .trim() .toLowerCase(); } finally { prompt.close(); } if (password.length < 12 || password.length > 128) { throw new Error('Password must contain between 12 and 128 characters'); } if (password !== passwordConfirmation) { throw new Error('Password confirmation does not match'); } const mustChangePassword = !['n', 'no'].includes(mustChangeAnswer); const passwordHash = await new PasswordService().hash(password); await dataSource.transaction(async (manager) => { await manager.query( "SELECT pg_advisory_xact_lock(hashtext('dhv2-bootstrap-admin'))", ); const adminCount = (await manager.query(` SELECT COUNT(*)::integer AS count FROM user_roles user_role INNER JOIN roles role ON role.id = user_role.role_id WHERE role.code = 'admin' `)) as Array<{ count: number }>; if (Number(adminCount[0]?.count ?? 0) > 0) { throw new Error('An administrator already exists; bootstrap was cancelled'); } const duplicate = (await manager.query( ` SELECT 1 FROM users WHERE LOWER(username) = $1 OR ($2::text IS NOT NULL AND LOWER(email) = $2) LIMIT 1 `, [username, email || null], )) as unknown[]; if (duplicate.length > 0) { throw new Error('Username or email already exists'); } const adminRole = await manager.getRepository(Role).findOne({ where: { code: 'admin' }, }); if (!adminRole) throw new Error('Admin role seed is missing'); const user = manager.getRepository(User).create({ username, email: email || null, passwordHash, firstName, lastName, status: UserStatus.ACTIVE, mustChangePassword, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, passwordChangedAt: new Date(), createdBy: null, updatedBy: null, }); await manager.getRepository(User).save(user); await manager.getRepository(UserRole).save( manager.getRepository(UserRole).create({ userId: user.id, roleId: adminRole.id, assignedAt: new Date(), assignedBy: user.id, }), ); await manager.getRepository(AuditEvent).save( manager.getRepository(AuditEvent).create({ actorUserId: user.id, actorUsername: user.username, action: AuditAction.SYSTEM_BOOTSTRAP_ADMIN_CREATED, entityType: 'user', entityId: user.id, requestId: null, source: AuditSource.SYSTEM, ip: null, userAgent: null, beforeData: null, afterData: { username: user.username, email: user.email, firstName: user.firstName, lastName: user.lastName, status: user.status, mustChangePassword: user.mustChangePassword, roles: ['admin'], }, metadata: { bootstrap: true }, }), ); process.stdout.write(`Administrador creado: ${user.username}\n`); }); } finally { await dataSource.destroy(); } } main().catch((error: unknown) => { const message = error instanceof Error ? error.message : 'Unknown bootstrap error'; process.stderr.write(`Bootstrap cancelado: ${message}\n`); process.exitCode = 1; });