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,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