// RootNode Academy — Platform API (Phase 3A revision). // // Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students, // cohorts, enrollments, curriculum assignments, progress, scenario attempts, instructor // accounts, persistent student/instructor sessions, RBAC, audit logging, and all Provisioner // (environment orchestration) calls. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md. // // Reached only through the Gateway (platform/gateway) — never directly by browser JS. const express = require('express'); const path = require('path'); const { getPool, runMigrations } = require('./db'); const sessions = require('./sessions'); const { getActiveInstructor } = require('./instructors'); const { provisioner } = require('./provisioner-client'); const curriculum = require('./curriculum'); const { loadProgress, saveOneProgress, touchStudent } = require('./progress'); const { recordAudit } = require('./audit'); const { parseCookies, checkCsrf, makeCsrfToken, startLoginAttemptSweeper } = require('./security'); const { buildCookie, clearCookie } = require('./cookies'); const createRbac = require('./rbac'); const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes'); const attemptsLib = require('./attempts'); const assessments = require('./assessments'); const achievements = require('./achievements'); const { computeCurriculumState } = require('./curriculum-rules'); const PORT = Number(process.env.PLATFORM_API_PORT) || 4300; const STUDENT_COOKIE = 'rn_session'; const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; // Deliberate, not automatic: honoring X-Forwarded-For for req.ip (audit source_ip, login rate // limiting) is only correct when there's a trusted reverse proxy in front of this process. Off // by default (direct connections / dev); set TRUST_PROXY=1 once fronted by one in production. const app = express(); if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1); app.use(express.json()); const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist'); app.use(express.static(FRONTEND_DIST)); // ── Student session middleware ────────────────────────────────────────────────────────── // 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; next(); } // ── Instructor session middleware ─────────────────────────────────────────────────────── // Re-fetches the instructor row from Postgres on every request rather than trusting anything // cached in the session — a role change or deactivation takes effect on the very next request, // with no separate "revoke sessions on role change" mechanism needed. async function instructorSession(req, res, next) { const token = parseCookies(req)[INSTRUCTOR_COOKIE]; const instructorId = await sessions.resolveInstructorSession(getPool(), token); if (!instructorId) return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' }); const instructor = await getActiveInstructor(getPool(), instructorId); if (!instructor) { await sessions.destroyInstructorSession(getPool(), token); // deactivated since the session was issued return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' }); } if (!checkCsrf(req, INSTRUCTOR_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' }); req.instructor = instructor; req.sessionToken = token; next(); } function wrap(fn) { return (req, res, next) => fn(req, res, next).catch(next); } // GET /healthz — unauthenticated liveness/readiness probe for uptime monitoring and Ansible // deploy smoke tests (Phase 4, docs/OBSERVABILITY.md §"uptime checks"). Confirms the process is // up AND can reach Postgres — deliberately minimal, no student/instructor data, no auth. app.get('/healthz', async (req, res) => { try { await getPool().query('SELECT 1'); res.json({ ok: true, db: 'ok' }); } catch (e) { res.status(503).json({ ok: false, db: 'unreachable' }); } }); // 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 // value used for the image tag), and the most recently applied migration as a stand-in for // schema version. No infrastructure detail, no secrets — this is "what build is this," not a // diagnostics endpoint. const PACKAGE_VERSION = require('../package.json').version; app.get('/api/version', async (req, res) => { let schemaVersion = null; try { const { rows } = await getPool().query('SELECT version FROM schema_migrations ORDER BY applied_at DESC LIMIT 1'); schemaVersion = rows[0]?.version || null; } catch (e) { /* best-effort — an unreachable DB shouldn't make /api/version itself fail */ } res.json({ version: PACKAGE_VERSION, git_sha: process.env.GIT_SHA || 'dev', schema_version: schemaVersion, }); }); // ═══════════════════════════════════════════════════════════════════════════════════════ // STUDENT-FACING: roster / login / session // ═══════════════════════════════════════════════════════════════════════════════════════ app.get('/api/roster/cohorts', async (req, res) => { const { rows } = await getPool().query( `SELECT DISTINCT c.cohort_id, c.name FROM cohorts c JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id AND e.enrollment_status = 'enrolled' WHERE c.status = 'active' ORDER BY c.name` ); res.json(rows.map((r) => r.cohort_id)); }); app.get('/api/roster', async (req, res) => { const { cohort } = req.query; const pool = getPool(); const { rows } = cohort ? await pool.query( `SELECT s.student_id, s.display_name, s.last_active_at FROM students s JOIN cohort_enrollments e ON e.student_id = s.student_id WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active' ORDER BY s.display_name`, [cohort]) : await pool.query(`SELECT student_id, display_name, last_active_at FROM students WHERE status = 'active' ORDER BY display_name`); res.json(rows); }); // Roster name-picking is CLASSROOM/PILOT IDENTIFICATION, not production authentication — there // is no password or possession factor here, only "does this student_id exist." What Phase 3A // hardens is everything AFTER that pick: the resulting session is server-controlled, persistent, // 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, 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(); res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: 8 * 3600 })); res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: 8 * 3600, httpOnly: false })); res.json({ student_id: student.student_id, display_name: student.display_name }); }); app.post('/api/logout', async (req, res) => { if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' }); const token = parseCookies(req)[STUDENT_COOKIE]; // Resolved BEFORE destroying, purely to know who to abandon open attempts for (Phase 4.1 // §13) — deliberately not behind studentSession middleware so logout still succeeds // gracefully against an already-invalid/expired token, same as before this phase. const studentId = await sessions.resolveStudentSession(getPool(), token); await sessions.destroyStudentSession(getPool(), token); if (studentId) await attemptsLib.abandonOpenAttemptsForStudent(getPool(), studentId); res.append('Set-Cookie', clearCookie(STUDENT_COOKIE)); res.append('Set-Cookie', clearCookie(STUDENT_CSRF_COOKIE)); res.json({ ok: true }); }); // GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab // ownership checks. Read-only, so it isn't behind the CSRF check (studentSession only enforces // CSRF on non-GET methods — see checkCsrf). app.get('/api/session/whoami', wrap(studentSession), (req, res) => { res.json({ student_id: req.studentId }); }); // ═══════════════════════════════════════════════════════════════════════════════════════ // STUDENT-FACING: curriculum / progress // ═══════════════════════════════════════════════════════════════════════════════════════ app.get('/api/curriculum/path', wrap(studentSession), async (req, res) => { const path = curriculum.loadCurriculumPath(); if (!path) return res.status(404).json({ error: 'Curriculum path not found' }); const scenarios = curriculum.loadScenarios(); const progress = await loadProgress(getPool(), req.studentId); let firstIncompleteStage = null; const stages = path.stages.map((stage) => { const scenarioList = stage.scenario_ids.map((id) => { const s = scenarios.find((x) => x.id === id); return { id, title: s?.title || id, difficulty: s?.difficulty || null, status: progress[id]?.status || 'not_started' }; }); const total = scenarioList.length; const completed = scenarioList.filter((s) => s.status === 'completed').length; const isComplete = total > 0 && completed === total; if (!isComplete && !stage.locked && firstIncompleteStage === null) firstIncompleteStage = stage.id; return { id: stage.id, name: stage.name, icon: stage.icon, locked: !!stage.locked, scenarios: scenarioList, stats: { total, completed } }; }); res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage }); }); app.get('/api/progress', wrap(studentSession), async (req, res) => { const scenarios = curriculum.loadScenarios(); const progress = await loadProgress(getPool(), req.studentId); const total = scenarios.length; const completed = Object.values(progress).filter((p) => p.status === 'completed').length; res.json({ total, completed, details: progress }); }); app.post('/api/progress/record', wrap(studentSession), async (req, res) => { const { scenario_id, status, environment_id } = req.body || {}; if (!scenario_id || !['completed', 'in_progress'].includes(status)) { return res.status(400).json({ error: 'scenario_id and a valid status are required' }); } const pool = getPool(); const passed = status === 'completed'; // Attempt telemetry (Phase 3B) — one row per continuous working session, updated in place. const curriculumPath = curriculum.loadCurriculumPath(); const stage = curriculumPath?.stages.find((s) => (s.scenario_ids || []).includes(scenario_id))?.id || null; const { rows: enrolledRows } = await pool.query( `SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`, [req.studentId] ); const cohortId = enrolledRows[0]?.cohort_id || null; const assessmentCtx = await assessments.findActiveAssessmentContextForScenario(pool, req.studentId, scenario_id); const attempt = await attemptsLib.recordValidation(pool, { studentId: req.studentId, scenarioId: scenario_id, environmentId: environment_id || null, cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id, }, passed); // Legacy aggregate progress row (Phase 1/2A/2B) — kept for the existing curriculum/dashboard // views that read it; attempt-level detail above is the new source of truth for telemetry. const progress = await loadProgress(pool, req.studentId); const prev = progress[scenario_id] || { attempts: 0 }; const now = new Date().toISOString(); await saveOneProgress(pool, req.studentId, scenario_id, { ...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now, completed_at: passed ? now : prev.completed_at, }); await touchStudent(pool, req.studentId); if (passed) { if (assessmentCtx) { const finalizedSession = await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, true); await achievements.recordAssessmentAchievement(pool, finalizedSession); } if (curriculumPath) { const freshProgress = await loadProgress(pool, req.studentId); const state = computeCurriculumState(curriculumPath, freshProgress); await achievements.checkStageAndCurriculumAchievements(pool, { studentId: req.studentId, path: curriculumPath, curriculumState: state }); } } else if (assessmentCtx) { await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, false); } res.json({ ok: true }); }); // ═══════════════════════════════════════════════════════════════════════════════════════ // STUDENT-FACING: environment lifecycle // ═══════════════════════════════════════════════════════════════════════════════════════ async function publicEnvView(env) { if (!env) return { status: 'none' }; const view = { status: env.status }; if (env.status === 'running') { let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null; if (!token) { const { status, body } = await provisioner.getToken(env.environment_id); if (status === 200) token = body.routing_token; } if (token) view.launch_path = `/lab/${token}/`; } if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.'; return view; } app.get('/api/environment/status', wrap(studentSession), async (req, res) => { const { status, body } = await provisioner.listForStudent(req.studentId); if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' }); const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error'); res.json(await publicEnvView(active)); }); app.post('/api/environment/launch', wrap(studentSession), async (req, res) => { const existing = await provisioner.listForStudent(req.studentId); const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status)); if (active) return res.json(await publicEnvView(active)); const { status, body } = await provisioner.provision(req.studentId); if (status === 409) { const retry = await provisioner.listForStudent(req.studentId); return res.json(await publicEnvView(retry.body?.[0])); } // Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic // provision_failed 502 below. The student sees "temporarily full, try again shortly," never // a raw 500/502 — this is expected operational backpressure, not an application error. if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') { return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' }); } if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error }); res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token })); }); app.post('/api/environment/reset', wrap(studentSession), async (req, res) => { const existing = await provisioner.listForStudent(req.studentId); const env = existing.body?.find((e) => e.status !== 'destroyed'); if (!env) return res.status(404).json({ error: 'no_environment' }); // Before tearing the old container down — Phase 4.1 §13, see attempts.js's // abandonOpenAttemptsForEnvironment. The reset reuses the same environment_id (fresh // container, same DB row — provisioner lifecycle.js), so this must run BEFORE reset, not // after, or the abandonment would incorrectly apply to attempts against the NEW container. await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), env.environment_id); const { status, body } = await provisioner.reset(env.environment_id); if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error }); res.json(await publicEnvView(body)); }); // Internal — the Gateway calls this to resolve a routing token to its owning environment. app.get('/api/environment/by-token/:token', async (req, res) => { const { status, body } = await provisioner.getEnvironmentByToken(req.params.token); res.status(status).json(body); }); // ═══════════════════════════════════════════════════════════════════════════════════════ // INSTRUCTOR-FACING: auth + admin API // ═══════════════════════════════════════════════════════════════════════════════════════ 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()); require('./admin-routes')(app, { instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, saveOneProgress, recordAudit, }); 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')); }); // Last-resort error handler — an unhandled DB error (or anything else) in any async route // reaches here via next(err) instead of crashing the process or leaking a stack trace to the // client. Every new async route/middleware added in Phase 3A funnels through this. app.use((err, req, res, next) => { console.error('[unhandled route error]', err); if (res.headersSent) return next(err); res.status(500).json({ error: 'internal_error' }); }); async function main() { const applied = await runMigrations(); if (applied.length) console.log('Applied migrations:', applied.join(', ')); sessions.startSessionSweeper(getPool()); startLoginAttemptSweeper(getPool()); // Phase 4.1 §15 app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`)); } if (require.main === module) { main().catch((e) => { console.error('FATAL:', e); process.exit(1); }); } module.exports = { app };