Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table (email/display_name/role/status), scrypt password hashing with a fresh random salt PER INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password — that assumption is gone), auth_identities table so a future OIDC login can map to the same instructor_id without a schema change. New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session, POST /api/auth/change-password (old password required, revokes other sessions). No account enumeration: identical response whether the email doesn't exist or the password is wrong. bootstrap-owner.js is the only supported way to create the first account — no manual database editing to stand up a fresh deployment. Refuses to run if an active owner already exists unless the target email is an existing instructor (zero-owners recovery path).
78 lines
3.3 KiB
JavaScript
78 lines
3.3 KiB
JavaScript
#!/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 [email protected] "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 <email> <display_name> [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); });
|