Files
rootnode-academy/platform/api/src/student-csv.js
T
ced4568 d34ddae458 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.
2026-08-25 01:19:19 -05:00

71 lines
3.5 KiB
JavaScript

// 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 };