chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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<void> {
|
||||
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<string> => {
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user