#!/usr/bin/env node // One-time owner bootstrap — the only supported way to create the first instructor account, // so standing up a fresh Platform API never requires editing Postgres by hand. // // node bootstrap-owner.js you@example.com "Your Name" [password] // // Prefer BOOTSTRAP_OWNER_PASSWORD over the CLI argument in anything other than a throwaway // local test — CLI args can leak into shell history and `ps`. // // Refuses to run if an active owner already exists, UNLESS the target email belongs to an // existing instructor — in that case it promotes them to owner (the intended recovery path if // a platform is ever locked out with zero owners: point this at a known instructor account). const { getPool, runMigrations } = require('./src/db'); const { hashPassword } = require('./src/auth/password'); async function main() { const [, , email, displayName, cliPassword] = process.argv; const password = cliPassword || process.env.BOOTSTRAP_OWNER_PASSWORD; if (!email || !displayName || !password) { console.error('Usage: node bootstrap-owner.js [password]'); console.error(' (or set BOOTSTRAP_OWNER_PASSWORD instead of passing the password)'); process.exit(1); } if (password.length < 12) { console.error('Password must be at least 12 characters.'); process.exit(1); } await runMigrations(); const pool = getPool(); const emailLower = email.toLowerCase().trim(); const { rows: existing } = await pool.query(`SELECT instructor_id, role, status FROM instructors WHERE email = $1`, [emailLower]); const { rows: ownerCount } = await pool.query(`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active'`); if (ownerCount[0].n > 0 && !existing[0]) { console.error(`Refusing: ${ownerCount[0].n} active owner(s) already exist and ${emailLower} isn't one of your instructors.`); console.error('Use the admin API (as an existing owner) to add instructors instead.'); process.exit(1); } const passwordHash = hashPassword(password); let instructorId; const client = await pool.connect(); try { await client.query('BEGIN'); if (existing[0]) { await client.query( `UPDATE instructors SET password_hash = $1, display_name = $2, role = 'owner', status = 'active', updated_at = now() WHERE instructor_id = $3`, [passwordHash, displayName.trim(), existing[0].instructor_id] ); instructorId = existing[0].instructor_id; } else { const { rows } = await client.query( `INSERT INTO instructors (email, display_name, password_hash, role, status) VALUES ($1, $2, $3, 'owner', 'active') RETURNING instructor_id`, [emailLower, displayName.trim(), passwordHash] ); instructorId = rows[0].instructor_id; await client.query( `INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`, [instructorId, String(instructorId), emailLower] ); } await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } console.log(`Owner ready: ${emailLower} (${instructorId})`); process.exit(0); } main().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });