From 6b52c9e1d2e3f9154d70223a1cdd8912f0ef7a62 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 12:22:58 -0500 Subject: [PATCH] feat(platform-api): split lightweight platform service from Lab image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New platform/api/ service owns students, cohorts, curriculum, progress, scenario attempts, and student/admin sessions. No k3s, no --privileged, no terminal runtime — starts instantly, unlike the old identity service which booted an unused k3s cluster just to serve roster/progress APIs. Provisioner calls (student and admin) are consolidated here too, in provisioner-client.js, so it's the only service that talks to the Provisioner directly. --- platform/api/Dockerfile | 42 +++ platform/api/README.md | 58 ++++ platform/api/migrate-sqlite.js | 121 +++++++ platform/api/migrations/001_students.sql | 16 + platform/api/migrations/002_cohorts.sql | 28 ++ .../migrations/003_curriculum_assignments.sql | 16 + platform/api/migrations/004_progress.sql | 18 ++ .../api/migrations/005_scenario_attempts.sql | 29 ++ platform/api/migrations/006_exam_sessions.sql | 24 ++ platform/api/package.json | 16 + platform/api/src/admin-auth.js | 29 ++ platform/api/src/admin-routes.js | 300 ++++++++++++++++++ platform/api/src/curriculum.js | 37 +++ platform/api/src/db.js | 60 ++++ platform/api/src/progress.js | 34 ++ platform/api/src/provisioner-client.js | 38 +++ platform/api/src/server.js | 243 ++++++++++++++ platform/api/src/sessions.js | 67 ++++ 18 files changed, 1176 insertions(+) create mode 100644 platform/api/Dockerfile create mode 100644 platform/api/README.md create mode 100644 platform/api/migrate-sqlite.js create mode 100644 platform/api/migrations/001_students.sql create mode 100644 platform/api/migrations/002_cohorts.sql create mode 100644 platform/api/migrations/003_curriculum_assignments.sql create mode 100644 platform/api/migrations/004_progress.sql create mode 100644 platform/api/migrations/005_scenario_attempts.sql create mode 100644 platform/api/migrations/006_exam_sessions.sql create mode 100644 platform/api/package.json create mode 100644 platform/api/src/admin-auth.js create mode 100644 platform/api/src/admin-routes.js create mode 100644 platform/api/src/curriculum.js create mode 100644 platform/api/src/db.js create mode 100644 platform/api/src/progress.js create mode 100644 platform/api/src/provisioner-client.js create mode 100644 platform/api/src/server.js create mode 100644 platform/api/src/sessions.js diff --git a/platform/api/Dockerfile b/platform/api/Dockerfile new file mode 100644 index 0000000..5bb1795 --- /dev/null +++ b/platform/api/Dockerfile @@ -0,0 +1,42 @@ +# RootNode Platform API — lightweight, no k3s, no --privileged, no Lab terminal runtime. +# This is the fix for the Phase 2A "identity" instance problem: that was a full, +# --privileged Lab image booting a k3s cluster nobody used, just to serve roster/curriculum/ +# progress APIs. This image never runs k3s and needs no elevated privileges at all. +# +# Built from the REPO ROOT as build context (see platform/docker-compose.dev.yml's +# `context: .. / dockerfile: platform/api/Dockerfile`), because it needs read-only access to +# scenarios/ and the frontend source — both live outside platform/api/'s own directory. +# +# Multi-stage: the root .dockerignore excludes frontend/dist/ from every build context (it's a +# build artifact, and the main Lab image always builds it fresh — see that Dockerfile's +# node-pty fix commit for why leaking host-built artifacts into an image is actively +# dangerous). Rather than special-case that, this image builds the frontend fresh too, the +# same way the Lab image does, then discards the Node/npm frontend toolchain from the final +# stage — the running image only ever needs the static output. + +FROM node:20-slim AS frontend-build +WORKDIR /fe +COPY frontend/package.json frontend/vite.config.js ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +FROM node:20-slim +WORKDIR /app +COPY platform/api/package.json ./ +RUN npm install --production +COPY platform/api/src/ ./src/ +COPY platform/api/migrations/ ./migrations/ +COPY platform/api/migrate-sqlite.js ./ + +# Read-only curriculum content — same source the Lab image ships, needed here purely for +# listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only). +COPY scenarios/ ./scenarios/ + +# Platform-shell + admin frontend, built in the stage above — served statically, no nginx +# needed for a lightweight Node API+static server. +COPY --from=frontend-build /fe/dist/ /frontend-dist/ +ENV FRONTEND_DIST_DIR=/frontend-dist + +EXPOSE 4300 +CMD ["node", "src/server.js"] diff --git a/platform/api/README.md b/platform/api/README.md new file mode 100644 index 0000000..fa976e7 --- /dev/null +++ b/platform/api/README.md @@ -0,0 +1,58 @@ +# RootNode Academy — Platform API (Phase 2B) + +Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See +`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the design; this is the operational reference. + +## Configuration reference + +| Variable | Required | Meaning | +|---|---|---| +| `PLATFORM_DB_URL` | no (defaults to local dev Postgres) | Postgres connection string | +| `PROVISIONER_URL` | no | Provisioner base URL | +| `PROVISIONER_SHARED_SECRET` | **yes** | Internal Provisioner auth — process refuses to start without it | +| `ADMIN_PASSWORD` | **yes** | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext | +| `PLATFORM_API_PORT` | no | Listen port, default 4300 | +| `FRONTEND_DIST_DIR` | no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev | +| `SCENARIOS_DIR` / `BUNDLES_DIR` / `CURRICULUM_PATH_FILE` | no | Override the default `scenarios/` locations, mainly for local dev outside Docker | + +## Local dev + +```bash +cd platform +echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env +echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env +docker compose -f docker-compose.dev.yml build +docker compose -f docker-compose.dev.yml up -d +``` + +Everything is reachable through the Gateway at `http://localhost:4200` — students at `/`, +instructors at `/admin`. Platform API itself isn't meant to be hit directly by a browser (same +boundary as the Provisioner relative to Platform API), though nothing stops you from curling +it directly at `:4300` for local debugging. + +## SQLite migration + +One-time cutover, run against the real data volume: + +```bash +docker run --rm -v :/data \ + -e SQLITE_PATH=/data/progress.db -e PLATFORM_DB_URL=postgres://postgres:postgres@:5433/rootnode_platform \ + node migrate-sqlite.js +``` + +Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see +`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §2/§3 for the full guarantees and rollback +procedure. + +## Tests + +`platform/test/e2e-phase2a.js` (student-flow regression, cookie-based) and +`platform/test/e2e-phase2b.js` (the classroom acceptance workflow: cohort → students → +curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives). +Both need the full stack up and `ADMIN_PASSWORD` set — see `platform/README.md`. + +## Known limitations + +See `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 — in-memory sessions (a restart logs +everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential +rather than per-instructor accounts. diff --git a/platform/api/migrate-sqlite.js b/platform/api/migrate-sqlite.js new file mode 100644 index 0000000..9cd3a52 --- /dev/null +++ b/platform/api/migrate-sqlite.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// SQLite -> Postgres migration — the one-time cutover for Phase 2B. Run once against the real +// data volume before treating Postgres as authoritative. See +// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Migration process / rollback" for the full +// procedure and how to roll back. +// +// Usage: +// SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js +// +// Guarantees: +// - Backs up the SQLite file before touching anything (never modifies or deletes the source). +// - Idempotent: safe to re-run. Every write uses ON CONFLICT — re-running re-syncs from the +// SQLite source, it does not duplicate rows or fail on a second run. +// - Never silently drops data: prints (and writes to a report file) exactly what was read, +// what was written, and anything that couldn't be migrated and why. +// - Preserves the old students.cohort column as a real cohort + enrollment, not just a +// dropped string. + +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); +const { getPool, runMigrations } = require('./src/db'); + +const SQLITE_PATH = process.env.SQLITE_PATH || '/data/progress.db'; + +async function main() { + const report = { students: 0, cohorts_created: 0, enrollments: 0, progress_rows: 0, exam_sessions: 0, skipped: [] }; + + if (!fs.existsSync(SQLITE_PATH)) { + console.error(`SQLite file not found at ${SQLITE_PATH} — nothing to migrate. Set SQLITE_PATH if it's elsewhere.`); + process.exit(1); + } + + // 1. Backup — never touch the only copy of pre-migration data. + const backupPath = `${SQLITE_PATH}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`; + fs.copyFileSync(SQLITE_PATH, backupPath); + console.log(`Backed up ${SQLITE_PATH} -> ${backupPath}`); + + const sqlite = new Database(SQLITE_PATH, { readonly: true }); + await runMigrations(); + const pool = getPool(); + + // 2. Students + cohorts + enrollments + const studentRows = safeAll(sqlite, `SELECT * FROM students`, report, 'students'); + const cohortsSeen = new Set(); + for (const s of studentRows) { + await pool.query( + `INSERT INTO students (student_id, display_name, created_at, last_active_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (student_id) DO UPDATE SET display_name = excluded.display_name, last_active_at = excluded.last_active_at`, + [s.student_id, s.display_name, s.created_at || new Date().toISOString(), s.last_active_at || null] + ); + report.students++; + + if (s.cohort && !cohortsSeen.has(s.cohort)) { + cohortsSeen.add(s.cohort); + const { rowCount } = await pool.query( + `INSERT INTO cohorts (cohort_id, name) VALUES ($1, $1) ON CONFLICT (cohort_id) DO NOTHING`, + [s.cohort] + ); + if (rowCount) report.cohorts_created++; + } + if (s.cohort) { + await pool.query( + `INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [s.cohort, s.student_id] + ); + report.enrollments++; + } + } + + // 3. Progress + const progressRows = safeAll(sqlite, `SELECT * FROM progress`, report, 'progress'); + for (const p of progressRows) { + await pool.query( + `INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (student_id, scenario_id) DO UPDATE SET + status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated, + completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes, + time_spent_seconds = excluded.time_spent_seconds`, + [p.student_id, p.scenario_id, p.status, p.attempts || 0, p.last_validated, p.completed_at, p.started_at, p.notes, p.time_spent_seconds || 0] + ); + report.progress_rows++; + } + + // 4. Exam sessions (historical import only — see migrations/006_exam_sessions.sql note) + const sessionRows = safeAll(sqlite, `SELECT * FROM sessions`, report, 'sessions'); + for (const s of sessionRows) { + await pool.query( + `INSERT INTO exam_sessions (session_id, student_id, bundle_id, started_at, submitted_at, status, exam_minutes, duration_secs, snapshot, scenario_ids) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (session_id) DO UPDATE SET status = excluded.status, submitted_at = excluded.submitted_at`, + [s.id, s.student_id || null, s.bundle_id, s.started_at, s.submitted_at, s.status, s.exam_minutes, s.duration_secs, + s.snapshot ? JSON.parse(s.snapshot) : null, s.scenario_ids ? JSON.parse(s.scenario_ids) : null] + ); + report.exam_sessions++; + } + + sqlite.close(); + + const reportPath = path.join(path.dirname(SQLITE_PATH), `migration-report-${Date.now()}.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + console.log('\nMigration report:'); + console.log(JSON.stringify(report, null, 2)); + console.log(`\nFull report written to ${reportPath}`); + console.log(`Backup preserved at ${backupPath} — not deleted, not modified.`); + await pool.end(); +} + +function safeAll(sqlite, sql, report, label) { + try { + return sqlite.prepare(sql).all(); + } catch (e) { + report.skipped.push({ table: label, reason: e.message }); + console.warn(`Could not read ${label}: ${e.message} (table may not exist in this SQLite file — recorded in report, not fatal)`); + return []; + } +} + +main().catch((e) => { console.error('Migration failed:', e); process.exit(1); }); diff --git a/platform/api/migrations/001_students.sql b/platform/api/migrations/001_students.sql new file mode 100644 index 0000000..bf368cd --- /dev/null +++ b/platform/api/migrations/001_students.sql @@ -0,0 +1,16 @@ +-- students — migrated off the Lab image's SQLite (Phase 1/2A). This is the FIRST table in +-- Postgres to become authoritative for platform academic/identity data — environments.student_id +-- (Provisioner schema) stays an opaque TEXT reference, no cross-database FK games; a real FK +-- from environments to this table is a natural follow-up once both are settled, not required +-- for Phase 2B. + +CREATE TABLE IF NOT EXISTS students ( + student_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'inactive' + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_active_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_students_status ON students(status); diff --git a/platform/api/migrations/002_cohorts.sql b/platform/api/migrations/002_cohorts.sql new file mode 100644 index 0000000..7b7c525 --- /dev/null +++ b/platform/api/migrations/002_cohorts.sql @@ -0,0 +1,28 @@ +-- cohorts + cohort_enrollments — replaces the old students.cohort TEXT column with a real +-- many-to-many-capable model (a student can move between cohorts over time; the old model +-- couldn't represent that at all). + +CREATE TABLE IF NOT EXISTS cohorts ( + cohort_id TEXT PRIMARY KEY, -- slug, e.g. 'root-fall-2026' + name TEXT NOT NULL, + code TEXT UNIQUE, -- optional short join-style code, distinct from cohort_id + tier TEXT, -- e.g. 'root' — free text, not an enum yet (YAGNI) + start_date DATE, + end_date DATE, + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'archived' + seat_target INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_cohorts_status ON cohorts(status); + +CREATE TABLE IF NOT EXISTS cohort_enrollments ( + cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id), + student_id TEXT NOT NULL REFERENCES students(student_id), + enrollment_status TEXT NOT NULL DEFAULT 'enrolled', -- 'enrolled' | 'removed' + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (cohort_id, student_id) +); + +CREATE INDEX IF NOT EXISTS idx_enrollments_student ON cohort_enrollments(student_id); diff --git a/platform/api/migrations/003_curriculum_assignments.sql b/platform/api/migrations/003_curriculum_assignments.sql new file mode 100644 index 0000000..979a310 --- /dev/null +++ b/platform/api/migrations/003_curriculum_assignments.sql @@ -0,0 +1,16 @@ +-- curriculum_assignments — which curriculum path a cohort or an individual student is on. +-- Only one curriculum exists today (scenarios/rootnode-path.json, "RootNode Learning Path") — +-- this table exists so assigning it is a real, visible instructor action (and so a second path +-- can be added later without a schema change), not because multiple paths exist yet. + +CREATE TABLE IF NOT EXISTS curriculum_assignments ( + assignment_id BIGSERIAL PRIMARY KEY, + cohort_id TEXT REFERENCES cohorts(cohort_id), + student_id TEXT REFERENCES students(student_id), + curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path', + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT NULL) +); + +CREATE INDEX IF NOT EXISTS idx_curriculum_cohort ON curriculum_assignments(cohort_id); +CREATE INDEX IF NOT EXISTS idx_curriculum_student ON curriculum_assignments(student_id); diff --git a/platform/api/migrations/004_progress.sql b/platform/api/migrations/004_progress.sql new file mode 100644 index 0000000..90e4870 --- /dev/null +++ b/platform/api/migrations/004_progress.sql @@ -0,0 +1,18 @@ +-- progress — same shape as the SQLite table it replaces (student_id, scenario_id composite +-- key), migrated data-for-data. Postgres is authoritative for this after migration runs. + +CREATE TABLE IF NOT EXISTS progress ( + student_id TEXT NOT NULL REFERENCES students(student_id), + scenario_id TEXT NOT NULL, + status TEXT, + attempts INTEGER DEFAULT 0, + last_validated TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + notes TEXT, + time_spent_seconds INTEGER DEFAULT 0, + PRIMARY KEY (student_id, scenario_id) +); + +CREATE INDEX IF NOT EXISTS idx_progress_student ON progress(student_id); +CREATE INDEX IF NOT EXISTS idx_progress_status ON progress(status); diff --git a/platform/api/migrations/005_scenario_attempts.sql b/platform/api/migrations/005_scenario_attempts.sql new file mode 100644 index 0000000..3020c02 --- /dev/null +++ b/platform/api/migrations/005_scenario_attempts.sql @@ -0,0 +1,29 @@ +-- scenario_attempts — new capability, did not exist before Phase 2B. The old model only ever +-- kept aggregate progress (status + a bare attempts counter); this is a per-attempt log, +-- giving instructors real "validation outcomes where recorded" visibility instead of just a +-- final status. +-- +-- hint_count is deliberately nullable and unpopulated: the existing Lab engine's hints are +-- static UI content only (frontend/src/components/ScenarioPanel.jsx renders them from the +-- scenario JSON) — nothing server-side ever records a hint being viewed. Do not invent a +-- number here. If hint-view tracking is added to the Lab engine later, this column is ready +-- for it without a schema change. + +CREATE TABLE IF NOT EXISTS scenario_attempts ( + attempt_id BIGSERIAL PRIMARY KEY, + student_id TEXT NOT NULL REFERENCES students(student_id), + scenario_id TEXT NOT NULL, + environment_id UUID REFERENCES environments(environment_id), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + result TEXT NOT NULL, -- 'passed' | 'failed' + validation_count INTEGER, -- number of validation checks run this attempt + hint_count INTEGER, -- NULL — not measurable today, see note above + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_attempts_student ON scenario_attempts(student_id); +CREATE INDEX IF NOT EXISTS idx_attempts_scenario ON scenario_attempts(scenario_id); +CREATE INDEX IF NOT EXISTS idx_attempts_environment ON scenario_attempts(environment_id); +CREATE INDEX IF NOT EXISTS idx_attempts_created ON scenario_attempts(created_at); diff --git a/platform/api/migrations/006_exam_sessions.sql b/platform/api/migrations/006_exam_sessions.sql new file mode 100644 index 0000000..4ad6375 --- /dev/null +++ b/platform/api/migrations/006_exam_sessions.sql @@ -0,0 +1,24 @@ +-- exam_sessions — historical import of the old SQLite `sessions` table (exam mode records). +-- Scope note (see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations"): exam mode's +-- LIVE session bookkeeping (start/submit/shuffle/abandon) remains Lab-image-local for Phase +-- 2B — it's tightly coupled to a single Lab's live cluster state the same way scenario +-- validation is, and productionizing it would need the same relay pattern progress got in +-- Phase 2A, which is scoped out here (not in the Phase 2B acceptance test). This table exists +-- so historical exam records survive the SQLite migration and are visible to instructors, not +-- because new exam sessions get written here yet. + +CREATE TABLE IF NOT EXISTS exam_sessions ( + session_id TEXT PRIMARY KEY, + student_id TEXT REFERENCES students(student_id), + bundle_id TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + submitted_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'active', + exam_minutes INTEGER NOT NULL DEFAULT 120, + duration_secs INTEGER, + snapshot JSONB, + scenario_ids JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_exam_sessions_student ON exam_sessions(student_id); diff --git a/platform/api/package.json b/platform/api/package.json new file mode 100644 index 0000000..e325949 --- /dev/null +++ b/platform/api/package.json @@ -0,0 +1,16 @@ +{ + "name": "rootnode-platform-api", + "version": "0.1.0", + "description": "RootNode Academy Platform API — students, cohorts, curriculum, progress, sessions, instructor/admin control plane. No k3s, no --privileged, no Lab terminal runtime.", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "migrate-sqlite": "node migrate-sqlite.js" + }, + "dependencies": { + "express": "^4.19.2", + "pg": "^8.11.5", + "better-sqlite3": "^9.4.3" + } +} diff --git a/platform/api/src/admin-auth.js b/platform/api/src/admin-auth.js new file mode 100644 index 0000000..3af5690 --- /dev/null +++ b/platform/api/src/admin-auth.js @@ -0,0 +1,29 @@ +// Admin password verification. Phase 2B scope, explicitly not production SSO/OIDC (that's +// Phase 3+, documented in docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md). +// +// ADMIN_PASSWORD is read from the environment (the same secret-injection channel every other +// component here uses — PROVISIONER_SHARED_SECRET, etc. — not a new pattern). It is never +// stored in a database or logged. Comparison is done via scrypt + a timing-safe compare rather +// than a plain string comparison, so a login attempt never directly compares raw password +// bytes (defense in depth, cheap to do correctly with only Node's stdlib — no new dependency). + +const crypto = require('crypto'); + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +const SALT = 'rootnode-admin-fixed-salt-v1'; // fixed salt is fine here: single credential, not + // a multi-user password table needing per-row salts + +if (!ADMIN_PASSWORD) { + console.error('FATAL: ADMIN_PASSWORD is not set. Refusing to start with no way to authenticate an instructor.'); + process.exit(1); +} + +const expectedHash = crypto.scryptSync(ADMIN_PASSWORD, SALT, 64); + +function verifyAdminPassword(submitted) { + if (!submitted || typeof submitted !== 'string') return false; + const submittedHash = crypto.scryptSync(submitted, SALT, 64); + return crypto.timingSafeEqual(expectedHash, submittedHash); +} + +module.exports = { verifyAdminPassword }; diff --git a/platform/api/src/admin-routes.js b/platform/api/src/admin-routes.js new file mode 100644 index 0000000..15c918b --- /dev/null +++ b/platform/api/src/admin-routes.js @@ -0,0 +1,300 @@ +// Instructor/Admin API — /api/admin/*. Every route here requires an admin session +// (adminSession middleware), completely separate from student sessions (different cookie, +// different token namespace, see sessions.js). Student sessions can never call these routes; +// admin sessions can never be used for /api/environment/* student routes either, since those +// require a *student* session specifically. +// +// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset, +// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as +// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12). + +module.exports = function registerAdminRoutes(app, deps) { + const { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps; + + // ── Admin auth ─────────────────────────────────────────────────────────────────────── + app.post('/api/admin/login', (req, res) => { + const { password } = req.body || {}; + if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' }); + const token = sessions.createAdminSession(); + res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`); + res.json({ ok: true }); + }); + + app.post('/api/admin/logout', (req, res) => { + const header = req.headers.cookie || ''; + const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE]; + sessions.destroyAdminSession(token); + res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`); + res.json({ ok: true }); + }); + + app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true })); + + const admin = express_router_guard(app, adminSession); + + // ── Dashboard ──────────────────────────────────────────────────────────────────────── + admin.get('/api/admin/dashboard', async (req, res) => { + const pool = getPool(); + const [cohorts, students, envs] = await Promise.all([ + pool.query(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`), + pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`), + pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`), + ]); + const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n])); + const recentCompletions = await pool.query( + `SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at + FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id + WHERE sa.result = 'passed' ORDER BY sa.completed_at DESC LIMIT 10` + ); + const recentFailures = await pool.query( + `SELECT student_id, lab_host_id, status, error_reason, started_at + FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10` + ); + res.json({ + active_cohorts: cohorts.rows[0].n, + total_students: students.rows[0].n, + labs: { + running: envCounts.running || 0, + provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0), + error: envCounts.error || 0, + idle_or_destroyed: envCounts.destroyed || 0, + }, + recent_completions: recentCompletions.rows, + recent_failures: recentFailures.rows, + }); + }); + + // ── Students ───────────────────────────────────────────────────────────────────────── + admin.get('/api/admin/students', async (req, res) => { + const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`); + res.json(rows); + }); + + admin.post('/api/admin/students', async (req, res) => { + const { display_name, cohort_id } = 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()]); + if (cohort_id) { + await pool.query( + `INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [cohort_id, studentId] + ); + } + res.status(201).json({ student_id: studentId, display_name: display_name.trim() }); + }); + + admin.patch('/api/admin/students/:id', async (req, res) => { + const { display_name, status } = req.body || {}; + const fields = [], values = []; + if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); } + if (status && ['active', 'inactive'].includes(status)) { fields.push(`status = $${fields.length + 1}`); values.push(status); } + if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); + fields.push(`updated_at = now()`); + values.push(req.params.id); + const { rowCount } = await getPool().query(`UPDATE students SET ${fields.join(', ')} WHERE student_id = $${values.length}`, values); + if (!rowCount) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); + }); + + admin.get('/api/admin/students/:id/progress', async (req, res) => { + const progress = await loadProgress(getPool(), req.params.id); + const path = curriculum.loadCurriculumPath(); + const scenarios = curriculum.loadScenarios(); + if (!path) return res.json({ progress }); + const stages = path.stages.map((stage) => ({ + id: stage.id, name: stage.name, + scenarios: stage.scenario_ids.map((sid) => ({ + id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid, + status: progress[sid]?.status || 'not_started', + })), + })); + res.json({ stages }); + }); + + admin.get('/api/admin/students/:id/attempts', async (req, res) => { + const { rows } = await getPool().query( + `SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id] + ); + res.json(rows); + }); + + admin.get('/api/admin/students/:id/environment', async (req, res) => { + const { status, body } = await provisioner.listForStudent(req.params.id); + if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' }); + res.json(body); + }); + + admin.post('/api/admin/students/:id/environment/provision', async (req, res) => { + const { status, body } = await provisioner.provision(req.params.id); + res.status(status === 409 ? 200 : status).json(body); + }); + + admin.post('/api/admin/students/:id/cohort', async (req, res) => { + const { cohort_id } = req.body || {}; + if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' }); + await getPool().query( + `INSERT INTO cohort_enrollments (cohort_id, student_id, enrollment_status) VALUES ($1, $2, 'enrolled') + ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`, + [cohort_id, req.params.id] + ); + res.json({ ok: true }); + }); + + admin.delete('/api/admin/students/:id/cohort/:cohortId', async (req, res) => { + await getPool().query( + `UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`, + [req.params.cohortId, req.params.id] + ); + res.json({ ok: true }); + }); + + // ── Cohorts ────────────────────────────────────────────────────────────────────────── + admin.get('/api/admin/cohorts', async (req, res) => { + const { rows } = await getPool().query(` + SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count + FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id + GROUP BY c.cohort_id ORDER BY c.created_at DESC + `); + res.json(rows); + }); + + admin.post('/api/admin/cohorts', async (req, res) => { + const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {}; + if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' }); + try { + await getPool().query( + `INSERT INTO cohorts (cohort_id, name, tier, start_date, end_date, seat_target) VALUES ($1, $2, $3, $4, $5, $6)`, + [cohort_id, name, tier || null, start_date || null, end_date || null, seat_target || null] + ); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' }); + throw e; + } + res.status(201).json({ cohort_id }); + }); + + admin.patch('/api/admin/cohorts/:id', async (req, res) => { + const { name, tier, start_date, end_date, status, seat_target } = req.body || {}; + const map = { name, tier, start_date, end_date, status, seat_target }; + const fields = [], values = []; + for (const [k, v] of Object.entries(map)) { + if (v !== undefined) { fields.push(`${k} = $${fields.length + 1}`); values.push(v); } + } + if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); + fields.push('updated_at = now()'); + values.push(req.params.id); + const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values); + if (!rowCount) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); + }); + + admin.get('/api/admin/cohorts/:id/students', async (req, res) => { + const { rows } = await getPool().query( + `SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_at + FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id + WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, + [req.params.id] + ); + res.json(rows); + }); + + admin.post('/api/admin/cohorts/:id/curriculum', async (req, res) => { + const { curriculum_path } = req.body || {}; + await getPool().query( + `INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`, + [req.params.id, curriculum_path || 'rootnode-path'] + ); + res.json({ ok: true }); + }); + + admin.get('/api/admin/cohorts/:id/progress', async (req, res) => { + const pool = getPool(); + const { rows: students } = await pool.query( + `SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`, + [req.params.id] + ); + const path = curriculum.loadCurriculumPath(); + if (!path) return res.json({ stages: [] }); + const stageTotals = path.stages.map((s) => ({ id: s.id, name: s.name, total: s.scenario_ids.length * students.length, completed: 0 })); + for (const { student_id } of students) { + const progress = await loadProgress(pool, student_id); + path.stages.forEach((stage, i) => { + stageTotals[i].completed += stage.scenario_ids.filter((sid) => progress[sid]?.status === 'completed').length; + }); + } + res.json({ + student_count: students.length, + stages: stageTotals.map((s) => ({ ...s, pct: s.total > 0 ? Math.round((s.completed / s.total) * 100) : 0 })), + }); + }); + + // ── Classroom prep: Prepare Labs / Destroy Cohort Labs ────────────────────────────────── + admin.post('/api/admin/cohorts/:id/prepare-labs', 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] + ); + // Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a + // 20-student cohort prep take the better part of an hour. Promise.allSettled so one + // student's failure doesn't block reporting on the rest. + 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 }; + })); + 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 }), + }); + }); + + admin.post('/api/admin/cohorts/:id/destroy-labs', async (req, res) => { + const { rows: students } = await getPool().query( + `SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`, + [req.params.id] + ); + const results = await Promise.allSettled(students.map(async (s) => { + const { body } = await provisioner.listForStudent(s.student_id); + const active = body?.find((e) => e.status !== 'destroyed'); + if (!active) return { student_id: s.student_id, status: 'none' }; + await provisioner.destroy(active.environment_id); + return { student_id: s.student_id, status: 'destroyed' }; + })); + // Academic progress is Postgres-side and untouched by any of this — destroying compute + // never touches the students/progress/scenario_attempts tables. + res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) }); + }); + + // ── Instructor environment controls ───────────────────────────────────────────────────── + admin.post('/api/admin/environments/:id/reset', async (req, res) => { + const { status, body } = await provisioner.reset(req.params.id); + res.status(status).json(body); + }); + + admin.delete('/api/admin/environments/:id', async (req, res) => { + const { status, body } = await provisioner.destroy(req.params.id); + res.status(status).json(body); + }); + + admin.get('/api/admin/environments/:id/events', async (req, res) => { + const { status, body } = await provisioner.events(req.params.id); + res.status(status).json(body); + }); +}; + +// Tiny helper so every admin.(...) call below doesn't need `adminSession` repeated +// as a second argument by hand — same effect, less visual noise. +function express_router_guard(app, adminSession) { + return { + get: (p, h) => app.get(p, adminSession, h), + post: (p, h) => app.post(p, adminSession, h), + patch: (p, h) => app.patch(p, adminSession, h), + delete: (p, h) => app.delete(p, adminSession, h), + }; +} diff --git a/platform/api/src/curriculum.js b/platform/api/src/curriculum.js new file mode 100644 index 0000000..e8d79d8 --- /dev/null +++ b/platform/api/src/curriculum.js @@ -0,0 +1,37 @@ +// Scenario/bundle/curriculum-path loading — same source files and cache pattern as +// backend/server.js (scenarios/data, scenarios/bundles, scenarios/rootnode-path.json). The +// Platform API only ever READS this content for listing/progress-shaping purposes; it never +// executes setup/validate/teardown commands — that stays exclusively the Lab image's job. + +const fs = require('fs'); +const path = require('path'); + +const SCENARIOS_DIR = process.env.SCENARIOS_DIR || path.join(__dirname, '../scenarios/data'); +const BUNDLES_DIR = process.env.BUNDLES_DIR || path.join(__dirname, '../scenarios/bundles'); +const CURRICULUM_PATH_FILE = process.env.CURRICULUM_PATH_FILE || path.join(__dirname, '../scenarios/rootnode-path.json'); + +function loadJsonDir(dir) { + return fs.readdirSync(dir).filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); +} + +let scenariosCache = []; +let bundlesCache = []; +let curriculumPathCache = null; + +function reloadCache() { + scenariosCache = loadJsonDir(SCENARIOS_DIR); + bundlesCache = loadJsonDir(BUNDLES_DIR); + curriculumPathCache = fs.existsSync(CURRICULUM_PATH_FILE) + ? JSON.parse(fs.readFileSync(CURRICULUM_PATH_FILE, 'utf8')) + : null; + console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`); +} +reloadCache(); + +module.exports = { + reloadCache, + loadScenarios: () => scenariosCache, + loadBundles: () => bundlesCache, + loadCurriculumPath: () => curriculumPathCache, +}; diff --git a/platform/api/src/db.js b/platform/api/src/db.js new file mode 100644 index 0000000..e82d804 --- /dev/null +++ b/platform/api/src/db.js @@ -0,0 +1,60 @@ +// Postgres connection + migration runner — same pattern as platform/provisioner/src/db.js +// (check-before-alter idempotency, tracked in schema_migrations). Same database +// (rootnode_platform) as the Provisioner, different table set: this service owns +// students/cohorts/enrollments/curriculum_assignments/progress/scenario_attempts/ +// exam_sessions; the Provisioner owns lab_hosts/environments/environment_events. Both connect +// to the same Postgres instance — scenario_attempts.environment_id is a real FK across that +// shared boundary, which Postgres allows regardless of which service's migrations created +// which table. + +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const MIGRATIONS_DIR = path.join(__dirname, '../migrations'); + +let _pool = null; +function getPool() { + if (_pool) return _pool; + _pool = new Pool({ + connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform', + }); + return _pool; +} + +async function runMigrations() { + const pool = getPool(); + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + const applied = new Set( + (await pool.query('SELECT version FROM schema_migrations')).rows.map((r) => r.version) + ); + + const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort(); + const ranNow = []; + for (const file of files) { + if (applied.has(file)) continue; + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [file]); + await client.query('COMMIT'); + ranNow.push(file); + } catch (e) { + await client.query('ROLLBACK'); + throw new Error(`Migration ${file} failed: ${e.message}`); + } finally { + client.release(); + } + } + return ranNow; +} + +module.exports = { getPool, runMigrations }; diff --git a/platform/api/src/progress.js b/platform/api/src/progress.js new file mode 100644 index 0000000..8e2b7a5 --- /dev/null +++ b/platform/api/src/progress.js @@ -0,0 +1,34 @@ +// Postgres-backed progress store — same shape/semantics as the SQLite loadProgress/saveProgress +// it replaces in backend/server.js, now backed by the `progress` table (migrations/004). + +async function loadProgress(pool, studentId) { + const { rows } = await pool.query('SELECT * FROM progress WHERE student_id = $1', [studentId]); + return Object.fromEntries(rows.map((r) => [r.scenario_id, { + status: r.status, + attempts: r.attempts, + last_validated: r.last_validated, + completed_at: r.completed_at, + started_at: r.started_at, + notes: r.notes, + time_spent_seconds: r.time_spent_seconds || 0, + }])); +} + +async function saveOneProgress(pool, studentId, scenarioId, p) { + await pool.query( + `INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (student_id, scenario_id) DO UPDATE SET + status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated, + completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes, + time_spent_seconds = excluded.time_spent_seconds`, + [studentId, scenarioId, p.status || null, p.attempts || 0, p.last_validated || null, + p.completed_at || null, p.started_at || null, p.notes || null, p.time_spent_seconds || 0] + ); +} + +async function touchStudent(pool, studentId) { + await pool.query(`UPDATE students SET last_active_at = now() WHERE student_id = $1`, [studentId]).catch(() => {}); +} + +module.exports = { loadProgress, saveOneProgress, touchStudent }; diff --git a/platform/api/src/provisioner-client.js b/platform/api/src/provisioner-client.js new file mode 100644 index 0000000..f131add --- /dev/null +++ b/platform/api/src/provisioner-client.js @@ -0,0 +1,38 @@ +// Thin client for the Provisioner's internal API — same role as +// platform/gateway/src/provisioner-client.js. Phase 2B consolidates ALL Provisioner access +// (both student launch/reset and admin prepare/destroy/reset) into this one service, per +// "environment orchestration calls" being a Platform API responsibility +// (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §1). The Gateway no longer calls the Provisioner +// directly — it proxies /api/environment/* to here instead. + +const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4100'; +const PROVISIONER_SHARED_SECRET = process.env.PROVISIONER_SHARED_SECRET; + +if (!PROVISIONER_SHARED_SECRET) { + console.error('FATAL: PROVISIONER_SHARED_SECRET is not set. Refusing to start.'); + process.exit(1); +} + +async function call(method, path, body) { + const res = await fetch(`${PROVISIONER_URL}${path}`, { + method, + headers: { 'Content-Type': 'application/json', 'X-Provisioner-Key': PROVISIONER_SHARED_SECRET }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; try { json = JSON.parse(text); } catch { json = null; } + return { status: res.status, body: json }; +} + +const provisioner = { + getEnvironmentByToken: (token) => call('GET', `/environments/by-token/${encodeURIComponent(token)}`), + getEnvironment: (id) => call('GET', `/environments/${encodeURIComponent(id)}`), + getToken: (id) => call('GET', `/environments/${encodeURIComponent(id)}/token`), + listForStudent: (studentId) => call('GET', `/environments?student_id=${encodeURIComponent(studentId)}`), + provision: (studentId) => call('POST', '/environments', { student_id: studentId }), + reset: (id) => call('POST', `/environments/${encodeURIComponent(id)}/reset`), + destroy: (id) => call('DELETE', `/environments/${encodeURIComponent(id)}`), + events: (id) => call('GET', `/environments/${encodeURIComponent(id)}/events`), +}; + +module.exports = { provisioner }; diff --git a/platform/api/src/server.js b/platform/api/src/server.js new file mode 100644 index 0000000..a0506ce --- /dev/null +++ b/platform/api/src/server.js @@ -0,0 +1,243 @@ +// RootNode Academy — Platform API (Phase 2B). +// +// Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students, +// cohorts, enrollments, curriculum assignments, progress, scenario attempts, student/admin +// session identity, and all Provisioner (environment orchestration) calls — both the +// student-facing launch/reset path and the instructor/admin control plane. See +// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md. +// +// Reached only through the Gateway (platform/gateway) — never directly by browser JS, the +// same trust boundary the Provisioner already has relative to this service. + +const express = require('express'); +const path = require('path'); +const { getPool, runMigrations } = require('./db'); +const sessions = require('./sessions'); +const { verifyAdminPassword } = require('./admin-auth'); +const { provisioner } = require('./provisioner-client'); +const curriculum = require('./curriculum'); +const { loadProgress, saveOneProgress, touchStudent } = require('./progress'); + +const PORT = Number(process.env.PLATFORM_API_PORT) || 4300; +const STUDENT_COOKIE = 'rn_session'; +const ADMIN_COOKIE = 'rn_admin'; + +const app = express(); +app.use(express.json()); +const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist'); +app.use(express.static(FRONTEND_DIST)); + +function parseCookies(req) { + const header = req.headers.cookie; + if (!header) return {}; + return Object.fromEntries( + header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)]) + ); +} + +// ── Student session middleware ────────────────────────────────────────────────────────── +// This is the actual fix for the Phase 2A pilot ?sid= mechanism: student_id is derived ONLY +// from an opaque, server-issued session cookie the browser cannot fabricate — never from a +// header or query parameter a client controls. See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3. +function studentSession(req, res, next) { + const token = parseCookies(req)[STUDENT_COOKIE]; + const studentId = sessions.resolveStudentSession(token); + if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' }); + req.studentId = studentId; + next(); +} + +function adminSession(req, res, next) { + const token = parseCookies(req)[ADMIN_COOKIE]; + if (!sessions.resolveAdminSession(token)) { + return res.status(401).json({ error: 'no_admin_session', message: 'Instructor login required.' }); + } + next(); +} + +// ═══════════════════════════════════════════════════════════════════════════════════════ +// 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); +}); + +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 student = rows[0]; + if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' }); + await touchStudent(getPool(), student.student_id); + const token = sessions.createStudentSession(student.student_id); + res.append('Set-Cookie', `${STUDENT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`); + res.json({ student_id: student.student_id, display_name: student.display_name }); +}); + +app.post('/api/logout', (req, res) => { + const token = parseCookies(req)[STUDENT_COOKIE]; + sessions.destroyStudentSession(token); + res.append('Set-Cookie', `${STUDENT_COOKIE}=; Path=/; HttpOnly; Max-Age=0`); + res.json({ ok: true }); +}); + +// GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab +// ownership checks (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3). Also usable by the frontend. +app.get('/api/session/whoami', studentSession, (req, res) => { + res.json({ student_id: req.studentId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════════ +// STUDENT-FACING: curriculum / progress +// ═══════════════════════════════════════════════════════════════════════════════════════ + +app.get('/api/curriculum/path', 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', 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 }); +}); + +// POST /api/progress/record — Phase 2A's progress relay, now Postgres-backed and also logging +// a scenario_attempts row (new in Phase 2B — real per-attempt history, not just aggregate +// status). Called by the Gateway after a real validate/answer succeeded against the student's +// own Lab — never re-runs validation itself. See relayScenarioOutcome in +// platform/gateway/src/server.js. +app.post('/api/progress/record', studentSession, async (req, res) => { + const { scenario_id, status, environment_id, validation_count } = 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 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: status === 'completed' ? now : prev.completed_at, + }); + await pool.query( + `INSERT INTO scenario_attempts (student_id, scenario_id, environment_id, completed_at, result, validation_count) + VALUES ($1, $2, $3, now(), $4, $5)`, + [req.studentId, scenario_id, environment_id || null, status === 'completed' ? 'passed' : 'failed', validation_count ?? null] + ); + await touchStudent(pool, req.studentId); + res.json({ ok: true }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════════ +// STUDENT-FACING: environment lifecycle (moved here from the Gateway in Phase 2A — Phase 2B +// consolidates ALL Provisioner access into this one service) +// ═══════════════════════════════════════════════════════════════════════════════════════ + +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', 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', 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])); + } + 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', 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' }); + 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 +// (student_id, internal_ip, status) for Lab proxy ownership checks. Same trust boundary as +// the Provisioner's own by-token endpoint (internal callers only, never the browser). +app.get('/api/environment/by-token/:token', async (req, res) => { + const { status, body } = await provisioner.getEnvironmentByToken(req.params.token); + res.status(status).json(body); +}); + +require('./admin-routes')(app, { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress, saveOneProgress }); + +// Fallback to the platform-shell SPA for any unmatched non-API route +app.get('*', (req, res) => { + res.sendFile(path.join(FRONTEND_DIST, 'index.html')); +}); + +async function main() { + const applied = await runMigrations(); + if (applied.length) console.log('Applied migrations:', applied.join(', ')); + 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 }; diff --git a/platform/api/src/sessions.js b/platform/api/src/sessions.js new file mode 100644 index 0000000..f830225 --- /dev/null +++ b/platform/api/src/sessions.js @@ -0,0 +1,67 @@ +// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied, +// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess. +// In-memory, single-process: fine for a pilot admin/classroom tool (see +// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs +// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored). +// +// Two separate, non-overlapping session kinds (student / admin) so a student session can never +// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie +// names AND different token namespaces. + +const crypto = require('crypto'); + +const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session +const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session + +const studentSessions = new Map(); // token -> { student_id, expiresAt } +const adminSessions = new Map(); // token -> { expiresAt } + +function makeToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +function createStudentSession(studentId) { + const token = makeToken(); + studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS }); + return token; +} + +function resolveStudentSession(token) { + if (!token) return null; + const s = studentSessions.get(token); + if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; } + return s.student_id; +} + +function destroyStudentSession(token) { + studentSessions.delete(token); +} + +function createAdminSession() { + const token = makeToken(); + adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS }); + return token; +} + +function resolveAdminSession(token) { + if (!token) return false; + const s = adminSessions.get(token); + if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; } + return true; +} + +function destroyAdminSession(token) { + adminSessions.delete(token); +} + +// Periodic sweep so long-idle sessions don't accumulate forever in memory. +setInterval(() => { + const now = Date.now(); + for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token); + for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token); +}, 10 * 60 * 1000).unref(); + +module.exports = { + createStudentSession, resolveStudentSession, destroyStudentSession, + createAdminSession, resolveAdminSession, destroyAdminSession, +};