Files
rootnode-academy/platform/api/src/instructors.js
T
ced4568 85fa920f96 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).
2026-08-18 13:35:54 -05:00

78 lines
3.0 KiB
JavaScript

// Instructor account CRUD + the small set of invariants that protect the platform from
// locking itself out (never zero active owners). Shared by the auth routes (login/session) and
// the admin instructor-management routes.
const { hashPassword } = require('./auth/password');
function publicInstructor(row) {
if (!row) return null;
const { password_hash, ...rest } = row;
return rest;
}
async function getActiveInstructor(pool, instructorId) {
const { rows } = await pool.query(
`SELECT * FROM instructors WHERE instructor_id = $1 AND status = 'active'`, [instructorId]
);
return rows[0] || null;
}
// Returns the row regardless of status — the login flow needs to tell "wrong password" apart
// from "correct password, but account is deactivated" (see instructor-auth-routes.js), which
// requires seeing inactive accounts too.
async function getInstructorByEmail(pool, email) {
const { rows } = await pool.query(`SELECT * FROM instructors WHERE email = $1`, [String(email).toLowerCase().trim()]);
return rows[0] || null;
}
async function listInstructors(pool) {
const { rows } = await pool.query(`SELECT * FROM instructors ORDER BY created_at`);
return rows.map(publicInstructor);
}
async function countActiveOwners(pool, excludingId) {
const { rows } = await pool.query(
`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active' AND instructor_id != $1`,
[excludingId || '00000000-0000-0000-0000-000000000000']
);
return rows[0].n;
}
async function createInstructor(pool, { email, displayName, role, password }) {
const emailLower = String(email).toLowerCase().trim();
const passwordHash = hashPassword(password);
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO instructors (email, display_name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *`,
[emailLower, displayName.trim(), passwordHash, role || 'instructor']
);
const instructor = rows[0];
await client.query(
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
[instructor.instructor_id, String(instructor.instructor_id), emailLower]
);
await client.query('COMMIT');
return instructor;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Guards the one invariant that matters: never let the platform end up with zero active
// owners. Called before any update that would deactivate an owner or demote their role.
async function assertNotLastOwner(pool, instructorId) {
const remaining = await countActiveOwners(pool, instructorId);
if (remaining === 0) {
const err = new Error('Cannot remove the last active owner.');
err.code = 'LAST_OWNER';
throw err;
}
}
module.exports = { publicInstructor, getActiveInstructor, getInstructorByEmail, listInstructors, countActiveOwners, createInstructor, assertNotLastOwner };