feat(student): admin student-account management + CSV import, session hardening

- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions,
  /sessions, /import (transactional CSV import — whole file validated, one Postgres
  transaction, never accepts a password/hash column from input). Fixed GET
  /api/admin/students to select an explicit safe column list instead of SELECT * — the new
  password_hash/credential_token_hash columns must never be exposed through this route.
- server.js: /api/login now also blocks on credential_token_hash (not just password_hash) —
  closes a real window where a student mid-conversion stayed reachable via the old
  bare-student_id path. studentSession middleware now re-verifies status='active' on every
  request, not just at session creation, matching instructorSession's existing behavior.
- sessions.js: revokeAllStudentSessions, listStudentSessions.
- rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the
  new classroom/help-request RBAC (next commit) reuses the exact same authorization rule
  instead of a parallel copy.
- student-csv.js + test: hand-rolled parser (no new dependency), unit tested.
- prometheus.yml.j2: activated the platform-api scrape job.
This commit is contained in:
2026-08-25 01:19:19 -05:00
parent 2f09b1a498
commit d34ddae458
7 changed files with 431 additions and 63 deletions
+147 -23
View File
@@ -16,6 +16,9 @@
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
module.exports = function registerAdminRoutes(app, deps) {
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
@@ -95,33 +98,168 @@ module.exports = function registerAdminRoutes(app, deps) {
});
// ── Students ─────────────────────────────────────────────────────────────────────────
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
// credential_token_hash (migration 017), which must never reach an API response
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT s.* FROM students s
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
: await pool.query(
`SELECT student_id, display_name, status, email, login_identifier,
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
FROM students ORDER BY display_name`);
res.json(rows);
});
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
// self-paced student gets email, a younger instructor-managed one gets login_identifier
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
// an activation token immediately (§5's "instructor creates account -> temporary credential"
// flow) and returns it ONCE in this response — the instructor relays it to the student
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
const { display_name, cohort_id } = req.body || {};
const { display_name, cohort_id, email, login_identifier } = req.body || {};
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
const pool = getPool();
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
try {
await pool.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
if (cohort_id) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[cohort_id, studentId]
);
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
let activationToken = null;
if (email || login_identifier) {
const issued = await issueCredentialToken(pool, studentId, 'activation');
activationToken = issued.token;
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
});
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
// account: assigns email/login_identifier and issues an activation token, same mechanism as
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
// membership is touched — student_id, the FK every other table hangs off of, never changes.
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
const { email, login_identifier } = req.body || {};
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
try {
await pool.query(
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, activation_token: token });
});
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
// token and revokes every existing session, same "instructor never needs to know the
// student's password" principle as activation.
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, reset_token: token });
});
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
res.json(sessionsList);
});
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
// returned, rather than silently importing the valid rows and skipping the rest (the safer
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
// accepts a password/password_hash column — activation tokens are always generated server-side.
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
const { csv } = req.body || {};
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
const pool = getPool();
const client = await pool.connect();
const created = [];
try {
await client.query('BEGIN');
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
let nextN = countRows[0].n + 1;
for (const row of parsedRows) {
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
await client.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, row.display_name, row.email || null, row.login_identifier || null]
);
if (row.cohort_id) {
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
}
let activationToken = null;
if (row.email || row.login_identifier) {
// issueCredentialToken only ever calls .query() on what it's given — a transaction
// client works identically to a pool here, so the whole import (including every
// token) commits or rolls back together with everything else.
const issued = await issueCredentialToken(client, studentId, 'activation');
activationToken = issued.token;
}
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
throw e;
} finally {
client.release();
}
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
res.status(201).json({ created });
});
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
@@ -306,23 +444,9 @@ module.exports = function registerAdminRoutes(app, deps) {
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
[req.params.id]
);
const results = await Promise.allSettled(students.map(async (s) => {
const existing = await provisioner.listForStudent(s.student_id);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
const { status, body } = await provisioner.provision(s.student_id);
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
}));
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
res.json({
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
});
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
res.json({ results });
});
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
+81 -31
View File
@@ -30,27 +30,34 @@ module.exports = function createRbac(pool) {
const requireAdminOrOwner = requireRole('owner', 'admin');
const requireAnyInstructor = requireRole('owner', 'admin', 'instructor');
// Core check, factored out so both the cohort_id-param routes below AND the
// classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead
// of two copies of the same query drifting apart over time.
async function checkCohortAccess(req, res, cohortId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
// same as any other authorization failure, no distinction made between "not your cohort" and
// "cohort doesn't exist" in the response.
function requireCohortAccess(cohortIdParam = 'id') {
return async (req, res, next) => {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return next();
const cohortId = req.params[cohortIdParam];
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
});
return res.status(403).json({ error: 'forbidden' });
}
next();
if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next();
};
}
@@ -84,24 +91,27 @@ module.exports = function createRbac(pool) {
// vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above,
// but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still
// bypass entirely, same as every other cohort-scoped check.
async function checkCohortWriteAccess(req, res, cohortId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rows } = await pool.query(
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
function requireCohortWriteAccess(cohortIdParam = 'id') {
return async (req, res, next) => {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return next();
const cohortId = req.params[cohortIdParam];
const { rows } = await pool.query(
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
});
return res.status(403).json({ error: 'forbidden' });
}
next();
if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next();
};
}
@@ -130,9 +140,49 @@ module.exports = function createRbac(pool) {
};
}
// Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly,
// so this resolves the classroom's cohort first (404 if it doesn't exist — a route param
// isn't proof the resource exists) and then applies the exact same rule
// requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the
// request (req.classroomCohortId) so handlers that need it don't re-query.
function requireClassroomAccess(classroomIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
if (await checkCohortAccess(req, res, rows[0].cohort_id)) next();
};
}
function requireClassroomWriteAccess(classroomIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
};
}
// Same idea, one join further — help_requests routes are keyed by help_request_id, resolved
// through classroom_sessions to the cohort. Used by the acknowledge/resolve routes.
function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(
`SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr
JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id
WHERE hr.help_request_id = $1`,
[req.params[helpRequestIdParam]]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
req.classroomSessionId = rows[0].classroom_session_id;
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
};
}
return {
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
requireCohortAccess, requireStudentCohortAccess,
requireCohortWriteAccess, requireStudentCohortWriteAccess,
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
};
};
+58 -1
View File
@@ -43,10 +43,20 @@ app.use(express.static(FRONTEND_DIST));
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
// controls. CSRF is checked here too, for every state-changing student request.
// Phase 5A: now re-checks the student is still 'active' on every request, matching
// instructorSession's existing pattern below — a deactivation takes effect on the very next
// request instead of waiting out the session's natural expiry. Previously this middleware only
// checked the session itself existed, which was fine when every student was pilot/trusted-roster
// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist.
async function studentSession(req, res, next) {
const token = parseCookies(req)[STUDENT_COOKIE];
const studentId = await sessions.resolveStudentSession(getPool(), token);
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]);
if (!rows.length || rows[0].status !== 'active') {
await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued
return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
}
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.studentId = studentId;
req.sessionToken = token;
@@ -88,6 +98,19 @@ app.get('/healthz', async (req, res) => {
}
});
// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry
// credentials here, matching every other exporter in infra/monitoring/) but restricted at the
// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already
// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public
// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js.
app.get('/metrics', async (req, res) => {
try {
res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner));
} catch (e) {
res.status(503).type('text/plain').send('# metrics collection failed\n');
}
});
// GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as
// minimal: an app version, the deployed Git SHA (or 'dev' locally — see
// infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same
@@ -140,11 +163,26 @@ app.get('/api/roster', async (req, res) => {
// can't be forged/switched by editing client state, and is checked on every Lab request. See
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
// institutional SSO) that upgrades identification without touching this session system.
// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real,
// credentialed account (password_hash set), this route refuses them and points at the real
// login, so a converted account can never be accessed by just knowing its bare student_id.
// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working
// exactly as before — no pilot data or access is lost.
app.post('/api/login', async (req, res) => {
const { student_id } = req.body || {};
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
const { rows } = await getPool().query(
`SELECT student_id, display_name, status, password_hash, credential_token_hash FROM students WHERE student_id = $1`,
[student_id]
);
const student = rows[0];
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
// Blocks on credential_token_hash too, not just password_hash — a student mid-conversion
// (instructor has issued an activation token but the student hasn't set a password yet) must
// not be reachable via the old insecure bare-id path either, or conversion doesn't actually
// close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js).
if (student.password_hash || student.credential_token_hash) {
return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' });
}
await touchStudent(getPool(), student.student_id);
const token = await sessions.createStudentSession(getPool(), student.student_id);
const csrf = makeCsrfToken();
@@ -329,6 +367,15 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal
// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding
// a second env var, to decide whether /api/student/request-password-reset may echo the reset
// token back for local/test use (never in a real deployment).
require('./student-auth-routes')(app, {
pool: getPool(), sessions, studentSession, curriculum, loadProgress,
isProduction: process.env.COOKIE_SECURE === '1',
});
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
const rbac = createRbac(getPool());
@@ -340,6 +387,16 @@ require('./admin-learning-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half
// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor
// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/
// admin-learning-routes.js — a classroom session is a distinct concept from curriculum
// progress, even though its dashboard reads curriculum/progress data.
require('./classroom-routes')(app, { studentSession, getPool, curriculum });
require('./admin-classroom-routes')(app, {
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+22
View File
@@ -71,6 +71,27 @@ async function resolveStudentSession(pool, token) {
}
const destroyStudentSession = revokeSession;
// Phase 5A §6/§10 — mirrors revokeAllInstructorSessions/listInstructorSessions exactly. Used
// by admin-triggered "revoke student sessions", and automatically after a student password
// change/reset (except the session that just made the change, if any).
async function revokeAllStudentSessions(pool, studentId, exceptToken) {
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
await pool.query(
`UPDATE sessions SET revoked_at = now()
WHERE student_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
[studentId, exceptHash]
);
}
async function listStudentSessions(pool, studentId) {
const { rows } = await pool.query(
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
FROM sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 20`,
[studentId]
);
return rows;
}
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
function createInstructorSession(pool, instructorId) {
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
@@ -131,6 +152,7 @@ function startSessionSweeper(pool) {
module.exports = {
createStudentSession, resolveStudentSession, destroyStudentSession,
revokeAllStudentSessions, listStudentSessions,
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
};
+70
View File
@@ -0,0 +1,70 @@
// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the
// expected input is simple (no quoted fields containing commas expected in practice for
// names/identifiers), and pulling in a dependency for a three-column parser isn't justified
// (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level
// error at once — the caller decides whether to import (transactional, all-or-nothing).
const REQUIRED_HEADERS = ['display_name'];
const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id'];
const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment
function splitCsvLine(line) {
// No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't
// contain commas in practice) — a plain split covers the real input shape.
return line.split(',').map((cell) => cell.trim());
}
function parseStudentCsv(csv) {
const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
const errors = [];
if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] };
const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
for (const required of REQUIRED_HEADERS) {
if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` });
}
for (const forbidden of FORBIDDEN_HEADERS) {
if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` });
}
const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h));
if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` });
if (errors.length) return { rows: [], errors };
const seenEmails = new Set();
const seenIdentifiers = new Set();
const rows = [];
for (let i = 1; i < lines.length; i++) {
const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees
const cells = splitCsvLine(lines[i]);
if (cells.length !== headers.length) {
errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` });
continue;
}
const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]]));
if (!raw.display_name) {
errors.push({ row: rowNum, message: 'display_name is required' });
continue;
}
const email = raw.email ? raw.email.toLowerCase() : null;
const loginIdentifier = raw.login_identifier || null;
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push({ row: rowNum, message: `invalid email: ${raw.email}` });
continue;
}
if (email && seenEmails.has(email)) {
errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` });
continue;
}
if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) {
errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` });
continue;
}
if (email) seenEmails.add(email);
if (loginIdentifier) seenIdentifiers.add(loginIdentifier);
rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null });
}
if (errors.length) return { rows: [], errors };
return { rows, errors: [] };
}
module.exports = { parseStudentCsv };
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// Fast, isolated unit test for student-csv.js — no Postgres, no HTTP. No framework, matching
// project convention (see platform/lab-router/test/run.js).
const assert = require('assert');
const { parseStudentCsv } = require('../src/student-csv');
function main() {
{
const r = parseStudentCsv('display_name,email,login_identifier,cohort_id\nAlice,[email protected],,root-fall\nBob,,bob01,root-fall');
assert.equal(r.errors.length, 0);
assert.equal(r.rows.length, 2);
assert.equal(r.rows[0].email, '[email protected]');
assert.equal(r.rows[1].login_identifier, 'bob01');
console.log('ok — valid file parses');
}
{
const r = parseStudentCsv('email\[email protected]');
assert.ok(r.errors.length > 0);
console.log('ok — missing display_name column rejected');
}
{
const r = parseStudentCsv('display_name,password\nAlice,hunter2');
assert.ok(r.errors.some((e) => e.message.includes('password')));
console.log('ok — password column never accepted from a file');
}
{
const r = parseStudentCsv('display_name,email\nAlice,[email protected],extra');
assert.ok(r.errors.length > 0);
assert.equal(r.rows.length, 0);
console.log('ok — a malformed row rejects the whole file (transactional, no partial import)');
}
{
const r = parseStudentCsv('display_name,email\nAlice,[email protected]\nBob,[email protected]');
assert.ok(r.errors.some((e) => e.message.includes('duplicate email')));
console.log('ok — duplicate email within one file rejected');
}
{
const r = parseStudentCsv('display_name,email\nAlice,not-an-email');
assert.ok(r.errors.some((e) => e.message.includes('invalid email')));
console.log('ok — invalid email format rejected');
}
console.log('\nAll student-csv unit tests passed.');
}
main();