Files
dh-inspeccion-v2/api-v3/src/cli/bootstrap-admin.ts
T
admin 7fe59bccd2
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
feat(f6.2): add per-act representative signing and user smtp
2026-09-14 15:53:55 -03:00

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;
});