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:
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user