/** * reset-admin.ts — Break-glass CLI: create or reset a local admin account (D-13). * * Usage (dev only): * docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts \ * --username admin --password '' * * Flags: * --username Required. Username to create/reset. * --password Required. New password (never logged). * --dry-run Validate args + DB connection without writing. * * Security (T-19-25, T-19-26, D-13, D-15): * - FIRST statement: dev-only guard — throws when NODE_ENV=production (defense-in-depth). * - This script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02). * - The password value is NEVER logged or printed. * - hashPassword is inlined (scrypt PHC) — cannot import compiled TS from a plain script (Pitfall 11). * * DB: * Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts). * Upserts users row (is_admin=true, claimed=true) then upserts local_credentials row. * Idempotent: safe to run multiple times with the same username. */ // ── DEV-ONLY GUARD — must be the FIRST executable statement (T-19-25 / D-15) ──────────── if (process.env.NODE_ENV === 'production') { throw new Error( 'reset-admin refused: NODE_ENV=production. ' + 'This CLI creates/resets local admin credentials and must NEVER run in production. ' + 'The script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).', ); } import { createConnection } from 'mysql2/promise'; import { scryptSync, randomBytes } from 'node:crypto'; // ── Inline hashPassword (PHC-style scrypt) ─────────────────────────────────────────────── // Cannot import compiled TS from a plain Node.js script at runtime (Pitfall 11). // Copy of the 5-line implementation from apps/api/src/auth/localCredentials.ts. const SCRYPT_N = 16384; const SCRYPT_R = 8; const SCRYPT_P = 1; const KEY_LEN = 32; function hashPassword(password: string): string { const salt = randomBytes(16); const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join( '$', ); } // ── CLI arg parsing (no new deps — process.argv only) ─────────────────────────────────── // WR-01: support both `--key=value` and `--key value`, and parse values EXPLICITLY rather // than inferring an empty string whenever the next token starts with '--'. The old heuristic // coerced `--password --foo` (and a legitimately `--`-prefixed or empty password) silently to // ''. Here, known value-taking flags (--username, --password) always consume the next token // verbatim as their value; the only boolean flag (--dry-run) takes no value. This keeps a // password that begins with '--', or an intentionally empty password, intact. const VALUE_FLAGS = new Set(['username', 'password']); const BOOLEAN_FLAGS = new Set(['dry-run']); function parseArgs(argv: string[]): Record { const result: Record = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg.startsWith('--')) continue; const eq = arg.indexOf('='); if (eq !== -1) { // `--key=value` form — value is everything after the first '=', taken verbatim // (so `--password=--weird` and `--password=` both work correctly). result[arg.slice(2, eq)] = arg.slice(eq + 1); continue; } const key = arg.slice(2); if (BOOLEAN_FLAGS.has(key)) { result[key] = ''; // presence-only flag; detected via hasOwnProperty continue; } if (VALUE_FLAGS.has(key)) { // Consume the NEXT token verbatim as the value — even if it starts with '--' or is // empty. If there is no next token, record undefined (genuinely absent, not ''). result[key] = argv[i + 1]; if (i + 1 < argv.length) i++; // skip the consumed value token continue; } // Unknown flag — record presence with no value (forward-compatible, no crash). result[key] = ''; } return result; } const args = parseArgs(process.argv.slice(2)); // ── Validate required args ──────────────────────────────────────────────────────────────── const username = args['username']; const password = args['password']; const dryRun = Object.prototype.hasOwnProperty.call(args, 'dry-run'); if (!username || username.trim() === '') { console.error('reset-admin: --username is required'); process.exit(1); } if (!dryRun && (!password || password.trim() === '')) { console.error('reset-admin: --password is required (use --dry-run to test without writing)'); process.exit(1); } if (dryRun && !password) { // In dry-run mode a placeholder password is acceptable — skip real validation console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)'); } // ── DB connection ───────────────────────────────────────────────────────────────────────── const conn = await createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3306), user: process.env.DB_USER ?? 'familysync', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_NAME ?? 'familysync', }); try { // Verify DB connectivity (used by --dry-run to confirm connection works) await conn.query('SELECT 1'); console.log('[reset-admin] DB connection OK'); if (dryRun) { console.log('[dry-run] Connection verified. Exiting without writing.'); await conn.end(); process.exit(0); } // ── Upsert users row ───────────────────────────────────────────────────────────────── // Find existing user by username (via local_credentials join) or create a new one. // is_admin=true + claimed=true for break-glass recovery (D-13). // Never logs the password value (T-19-26). const [lcRows] = await conn.execute<{ user_id: number }[]>( 'SELECT user_id FROM local_credentials WHERE username = ? LIMIT 1', [username], ); let userId: number; if (lcRows.length > 0) { // Existing local_credentials row — update password and ensure is_admin userId = lcRows[0].user_id; await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]); // WR-01: do not echo the username — log only the resolved user id (no credential data). console.log(`[reset-admin] Found existing user id=${userId}`); } else { // No existing row — insert a new user const displayName = username; const [insertResult] = await conn.execute<{ insertId: number }>( `INSERT INTO users (oidc_iss, oidc_sub, display_name, color, is_admin, claimed) VALUES (NULL, NULL, ?, '#4A90D9', true, true)`, [displayName], ); userId = (insertResult as unknown as { insertId: number }).insertId; // WR-01: do not echo the username — log only the resolved user id. console.log(`[reset-admin] Created new user id=${userId}`); } // ── Upsert local_credentials row ───────────────────────────────────────────────────── const passwordHash = hashPassword(password!); await conn.execute( `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), username = VALUES(username)`, [userId, username, passwordHash], ); console.log(`[reset-admin] Local credential upserted for user id=${userId}`); console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`); } finally { await conn.end(); }