Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
263 lines
7.5 KiB
TypeScript
263 lines
7.5 KiB
TypeScript
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: ')).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],
|
|
)) 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,
|
|
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;
|
|
});
|