feat(platform-api): split lightweight platform service from Lab image

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.
This commit is contained in:
2026-08-18 12:22:58 -05:00
parent 42dfb1c31b
commit 6b52c9e1d2
18 changed files with 1176 additions and 0 deletions
+42
View File
@@ -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"]
+58
View File
@@ -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 <identity-data-volume>:/data \
-e SQLITE_PATH=/data/progress.db -e PLATFORM_DB_URL=postgres://postgres:postgres@<host>:5433/rootnode_platform \
<platform-api-image> 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.
+121
View File
@@ -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); });
+16
View File
@@ -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);
+28
View File
@@ -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);
@@ -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);
+18
View File
@@ -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);
@@ -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);
@@ -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);
+16
View File
@@ -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"
}
}
+29
View File
@@ -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 };
+300
View File
@@ -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.<verb>(...) 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),
};
}
+37
View File
@@ -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,
};
+60
View File
@@ -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 };
+34
View File
@@ -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 };
+38
View File
@@ -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 };
+243
View File
@@ -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 };
+67
View File
@@ -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,
};