- 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.
71 lines
3.5 KiB
JavaScript
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 };
|