feat(auth): add per-instructor accounts and local password login

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).
This commit is contained in:
2026-08-18 13:35:54 -05:00
parent 52c17c1907
commit 85fa920f96
8 changed files with 323 additions and 30 deletions
+77
View File
@@ -0,0 +1,77 @@
#!/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); });