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,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 };
|
||||
Reference in New Issue
Block a user