Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a950afb | ||
|
|
7baf7074e5 | ||
|
|
777efd168d | ||
|
|
b38c46aaec | ||
|
|
be92be81cd | ||
|
|
f25535027c | ||
|
|
3c7f0ca59c | ||
|
|
9d382c95c5 | ||
|
|
5183a6af09 | ||
|
|
1c08a30c7f | ||
|
|
23f881884d | ||
|
|
f89a769540 | ||
|
|
4bc202e5f3 | ||
|
|
1b6da0a966 | ||
|
|
17160da89e | ||
|
|
611e1ac4ba | ||
|
|
895e16fea4 | ||
|
|
896c230142 | ||
|
|
bfa16d9162 | ||
|
|
7557fdc753 | ||
|
|
5c7e49c399 | ||
|
|
ee8ccb2ab6 | ||
|
|
d34ddae458 | ||
|
|
2f09b1a498 | ||
|
|
5bc0743b08 | ||
|
|
f3c356951c | ||
|
|
f5110d3c1b | ||
|
|
a8e543234c | ||
|
|
addf474b70 | ||
|
|
6dda13b837 | ||
|
|
eae8e48d43 | ||
|
|
bf1e1b5c76 | ||
|
|
1d3afc736b | ||
|
|
51a09241ad | ||
|
|
67e172c800 | ||
|
|
b1b22b9285 | ||
|
|
4e8fa1d413 | ||
|
|
d2280d0f0d | ||
|
|
507ac4c5d3 | ||
|
|
cbf81b09e7 | ||
|
|
45a147a61f | ||
|
|
8956a95446 | ||
|
|
d4db8f99f5 | ||
|
|
7e5f357b6f | ||
|
|
4e3dacc043 | ||
|
|
0beb0e8f5e | ||
|
|
b908ade8e0 | ||
|
|
01a862e007 | ||
|
|
e793d9476b | ||
|
|
957a99645a | ||
|
|
60a1e79930 | ||
|
|
40692926ee | ||
|
|
face0ae132 | ||
|
|
1ca5dd3995 | ||
|
|
777a1ef880 | ||
|
|
7ce435ad32 | ||
|
|
62726455d5 | ||
|
|
37230d4ef1 | ||
|
|
85fa920f96 | ||
|
|
52c17c1907 | ||
|
|
08ff5315de | ||
|
|
6f1d202242 | ||
|
|
9b28d786de | ||
|
|
8e4ef9ca53 | ||
|
|
ae75bb4cbe | ||
|
|
6b52c9e1d2 | ||
|
|
42dfb1c31b | ||
|
|
05421bcb0e | ||
|
|
fb2945069c | ||
|
|
5e4f5518fd | ||
|
|
ce6abfe6c7 | ||
|
|
c89e39c5ee | ||
|
|
de03dbde9a | ||
|
|
a15897c78f | ||
|
|
17bd01045f | ||
|
|
9359abc9cb | ||
|
|
0116ee1522 | ||
|
|
c8dd33522f | ||
|
|
62241d26d2 | ||
|
|
60fbf0c385 |
@@ -0,0 +1,17 @@
|
||||
# Root cause of the node-pty "Cannot find module .../pty.node" failure (Phase 2A): with no
|
||||
# .dockerignore, a host-side `npm install` (e.g. for local testing) leaves backend/node_modules
|
||||
# on disk, built against the host's own Node/libnode — not the container's. The Dockerfile's
|
||||
# later `COPY backend/ ./backend/` (done after the npm-install RUN layer, for build caching)
|
||||
# then silently overwrites the correctly-built-in-container node_modules with that host copy,
|
||||
# which fails to load at runtime with an ABI/architecture mismatch. Native modules (node-pty,
|
||||
# better-sqlite3) MUST always be built inside the image, never leaked in from the host.
|
||||
#
|
||||
# IMPORTANT: bare `node_modules/` only matches a node_modules directory at the build context
|
||||
# ROOT in Docker's .dockerignore matching rules — it does NOT recursively match
|
||||
# backend/node_modules or frontend/node_modules. This bit us once already (fixed here with
|
||||
# `**/`): a first pass at this file used the bare pattern and the leak still happened.
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
.git/
|
||||
*.db
|
||||
*.db-journal
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
/data/
|
||||
.env
|
||||
.ansible/
|
||||
+22
-1
@@ -22,6 +22,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# node-gyp fix (Phase 2A root cause, see the note on the backend npm-install line below):
|
||||
# set as a persistent image ENV, not a one-off RUN prefix, so it applies no matter when or how
|
||||
# many times a native module gets (re)built in this image — inline `VAR=x npm install` only
|
||||
# scopes the var to that one RUN command, which isn't a strong enough guarantee.
|
||||
ENV npm_config_node_shared=false
|
||||
|
||||
# ── k3s (bundles kubectl, containerd, everything) ────────────────────────────
|
||||
# We download the binary and the airgap images so the cluster starts offline.
|
||||
RUN set -eux && \
|
||||
@@ -41,8 +47,23 @@ RUN set -eux && \
|
||||
WORKDIR /app
|
||||
|
||||
# Install backend dependencies
|
||||
#
|
||||
# npm_config_node_shared=false (set above, image-level) works around a real node-gyp bug
|
||||
# affecting native modules (node-pty, better-sqlite3): node-gyp generates build/config.gypi
|
||||
# from a Node headers/config reference it downloads for the target version, and for this
|
||||
# NodeSource-packaged Node build that reference incorrectly reports node_shared=true (a shared
|
||||
# libnode.so build) when the actual installed Node is statically linked (no libnode.so.<abi>
|
||||
# exists at all). Left uncorrected, node-gyp links native addons against -lnode, producing a
|
||||
# binary that fails at require() time with "libnode.so.<abi>: cannot open shared object file"
|
||||
# — this is the prior node-pty runtime failure ("Cannot find module .../pty.node"),
|
||||
# root-caused during Phase 2A: node-pty's own error handling collapses the real dlopen error
|
||||
# into a generic "module not found" message across all the paths it checks, which is what made
|
||||
# this look like a missing-file problem rather than a link-time misconfiguration. The
|
||||
# `node -e require(...)` check below makes this a hard build failure if it ever regresses,
|
||||
# instead of a silent runtime failure discovered later.
|
||||
COPY backend/package.json ./backend/
|
||||
RUN cd backend && npm install --production
|
||||
RUN cd backend && npm install --production \
|
||||
&& node -e "require('./node_modules/node-pty'); console.log('node-pty loads OK')"
|
||||
|
||||
# Build frontend
|
||||
COPY frontend/package.json frontend/vite.config.js ./frontend/
|
||||
|
||||
@@ -127,22 +127,102 @@ Everything runs inside one image managed by `scripts/entrypoint.sh`.
|
||||
|
||||
## Multi-Student Deployment (RootNode Academy addition)
|
||||
|
||||
The upstream project assumes one learner, one container. RootNode Academy runs it across three
|
||||
delivery modes. **Never publish the raw privileged container** — front it with isolation.
|
||||
The upstream project assumes one learner, one container — this image is still exactly that.
|
||||
Its backend/entrypoint/Dockerfile are unmodified except for a node-pty build fix (see
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1); its frontend bundle gained progressive-hint
|
||||
controls and an assessment-mode indicator in Phase 3B (`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`
|
||||
§10–11) — additive UI, not a rewrite of the Lab experience. RootNode Academy multiplies the
|
||||
image into a real platform via separate provisioning and gateway layers rather than changing
|
||||
its core:
|
||||
|
||||
| Mode | Provisioning | Isolation |
|
||||
| ---- | ------------ | --------- |
|
||||
| In-person camp | Pre-spun container per seat, torn down after | Lab VLAN, no public ingress |
|
||||
| Live cohort | Container per enrolled student for the session window | Per-student auth at the proxy |
|
||||
| Self-paced | On-demand spin-up per session, idle-timeout teardown | Per-user volume + auth + resource limits |
|
||||
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
|
||||
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
|
||||
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress, detailed
|
||||
scenario attempt telemetry, progressive hints, assessment/capstone engine, achievements,
|
||||
persistent student/instructor sessions, per-instructor accounts (RBAC: owner/admin/
|
||||
instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
|
||||
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`,
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`).
|
||||
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
|
||||
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
|
||||
HTTP/WebSocket into that one student's container, never a raw container IP/port, and
|
||||
server-gates progressive hint reveal by stripping the Lab's own hint payload before it reaches
|
||||
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
|
||||
- The student flow (real login (email or instructor-assigned login ID) or legacy pilot roster
|
||||
entry → curriculum dashboard → Launch Lab → practice/assessments/capstone → achievements →
|
||||
live classroom → profile) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students with credential/CSV-import management, a remote
|
||||
classroom control center, instructor accounts, audit log, environment controls, learning
|
||||
analytics, assessment/capstone management) lives in `frontend/src/admin/` at the `/admin`
|
||||
route — both additions alongside the existing Lab UI, not a rewrite of it.
|
||||
- Students authenticate with real credentials as of Phase 5A (email+password or
|
||||
login-identifier+password, first-login/reset via a single-use token an instructor relays) —
|
||||
see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. Instructors run live, remote-capable
|
||||
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
|
||||
guidance-only current-activity) — RootNode owns identity/coordination, an external service
|
||||
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
|
||||
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
|
||||
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
|
||||
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
|
||||
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
|
||||
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
|
||||
boundary — server-to-server only, no browser session involved — is what the future public
|
||||
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
- As of Phase 5C, RootNode actually tells students what's happening — real activation,
|
||||
enrollment-confirmation, and password-reset email, delivered asynchronously through a
|
||||
provider-neutral notification worker (Google Workspace via the Gmail API in production, a
|
||||
deterministic dev provider everywhere else — no test/dev run ever emails a real person). See
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`.
|
||||
|
||||
Hardening checklist for shared infra:
|
||||
**Never publish the raw privileged container** — front it with isolation (that's what the
|
||||
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
|
||||
supported for local development and scenario authoring; it is not how this gets deployed to
|
||||
real students.
|
||||
|
||||
- One container **per student** — never share a cluster between learners.
|
||||
- Apply CPU/memory limits so one runaway pod can't starve a host.
|
||||
- Put authentication in front (proxy-level), never expose `:7554` to the internet.
|
||||
- Persist each student's `/data` to a dedicated volume.
|
||||
- Idle-timeout + auto-teardown for self-paced sessions to control cost.
|
||||
Key properties of the platform (not this image):
|
||||
|
||||
- One container **per student**, its own Docker network, its own firewall chain — never a
|
||||
shared cluster between learners. Verified with two simultaneous students: separate clusters,
|
||||
separate progress, no cross-access.
|
||||
- Progress/identity live outside the disposable container, in the Platform API's own
|
||||
PostgreSQL (authoritative as of Phase 2B) — destroying or recreating a student's Lab never
|
||||
loses their academic record. Verified with a full class-sized (3-student) cohort workflow:
|
||||
provision, complete different scenarios, reset one student's Lab, destroy all Labs after
|
||||
class — progress survives every step.
|
||||
- Instructors sign in with their own account (no shared password) and manage cohorts, students,
|
||||
curriculum assignment, and classroom Lab prep (provision an entire cohort before class, verify
|
||||
readiness, destroy Labs after) from `/admin` — no manual database, Docker, or Kubernetes
|
||||
access needed. Owners manage instructor accounts and roles; every instructor/admin action is
|
||||
recorded in an audit log.
|
||||
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
|
||||
verified by actually restarting it mid-session and confirming access still works.
|
||||
- Every validation attempt, hint reveal, curriculum stage completion, exam, and Capstone
|
||||
attempt is recorded in Postgres, not the disposable Lab — a Lab reset or recreate never loses
|
||||
attempt telemetry, hint progress, or an in-progress assessment's timer. Verified live:
|
||||
reset a Lab mid-assessment, timer and drawn scenario set were unchanged afterward.
|
||||
- Exams and the RootNode Capstone use a server-authoritative timer and a server-drawn,
|
||||
server-stored scenario set — nothing the browser sends can change either.
|
||||
- CPU/memory/PID/storage limits are enforced per container, config-driven.
|
||||
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
|
||||
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
|
||||
one and keep going without losing progress.
|
||||
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`,
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
|
||||
that runs all of this on real hardware lives in `infra/` — see
|
||||
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` and `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` (the
|
||||
Gateway-to-Lab cross-VM routing fix, plus application hardening — real pagination, abandonment
|
||||
handling, a Postgres-backed rate limiter, a structured assessment editor, admission control).
|
||||
Built and locally validated now; deployment is pending purchase of the target Hetzner dedicated
|
||||
server.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+1184
File diff suppressed because it is too large
Load Diff
+237
-43
@@ -14,6 +14,7 @@ app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data');
|
||||
const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles');
|
||||
const CURRICULUM_PATH_FILE = path.join(__dirname, '../scenarios/rootnode-path.json');
|
||||
const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db';
|
||||
|
||||
// ── SQLite progress store ─────────────────────────────────────────────────────
|
||||
@@ -55,6 +56,51 @@ function getDb() {
|
||||
console.error('Failed to run schema migrations on progress table:', e.message);
|
||||
}
|
||||
|
||||
// ── Students / roster ────────────────────────────────────────────────────
|
||||
// ponytail: pick-your-name login, no password — instructor adds students to the
|
||||
// roster, student just selects their name. Trust model is "supervised classroom
|
||||
// device", same as Clever/ClassLink instant login. Upgrade to per-student PIN
|
||||
// if remote/unsupervised access is ever needed.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
cohort TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_active_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate progress table to a per-student composite key (student_id, scenario_id).
|
||||
// SQLite can't ALTER a primary key, so this rebuilds the table. Rows that predate
|
||||
// student accounts are kept under student_id='unassigned' rather than dropped.
|
||||
try {
|
||||
const progressCols = _db.prepare("PRAGMA table_info(progress)").all().map(c => c.name);
|
||||
if (!progressCols.includes('student_id')) {
|
||||
console.log('Migrating progress table to per-student schema...');
|
||||
_db.exec(`
|
||||
CREATE TABLE progress_new (
|
||||
student_id TEXT NOT NULL DEFAULT 'unassigned',
|
||||
scenario_id TEXT NOT NULL,
|
||||
status TEXT,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_validated TEXT,
|
||||
completed_at TEXT,
|
||||
started_at TEXT,
|
||||
notes TEXT,
|
||||
time_spent_seconds INTEGER,
|
||||
PRIMARY KEY (student_id, scenario_id)
|
||||
);
|
||||
INSERT INTO progress_new (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
SELECT 'unassigned', scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds FROM progress;
|
||||
DROP TABLE progress;
|
||||
ALTER TABLE progress_new RENAME TO progress;
|
||||
`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate progress table to per-student schema:', e.message);
|
||||
}
|
||||
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -75,6 +121,9 @@ function getDb() {
|
||||
if (!cols.includes('scenario_ids')) {
|
||||
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
|
||||
}
|
||||
if (!cols.includes('student_id')) {
|
||||
_db.exec(`ALTER TABLE sessions ADD COLUMN student_id TEXT`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate sessions table:', e.message);
|
||||
}
|
||||
@@ -94,9 +143,26 @@ function getDb() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
function loadProgress() {
|
||||
// Resolves the acting student for a request. No session tokens yet (see students
|
||||
// table comment) — frontend just sends back the student_id it got from /api/login.
|
||||
// Falls back to 'unassigned' so the app still works before any student logs in
|
||||
// (dev/test use, and pre-migration progress lives there too).
|
||||
function getStudentId(req) {
|
||||
return req.get('x-student-id') || req.query.studentId || 'unassigned';
|
||||
}
|
||||
|
||||
function touchStudent(studentId) {
|
||||
if (!studentId || studentId === 'unassigned') return;
|
||||
try {
|
||||
const rows = getDb().prepare('SELECT * FROM progress').all();
|
||||
getDb().prepare(`UPDATE students SET last_active_at = datetime('now') WHERE student_id = ?`).run(studentId);
|
||||
} catch (e) {
|
||||
console.error('Failed to update student last_active_at:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadProgress(studentId = 'unassigned') {
|
||||
try {
|
||||
const rows = getDb().prepare('SELECT * FROM progress WHERE student_id = ?').all(studentId);
|
||||
return Object.fromEntries(rows.map(r => [r.scenario_id, {
|
||||
status: r.status,
|
||||
attempts: r.attempts,
|
||||
@@ -109,13 +175,13 @@ function loadProgress() {
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveProgress(progress) {
|
||||
function saveProgress(studentId, progress) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scenario_id) DO UPDATE SET
|
||||
INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(student_id, scenario_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
attempts = excluded.attempts,
|
||||
last_validated = excluded.last_validated,
|
||||
@@ -127,6 +193,7 @@ function saveProgress(progress) {
|
||||
const tx = db.transaction((entries) => {
|
||||
for (const [id, p] of entries) {
|
||||
upsert.run(
|
||||
studentId,
|
||||
id,
|
||||
p.status || null,
|
||||
p.attempts || 0,
|
||||
@@ -153,11 +220,15 @@ function loadJsonDir(dir) {
|
||||
|
||||
let scenariosCache = [];
|
||||
let bundlesCache = [];
|
||||
let curriculumPathCache = null;
|
||||
|
||||
function reloadCache() {
|
||||
try {
|
||||
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.`);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload cache:', e.message);
|
||||
@@ -175,6 +246,10 @@ function loadBundles() {
|
||||
return bundlesCache;
|
||||
}
|
||||
|
||||
function loadCurriculumPath() {
|
||||
return curriculumPathCache;
|
||||
}
|
||||
|
||||
async function runCommand(cmd, timeoutMs = 15000) {
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
@@ -223,31 +298,77 @@ function refreshPrompt(delayMs = 80) {
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Roster / student login ──────────────────────────────────────────────────
|
||||
|
||||
// POST /api/roster — instructor adds a student to a cohort
|
||||
app.post('/api/roster', (req, res) => {
|
||||
const { display_name, cohort } = req.body;
|
||||
if (!display_name || !cohort) {
|
||||
return res.status(400).json({ error: 'display_name and cohort are required' });
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM students').get().n;
|
||||
const studentId = `student-${String(count + 1).padStart(3, '0')}`;
|
||||
db.prepare(`INSERT INTO students (student_id, display_name, cohort, created_at) VALUES (?, ?, ?, datetime('now'))`)
|
||||
.run(studentId, display_name.trim(), cohort.trim());
|
||||
res.json({ student_id: studentId, display_name: display_name.trim(), cohort: cohort.trim() });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/roster/cohorts — distinct cohort names, for the login cohort picker
|
||||
app.get('/api/roster/cohorts', (req, res) => {
|
||||
const rows = getDb().prepare('SELECT DISTINCT cohort FROM students ORDER BY cohort').all();
|
||||
res.json(rows.map(r => r.cohort));
|
||||
});
|
||||
|
||||
// GET /api/roster?cohort=root-fall-2026 — student names for the pick-your-name dropdown
|
||||
app.get('/api/roster', (req, res) => {
|
||||
const { cohort } = req.query;
|
||||
const db = getDb();
|
||||
const rows = cohort
|
||||
? db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students WHERE cohort = ? ORDER BY display_name').all(cohort)
|
||||
: db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students ORDER BY cohort, display_name').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/login — student picks their name; no password (see students table comment)
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { student_id } = req.body;
|
||||
const student = getDb().prepare('SELECT student_id, display_name, cohort FROM students WHERE student_id = ?').get(student_id);
|
||||
if (!student) return res.status(404).json({ error: 'Student not found' });
|
||||
touchStudent(student.student_id);
|
||||
res.json(student);
|
||||
});
|
||||
|
||||
// POST /api/progress/reset — reset progress for scenario | category | bundle
|
||||
app.post('/api/progress/reset', (req, res) => {
|
||||
const { scope, scenarioId, category, bundleId } = req.body;
|
||||
const studentId = getStudentId(req);
|
||||
const db = getDb();
|
||||
const resetProgressStmt = db.prepare(`
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ?
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ? AND student_id = ?
|
||||
`);
|
||||
|
||||
try {
|
||||
if (scope === 'scenario') {
|
||||
resetProgressStmt.run(scenarioId);
|
||||
resetProgressStmt.run(scenarioId, studentId);
|
||||
} else if (scope === 'category') {
|
||||
const scenarios = loadScenarios();
|
||||
const ids = scenarios.filter(s => s.category === category).map(s => s.id);
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id)));
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
||||
tx(ids);
|
||||
} else if (scope === 'bundle') {
|
||||
const bundle = loadBundles().find(b => b.id === bundleId);
|
||||
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id)));
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
||||
tx(bundle.scenario_ids);
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
@@ -285,9 +406,10 @@ app.post('/api/sessions', (req, res) => {
|
||||
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
||||
WHERE status='active'`).run();
|
||||
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
|
||||
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
|
||||
const studentId = getStudentId(req);
|
||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids, student_id)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?, ?, ?)`)
|
||||
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds), studentId);
|
||||
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
|
||||
});
|
||||
|
||||
@@ -380,7 +502,8 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
||||
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
|
||||
|
||||
// Also pull time_spent_seconds from global progress (tracked per scenario during the exam)
|
||||
const globalProgress = loadProgress();
|
||||
// Use the session's own student, not whoever's request is submitting it.
|
||||
const globalProgress = loadProgress(session.student_id || 'unassigned');
|
||||
|
||||
const snapshot = bundleScenarios.map(s => ({
|
||||
id: s.id, title: s.title, weight: s.weight,
|
||||
@@ -396,9 +519,10 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
||||
duration_secs=?, snapshot=? WHERE id=?`)
|
||||
.run(durationSecs, JSON.stringify(snapshot), req.params.id);
|
||||
|
||||
// Reset timers of all scenarios in this bundle
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
|
||||
// Reset timers of all scenarios in this bundle, for this session's student only
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
||||
const sid = session.student_id || 'unassigned';
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
||||
tx(bundle.scenario_ids);
|
||||
|
||||
res.json({ ok: true, snapshot, durationSecs });
|
||||
@@ -416,8 +540,9 @@ app.post('/api/sessions/:id/abandon', (req, res) => {
|
||||
WHERE id=? AND status='active'`).run(req.params.id);
|
||||
|
||||
if (bundle) {
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
||||
const sid = session.student_id || 'unassigned';
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
||||
tx(bundle.scenario_ids);
|
||||
}
|
||||
|
||||
@@ -475,7 +600,7 @@ app.post('/api/scenarios/:id/context', async (req, res) => {
|
||||
app.get('/api/bundles', (req, res) => {
|
||||
const bundles = loadBundles();
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
const result = bundles.map(b => {
|
||||
const total = b.scenario_ids.length;
|
||||
const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length;
|
||||
@@ -484,10 +609,47 @@ app.get('/api/bundles', (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /api/curriculum/path — the guided RootNode learning path, staged with completion status
|
||||
app.get('/api/curriculum/path', (req, res) => {
|
||||
const path = loadCurriculumPath();
|
||||
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
// GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
|
||||
app.get('/api/scenarios', (req, res) => {
|
||||
const scenarios = loadScenarios()
|
||||
const progress = loadProgress()
|
||||
const progress = loadProgress(getStudentId(req))
|
||||
const { bundle, session: sessionId } = req.query
|
||||
|
||||
// Session-scoped: return only session scenario_ids in their shuffled order
|
||||
@@ -531,7 +693,7 @@ app.get('/api/scenarios/:id', (req, res) => {
|
||||
const scenarios = loadScenarios();
|
||||
const scenario = scenarios.find(s => s.id === req.params.id);
|
||||
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
||||
const progress = loadProgress();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
res.json({
|
||||
...scenario,
|
||||
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 }
|
||||
@@ -556,7 +718,8 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
|
||||
}
|
||||
|
||||
// Mark scenario as in-progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
|
||||
progress[scenario.id] = {
|
||||
...progress[scenario.id],
|
||||
@@ -564,7 +727,7 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
|
||||
attempts: (progress[scenario.id]?.attempts || 0),
|
||||
started_at: progress[scenario.id]?.started_at || new Date().toISOString()
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
}
|
||||
|
||||
res.json({ setup_results: results });
|
||||
@@ -602,7 +765,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
||||
}
|
||||
|
||||
// Update global practice progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
const prev = progress[scenario.id] || { attempts: 0 };
|
||||
progress[scenario.id] = {
|
||||
...prev,
|
||||
@@ -611,7 +775,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
||||
last_validated: new Date().toISOString(),
|
||||
completed_at: allPassed ? new Date().toISOString() : prev.completed_at
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
touchStudent(studentId);
|
||||
|
||||
// Also update exam_progress if there's an active session
|
||||
const db = getDb();
|
||||
@@ -649,7 +814,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
||||
const correct = selected === scenario.correct_option;
|
||||
|
||||
// Update global practice progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
const prev = progress[scenario.id] || { attempts: 0 };
|
||||
progress[scenario.id] = {
|
||||
...prev,
|
||||
@@ -659,7 +825,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
||||
last_validated: new Date().toISOString(),
|
||||
completed_at: correct ? new Date().toISOString() : prev.completed_at
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
touchStudent(studentId);
|
||||
|
||||
// Also update exam_progress if there's an active session
|
||||
const db = getDb();
|
||||
@@ -702,7 +869,8 @@ app.post('/api/scenarios/:id/time', (req, res) => {
|
||||
if (!activeSession) {
|
||||
return res.json({ ok: true, message: 'No active session' });
|
||||
}
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
if (!progress[req.params.id]) {
|
||||
progress[req.params.id] = {
|
||||
status: 'in_progress',
|
||||
@@ -714,14 +882,40 @@ app.post('/api/scenarios/:id/time', (req, res) => {
|
||||
progress[req.params.id].started_at = new Date().toISOString();
|
||||
}
|
||||
progress[req.params.id].time_spent_seconds = time_spent_seconds;
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/progress/record — Phase 2A addition. Records a scenario outcome WITHOUT
|
||||
// re-running validation. Exists because scenario validation itself must run against the
|
||||
// student's own live cluster (inside their ephemeral Lab container), but the canonical
|
||||
// progress record needs to live here, on the persistent identity instance — the Gateway
|
||||
// calls this after a real validate/answer already succeeded against the student's Lab, to
|
||||
// relay the outcome. See docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Progress relay."
|
||||
app.post('/api/progress/record', (req, res) => {
|
||||
const { scenario_id, status } = 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 studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
const prev = progress[scenario_id] || { attempts: 0 };
|
||||
progress[scenario_id] = {
|
||||
...prev,
|
||||
status,
|
||||
attempts: (prev.attempts || 0) + 1,
|
||||
last_validated: new Date().toISOString(),
|
||||
completed_at: status === 'completed' ? new Date().toISOString() : prev.completed_at,
|
||||
};
|
||||
saveProgress(studentId, progress);
|
||||
touchStudent(studentId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/progress — full progress summary
|
||||
app.get('/api/progress', (req, res) => {
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
const total = scenarios.length;
|
||||
const completed = Object.values(progress).filter(p => p.status === 'completed').length;
|
||||
const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0);
|
||||
@@ -740,13 +934,13 @@ app.post('/api/progress/reset/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ?
|
||||
`).run(req.params.id);
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ? AND student_id = ?
|
||||
`).run(req.params.id, getStudentId(req));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Backup & Disaster Recovery
|
||||
|
||||
## What's backed up (Tier 1) vs. not
|
||||
|
||||
| | Backed up | Why |
|
||||
|---|---|---|
|
||||
| Postgres data | Yes, daily | The only irreplaceable data — student records, cohort/session state, scenario attempts, assessments, achievements, audit events |
|
||||
| `{{ rootnode_data_root }}` config/secrets (Vault files, env templates) | Yes, via the infra repo + Ansible Vault itself | Small, static, versioned |
|
||||
| Infra code (`infra/`, Ansible, Terraform state) | Yes — it's in git; Terraform state should be backed up alongside Postgres if not using a remote backend | Needed to rebuild either VM from scratch |
|
||||
| Disposable student Labs | **No, intentionally** | Ephemeral by design — a Lab is meant to be destroyed and recreated; backing it up would contradict the whole isolation/disposability model |
|
||||
| Prometheus/Loki metrics & log history | No | Operational telemetry, not source-of-truth data; losing 30 days of dashboards on a rebuild is an inconvenience, not data loss |
|
||||
|
||||
## Backup mechanism
|
||||
|
||||
`infra/backup/backup-postgres.sh` (deployed to `/opt/rootnode/backup/` by the `backup` Ansible
|
||||
role, run daily at 03:15 via cron):
|
||||
|
||||
1. `pg_dump --format=plain --no-owner --no-privileges | gzip -9`
|
||||
2. Sanity check: reject if the compressed dump is under 1KB (almost certainly means Postgres
|
||||
was unreachable or empty, not a real backup).
|
||||
3. `gpg --symmetric --cipher-algo AES256` — encrypt, then delete the plaintext dump immediately.
|
||||
4. Optional off-host copy via `rsync -az` over SSH — works for Hetzner Storage Box or any SSH
|
||||
target; that's the extension point for S3-compatible alternatives (swap the rsync line for
|
||||
`aws s3 cp`/`rclone` if a different target is chosen later).
|
||||
5. Retention pruning (`find ... -mtime +{{ backup_retention_days }} -delete`, default 30 days).
|
||||
6. Writes Prometheus textfile-collector metrics
|
||||
(`rootnode_backup_last_run_success`, `..._last_success_timestamp_seconds`, `..._size_bytes`)
|
||||
atomically (tmp file + rename) so a half-written metric file is never scraped.
|
||||
|
||||
## Restore
|
||||
|
||||
`infra/backup/restore-postgres.sh` — requires an explicit target DB URL (never infers or
|
||||
defaults to production, to prevent an accidental overwrite), shows the target's current tables,
|
||||
requires typing `RESTORE` to confirm, then decrypts and pipes into `psql`.
|
||||
`infra/backup/verify-restore.sh` — confirms all 8 expected tables exist plus the
|
||||
`schema_migrations` row count, so a "successful" restore that silently missed a table is caught.
|
||||
|
||||
**Genuinely tested**, not just written: `infra/backup/test-backup-restore.sh` runs the full
|
||||
cycle against two disposable local Postgres containers, with the actual production migrations
|
||||
applied (3 Provisioner + Platform API, in the correct cross-service order), a
|
||||
sample row inserted, backed up, restored into the second container, and verified byte-for-byte.
|
||||
Confirmed passing, exit 0.
|
||||
|
||||
**Phase 4.1's destructive drill goes one step further**
|
||||
(`infra/backup/test-destructive-restore-drill.sh`): realistic data (owner, admin instructor,
|
||||
cohort, 3 students, progress, attempts, an assessment definition, achievements) seeded through
|
||||
the REAL Platform API, backed up, the source Postgres **container genuinely destroyed**
|
||||
(`docker rm`, not just left running alongside a second one), a fresh Postgres created from
|
||||
nothing, restored, and the real Platform API booted again against the restored database and
|
||||
used to log in as the restored admin instructor over HTTP — proving the whole stack recovers,
|
||||
not just the SQL. Never touches the developer's real persistent database. Confirmed passing.
|
||||
|
||||
## Disaster recovery scenarios
|
||||
|
||||
| Scenario | RPO | RTO (estimate) | What happens |
|
||||
|---|---|---|---|
|
||||
| Platform VM lost, disks intact | 0 | ~30 min | `docs/RUNBOOK_VM_REBUILD.md` — Ansible re-run against the same disks |
|
||||
| Platform VM + its disks lost | Up to 24h (since last backup) | ~1-2h | Rebuild VM (Terraform), re-run Ansible, restore Postgres from the latest off-host backup (`docs/RUNBOOK_POSTGRES_RESTORE.md`) |
|
||||
| Lab Host VM lost, any reason | 0 (intentional) | ~30 min | Rebuild VM, re-run Ansible — no data to restore, all Labs were disposable and get recreated on next student login |
|
||||
| Single disk failure in a mirror | 0 | 0 (no outage) | `docs/RUNBOOK_DISK_FAILURE.md` — pool stays online DEGRADED, replace and resilver |
|
||||
| Whole storage pool lost (both mirror sides) | Up to 24h | ~1-2h | Same as "Platform VM + disks lost" above — this is precisely the scenario the off-host copy exists for; a local-only backup on the same failed pool is useless here |
|
||||
| Encryption passphrase lost | N/A | Unrecoverable | The single most important secret to protect and back up separately (e.g., in the same secrets manager as other Vault secrets, not only in `production.vault.yml`) — losing it makes every existing backup file permanently unreadable |
|
||||
|
||||
RPO is bounded by backup cadence (daily) — a mid-day incident can lose up to a day of student
|
||||
activity. If that's ever unacceptable, the fix is a shorter cron interval or WAL archiving for
|
||||
point-in-time recovery, not decided or built here since daily has not yet been proven
|
||||
insufficient against real usage.
|
||||
|
||||
## What backup does NOT cover
|
||||
|
||||
Application code and container images — those live in Gitea and the image registry, both
|
||||
already durable and versioned independently of this backup mechanism. TLS certificates — those
|
||||
are regenerable (`docs/RUNBOOK_DOCKER_TLS_ROTATION.md`, `docs/RUNBOOK_TLS_RENEWAL.md`), not
|
||||
backed up.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Capacity Planning
|
||||
|
||||
How many concurrent student Labs `rn-lab-host-01` can actually run — determined from measured
|
||||
data after deployment, not guessed or marketed now.
|
||||
|
||||
## The model
|
||||
|
||||
`infra/scripts/capacity-model.js`:
|
||||
|
||||
```
|
||||
usable = hostTotal * (1 - overheadPct/100) - platformReserved
|
||||
withMargin = usable * (1 - marginPct/100)
|
||||
perLabLimit = floor(withMargin / perLabUsage)
|
||||
recommended = min(cpuBoundLimit, memoryBoundLimit) # whichever runs out first
|
||||
warning = floor(recommended * 0.8)
|
||||
hardAdmission = recommended
|
||||
```
|
||||
|
||||
Computed once for CPU, once for memory; the tighter (smaller) of the two constrains the actual
|
||||
recommendation — a host can be memory-starved long before its CPU threads are exhausted, or
|
||||
vice versa, and the model doesn't pretend otherwise.
|
||||
|
||||
**Inputs** (`infra/environments/production/capacity.yml.example` — explicitly placeholder data
|
||||
until real numbers exist):
|
||||
- `host_cpu_threads`, `host_memory_mb` — from the real Lab Host VM, once sized
|
||||
- `overhead_pct` — Proxmox/hypervisor + OS overhead reserved off the top
|
||||
- `platform_reserved_cpu`/`platform_reserved_memory_mb` — headroom for the egress proxy,
|
||||
node-exporter, cadvisor, promtail themselves (they run on the Lab Host too)
|
||||
- `per_lab_cpu`/`per_lab_memory_mb` — **measured**, from `infra/scripts/loadtest/`, not assumed
|
||||
- `safety_margin_pct` — extra headroom below the raw math, for burst/variance
|
||||
|
||||
**Outputs**: recommended concurrent Labs, an 80%-of-recommended warning threshold (for
|
||||
alerting — `LabHostHighMemory` in `docs/OBSERVABILITY.md`), and a hard admission-control
|
||||
threshold the Provisioner's resource policy should enforce so it refuses to boot a Lab past that
|
||||
point rather than degrading every running Lab.
|
||||
|
||||
## Load-test tooling that produces the measured inputs
|
||||
|
||||
`infra/scripts/loadtest/`:
|
||||
- `boot-labs.js` — reuses `platform/test/session-helper.js` (built in Phase 3A) to drive the
|
||||
real admin Prepare-Labs endpoint, booting N Labs and recording boot time/failure rate per Lab
|
||||
into a JSON report.
|
||||
- `sample-resources.sh` — samples real `docker stats` for every `rootnode-lab-*` container every
|
||||
5 seconds to CSV (timestamp, name, CPU%, mem usage, mem%, net I/O, block I/O, PIDs).
|
||||
|
||||
Together these produce the `per_lab_cpu`/`per_lab_memory_mb` this model needs, plus data on how
|
||||
boot time and failure rate change as concurrency rises — the dimensions this phase was told to
|
||||
cover: single Lab boot, multiple simultaneous boots, steady-state active Labs, terminal
|
||||
WebSocket load, scenario validation load, Gateway throughput, API request volume, Postgres
|
||||
connection activity.
|
||||
|
||||
## What this document deliberately does not claim
|
||||
|
||||
No concurrency number in this repo (`capacity.yml.example`'s numbers included) is a real,
|
||||
validated figure — every one is a placeholder until run against the real Lab Host VM with real
|
||||
per-Lab measurements. Treating a placeholder as a marketing/sales number would be actively
|
||||
misleading; the whole point of the model is that the real number gets filled in once, from
|
||||
measurement, after deployment (`docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md` step 4 is the
|
||||
trigger to re-measure and update it if the per-Lab footprint later changes).
|
||||
@@ -0,0 +1,421 @@
|
||||
# RootNode Academy — Engineering SOP
|
||||
|
||||
Living doc. Covers the "how" of building this platform — the "why" and business/curriculum
|
||||
process live in the Business Pack (`~/Documents/CDAA Technologies/07 RootNode academy/RootNode_Academy_Business_Pack/`),
|
||||
this doc doesn't duplicate that. Update this file in the same commit as the change it describes.
|
||||
|
||||
## 1. Where this actually lives
|
||||
|
||||
- **Repo:** `~/Dev/Gitea/ced4568/rootnode-academy` — remote is **Gitea**
|
||||
(`git.synthossystems.com/ced4568/rootnode-academy`), not GitHub.
|
||||
- **Gotcha:** `~/Dev/GitHub/ced4568/kubekosh` is a *different* clone of the same original
|
||||
upstream fork, still pointed at GitHub, and it's behind (missing the RootNode logo commit
|
||||
and the Real Ops enforce-limits scenario as of this writing). It's not deployed anywhere.
|
||||
Don't edit it by mistake — always confirm `git remote -v` shows `gitea:` before making changes.
|
||||
- Known-good baseline as of this doc: commit `6ddfb1c` ("add probe-port-mismatch and
|
||||
enforce-limits to rootnode-real-ops bundle"). Everything below builds on top of that,
|
||||
clean working tree.
|
||||
|
||||
## 2. Architecture at a glance
|
||||
|
||||
```
|
||||
backend/server.js Express API + SQLite (better-sqlite3) + WebSocket PTY terminal — the
|
||||
"Lab image," single-tenant, unmodified by the Platform work below
|
||||
frontend/ Vite + React SPA, built to frontend/dist and served by nginx
|
||||
scenarios/data/ One JSON file per scenario (task or MCQ)
|
||||
scenarios/bundles/ Flat scenario_id lists grouped by cert track (basics/appdev/admin/security/real-ops)
|
||||
scenarios/rootnode-path.json The RootNode guided learning path (staged, see §4)
|
||||
scripts/entrypoint.sh Container boot: starts k3s, nginx, node backend, sets the shell banner/PS1
|
||||
Dockerfile ubuntu:22.04 + k3s + node20 + nginx, single image, one port (80)
|
||||
|
||||
platform/provisioner/ Phase 1 Platform Provisioner — disposable per-student Lab lifecycle,
|
||||
PostgreSQL registry, egress firewall. See §8 and
|
||||
platform/provisioner/README.md.
|
||||
platform/egress-proxy/ Shared allowlisting egress proxy every Lab environment's traffic must
|
||||
pass through.
|
||||
```
|
||||
|
||||
Single Express process, single SQLite file at `/data/progress.db` (or `$PROGRESS_DB`),
|
||||
single embedded k3s cluster **per Lab environment**. The Lab image itself is still
|
||||
single-tenant by design — see §5 for identity, and §8 for how Phase 1 multiplies it into a
|
||||
real multi-student platform without modifying it.
|
||||
|
||||
## 3. Local dev / safe smoke-testing
|
||||
|
||||
Never test schema changes against the real `/data/progress.db` first. Point at a scratch file:
|
||||
|
||||
```bash
|
||||
cd backend && npm install # first time only
|
||||
node --check server.js # syntax check, catches typos fast
|
||||
PROGRESS_DB=/tmp/scratch/progress.db node server.js &
|
||||
curl -s http://localhost:4000/api/health
|
||||
# ...exercise the endpoints you changed...
|
||||
fuser -k 4000/tcp # stop it
|
||||
```
|
||||
|
||||
To test a migration against **real prior data** without touching prod: copy the actual
|
||||
`/data/progress.db` out of the running container/volume, point `PROGRESS_DB` at the copy,
|
||||
boot, and verify the data you expect is still there before ever pointing at the real file.
|
||||
|
||||
kubectl/k3s-backed endpoints (`/setup`, `/validate`, `/context`, the WS terminal) won't work
|
||||
outside the container — that's expected. API-and-DB-only endpoints (roster, login, progress,
|
||||
curriculum, bundles, scenarios list) all smoke-test fine on a bare host.
|
||||
|
||||
## 4. Curriculum content
|
||||
|
||||
Two separate structures, don't confuse them:
|
||||
|
||||
- **Bundles** (`scenarios/bundles/*.json`) — flat scenario_id lists, one per cert track.
|
||||
Loaded by `loadBundles()`/`GET /api/bundles`. Adding a bundle file here = adding a new
|
||||
cert-track library. Every scenario referenced must have a matching file in `scenarios/data/`.
|
||||
- **RootNode path** (`scenarios/rootnode-path.json`) — the guided onboarding sequence,
|
||||
separate from bundles on purpose (it's *staged*, bundles are *flat*, and the bundle loader
|
||||
would choke on a staged file if it were dropped in `scenarios/bundles/`). Loaded by
|
||||
`loadCurriculumPath()`/`GET /api/curriculum/path` (raw content) and, as of Phase 3B,
|
||||
`computeCurriculumState()`/`GET /api/curriculum/state` (rule-driven lock/complete/next-scenario
|
||||
state — see `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §4). Stages are Foundation → Workloads →
|
||||
Networking → Configuration → Troubleshooting → Real Ops → Capstone, built by reusing existing
|
||||
`1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing `category` field —
|
||||
no new scenario content was written for this. Each stage carries a `completion_rule`
|
||||
(`all_required`/`threshold`/`prerequisites`). **Capstone is no longer a hardcoded placeholder**
|
||||
— it's `"type": "capstone"` with an `"unlock_rule"` and a `"capstone_assessment_id"` pointing
|
||||
at the `rootnode-capstone` assessment definition (Postgres, `assessment_definitions` table),
|
||||
which is where its actual scenario draw lives — see the Phase 3B doc §7. Don't put real
|
||||
scenarios directly into the JSON stage's `scenario_ids` for capstone; that field stays empty
|
||||
by design, the draw is defined in the assessment definition instead.
|
||||
|
||||
To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
|
||||
`POST /api/cache/reload` or on server restart.
|
||||
|
||||
## 5. Student identity — what's built, what isn't
|
||||
|
||||
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
|
||||
full design):**
|
||||
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
|
||||
of and never changes across an account's lifecycle, including conversion.
|
||||
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
|
||||
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
|
||||
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
|
||||
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
|
||||
{ student_id }`, no password, instructor-roster-only.
|
||||
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
|
||||
(`students-auth.js`) — instructors never see or set a student's actual password.
|
||||
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
|
||||
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
|
||||
instructor login (Phase 4.1), now also applied to students.
|
||||
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
|
||||
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
|
||||
already in place well before this — Phase 5A only changed how a student proves who they are,
|
||||
not how their data or Labs are isolated.
|
||||
|
||||
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
|
||||
- No public self-registration — accounts are still instructor-created/converted/imported only.
|
||||
- No email delivery infrastructure — password reset works via an instructor-issued token today;
|
||||
self-service reset returns a real token only in non-production, by design.
|
||||
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
|
||||
|
||||
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
|
||||
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
|
||||
reflects any deployed version of this codebase; that content has been superseded by the above.
|
||||
|
||||
## 6. Schema migration pattern
|
||||
|
||||
SQLite can't `ALTER` a primary key. The codebase's existing convention (see `progress` table
|
||||
history) is:
|
||||
|
||||
1. `PRAGMA table_info(<table>)` → check if the new column/shape is already there (idempotent,
|
||||
safe to run on every boot).
|
||||
2. Simple new nullable column → `ALTER TABLE ... ADD COLUMN`.
|
||||
3. Primary-key or structural change → build `<table>_new` with the new shape, `INSERT ...
|
||||
SELECT` the old data across with sane defaults for new required columns, `DROP TABLE`,
|
||||
`RENAME TO`. Never drop data — give it a legacy/default value (`'unassigned'`, etc.)
|
||||
instead of discarding rows.
|
||||
4. Test the migration against a **copy of real data** (§3) before it ever runs against
|
||||
`/data/progress.db` in the container.
|
||||
|
||||
## 7. Roadmap status
|
||||
|
||||
Order agreed 2026-08-17/18. Status as of this doc:
|
||||
|
||||
1. RootNode identity/branding pass — **partial.** Shell banner fixed
|
||||
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
|
||||
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
|
||||
2. RootNode curriculum mode — **done**. Rule-driven stage completion/unlock as of Phase 3B
|
||||
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
|
||||
`frontend/src/platform/`.
|
||||
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
|
||||
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
|
||||
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
|
||||
name" gap as of Phase 5A — see §5 above and
|
||||
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
|
||||
unchanged for anyone not yet converted.
|
||||
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
|
||||
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
|
||||
owner/admin/instructor roles, audit log.
|
||||
5. Per-student isolation — **Phase 1 + Phase 2A + Phase 2B + Phase 3A done**, see §8/§9/§10.
|
||||
Ephemeral per-student Lab environments, Gateway-mediated routing with persistent
|
||||
server-controlled sessions, a full instructor/admin control plane with real per-person
|
||||
accounts and RBAC, and a working entry→dashboard→launch→lab→progress flow are implemented
|
||||
and end-to-end tested on a single dev machine, including a 3-student cohort classroom
|
||||
workflow and a real Platform API restart mid-session. **Still not cleared for minors** — the
|
||||
production two-VM Proxmox split this depends on doesn't exist yet, and OIDC/SSO is a
|
||||
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
|
||||
§16).
|
||||
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
|
||||
7. Capstone/exam engine — **done** as of Phase 3B (§12). RootNode Capstone (Real Ops-weighted,
|
||||
unlockable by curriculum-completion rule, not a permanent placeholder) plus a general exam
|
||||
engine (definitions, cohort assignment, random scenario draw, server-authoritative timer).
|
||||
8. Progressive hint levels — **done** as of Phase 3B (§12). Server-enforced sequential reveal,
|
||||
tracked per attempt, policy-aware under assessment mode (free/disabled/flagged).
|
||||
9. Production observability — not started.
|
||||
10. CI scenario testing — not started.
|
||||
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
|
||||
|
||||
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
|
||||
scenario content are the biggest remaining items. See
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §21 for the full Phase 4 boundary.
|
||||
|
||||
## 8. Platform Provisioner (Phase 1)
|
||||
|
||||
Full design: `docs/LAB_ARCHITECTURE.md` + `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational
|
||||
reference (config, local dev, known limitations): `platform/provisioner/README.md`. This
|
||||
section is just the map.
|
||||
|
||||
- **What it is:** an internal-only service that provisions a disposable, fully isolated Lab
|
||||
environment per student — its own Docker network, its own container running the existing,
|
||||
unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry
|
||||
(`environments`, `environment_events`, `lab_hosts`), idle reaper, startup reconciliation.
|
||||
- **What it is not:** directly reachable by a student or the Gateway anymore — as of Phase 2B,
|
||||
the Platform API (§9) is the *only* caller (student launch/reset AND admin prepare/destroy/
|
||||
reset both go through it). Still exercised directly via its internal API (shared-secret
|
||||
auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
|
||||
- **Where identity lives:** as of Phase 2B, Postgres is authoritative —
|
||||
`students`/`cohorts`/`progress`/`scenario_attempts` live in the same `rootnode_platform`
|
||||
database as `environments`, owned by the Platform API's own migrations. See §9. As of Phase
|
||||
3A, `instructors`/`sessions`/`cohort_instructors`/`audit_events` live there too. See §10.
|
||||
`environments.student_id` is still a plain opaque reference, not a real FK — that's a
|
||||
deliberate, documented choice (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2), not an oversight.
|
||||
- **Local dev:** `platform/provisioner/docker-compose.dev.yml` runs Postgres + the egress
|
||||
proxy + the Provisioner on one machine — the documented Phase 1 dev exception. Production
|
||||
is a two-VM Proxmox split (Platform VM / Lab Host VM); the dev compose file is explicitly
|
||||
not that.
|
||||
- **Test procedure:** `platform/provisioner/test/run.js` — 12 automated integration tests
|
||||
against a live dev stack (isolation, firewall ordering, reset/destroy cleanup, audit
|
||||
events). Some properties (idle reaper timing, remote TLS lab-host, reconciliation-on-crash,
|
||||
real-image k3s behavior) were validated manually during implementation rather than
|
||||
automated — see the Phase 1 final report for what and why.
|
||||
|
||||
## 9. Platform API (Phase 2B)
|
||||
|
||||
Full design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference:
|
||||
`platform/api/README.md`.
|
||||
|
||||
- **What it is:** `platform/api/` — a lightweight Node/Express service (no k3s, no
|
||||
`--privileged`, no Lab terminal runtime) owning students, cohorts, enrollments, curriculum
|
||||
assignments, progress, scenario attempts, and — as of Phase 3A — instructor accounts,
|
||||
persistent sessions, and the audit log (§10). The sole caller of the Provisioner for both the
|
||||
student launch/reset path and the instructor/admin control plane.
|
||||
- **What it is not:** reachable directly by a browser — always through the Gateway
|
||||
(`platform/gateway`), same trust boundary the Provisioner has relative to this service.
|
||||
- **Local dev:** part of the same `platform/docker-compose.dev.yml` stack as the Provisioner —
|
||||
no separate compose file.
|
||||
- **Test procedure:** `platform/test/e2e-phase2a.js` (student flow) and
|
||||
`platform/test/e2e-phase2b.js` (classroom acceptance workflow) — both need a bootstrapped
|
||||
instructor account as of Phase 3A, see §10.
|
||||
|
||||
## 10. Identity and instructor access (Phase 3A)
|
||||
|
||||
Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational reference:
|
||||
`platform/api/README.md` (bootstrap, config).
|
||||
|
||||
- **What it is:** persistent Postgres-backed sessions (student + instructor, survive a Platform
|
||||
API restart), real per-instructor accounts (`owner`/`admin`/`instructor` roles, scrypt
|
||||
password hashing, per-instructor salt), server-enforced RBAC on every `/api/admin/*` route,
|
||||
cohort-scoped access for the plain `instructor` role, CSRF double-submit protection, login
|
||||
rate limiting, and a platform-level `audit_events` log.
|
||||
- **What it replaced:** Phase 2B's in-memory session `Map`s (gone on every restart) and the
|
||||
single shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation).
|
||||
- **Bootstrap:** `node platform/api/bootstrap-owner.js <email> <name> <password>` — the only
|
||||
supported way to create the first instructor account; no manual database editing.
|
||||
- **Test procedure:** `platform/test/e2e-phase3a.js` — 21 automated tests covering session
|
||||
persistence (including a real `docker compose restart api`), instructor auth, RBAC, cohort
|
||||
scoping, CSRF, rate limiting, password change, and audit logging.
|
||||
- **Known limitations, future OIDC path, production HTTPS expectations:** see
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
|
||||
|
||||
## 11. Dependency lock convention
|
||||
|
||||
Every first-party Node service commits its `package-lock.json`: `backend/`, `frontend/`,
|
||||
`platform/api/`, `platform/gateway/`, `platform/provisioner/`. This wasn't consistently true
|
||||
before Phase 3B — `platform/api/`'s lockfile existed locally but was never committed, and
|
||||
`platform/gateway/`/`platform/provisioner/` never had one generated at all. Neither was a
|
||||
deliberate policy; both were oversights, fixed in Phase 3B (`chore(deps)` commits).
|
||||
|
||||
Going forward: after any `npm install` that changes a service's dependencies, the updated
|
||||
`package-lock.json` is part of the same commit as the `package.json` change — never left
|
||||
untracked, never regenerated-and-ignored. Validate a lockfile actually matches its
|
||||
`package.json` with `npm ci` (fails loudly on drift) before committing either.
|
||||
|
||||
## 12. Learning & Assessment Engine (Phase 3B)
|
||||
|
||||
Full design: `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
|
||||
|
||||
- **What it is:** the teaching/assessment layer on top of everything Phases 1–3A built —
|
||||
detailed per-attempt telemetry (`scenario_attempts`, evolved from Phase 2B's one-row-per-click
|
||||
shape into a real attempt lifecycle), server-enforced progressive hints, rule-driven
|
||||
curriculum stage/capstone unlock logic, a full exam + capstone assessment engine
|
||||
(`assessment_definitions`/`assessment_sessions`/`assessment_session_scenarios`), and
|
||||
persistent `achievements` records.
|
||||
- **Where the boundary moved:** the Gateway now rewrites one Lab response
|
||||
(`GET /lab/:token/api/scenarios/:scenarioId`, stripping `hints`) so progressive hints can be
|
||||
genuinely server-gated rather than just UI-collapsed — the Lab image's own code/bytes are
|
||||
still untouched; this is a response rewrite at the Gateway boundary, same pattern as every
|
||||
other Lab-mediation decision made in earlier phases. The Lab's own frontend bundle *did*
|
||||
change this phase (`ScenarioPanel.jsx`, new `ProgressiveHints`/`AssessmentBanner` components)
|
||||
— Phase 3B explicitly authorized enhancing Lab UX, unlike prior phases.
|
||||
- **Test procedure:** `platform/test/e2e-phase3b.js` — covers attempt telemetry, hint
|
||||
sequencing/persistence-through-Lab-reset, curriculum/capstone lock-unlock, the exam engine
|
||||
(random draw, timer, expiry, restart persistence), two-student isolation, and instructor
|
||||
analytics/RBAC.
|
||||
- **Known limitations:** see the Phase 3B doc §20 — no automatic abandoned-attempt detection, no
|
||||
per-cohort needs-attention threshold configuration, raw-JSON pool_spec authoring in the admin
|
||||
UI (correct, not yet a guided form).
|
||||
|
||||
## 13. Production Infrastructure (Phase 4)
|
||||
|
||||
Full design: `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`, plus detail docs
|
||||
`PRODUCTION_DEPLOYMENT.md`, `PRODUCTION_NETWORK_SECURITY.md`, `BACKUP_DR.md`,
|
||||
`OBSERVABILITY.md`, `CAPACITY_PLANNING.md`, `PRODUCTION_VALIDATION_CHECKLIST.md`, and 12
|
||||
`RUNBOOK_*.md` files, all under `docs/`. Everything lives in `infra/` (Ansible, Terraform,
|
||||
Compose, firewall, monitoring, backup, CI, load-test tooling).
|
||||
|
||||
- **What it is:** the infrastructure-as-code needed to run rn-platform-01/rn-lab-host-01 on a
|
||||
real Hetzner/Proxmox host — buildable and locally testable now, even though the dedicated
|
||||
server hasn't been purchased. Status is explicitly **PHASE 4 BUILD COMPLETE / PRODUCTION
|
||||
DEPLOYMENT PENDING HARDWARE**, not "deployed."
|
||||
- **Where the boundary held:** the Platform VM / Lab Host VM security split from
|
||||
`docs/LAB_ARCHITECTURE.md` is codified in firewall rulesets and mutual-TLS Docker config, not
|
||||
weakened anywhere.
|
||||
- **Known gap surfaced, then resolved in Phase 4.1:** the Gateway's Lab-reachability mechanism
|
||||
didn't survive the move to two real separate VMs — flagged explicitly rather than silently
|
||||
fixed, then actually built and simulation-tested (a Lab Host Router,
|
||||
`platform/lab-router/`) in Phase 4.1. See §13 below.
|
||||
- **Validated without the hardware:** real `tofu validate`, `ansible-playbook --syntax-check`,
|
||||
`docker compose config`, `caddy validate`, `promtool`/`amtool`, and a genuine end-to-end
|
||||
backup→encrypt→restore→verify cycle against disposable local Postgres with the real
|
||||
production migrations applied. None of this proves the real Hetzner network boundary — that
|
||||
needs `docs/PRODUCTION_VALIDATION_CHECKLIST.md` walked against the real server.
|
||||
|
||||
## 13. Pre-Production Hardening (Phase 4.1)
|
||||
|
||||
Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md` (routing resolution), `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
|
||||
`docs/SECURITY_SCANNING.md`.
|
||||
|
||||
- **What it is:** closes the Gateway-to-Lab cross-VM gap Phase 4 flagged (a Lab Host Router,
|
||||
`platform/lab-router/`, plus a routing abstraction in the Gateway,
|
||||
`platform/gateway/src/lab-endpoint.js`), then hardens the application layer found along the
|
||||
way: abandoned-attempt handling (`scenario_attempts.status = 'abandoned'`, never a punitive
|
||||
failure), a Postgres-backed login rate limiter (survives a restart, ready to be
|
||||
shared/multi-instance), real pagination on audit/attempt/assessment-session history, a
|
||||
structured assessment authoring UI (owner-only raw JSON remains as a fallback), a minimal
|
||||
`role_in_cohort` enforcement (`teacher`/`viewer`), a `/api/version` endpoint, and admission
|
||||
control (a clean 503 "capacity full" instead of a 500 once a configured cap is reached).
|
||||
- **Tested, not just designed:** a real two-Docker-network routing simulation
|
||||
(`infra/scripts/simulate-two-host/`, 13/13), a destructive Postgres restore drill
|
||||
(`infra/backup/test-destructive-restore-drill.sh`), a Lab Host loss/rebuild drill
|
||||
(`infra/scripts/drills/test-lab-host-loss.js`), a deployment rollback drill, a CI-fails-closed
|
||||
drill (which found and fixed a real pre-existing `set -e` bug in both smoke-test scripts —
|
||||
see that drill's own output), and a full fake-cohort dress rehearsal
|
||||
(`infra/scripts/drills/test-fake-cohort-rehearsal.js`) — all passing, all in
|
||||
`infra/scripts/drills/` or `infra/backup/`.
|
||||
- **Known limitation carried forward:** real remote/self-paced delivery needs actual student
|
||||
authentication (today's `student_id`-only login relies on instructors controlling who gets a
|
||||
valid id) — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` for the full accounting of what
|
||||
already works vs. what's genuinely Phase 5 scope. **Closed in Phase 5A, below.**
|
||||
|
||||
## 14. Student Identity & Remote Classroom (Phase 5A)
|
||||
|
||||
Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
|
||||
`docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`.
|
||||
|
||||
- **What it is:** two deliverables built on the Phase 4.1 milestone. (A) Real student accounts —
|
||||
email-or-login-identifier + password, reusing `instructors`' exact credential machinery, a
|
||||
single first-login/reset token mechanism, deactivation enforced per-request — replacing pilot
|
||||
"pick your name" identification without touching a single existing pilot student's progress.
|
||||
(B) A remote classroom control center — scheduled/live/ended sessions, derived (not stored)
|
||||
presence via heartbeat, a live instructor dashboard combining real Provisioner Lab state and
|
||||
real academic records, a help-request queue, guidance-only announcements/current-activity, and
|
||||
a generic external meeting-URL field. Explicitly **not** video/voice/chat/shell-takeover —
|
||||
RootNode owns identity and coordination, an external service owns the call.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5a.js` (21 checks: the two-student RBAC
|
||||
matrix and full password-security checklist, including a real Platform API restart proving
|
||||
rate-limit state survives it) and `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks:
|
||||
5 pilot students with seeded real history, a live classroom, help requests, **a real
|
||||
mid-rehearsal API restart** proving classroom/session/presence recovery, then converting two
|
||||
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
|
||||
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
|
||||
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
|
||||
|
||||
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
|
||||
|
||||
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
|
||||
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
|
||||
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
|
||||
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
|
||||
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
|
||||
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
|
||||
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
|
||||
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
|
||||
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
|
||||
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
|
||||
just hidden in the UI. Completion is entirely server-derived from the existing capstone
|
||||
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
|
||||
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
|
||||
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
|
||||
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
|
||||
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
|
||||
concurrent duplicate requests, not just sequential retries.
|
||||
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
|
||||
async-error-handling convention — an unexpected internal error there (a malformed
|
||||
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
|
||||
(wrapped + input-validated) and regression-tested.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
|
||||
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
|
||||
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
|
||||
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
|
||||
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
|
||||
(see that doc §14).
|
||||
|
||||
## 16. Enrollment Operations & Communications (Phase 5C)
|
||||
|
||||
Full design: `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`, plus
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` and `docs/RUNBOOK_STUDENT_ACTIVATION.md`.
|
||||
|
||||
- **What it is:** a durable, provider-neutral notification pipeline (`notification_events`, a
|
||||
`FOR UPDATE SKIP LOCKED` worker polling on a plain `setInterval` — same pattern as the
|
||||
existing session/login-attempt sweepers, no new infrastructure) that finally sends real email
|
||||
for activation, enrollment confirmation, password reset, and password-changed — closing the
|
||||
gap Phase 5A explicitly left open. Google Workspace adapter: Gmail API via a service account
|
||||
with domain-wide delegation (no human password, no SMTP basic auth — see that doc §2 for why),
|
||||
behind the same provider interface a deterministic dev provider implements, so no test or dev
|
||||
run ever reaches a real inbox. Reuses Phase 5A's activation/reset token mechanism entirely — no
|
||||
competing invitation-token system.
|
||||
- **A real bug found and fixed:** `request-password-reset` would mint a reset token for a
|
||||
never-activated student, which doesn't make sense (there's no password yet to reset) and would
|
||||
have sent a confusing email. Fixed to fall through to the same anti-enumeration response as a
|
||||
nonexistent account.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5c.js` (28 checks: activation vs.
|
||||
confirmation routing, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted
|
||||
retry classification, resend/regenerate token invalidation, production token-secrecy gating
|
||||
via a real container recreate with `COOKIE_SECURE=1`, minor/contact-email resolution, RBAC
|
||||
scoping, metrics/audit hygiene, a real restart) and
|
||||
`infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks: one continuous story
|
||||
from website enrollment through a real rendered/extracted activation email, a real Lab launch
|
||||
and scenario completion, a second enrollment's confirmation email, and a full password-reset
|
||||
round trip) — both against a live stack, using only the dev provider (no real network calls).
|
||||
Live Google Workspace delivery itself is implemented and unit-level ready
|
||||
(`infra/scripts/gws-email-test.js`, opt-in) but not yet validated against a real Workspace
|
||||
domain — see that doc's final section.
|
||||
@@ -0,0 +1,521 @@
|
||||
# RootNode Academy — Ephemeral Lab Architecture
|
||||
|
||||
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
|
||||
(Platform API split, Postgres academic data, server sessions, admin portal) + Phase 3A
|
||||
(persistent sessions, per-instructor accounts, RBAC, audit log) + Phase 3B (learning/assessment
|
||||
engine: attempt telemetry, progressive hints, curriculum rules, exam + capstone engine,
|
||||
achievements) IMPLEMENTED, 2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`,
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, and
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for concrete schema/API/test results, and
|
||||
`platform/README.md` for how to run the stack. This document remains the architecture record
|
||||
for the full ephemeral-lab direction; production OIDC/SSO (an adapter interface exists, not a
|
||||
working integration) and production Proxmox deployment are still future work (Phase 4 — see the
|
||||
Phase 3B doc §21). Supersedes the namespace-isolation
|
||||
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
|
||||
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
|
||||
|
||||
Committed direction: **one fully isolated, disposable environment per student. No shared
|
||||
Kubernetes control plane, ever.** This is the long-term architecture, not a stopgap.
|
||||
|
||||
**Decisions locked 2026-08-18** (resolves the four open questions this doc originally ended
|
||||
with — see §2–§5 below for where each lands, and `docs/PHASE1_PLATFORM_FOUNDATION.md` for the
|
||||
Phase 1 build plan against these decisions):
|
||||
1. **Egress:** restricted, explicit allowlist. No unrestricted internet egress, ever. No route
|
||||
to trusted RootNode management/CDAA/home/business networks, under any allowlist entry.
|
||||
2. **Platform DB:** PostgreSQL. The Platform control plane is not built around SQLite. The
|
||||
existing single-container local dev mode keeps using its own local SQLite as today —
|
||||
unaffected, unrelated database.
|
||||
3. **Suspend/resume:** experimental only, not load-bearing. The guaranteed lifecycle is
|
||||
`provision → run → destroy → recreate`. Persistent academic state lives outside the Lab
|
||||
specifically so destroying one never loses student progress — suspend/resume is a possible
|
||||
future optimization, not something correctness depends on.
|
||||
4. **Resource sizing:** not hard-coded. Quotas are policy/config-driven. Target host class is
|
||||
~16 cores/32 threads, 128GB RAM, SSD/NVMe — actual per-lab limits and concurrency get set
|
||||
by benchmarking against that class, not guessed up front.
|
||||
|
||||
**Corrections locked 2026-08-18** (round 2 — architectural fixes required before Phase 1
|
||||
implementation begins; folded into §1, §4, §7, §9 below, not just listed here):
|
||||
|
||||
- **A. VM boundary.** Privileged Lab containers must never share an OS/VM boundary with the
|
||||
Platform (Postgres, control-plane) in production — separate VMs under Proxmox, connected by
|
||||
a Provisioner-initiated control channel only. The Lab Host is a *configured target*, not an
|
||||
assumption that Docker is local. Single-machine is still fine for Phase 1 development — see §1/§9.
|
||||
- **B. Egress is a firewall control, not an app convention.** `HTTP_PROXY`/`HTTPS_PROXY`
|
||||
configure well-behaved apps for convenience; they enforce nothing. The firewall is the
|
||||
control, and rule *ordering* matters — required infrastructure (proxy, DNS) must be
|
||||
explicitly permitted before the blanket RFC1918 drops, or those drops shadow the allow
|
||||
rules and nothing works. See §4 — this also corrects a real ordering bug in the original
|
||||
design (explained there).
|
||||
- **C. `student_id` is opaque in Phase 1.** No real foreign key to a `students` table —
|
||||
students/progress stay on SQLite until a later, separate Platform DB migration. Documented,
|
||||
not silently assumed. See §3/§9.
|
||||
- **D. Lab host is an abstraction from day one**, even with exactly one configured host in
|
||||
Phase 1 — the Provisioner is never allowed to hard-code "Docker is local." See §4.
|
||||
|
||||
---
|
||||
|
||||
## 0. What already exists (grounding, not invention)
|
||||
|
||||
The current repo (`~/Dev/Gitea/ced4568/rootnode-academy`) already documents almost this exact
|
||||
intent in its own README, just never built out:
|
||||
|
||||
> "The upstream project assumes one learner, one container. RootNode Academy runs it across
|
||||
> three delivery modes... One container **per student** — never share a cluster between
|
||||
> learners... Put authentication in front (proxy-level)... Idle-timeout + auto-teardown."
|
||||
|
||||
And the existing image already **is** a complete single-student lab: nginx + React frontend +
|
||||
Node/Express backend + `node-pty` WebSocket terminal + a single-node k3s cluster, all in one
|
||||
`--privileged` container (confirmed required — k3s needs kernel namespaces/cgroups, per
|
||||
`README.md:52`). That container is the unit this design multiplies, not something it replaces.
|
||||
|
||||
What changes: today that one container also owns identity/progress (SQLite at
|
||||
`/data/progress.db`, one volume per student, mounted by hand). This design pulls that
|
||||
ownership out into a persistent Platform, and makes the container itself fully disposable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Proposed architecture
|
||||
|
||||
Two roles — and in production, on **two separate VM/OS boundaries**, not just two logical
|
||||
components sharing a box. Correction A drives this: `--privileged` Docker containers are not
|
||||
a security boundary from their own Docker host, so the boundary has to be a real VM boundary,
|
||||
not a process boundary.
|
||||
|
||||
**Production topology:**
|
||||
|
||||
```
|
||||
Proxmox VE (or equivalent hypervisor)
|
||||
│
|
||||
├── RootNode Platform VM (trusted — never runs student code)
|
||||
│ │
|
||||
│ │ Platform API roster · login · progress · curriculum · exam records ·
|
||||
│ │ environment registry · instructor controls
|
||||
│ │ Platform DB PostgreSQL — environments + audit log now;
|
||||
│ │ students/progress join later (§3/§9, correction C)
|
||||
│ │ Provisioner talks OUT to a *configured* Lab Host endpoint —
|
||||
│ │ never assumes Docker is local (§4, correction D)
|
||||
│ │ Gateway opaque-token routing to student lab containers;
|
||||
│ │ only component with a leg in both worlds
|
||||
│ │
|
||||
│ ▼ (Provisioner-initiated control channel only — TCP+TLS to the Lab
|
||||
│ Host's Docker API. Nothing on the Lab Host VM ever initiates a
|
||||
│ connection back. See §4/§7 for why this direction matters.)
|
||||
│
|
||||
└── RootNode Lab Host VM (untrusted — this is where privileged student
|
||||
│ containers actually run; holds nothing the
|
||||
│ Platform needs protected)
|
||||
│
|
||||
├── Student A's LAB ── own private network, own nested k3s, no identity
|
||||
├── Student B's LAB ── own private network, own nested k3s, no identity
|
||||
└── Student C's LAB ── own private network, own nested k3s, no identity
|
||||
```
|
||||
|
||||
Each Lab is **today's `Dockerfile` and `entrypoint.sh`, functionally unchanged** — still runs
|
||||
k3s + the terminal + scenario setup/validate/teardown exactly as now. The only change inside
|
||||
it: it stops being the system of record for who's using it or what they've completed (§3, §9).
|
||||
|
||||
A compromised student container's worst case is the Lab Host VM — never Postgres, never the
|
||||
Platform API, because there is no network path from that VM back to the Platform VM at all
|
||||
(§4, §7). That containment is the actual point of the two-VM split, not a deployment nicety.
|
||||
|
||||
**Phase 1 development exception:** running the Platform and a Lab container on one machine is
|
||||
fine for local testing (§9) — the Provisioner just gets configured with a single `lab_host`
|
||||
pointing at the local Docker socket instead of a remote TLS endpoint. The *code* never
|
||||
special-cases "local" vs "remote" — same abstraction either way (§4/§9 of
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment lifecycle
|
||||
|
||||
State machine per environment, tracked in the Platform DB (`environments` table, §3):
|
||||
|
||||
**Guaranteed path** (what correctness depends on): `requested → provisioning → running →
|
||||
resetting → running` (reset loop, same route) and `(provisioning|running|error) → destroying
|
||||
→ destroyed`. `provisioning → error` and `running → error` cover failed/lost environments;
|
||||
`error → destroying → destroyed` (or `→ resetting → running`, when recoverable) is the way out.
|
||||
|
||||
```
|
||||
requested → provisioning → running ─┬─→ resetting → running (clean slate, same route)
|
||||
└─→ destroying → destroyed
|
||||
provisioning → error (failed to reach ready state)
|
||||
running → error (health check fails mid-session)
|
||||
error → destroying → destroyed (or → resetting → running, if recoverable)
|
||||
|
||||
[EXPERIMENTAL, not relied on for correctness — see decision #3]
|
||||
running ──suspend──→ suspended ──resume──→ running (or → error, if resume fails)
|
||||
```
|
||||
|
||||
- **provision** — Provisioner picks a host with capacity (§5), creates a private network for
|
||||
this student (§4), starts a container from the pinned Lab image, generates a random
|
||||
high-entropy routing token, waits for the existing readiness signal (k3s API ready + backend
|
||||
`/api/health` OK — same check the current entrypoint already does), writes the `environments`
|
||||
row, hands the route back to the Platform API.
|
||||
- **start** — used only for `provisioning → running` (starting a freshly created container).
|
||||
There is no supported path that starts a previously-stopped container in the guaranteed
|
||||
lifecycle — see suspend/resume below.
|
||||
- **suspend/resume — EXPERIMENTAL, decision #3.** `docker stop`/`docker start` on a k3s-in-container
|
||||
Lab is *not assumed to work* and is not built as part of the guaranteed lifecycle. It's
|
||||
worth testing (see the Phase 1 test plan) because if it turns out reliable it's a cheap
|
||||
latency win later — but nothing in the platform depends on it succeeding. The only supported
|
||||
way to bring an environment back is `destroy` + `provision` (or `reset`, which is that same
|
||||
pair against one `environments` row/routing token). This is also *why* academic state living
|
||||
outside the Lab (§3) isn't optional — it's what makes "just destroy and recreate" a fully
|
||||
safe default instead of a data-loss risk.
|
||||
- **reset** — destroy and recreate the container from the pinned image, **same routing token
|
||||
and same `environments` row**, so the student's "return to my lab" link doesn't change.
|
||||
This is the instructor-facing "reset student environment" control.
|
||||
- **destroy** — stop + remove container, remove its private network, release the routing
|
||||
token, mark the `environments` row `destroyed` with a timestamp. Progress already lives in
|
||||
the Platform DB (§3), so destroying an environment never loses academic data — only
|
||||
in-terminal, not-yet-validated work.
|
||||
- **Idle reaper** (new, required — not optional) — background job in the Provisioner checks
|
||||
`last_active_at` per running environment (heartbeat from the Gateway or from Lab `/api/health`
|
||||
polling) and auto-suspends past an idle threshold, auto-destroys past a longer abandoned
|
||||
threshold. Without this, "ephemeral by default" is just a description, not a guarantee —
|
||||
a closed browser tab does not stop a running k3s cluster.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model changes
|
||||
|
||||
Everything below lives in the **Platform DB**, not inside any Lab container.
|
||||
|
||||
Already built (previous slice, unaffected by this redirect — still correct under this
|
||||
architecture):
|
||||
- `students(student_id, display_name, cohort, created_at, last_active_at)`
|
||||
- `progress(student_id, scenario_id, status, attempts, ...)` — composite key, already
|
||||
per-student.
|
||||
- `sessions` / `exam_progress` — exam records, already student-attributed.
|
||||
|
||||
New:
|
||||
- `environments(environment_id UUID PK, student_id, status, routing_token, container_id,
|
||||
lab_host_id FK, internal_ip, cpu_limit, mem_limit_mb, storage_limit_mb, image_ref, created_at,
|
||||
started_at, last_active_at, destroy_at, error_reason)` — **`student_id` is an opaque external
|
||||
identifier here (correction C), not a foreign key.** Students/progress stay on the existing
|
||||
SQLite in Phase 1 (see §9); Postgres has no `students` table yet, so there is nothing to key
|
||||
against. The Provisioner trusts whatever `student_id` its caller passes — it does not
|
||||
validate it against a roster. A real FK gets added when students/progress actually migrate
|
||||
onto this Postgres instance, not before. `lab_host_id`, by contrast, *is* a real FK — both
|
||||
tables live in the same Provisioner-owned Postgres DB (correction D, full schema in
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`).
|
||||
- `lab_hosts(lab_host_id PK, endpoint, enabled, ...)` — the abstraction correction D requires:
|
||||
which Docker endpoint a given environment's container actually lives on. Phase 1 ships with
|
||||
exactly one row (pointing at either a local socket in dev or the Lab Host VM's TLS endpoint
|
||||
in production) — the table exists so environment creation is never coupled to "Docker is
|
||||
local" as an assumption, even though there's no scheduling logic yet.
|
||||
- `environment_events(id, environment_id, event_type, actor, detail, created_at)` — audit
|
||||
trail for provision/start/stop/reset/destroy/error, needed for both incident response and
|
||||
ordinary "why did my lab disappear" support.
|
||||
|
||||
**Removed from the Lab image's responsibility:** the `students` and roster-facing parts of the
|
||||
schema I added to `backend/server.js` in the previous slice move to be Platform-only. The Lab
|
||||
image's own SQLite either goes away entirely or is kept only as scratch/ephemeral state that's
|
||||
allowed to die with the container — it must not be treated as authoritative for anything.
|
||||
|
||||
**Decision #2: PostgreSQL, not SQLite.** The Platform control plane — the environment registry
|
||||
specifically, and the target for progress/roster once they migrate over — is Postgres. The
|
||||
existing single-container local dev mode keeps its own local SQLite exactly as today; that's a
|
||||
different, unrelated database serving a different purpose (single-tenant local testing), not
|
||||
something this decision touches. See `docs/PHASE1_PLATFORM_FOUNDATION.md` for the exact schema
|
||||
being stood up first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Networking model
|
||||
|
||||
- **One private Docker network per student environment**, not one shared network for all
|
||||
labs. Docker's default per-network isolation means Student A's container has no L2/L3 path
|
||||
to Student B's container at all — this is stronger and simpler than trying to firewall
|
||||
students apart on a shared network.
|
||||
- **No published ports on Lab containers.** Today's image publishes `-p 7554:80`; under this
|
||||
design that stops — the only thing that can reach a Lab container is the Gateway, over that
|
||||
student's private network.
|
||||
|
||||
### Egress — correction B: the firewall is the control, not the proxy env vars
|
||||
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` are set on each Lab container so well-behaved processes (containerd
|
||||
pulling scenario images) route through the allowlisting proxy cleanly. **That is configuration,
|
||||
not enforcement** — a process that ignores those env vars and dials out directly must still be
|
||||
blocked, by something that doesn't depend on the container's cooperation. That something is a
|
||||
host firewall rule, evaluated on every packet regardless of what any process inside the
|
||||
container does.
|
||||
|
||||
**Ordering bug in the original draft, corrected here:** the previous version of this section
|
||||
listed proxy/DNS `ACCEPT` rules before the RFC1918 `DROP` rules in prose, but specified
|
||||
installing them with `iptables -I` (insert-at-head) in that same top-to-bottom order — which
|
||||
actually reverses the result, since each `-I` without a rule number pushes the *new* rule to
|
||||
position 1. Run in that order, the RFC1918 drops end up evaluated *before* the proxy/DNS
|
||||
accepts, silently blocking the proxy itself if it happens to sit on an RFC1918 address (it
|
||||
will — it's Platform-side infrastructure). Corrected mechanism (exact rules in
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md` §5): each student network gets its own dedicated iptables
|
||||
chain, built with `-A` (append, so rules land in the order they're added — no head-insertion
|
||||
trap), containing explicit `ACCEPT` rules for the egress proxy, DNS, and any specifically
|
||||
required Platform endpoint *first*, then the RFC1918 `DROP`s, then a final default-`DROP`. That
|
||||
chain is reached from `DOCKER-USER` (the chain Docker guarantees it won't silently rewrite) via
|
||||
one jump rule matching that student's subnet.
|
||||
|
||||
**What no student lab may ever reach, regardless of allowlist state:**
|
||||
- RootNode management networks
|
||||
- the Platform database (Postgres)
|
||||
- Docker/Proxmox management interfaces (including the Lab Host VM's own Docker API endpoint —
|
||||
a Lab container talking back to the thing that provisioned it is exactly the escalation path
|
||||
this design exists to prevent)
|
||||
- CDAA infrastructure
|
||||
- home/trusted networks
|
||||
- other students' lab networks (already unreachable via Docker's own per-network isolation
|
||||
above; the RFC1918 drop is a deliberate second layer, not redundant — it also catches any
|
||||
case where two student networks' address ranges could otherwise see each other at the host
|
||||
routing level)
|
||||
|
||||
**Decision #1, restated with the ordering fix:** restricted egress, explicit allowlist, no
|
||||
unrestricted internet access ever, enforced at the firewall layer with required infrastructure
|
||||
explicitly permitted *before* the blanket denies — not the reverse, and not left implicit.
|
||||
|
||||
### VM-to-VM boundary — correction A
|
||||
|
||||
The Lab Host VM has **no route back to the Platform VM at all**, in either direction, except
|
||||
the one the Provisioner itself opens. The Provisioner (on the Platform VM) connects *out* to
|
||||
the Lab Host's Docker API over TCP+TLS with a client certificate only the Provisioner holds;
|
||||
nothing on the Lab Host VM — student containers or the VM's own OS — can open a connection back
|
||||
toward the Platform VM. This is enforced at the hypervisor/network level (Proxmox VLAN/firewall
|
||||
rules between the two VMs), the same category of control as the CDAA/home boundary below, not
|
||||
something Docker networking alone provides. A compromised student container's blast radius
|
||||
stops at the Lab Host VM.
|
||||
|
||||
**No route to trusted CDAA/home/business networks, full stop.** This is a host/hypervisor
|
||||
boundary, not something Docker networking alone can guarantee — the box(es) these containers
|
||||
run on need to sit on their own segment with no bridge/route to those networks, consistent with
|
||||
the existing `RootNode_Academy_Business_Pack/06_Technology/01_Technical_Platform_Plan.md`
|
||||
("dedicated student network/DMZ separated from trusted networks"). This design assumes that
|
||||
host-level segmentation is a prerequisite, not something the app layer can create on its own.
|
||||
|
||||
### Lab host abstraction — correction D
|
||||
|
||||
The Provisioner never talks to "the local Docker socket" as a hard-coded assumption — it talks
|
||||
to whichever `lab_hosts` row it's configured to use (§3), resolved to a Docker endpoint that
|
||||
may be `unix:///var/run/docker.sock` (Phase 1 dev, single machine) or a remote
|
||||
`tcp://<lab-host-vm>:2376` with TLS (production, per the topology in §1). Phase 1 ships exactly
|
||||
one enabled `lab_host` and zero scheduling logic — the abstraction exists so adding a second
|
||||
host later is a config row, not a rewrite.
|
||||
|
||||
- **Gateway** is the only component with a leg in both the public-facing network and every
|
||||
student's private network. It resolves `routing_token → container internal IP:port` by
|
||||
querying the Platform API (or a shared registry cache) per request/connection, then proxies
|
||||
both HTTPS (scenario API, static frontend) and the WSS terminal connection.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resource model
|
||||
|
||||
- Per-environment caps applied at container-create time: `--cpus`, `--memory` (with
|
||||
`--memory-swap` pinned equal to `--memory` so nothing swaps), `--pids-limit` (fork-bomb
|
||||
guard). Storage: needs a size-capped volume — exact mechanism (`--storage-opt size=`, a
|
||||
loopback-backed bind mount, or a tmpfs cap) depends on the host's actual Docker storage
|
||||
driver, which I haven't inspected — needs to be confirmed against the real deploy target
|
||||
before picking one.
|
||||
- **Decision #4: policy-driven, not hard-coded.** Limits come from config, not constants in
|
||||
code — a resource-policy file the Provisioner reads at startup (and can reload), not numbers
|
||||
baked into the create-container call. No specific numbers are chosen yet. Target host class
|
||||
for planning purposes: ~16 cores/32 threads, 128GB RAM, SSD/NVMe. Actual per-lab limits and
|
||||
how many environments run concurrently on that class get set by benchmarking (Phase 1's test
|
||||
plan includes the first real measurements), not by guessing backward from the spec sheet.
|
||||
- **Admission control:** Provisioner refuses to `provision` past the point where already-running
|
||||
environments' summed limits would exceed host capacity. Simple counter/gate to start; can
|
||||
become real bin-packing later if there's ever more than one host.
|
||||
|
||||
---
|
||||
|
||||
## 6. Routing/auth model
|
||||
|
||||
1. Student logs in at the Platform (existing roster/pick-your-name flow, unchanged).
|
||||
2. Frontend asks the Platform for "my lab." If none running, Platform calls the Provisioner to
|
||||
`provision` (or `start`, if one's suspended).
|
||||
3. Platform returns an **opaque, high-entropy routing token** scoped to
|
||||
`(student_id, environment_id)`, short TTL, revoked on `destroy`.
|
||||
4. Frontend connects to the Gateway using that token — `https://labs.<domain>/e/<token>/...`
|
||||
and `wss://labs.<domain>/e/<token>/shell-ws`. The Lab container itself never sees a
|
||||
student identity; it's purely an execution surface addressed by an opaque route.
|
||||
5. Gateway validates the token maps to a currently `running` environment before proxying —
|
||||
rejects stale tokens from destroyed/reset environments outright, so there's no window where
|
||||
an old link reaches a different (possibly now-different-student's) container.
|
||||
6. **Instructor auth** extends the shared-passcode model already used for the roster-add
|
||||
endpoint: instructor-only calls (`list environments`, `reset`, `terminate`, `resource usage`)
|
||||
go through the Platform API under that same gate, which then calls the Provisioner
|
||||
internally. Students and the public internet never talk to the Provisioner or the Docker
|
||||
socket directly — only the Platform backend does.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security boundaries (defense in depth)
|
||||
|
||||
1. **Browser ↔ Gateway** — public-facing, TLS, opaque routing tokens only. Nothing
|
||||
cluster-admin-shaped is reachable from here.
|
||||
2. **Gateway ↔ Lab container** — private per-student Docker network only, no published ports,
|
||||
Gateway is the sole entry point.
|
||||
3. **Inside the Lab container** — `--privileged` is required for k3s (confirmed, not
|
||||
avoidable — this isn't a place to try to "hold the line" without breaking k3s). The
|
||||
mitigation is containment, not avoidance: this privileged container has no reachable
|
||||
network path to anything except the Gateway, runs as a throwaway that gets destroyed/reset
|
||||
routinely, and cluster-admin *inside a student's own disposable cluster* is expected and
|
||||
fine — that's the entire point of full isolation, a student doing CKS-tier cluster-admin
|
||||
work only ever affects their own throwaway cluster.
|
||||
4. **Lab container ↔ its own Docker host (correction A — the boundary that actually matters
|
||||
here)** — `--privileged` is explicitly **not treated as a sufficient isolation boundary from
|
||||
its own Docker host.** In production the Lab Host VM holding these containers is a separate
|
||||
VM from the Platform VM, with no route back to it at all (§4). If a student ever escapes
|
||||
their container, the ceiling is the Lab Host VM — never Postgres, never the Platform API,
|
||||
never another student's environment.
|
||||
5. **Provisioner ↔ Lab host's Docker API** — only the Provisioner can invoke it, over a
|
||||
connection *it* initiates (TCP+TLS, client-cert auth, per §4's lab-host abstraction). The
|
||||
Docker endpoint is never student-reachable, never internet-reachable, and nothing on the Lab
|
||||
Host VM can initiate a connection back toward the Provisioner or the Platform VM.
|
||||
6. **Platform VM ↔ other networks** — dedicated segment, no bridge to home/business/CDAA
|
||||
trusted infrastructure, matching the existing Technical Platform Plan doc. Prerequisite, not
|
||||
something this design builds. Same standard applies to the Lab Host VM independently — it
|
||||
isn't exempted just because it's "the untrusted one."
|
||||
7. **Routing tokens** — CSPRNG-generated, short TTL, invalidated on any lifecycle transition
|
||||
out of `running`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Failure/recovery behavior
|
||||
|
||||
- **Provisioning fails** (host at capacity, image pull fails, k3s doesn't reach ready state in
|
||||
time) — environment marked `error`, student gets an explicit "your lab failed to start, try
|
||||
again" message (never a silent hang), one automatic retry, then surfaced to the instructor
|
||||
dashboard (future) as a flagged failure.
|
||||
- **Mid-session crash** — Gateway's health check detects the upstream is unreachable, marks
|
||||
the environment `error`, offers the student a "restart my lab" action (→ `reset`, since a
|
||||
crashed in-container k3s is unlikely to resume cleanly). Because progress/identity/exam
|
||||
records live in the Platform DB, this never loses the student's academic record — worst case
|
||||
is losing in-progress, not-yet-validated terminal work.
|
||||
- **Host failure / reboot** — every environment on that host becomes unreachable. On
|
||||
Provisioner restart, it must **reconcile its registry against actual running containers**
|
||||
rather than trust stale DB state — some containers may auto-restart via Docker's restart
|
||||
policy, most won't; anything the registry claims is `running` that isn't actually running
|
||||
gets marked `error`, not silently left wrong.
|
||||
- **Idle/abandoned environments** — the reaper (§2) bounds resource leakage from crashed tabs
|
||||
or students who just walked away, independent of any other failure path.
|
||||
- **Core resilience property of this whole design:** because the Platform DB — not the
|
||||
disposable environment — is the system of record, every failure mode above is recoverable
|
||||
without academic data loss. That's the actual payoff of the split in §1/§3, not just an
|
||||
architectural nicety.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration path from the current single shared container
|
||||
|
||||
- **Phase 0 (already true today, keep it):** running the existing image standalone —
|
||||
`docker run --privileged -p 7554:80 ... rootnode-academy:latest` — is exactly the
|
||||
single-user local dev/test mode you asked to keep available. No change needed to support
|
||||
this; it already works and stays supported as-is.
|
||||
- **Phase 1:** Build Provisioner + `environments`/`environment_events`/`lab_hosts` registry
|
||||
around the **existing, unmodified** Lab image, on Postgres, with the egress firewall/proxy
|
||||
from §4 in place from the start (not bolted on later). Development exception (correction A):
|
||||
running Platform + Provisioner + one Lab container all on a single machine is fine here — the
|
||||
Provisioner is configured with one `lab_host` pointing at the local Docker socket instead of
|
||||
a remote TLS endpoint, same code path either way. Prove two lab environments can run side by
|
||||
side, fully isolated from each other, before any production traffic or a second VM enters
|
||||
the picture. `students`/`progress` stay on the existing SQLite through this phase
|
||||
(correction C) — `environments.student_id` is opaque, not a foreign key, until a later,
|
||||
separate migration actually moves roster/progress onto Postgres.
|
||||
- **Phase 2 (the one real code change inside the Lab image):** narrow `backend/server.js`
|
||||
inside the Lab image down to a "dumb executor" — scenario setup/validate/teardown/answer and
|
||||
the WS terminal stay, but it stops owning `students`/roster and stops being the source of
|
||||
truth for progress. Either it calls back to the Platform API to report validation results,
|
||||
or — cleaner — the Platform API becomes the only thing the frontend talks to for
|
||||
progress/curriculum/identity, and it calls the Lab container (via the route the Provisioner
|
||||
already tracks) purely to execute against that student's cluster and get a pass/fail back.
|
||||
- **Phase 3:** Wire instructor controls (list/reset/terminate/resource-usage) against the new
|
||||
registry — this is also where the "no auth on roster-add" gap from the previous slice gets
|
||||
closed for real, since instructor auth is now a load-bearing security boundary, not just
|
||||
roster hygiene.
|
||||
- **Phase 4 (cutover):** point the real domain at the Gateway instead of a single container.
|
||||
Keep the Phase 0 single-container path available under local/dev tooling, per your explicit
|
||||
requirement.
|
||||
- **Rollback safety:** at every phase through Phase 3, the current single-shared-container path
|
||||
keeps working completely unmodified — nothing about this migration risks the working
|
||||
baseline until the Phase 4 cutover, which is also the only step that's meaningfully
|
||||
reversible-with-effort rather than trivially reversible.
|
||||
|
||||
---
|
||||
|
||||
## 10. Exact files/components this touches (once approved — no code written yet)
|
||||
|
||||
**New, none of it touching the existing Lab image's core logic:**
|
||||
- `platform/provisioner/` — Docker lifecycle management against a *configured* `lab_host`
|
||||
endpoint (correction D — never a hard-coded local socket), `environments`/`lab_hosts` CRUD,
|
||||
per-environment firewall chain management (correction B), idle reaper, admission control.
|
||||
- `platform/egress-proxy/` — the shared allowlisting proxy every Lab container's egress must
|
||||
pass through (correction B).
|
||||
- `platform/gateway/` — token→route resolution, HTTPS + WSS proxying (candidate: Traefik with
|
||||
a dynamic provider backed by the Platform API, or a small custom Node proxy — not decided,
|
||||
not needed for this design review).
|
||||
- Platform DB migrations: `environments`, `environment_events`, `lab_hosts` tables — full
|
||||
schema in `docs/PHASE1_PLATFORM_FOUNDATION.md`.
|
||||
|
||||
**Modified, once implementation starts:**
|
||||
- `backend/server.js` — split as described in §9 Phase 2. This is presently both "the lab
|
||||
image's backend" and "the only backend that exists"; under this design it forks into a
|
||||
narrower Lab-side executor and a separate, persistent Platform-side API. Exact file layout
|
||||
for that split is an implementation decision, not part of this design doc.
|
||||
- `README.md` — the "Multi-Student Deployment" section (`README.md:128-146`) currently
|
||||
documents per-student **volumes** for persistence; that persistence model is superseded by
|
||||
§3 above and the README needs updating once this ships, so it doesn't contradict reality.
|
||||
- `docs/ENGINEERING_SOP.md` — new section for the Platform/Provisioner/Gateway components and
|
||||
the reconciliation/reaper operational procedures from §8.
|
||||
|
||||
**Unchanged:**
|
||||
- `Dockerfile`, `scripts/entrypoint.sh`, `scripts/nginx.conf` for the Lab image itself — it's
|
||||
still one container running k3s + backend + terminal, exactly as today, just multiplied and
|
||||
stripped of identity ownership.
|
||||
- All scenario content (`scenarios/data/`, `scenarios/bundles/`, `scenarios/rootnode-path.json`)
|
||||
— unaffected either way; every scenario now runs with full admin of its own disposable
|
||||
cluster, so the earlier `shared_namespace`/`ephemeral_cluster` split has no bearing here.
|
||||
|
||||
**New Dockerfile needed (not yet written):** one for the Provisioner+Gateway services — these
|
||||
are new components, not variants of the existing Lab image.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
All four open questions this doc originally ended with are resolved — see the decisions block
|
||||
at the top and §2–§5 above. Next: `docs/PHASE1_PLATFORM_FOUNDATION.md` — the concrete build
|
||||
plan for the platform foundation (Postgres environment registry, provisioner skeleton,
|
||||
networking, egress, resource policy, reaper) against these decisions.
|
||||
|
||||
## Phase 4: this boundary in production
|
||||
|
||||
Phase 4 (`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`) codifies the Platform/Lab-Host split this
|
||||
document designed as two real, separately-firewalled Proxmox VMs (`rn-platform-01`,
|
||||
`rn-lab-host-01`) connected by two separate mTLS channels (Provisioner↔dockerd CONTROL plane,
|
||||
Gateway↔Lab Host Router DATA plane) and a tightly scoped firewall allowance — see
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`. Phase 4 surfaced a gap in the Gateway's `env.internal_ip`
|
||||
mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docker daemon, which
|
||||
breaks once they're genuinely on separate VMs); Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
|
||||
Router (`platform/lab-router/`).
|
||||
|
||||
## Phase 5A: reused, not modified
|
||||
|
||||
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
|
||||
remote classroom control center on top of everything this document describes, without changing
|
||||
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
|
||||
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
|
||||
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
|
||||
the same state enum) rather than inventing a second one.
|
||||
|
||||
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
|
||||
|
||||
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
|
||||
of the exact same disposable Lab this document describes — again without changing the
|
||||
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
|
||||
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
|
||||
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
|
||||
Lab container out-of-band and restarting the Provisioner to trigger its actual
|
||||
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
|
||||
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
|
||||
is completely unaffected and a fresh Lab boots normally.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Observability
|
||||
|
||||
Self-hosted Prometheus + Grafana + Alertmanager + Loki/Promtail, deployed via
|
||||
`infra/docker/platform/docker-compose.production.yml` on rn-platform-01, with lightweight agents
|
||||
(node-exporter, cadvisor, promtail) also on rn-lab-host-01. No paid SaaS.
|
||||
|
||||
This is operational observability — host/container/database health, uptime, resource pressure.
|
||||
It is deliberately separate from the application's own instructor learning analytics
|
||||
(Phase 3B's cohort/scenario/student dashboards) — different audience (ops vs. instructors),
|
||||
different data (infrastructure vs. learning outcomes), and mixing them would make both worse.
|
||||
|
||||
## What's real today
|
||||
|
||||
- **Host metrics** (both VMs): node-exporter — CPU, memory, disk, network.
|
||||
- **Container metrics** (Lab Host): cadvisor — per-container CPU/memory/network, including
|
||||
student Lab containers (`rootnode-lab-*`).
|
||||
- **Postgres metrics**: postgres-exporter — connections, transaction rate, table sizes.
|
||||
- **Uptime/liveness**: blackbox-exporter probing `/healthz` on the Gateway
|
||||
(`platform/gateway/src/server.js`) and the Platform API (`platform/api/src/server.js`), both
|
||||
added this phase specifically for this purpose, plus the Provisioner's pre-existing `/health`.
|
||||
- **Logs**: Loki + Promtail, scraping Docker container logs and journald from both VMs, with a
|
||||
redaction pipeline stage that strips `password|token|secret=`-pattern values before they're
|
||||
stored (`infra/monitoring/promtail/`).
|
||||
- **Dashboards**: Grafana, reachable only via `https://<origin>/grafana/` and only from
|
||||
`management_cidr` (see `docs/PRODUCTION_NETWORK_SECURITY.md`). One starter dashboard,
|
||||
`infra/monitoring/grafana/provisioning/dashboards/rootnode-overview.json` — 8 panels (Platform
|
||||
+ Lab Host CPU/memory/disk, Postgres up/connections, running-Lab-count via cadvisor label
|
||||
match [best-effort, caveated], uptime probes).
|
||||
|
||||
## What's NOT built yet (honest accounting, not silently assumed)
|
||||
|
||||
Application-level metrics — active student count, Lab provisioning success/failure rate,
|
||||
Gateway request latency/error rate, per-endpoint API metrics — would need real instrumentation
|
||||
added to the Platform API/Gateway/Provisioner (e.g. a `/metrics` endpoint via `prom-client`).
|
||||
`infra/monitoring/prometheus/prometheus.yml.j2` has a commented-out scrape job as the landing
|
||||
spot for this, explicitly marked as a follow-up, not wired to anything real yet.
|
||||
|
||||
## Alerting
|
||||
|
||||
`infra/monitoring/prometheus/rules/rootnode.yml` — two severities:
|
||||
|
||||
**Critical** (fires fast, pages): PlatformAPIDown, GatewayDown, PostgresDown, LabHostDown,
|
||||
BackupFailed, DiskCriticallyFull.
|
||||
|
||||
**Warning** (informational, slower repeat): LabHostHighMemory, HighDiskUsage, BackupAging.
|
||||
(RepeatedProvisioningFailures and ExcessiveFailedLogins are present as inert placeholders —
|
||||
they need the not-yet-built application metrics above before they can fire on anything real.)
|
||||
|
||||
Never alerts on an individual student's scenario failure or a single Lab crash — those are
|
||||
expected, routine outcomes of normal usage, not operational incidents.
|
||||
|
||||
`infra/monitoring/alertmanager/alertmanager.yml.j2` — critical alerts route separately with a
|
||||
30-minute repeat interval, everything else at 4 hours, one webhook receiver
|
||||
(`vault_alertmanager_webhook_url` — points at whatever channel gets configured at deploy time,
|
||||
e.g. a Slack/Discord/Matrix webhook), and one inhibit rule so a LabHostDown critical suppresses
|
||||
its own dependent warnings instead of paging twice for the same root cause.
|
||||
|
||||
## Validation performed (local, real tooling — not hardware-dependent)
|
||||
|
||||
`infra/monitoring/validate-monitoring.sh` wraps: `promtool check config` (Prometheus config),
|
||||
`promtool check rules` (alert rules — 11 rules found, valid), `amtool check-config`
|
||||
(Alertmanager — 1 inhibit rule, 2 receivers, valid). These prove syntactic/semantic correctness
|
||||
of the configs, not that the real network paths between rn-platform-01 and rn-lab-host-01 work —
|
||||
that requires the real hardware and is covered by `docs/PRODUCTION_VALIDATION_CHECKLIST.md`.
|
||||
@@ -0,0 +1,483 @@
|
||||
# RootNode Academy — Phase 1: Platform Foundation
|
||||
|
||||
Status: **IMPLEMENTED, tested on a single dev machine, 2026-08-18.** Code lives in
|
||||
`platform/provisioner/` and `platform/egress-proxy/`. This doc still reflects the corrected
|
||||
design (four architecture corrections: VM boundary, egress-as-firewall-not-app-config, opaque
|
||||
`student_id`, lab-host abstraction) — the implementation matches it, with a handful of
|
||||
deviations found and fixed during testing, listed in "Implementation notes" at the end of each
|
||||
section below where relevant, and summarized in `platform/provisioner/README.md`'s "Known
|
||||
limitations." Builds on `docs/LAB_ARCHITECTURE.md` (read that first).
|
||||
|
||||
**Scope — this phase only:**
|
||||
Postgres-backed environment registry · lifecycle state model · provisioner service skeleton ·
|
||||
per-student private Docker network · disposable Lab container creation from the existing
|
||||
image · configurable CPU/RAM/PID/storage limits · restricted egress · unique opaque routing
|
||||
token · environment event/audit log · destroy/reset · idle-reaper design · current
|
||||
single-container dev mode kept working, untouched.
|
||||
|
||||
**Explicitly not in this phase:** instructor dashboard, self-paced public enrollment, any
|
||||
scenario content change, unrestricted egress, removing local single-user mode. Nothing below
|
||||
proposes touching `backend/server.js`, `Dockerfile`, `scripts/entrypoint.sh`, or
|
||||
`scenarios/**` — the Lab image is used exactly as it exists today.
|
||||
|
||||
---
|
||||
|
||||
## 1. Service boundaries
|
||||
|
||||
Two new components. Neither is the Lab image; neither is public-facing.
|
||||
|
||||
- **Provisioner** (`platform/provisioner/`) — new Node service. Owns: the Postgres
|
||||
`environments`/`environment_events`/`lab_hosts` tables, the Docker Engine API connection (via
|
||||
`dockerode`, not shelling out to the `docker` CLI), network/container lifecycle, the idle
|
||||
reaper, and egress-firewall management for each student network. Exposes an internal HTTP API
|
||||
(§4) gated by a shared-secret header — same pattern already used for the instructor-passcode
|
||||
gate on the roster endpoint, so this doesn't introduce a new auth idiom.
|
||||
**Docker connection is always resolved through `lab_hosts` (§2), never hard-coded** — Phase 1
|
||||
configures exactly one row, pointing at `unix:///var/run/docker.sock` for single-machine dev
|
||||
or a `tcp://<lab-host-vm>:2376` TLS endpoint for the two-VM production topology in
|
||||
`docs/LAB_ARCHITECTURE.md` §1. Same code path either way; only the config differs.
|
||||
- **Egress proxy** (`platform/egress-proxy/`) — one shared instance (not per-student), a
|
||||
minimal HTTP(S) forward proxy (Squid) with a static host allowlist. Every Lab container's
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` env vars point at it; the student network's firewall rule permits
|
||||
egress *only* to this proxy and to DNS, so there's no way for a Lab container to reach the
|
||||
internet except through something the Platform's allowlist controls.
|
||||
|
||||
Nothing else changes. No new Platform API / roster-progress migration in this phase — that's
|
||||
explicitly out of scope (see §2 note on `student_id`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Exact DB schema (PostgreSQL)
|
||||
|
||||
New database, e.g. `rootnode_platform`. Only the environment registry ships this phase —
|
||||
`students`/`progress`/`sessions` stay on the existing SQLite inside the Lab image for now;
|
||||
migrating those onto this Postgres instance is a later phase, not bundled in here.
|
||||
|
||||
```sql
|
||||
-- ── lab_hosts — correction D: the abstraction that keeps Docker connectivity out of code ──
|
||||
CREATE TABLE lab_hosts (
|
||||
lab_host_id TEXT PRIMARY KEY, -- e.g. 'dev-local', 'labhost-01'
|
||||
display_name TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL, -- 'unix:///var/run/docker.sock' (dev) or
|
||||
-- 'tcp://10.x.x.x:2376' (prod, TLS-verified)
|
||||
tls_ca_path TEXT, -- null for the local-socket dev case
|
||||
tls_cert_path TEXT, -- Provisioner's client cert for this host
|
||||
tls_key_path TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
-- Future (not this phase): capacity/status columns for real scheduling once there's
|
||||
-- more than one host. Phase 1 selects "the one enabled row" — no scheduler.
|
||||
);
|
||||
|
||||
CREATE TYPE environment_status AS ENUM (
|
||||
'requested', 'provisioning', 'running', 'resetting',
|
||||
'suspended', -- experimental path only, see decision #3
|
||||
'destroying', 'destroyed', 'error'
|
||||
);
|
||||
|
||||
CREATE TABLE environments (
|
||||
environment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
student_id TEXT NOT NULL, -- CORRECTION C: opaque external identifier, not a
|
||||
-- foreign key. students/progress stay on the
|
||||
-- existing SQLite through Phase 1 (see
|
||||
-- docs/LAB_ARCHITECTURE.md §3/§9) — there is no
|
||||
-- students table in this database yet, so there
|
||||
-- is nothing to reference. The Provisioner trusts
|
||||
-- whatever value its caller passes; it does not
|
||||
-- validate against a roster. A real FK gets added
|
||||
-- only when roster/progress actually migrate onto
|
||||
-- this Postgres instance, in a later, separate
|
||||
-- migration — not assumed or faked here.
|
||||
lab_host_id TEXT NOT NULL REFERENCES lab_hosts(lab_host_id), -- real FK: both tables
|
||||
-- are Provisioner-owned Postgres, correction D.
|
||||
status environment_status NOT NULL DEFAULT 'requested',
|
||||
routing_token TEXT NOT NULL, -- CSPRNG, opaque, unique
|
||||
container_id TEXT, -- null until provisioned
|
||||
container_name TEXT,
|
||||
network_id TEXT,
|
||||
network_name TEXT,
|
||||
network_subnet CIDR, -- needed to install/remove the per-env firewall chain
|
||||
image_ref TEXT NOT NULL, -- pinned Lab image, e.g. rootnode-academy:1.4.0
|
||||
cpu_limit NUMERIC NOT NULL,
|
||||
mem_limit_mb INTEGER NOT NULL,
|
||||
pids_limit INTEGER NOT NULL,
|
||||
storage_limit_mb INTEGER NOT NULL,
|
||||
internal_ip TEXT, -- set once running
|
||||
internal_port INTEGER NOT NULL DEFAULT 80,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
last_active_at TIMESTAMPTZ,
|
||||
destroy_at TIMESTAMPTZ, -- idle-reaper's scheduled destroy time
|
||||
error_reason TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_environments_routing_token ON environments(routing_token);
|
||||
CREATE INDEX idx_environments_student ON environments(student_id);
|
||||
CREATE INDEX idx_environments_status ON environments(status);
|
||||
CREATE INDEX idx_environments_lab_host ON environments(lab_host_id);
|
||||
-- Phase 1 invariant: at most one non-terminal environment per student.
|
||||
-- Enforced in application logic (see §4 POST /environments), not a DB constraint yet —
|
||||
-- a partial unique index is the natural upgrade once this needs to be bulletproof under
|
||||
-- concurrent requests, not needed for a single-instance provisioner in this phase.
|
||||
|
||||
CREATE TABLE environment_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
environment_id UUID NOT NULL REFERENCES environments(environment_id),
|
||||
event_type TEXT NOT NULL, -- provision_requested | provisioned | started | reset
|
||||
-- | destroyed | error | reaped_idle | suspend | resume
|
||||
actor TEXT NOT NULL, -- 'system' | 'instructor:<id>' | 'student:<id>' | 'reaper'
|
||||
detail JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_env_events_environment ON environment_events(environment_id);
|
||||
```
|
||||
|
||||
**Resource policy and the egress allowlist are config files, not tables.** There's no admin UI
|
||||
to manage them via a database yet (that's dashboard work, explicitly deferred), so a
|
||||
DB-backed policy table would just be a write path nothing uses. `config/resource-policy.yaml`
|
||||
(cpu/mem/pids/storage defaults, reloadable) and `platform/egress-proxy/allowlist.conf` (Squid
|
||||
ACL) are the actual source of truth — plain files an operator edits, matching how
|
||||
`entrypoint.sh` already does config via env vars rather than a database.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lifecycle state machine
|
||||
|
||||
Exactly what `docs/LAB_ARCHITECTURE.md` §2 now specifies, decision #3 applied:
|
||||
|
||||
```
|
||||
requested → provisioning → running ─┬─→ resetting → running (same environment_id + routing_token)
|
||||
└─→ destroying → destroyed
|
||||
provisioning → error
|
||||
running → error
|
||||
error → destroying → destroyed
|
||||
error → resetting → running (when recoverable)
|
||||
|
||||
[EXPERIMENTAL — not required to pass for Phase 1 sign-off]
|
||||
running → suspended → running (resume)
|
||||
running → suspended → error (resume fails)
|
||||
```
|
||||
|
||||
`reset` is implemented as `destroy` + `provision` against the same DB row (new container,
|
||||
network, and internal_ip; same `environment_id`/`routing_token` so a student's saved link
|
||||
doesn't change).
|
||||
|
||||
---
|
||||
|
||||
## 4. API endpoints (Provisioner, internal-only)
|
||||
|
||||
All routes require `X-Provisioner-Key: <shared secret>`. Nothing here is reachable by a
|
||||
student or the public internet in this phase — there's no frontend/gateway wiring yet, this
|
||||
is tested directly (curl / a test script), which is also what makes it independently
|
||||
verifiable before any later phase builds on top of it.
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `POST /environments` | Body `{ student_id }` — `student_id` is opaque (correction C, §2), not validated against a roster. `lab_host_id` is not a caller-supplied parameter in Phase 1: the Provisioner selects the single enabled `lab_hosts` row itself (`SELECT ... WHERE enabled LIMIT 1`) — no scheduling logic, no host-selection API, matching "don't overbuild scheduling yet." Rejects if the student already has a non-terminal environment (Phase 1 invariant, see §2). Synchronous: creates network → installs firewall chain (§5) → creates+starts container → polls Lab `/api/health` → marks `running`, or `error` on timeout. Returns `{ environment_id, status, routing_token }`. |
|
||||
| `GET /environments/:id` | Full row: status, container_id, limits, timestamps. |
|
||||
| `GET /environments?student_id=` | A student's environments (expected: zero or one active). |
|
||||
| `POST /environments/:id/reset` | Destroy + recreate, same `environment_id`/`routing_token`. |
|
||||
| `DELETE /environments/:id` | Destroy: stop, remove container, remove network + its firewall rules, mark `destroyed`. |
|
||||
| `POST /environments/:id/suspend` *(experimental)* | `docker stop`, mark `suspended`. Documented as unreliable — see test plan §7. |
|
||||
| `POST /environments/:id/resume` *(experimental)* | `docker start` + re-poll readiness. On failure, mark `error` rather than leaving a wrong `suspended` status. |
|
||||
| `GET /environments/:id/events` | Audit trail for that environment. |
|
||||
| `GET /health` | Provisioner's own health: Docker reachable, Postgres reachable. |
|
||||
|
||||
Synchronous provisioning is a deliberate simplification for this phase — no job queue, no
|
||||
webhook callback. Given expected Phase 1 usage (developer testing, not a live cohort), a
|
||||
blocking HTTP call that takes a few seconds while k3s comes up is fine; converting to
|
||||
async-with-polling is the obvious upgrade if provisioning latency becomes a real UX problem
|
||||
once this is behind an actual frontend.
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker / network operations (via `dockerode`, shown as CLI-equivalent for readability)
|
||||
|
||||
**Provision — network:**
|
||||
```bash
|
||||
docker network create --driver bridge rootnode-lab-net-<short-env-id>
|
||||
# inspect to get the allocated subnet, store as environments.network_subnet
|
||||
```
|
||||
|
||||
**Provision — egress firewall, corrected (§4 of `docs/LAB_ARCHITECTURE.md`, correction B).**
|
||||
The previous draft chained individual `iptables -I DOCKER-USER ...` inserts in
|
||||
ACCEPT-then-DROP order, which is backwards: `-I` without a rule number pushes each new rule to
|
||||
*position 1*, so the rules end up installed in the *reverse* of the order they were run —
|
||||
the final default-DROP would land at the top of `DOCKER-USER` and shadow everything below it,
|
||||
including the proxy/DNS accepts. Fixed by giving each environment its own dedicated chain,
|
||||
built with `-A` (append — rules land in the order added, no head-insertion trap), and reaching
|
||||
it from `DOCKER-USER` with exactly one jump rule per environment:
|
||||
|
||||
```bash
|
||||
NET_SUBNET=$(docker network inspect rootnode-lab-net-<id> -f '{{(index .IPAM.Config 0).Subnet}}')
|
||||
CHAIN="ROOTNODE-ENV-<short-env-id>"
|
||||
|
||||
iptables -N "$CHAIN" # fresh, empty chain for this environment
|
||||
|
||||
# Required infrastructure — explicitly permitted FIRST, before any blanket deny:
|
||||
iptables -A "$CHAIN" -d <egress-proxy-ip> -p tcp --dport 3128 -j ACCEPT
|
||||
iptables -A "$CHAIN" -d <dns-resolver-ip> -p udp --dport 53 -j ACCEPT
|
||||
iptables -A "$CHAIN" -d <dns-resolver-ip> -p tcp --dport 53 -j ACCEPT
|
||||
# (any explicitly-required Platform endpoint would be an additional ACCEPT line here —
|
||||
# none needed in Phase 1; the Lab image doesn't call back to the Platform yet)
|
||||
|
||||
# Everything that must never be reachable, regardless of the above — unconditional, no
|
||||
# allowlist entry can ever bypass these, satisfying "no route to trusted networks" as a
|
||||
# hard rule rather than a policy convention:
|
||||
iptables -A "$CHAIN" -d 10.0.0.0/8 -j DROP
|
||||
iptables -A "$CHAIN" -d 172.16.0.0/12 -j DROP
|
||||
iptables -A "$CHAIN" -d 192.168.0.0/16 -j DROP
|
||||
iptables -A "$CHAIN" -j DROP # default deny, catches everything else (public internet)
|
||||
|
||||
# Hook the student's subnet into this chain. -I here is fine and intentional: we want each
|
||||
# new environment's jump rule evaluated ahead of any broader, pre-existing DOCKER-USER rule,
|
||||
# and since every environment matches a disjoint source subnet, the relative order of jump
|
||||
# rules against each other never matters — only that each is ahead of a catch-all, if one
|
||||
# ever exists at the DOCKER-USER level.
|
||||
iptables -I DOCKER-USER -s "$NET_SUBNET" -j "$CHAIN"
|
||||
```
|
||||
|
||||
This also directly satisfies the explicit "must never reach" list from
|
||||
`docs/LAB_ARCHITECTURE.md` §4: RootNode management networks, the Platform database, Docker/
|
||||
Proxmox management interfaces (including the Lab Host VM's own Docker API — notably, since
|
||||
that API is what the Provisioner itself talks to from the *Platform* VM, a Lab container has
|
||||
no way to reach it either way, but the RFC1918 drop covers it as a second, independent layer),
|
||||
CDAA infrastructure, home/trusted networks, and other students' lab networks all fall inside
|
||||
one of the three RFC1918 ranges above and are dropped unconditionally by this same ruleset —
|
||||
one mechanism, not five separate ones to keep in sync.
|
||||
|
||||
**Provision — container** (limits from `resource-policy.yaml`, no image/entrypoint change):
|
||||
```bash
|
||||
docker create \
|
||||
--name rootnode-lab-<short-env-id> \
|
||||
--network rootnode-lab-net-<short-env-id> \
|
||||
--privileged \
|
||||
--cpus <cpu_limit> --memory <mem_limit_mb>m --memory-swap <mem_limit_mb>m \
|
||||
--pids-limit <pids_limit> \
|
||||
--storage-opt size=<storage_limit_mb>m \
|
||||
-e HTTP_PROXY=http://<egress-proxy-host>:3128 -e HTTPS_PROXY=http://<egress-proxy-host>:3128 \
|
||||
-e NO_PROXY=localhost,127.0.0.1,10.42.0.0/16,10.43.0.0/16 \
|
||||
<pinned-lab-image-ref>
|
||||
docker start rootnode-lab-<short-env-id>
|
||||
```
|
||||
**Implementation note: `<egress-proxy-host>` is resolved per environment, not a fixed
|
||||
hostname/IP.** The egress proxy runs with host networking (`docker-compose.dev.yml`), so it's
|
||||
present on every per-student bridge network's *gateway* interface — that gateway IP (from
|
||||
`docker network inspect`'s `IPAM.Config[0].Gateway`, right alongside the subnet already used
|
||||
for the firewall chain) is what's actually reachable from that specific environment's private
|
||||
network. A single static proxy hostname would only be reachable from whichever one network
|
||||
happened to be able to resolve/route to it — this wasn't apparent until testing with two
|
||||
simultaneous environments on two different subnets. Same resolution is used for both the
|
||||
firewall's `ACCEPT` rule destination (§ above) and this container's proxy env vars, so they
|
||||
always agree.
|
||||
|
||||
`NO_PROXY` carves out k3s's own internal pod/service CIDRs so in-cluster traffic never routes
|
||||
through the proxy — only the container's *outbound-to-the-internet* path does. containerd
|
||||
(started by the existing, unmodified `entrypoint.sh`) honors `HTTP_PROXY`/`HTTPS_PROXY` from
|
||||
its process environment automatically, which is why no Lab image or entrypoint change is
|
||||
needed for well-behaved image pulls to go through the proxy.
|
||||
|
||||
**Correction B, stated plainly: these env vars configure containerd for a clean pull path —
|
||||
they are not what makes egress restricted.** A process inside the container that ignores them
|
||||
and dials out directly is stopped by the `ROOTNODE-ENV-<id>` firewall chain above regardless,
|
||||
because that chain evaluates every packet leaving the container's network, independent of
|
||||
what any process inside chose to do. If the env vars were somehow wrong or absent, the only
|
||||
consequence is broken image pulls (a visible, debuggable failure) — never a silent hole in the
|
||||
egress boundary, because the boundary was never the env vars in the first place.
|
||||
|
||||
Readiness: poll the container's internal IP (`docker inspect` → `NetworkSettings`) for
|
||||
`GET /api/health` — the exact endpoint `entrypoint.sh` already waits on internally, so the
|
||||
Provisioner is just doing the same check from outside instead of inventing a new one.
|
||||
|
||||
**Destroy:**
|
||||
```bash
|
||||
docker stop rootnode-lab-<id> --time 10 && docker rm rootnode-lab-<id>
|
||||
|
||||
# Unhook and remove this environment's dedicated chain — three ops, no rule-scanning/matching
|
||||
# needed since it's one self-contained chain per environment, not tagged rules mixed into a
|
||||
# shared one:
|
||||
iptables -D DOCKER-USER -s "$NET_SUBNET" -j "$CHAIN"
|
||||
iptables -F "$CHAIN"
|
||||
iptables -X "$CHAIN"
|
||||
|
||||
docker network rm rootnode-lab-net-<id>
|
||||
```
|
||||
|
||||
**Reset** = destroy, then provision again against the same `environments` row (new
|
||||
`container_id`/`network_id`/`internal_ip`, unchanged `environment_id`/`routing_token`).
|
||||
|
||||
**Idle reaper** (background loop in the Provisioner, e.g. every 60s):
|
||||
- Query `environments WHERE status='running' AND COALESCE(last_active_at, created_at) < now() - idle_threshold`.
|
||||
- Past a shorter "idle" threshold: log a warning event (future: notify student/instructor —
|
||||
not built yet).
|
||||
- Past a longer "abandoned" threshold: call the same destroy path as `DELETE /environments/:id`,
|
||||
log `event_type='reaped_idle'`. Config-driven thresholds, not hard-coded (same policy file
|
||||
as resource limits).
|
||||
|
||||
**Implementation notes (found during testing, not in the original design):**
|
||||
- **`COALESCE(last_active_at, created_at)`, not bare `last_active_at`.** An environment that
|
||||
fails during provisioning (before ever reaching `running`) has a NULL `last_active_at` —
|
||||
`NULL < anything` is never true in SQL, so without the fallback such an environment would
|
||||
never age out via the reaper at all, regardless of how long ago it failed.
|
||||
- **Provisioning failure now does best-effort cleanup of whatever was already created**
|
||||
(container/network/firewall chain) before marking the row `error`, rather than leaving that
|
||||
infrastructure orphaned. Found by testing: a deliberate readiness-timeout failure left a real
|
||||
running container and firewall chain in place with only the DB row marked `error` — exactly
|
||||
the kind of leak the reaper exists to prevent, except this case predates the reaper ever
|
||||
having something to catch. Cleanup on the failure path directly is more honest than relying
|
||||
on the reaper for a gap it wasn't actually catching. See `src/lifecycle.js`'s
|
||||
`provisionEnvironment` catch block and `src/reaper.js`.
|
||||
- **Startup reconciliation (docs/LAB_ARCHITECTURE.md §8) is implemented** as
|
||||
`reconcileOnStartup()` in `src/lifecycle.js`, called once at Provisioner boot before the
|
||||
reaper starts. Tested by killing a container out-of-band and restarting the Provisioner —
|
||||
confirmed it correctly flags the mismatch as `error` rather than trusting the stale
|
||||
`running` status. Any environment caught mid-transition (`provisioning`/`resetting`/
|
||||
`destroying`) by a crash is also always marked `error` on restart, never assumed to have
|
||||
finished.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security assumptions (stated explicitly, not implied)
|
||||
|
||||
- **Correction A — the Lab Host is not the Platform's trust boundary, the VM is.** In
|
||||
production, the host running privileged Lab containers is a separate VM from the one running
|
||||
Postgres/Provisioner/Platform API, with no route back from the Lab Host VM to the Platform VM
|
||||
at all — enforced at the hypervisor/network level, not by this phase's code. Phase 1's
|
||||
single-machine dev mode is a deliberate, documented exception (`docs/LAB_ARCHITECTURE.md` §1)
|
||||
— it does not redefine what "isolated" means for the real deployment.
|
||||
- The Docker host(s) sit on a segment with no route to trusted RootNode/CDAA/home/business
|
||||
networks — a prerequisite this phase depends on but does not create (matches the existing
|
||||
Business Pack Technical Platform Plan). Everything below assumes that's already true.
|
||||
- No other host firewall tooling (ufw, firewalld) is silently rewriting `DOCKER-USER` or
|
||||
removing the per-environment `ROOTNODE-ENV-<id>` chains — needs a one-time check on the real
|
||||
host before trusting the egress rules; not something this design can verify in advance.
|
||||
- `--privileged` is required for k3s and is not being avoided (confirmed in
|
||||
`docs/LAB_ARCHITECTURE.md` §7) — the security boundary is the VM split plus network
|
||||
containment, not reduced container privilege.
|
||||
- **Correction B — `HTTP_PROXY`/`HTTPS_PROXY` are not a security control**, stated here again
|
||||
because it's the assumption most likely to get silently reintroduced during implementation:
|
||||
the firewall chain enforces the boundary; the env vars only make well-behaved pulls work
|
||||
cleanly through it.
|
||||
- The egress proxy's allowlist is operator-controlled (a file the Provisioner's operator
|
||||
edits), never student- or Lab-container-writable.
|
||||
- The Provisioner API's shared secret is the only thing standing between "internal service"
|
||||
and "anyone who can create/destroy arbitrary privileged containers on the host" — treat that
|
||||
secret with the same care as the instructor passcode, not as a minor implementation detail.
|
||||
This matters more, not less, once the Provisioner is issuing commands to a remote Docker
|
||||
endpoint (correction D) — the TLS client cert for that connection carries the same weight.
|
||||
- `storage_limit_mb` enforcement via `--storage-opt size=` depends on the host's Docker storage
|
||||
driver supporting it (overlay2 with specific backing filesystems does; not all do) — unverified
|
||||
until tested against the real host (§8).
|
||||
- Nothing in this phase is internet-facing. The Provisioner API assumes internal-network-only
|
||||
reachability; it does not implement its own TLS. The Provisioner→Lab-Host Docker connection
|
||||
(correction D), by contrast, is TLS by design once it's not a local socket — that's a
|
||||
different boundary with different stakes and isn't optional the way the Provisioner API's
|
||||
own TLS currently is.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to be created
|
||||
|
||||
All new. Nothing existing is modified beyond the two docs already updated.
|
||||
|
||||
```
|
||||
platform/provisioner/
|
||||
├── package.json
|
||||
├── src/
|
||||
│ ├── server.js # Express app, routes from §4
|
||||
│ ├── db.js # Postgres connection (pg), migration runner
|
||||
│ ├── docker.js # dockerode client factory — resolves a lab_hosts row to a connected
|
||||
│ │ client (local socket or remote TLS), never a hard-coded default
|
||||
│ ├── lifecycle.js # state machine + orchestration (calls docker.js + db.js)
|
||||
│ ├── egress.js # per-environment ROOTNODE-ENV-<id> chain create/hook/unhook/remove
|
||||
│ └── reaper.js # idle-reaper background loop
|
||||
├── migrations/
|
||||
│ ├── 001_lab_hosts.sql
|
||||
│ ├── 002_environments.sql
|
||||
│ └── 003_environment_events.sql
|
||||
├── config/
|
||||
│ ├── resource-policy.yaml # cpu/mem/pids/storage defaults + idle/abandoned thresholds
|
||||
│ └── lab-hosts.seed.yaml # the one Phase 1 lab_hosts row (endpoint + TLS paths),
|
||||
│ # applied by the migration runner — not a runtime API,
|
||||
│ # per "don't overbuild scheduling yet"
|
||||
├── Dockerfile
|
||||
└── docker-compose.dev.yml # Postgres + egress-proxy + provisioner, for local Phase 1 testing
|
||||
|
||||
platform/egress-proxy/
|
||||
├── Dockerfile # minimal Squid image
|
||||
└── allowlist.conf # Squid ACL: registry-1.docker.io, ghcr.io, etc. — starting set
|
||||
# to be confirmed against what scenarios actually pull
|
||||
```
|
||||
|
||||
No changes to `backend/server.js`, `Dockerfile`, `scripts/entrypoint.sh`,
|
||||
`scripts/nginx.conf`, or anything under `scenarios/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
1. **Schema migration idempotency** — run `001_environments.sql` against a scratch Postgres
|
||||
twice, confirm the second run is a no-op (same pattern already used for the SQLite
|
||||
migrations: check-before-alter).
|
||||
2. **Provision → destroy round trip** — create an environment, confirm the container is
|
||||
`running` and reachable on its internal IP, confirm `environment_events` has one row per
|
||||
transition, destroy, confirm container/network are gone and the environment's
|
||||
`ROOTNODE-ENV-<id>` chain no longer exists (`iptables -L ROOTNODE-ENV-<id>` errors "chain
|
||||
does not exist") and its `DOCKER-USER` jump rule is gone (`iptables -S DOCKER-USER | grep
|
||||
<id>` returns nothing).
|
||||
3. **Reset** — provision, note `environment_id`/`routing_token`, reset, confirm both are
|
||||
unchanged, confirm `container_id` changed (genuinely new container), confirm no state
|
||||
carried over (fresh k3s).
|
||||
4. **Isolation** — provision two environments, confirm A cannot reach B at all
|
||||
(`docker exec <A> ping <B's internal_ip>` times out) — this is the core security property
|
||||
and must be an explicit test, not an assumption.
|
||||
5. **Egress allowlist, including the ordering fix specifically** — from inside a provisioned
|
||||
environment: an allowlisted registry pull succeeds via the proxy; a non-allowlisted host
|
||||
fails; a direct request to an RFC1918 address (simulating a trusted-network address) is
|
||||
dropped regardless of allowlist state. Critically: **the egress proxy's own IP is itself an
|
||||
RFC1918 address** — this test only actually exercises the fix from correction B if the proxy
|
||||
is reachable *and* the broader RFC1918 range is still blocked. If the proxy were unreachable
|
||||
here, that's the exact symptom the original ordering bug would have caused (the DROP rules
|
||||
shadowing the ACCEPT rules) — this test is what would have caught it before it shipped.
|
||||
6. **Idle reaper** — provision, force `last_active_at` backward past the abandoned threshold,
|
||||
run one reaper tick, confirm the environment is destroyed and logged as `reaped_idle`.
|
||||
7. **Suspend/resume (experimental, decision #3 — informational, not a pass/fail gate)** —
|
||||
`docker stop`/`docker start` a running Lab container, time how long it takes (if ever) to
|
||||
reach `/api/health` ready again versus a fresh `provision`. This is the actual answer to
|
||||
the question `docs/LAB_ARCHITECTURE.md` flagged as untested — record the result either way,
|
||||
but Phase 1 sign-off does not depend on this succeeding.
|
||||
8. **Reconciliation on Provisioner restart** — manually create a container outside the
|
||||
Provisioner's knowledge, restart the Provisioner, confirm it doesn't silently trust stale
|
||||
`environments` rows — either adopts or flags the mismatch (per
|
||||
`docs/LAB_ARCHITECTURE.md` §8).
|
||||
9. **Resource limits hold** — inside a provisioned environment, confirm `--cpus`/`--memory`/
|
||||
`--pids-limit` are actually enforced (e.g. a deliberate memory-hog process gets OOM-killed
|
||||
at the configured limit, not the host's full RAM); confirm whether `--storage-opt size=`
|
||||
is honored on the real host's storage driver — this is the resource-model unknown from
|
||||
`docs/LAB_ARCHITECTURE.md` §5, and this test is the first real benchmarking data point
|
||||
toward the 16-core/128GB target sizing.
|
||||
10. **Local dev-mode regression check** — confirm `docker run --privileged -p 7554:80 ...
|
||||
rootnode-academy:latest` (today's standalone command) is completely unaffected —
|
||||
`git diff` scoped to `backend/`, `Dockerfile`, `scripts/` should show zero changes from
|
||||
this phase.
|
||||
11. **Lab host abstraction actually abstracts (correction D)** — point the Provisioner's
|
||||
`lab_hosts` config at a Docker daemon exposed over TCP+TLS on the *same* dev machine
|
||||
(`dockerd -H tcp://127.0.0.1:2376 --tlsverify ...`) instead of the local Unix socket, with
|
||||
no code change — only the `lab_hosts` row differs. Provision/destroy round trip (test 2)
|
||||
must pass identically against that endpoint. This is the practical proof that "Docker is
|
||||
local" was actually removed as an assumption, not just renamed — it doesn't require a
|
||||
second real VM to verify, only a second way of reaching the same daemon.
|
||||
12. **VM-boundary reasoning check (correction A, documentation-level, not a running test)** —
|
||||
walk the failure scenario explicitly: if a student escaped their container onto the Lab
|
||||
Host VM's OS, what could they reach? Confirm the answer stays "nothing on the Platform VM"
|
||||
given the topology in `docs/LAB_ARCHITECTURE.md` §1 — i.e. confirm the design doesn't rely
|
||||
on anything *inside* the Lab Host VM (a firewall rule, a credential, an agent) as the only
|
||||
thing standing between a compromised container and Postgres. This is a design-review check
|
||||
now; it becomes a real network-level test once a second VM exists to test against.
|
||||
|
||||
Tests 1–6, 8, 10, 11 are runnable in a plain Docker environment with no special hardware.
|
||||
Tests 5 and 9 need the actual target host (or something close to it) to produce meaningful
|
||||
numbers, test 7 needs the real Lab image, and test 12 needs a second VM to be more than a
|
||||
design-review exercise — flagging that distinction so "passed the test plan" doesn't get read
|
||||
as "validated on production-class, two-VM hardware" when parts of it were necessarily
|
||||
validated on a single dev machine.
|
||||
@@ -0,0 +1,292 @@
|
||||
# RootNode Academy — Phase 2A: Gateway & Student Flow
|
||||
|
||||
Status: **IMPLEMENTED and end-to-end tested, 2026-08-18.** Builds on
|
||||
`docs/LAB_ARCHITECTURE.md` and `docs/PHASE1_PLATFORM_FOUNDATION.md`
|
||||
(Phase 1 milestone `a15897c78f7012c59ea3629a74dd8b1425f6f7f2`). This doc covers the three
|
||||
Phase 2A workstreams: fixing the real Lab image, the Gateway, and the student frontend flow.
|
||||
|
||||
---
|
||||
|
||||
## 1. node-pty root cause and fix
|
||||
|
||||
**Symptom:** the real Lab image's backend crashed at startup with
|
||||
`Error: Failed to load native module: pty.node, checked: build/Release, build/Debug,
|
||||
prebuilds/linux-x64: Cannot find module './prebuilds/linux-x64//pty.node'`.
|
||||
|
||||
**What it looked like vs. what it was:** node-pty's own error handling collapses whatever
|
||||
actually went wrong at each candidate path into one generic "module not found" message using
|
||||
only the *last* path it tried — this made a link-time failure look like a missing-file problem.
|
||||
|
||||
**Root cause, confirmed empirically (not guessed):**
|
||||
1. **node-gyp generates an incorrect `config.gypi`.** For this NodeSource-packaged Node 20
|
||||
build, `node-gyp`'s downloaded headers/config reference reports `node_shared: true` (a
|
||||
shared `libnode.so` build), but the actual installed Node is statically linked — there is no
|
||||
`libnode.so.<abi>` anywhere on the system (confirmed: `find / -iname 'libnode*'` → nothing;
|
||||
`process.config.variables.node_shared` on the real running Node → `false`). Node-gyp
|
||||
trusted the wrong config and linked node-pty's native addon against `-lnode`, producing a
|
||||
binary that fails at `require()` time with `libnode.so.127: cannot open shared object file`
|
||||
— confirmed by loading the compiled `.node` file directly, bypassing node-pty's own
|
||||
path-search/error-masking logic.
|
||||
2. **A second, independent bug compounded it:** the repo had no `.dockerignore`. A host-side
|
||||
`npm install` (run earlier in this project for unrelated backend testing) left a
|
||||
`backend/node_modules/` on disk, built against the *host's* environment. The Dockerfile's
|
||||
`COPY backend/ ./backend/` (placed after the npm-install `RUN` layer, for build-cache
|
||||
efficiency) then silently overwrote the correctly-built-in-container `node_modules` with
|
||||
that host copy — including after a first `.dockerignore` fix that used the pattern
|
||||
`node_modules/`, which (a real gotcha) only matches a `node_modules` directory at the build
|
||||
**context root** in Docker's matching rules, not `backend/node_modules` at any depth.
|
||||
|
||||
**Fix (`Dockerfile`, `.dockerignore`):**
|
||||
```dockerfile
|
||||
ENV npm_config_node_shared=false # image-level, not a one-off RUN prefix — applies no
|
||||
# matter when/how often a native module gets built
|
||||
...
|
||||
RUN cd backend && npm install --production \
|
||||
&& node -e "require('./node_modules/node-pty'); console.log('node-pty loads OK')"
|
||||
```
|
||||
The `node -e require(...)` line makes this a **hard build failure** if it ever regresses again,
|
||||
instead of a silent runtime failure discovered later — not a workaround for the terminal, a
|
||||
real build-time verification of a real native dependency.
|
||||
|
||||
```
|
||||
# .dockerignore
|
||||
**/node_modules/ # NOT `node_modules/` — that only matches the context root
|
||||
frontend/dist/
|
||||
.git/
|
||||
*.db
|
||||
*.db-journal
|
||||
```
|
||||
|
||||
**Nothing else changed.** No terminal architecture change, no fake shell, no rewrite of
|
||||
`backend/server.js`'s PTY handling. RootNode branding and upstream KubeKosh attribution
|
||||
untouched. The single-container dev workflow (`docker build -t rootnode-academy:test .`) is
|
||||
the same command as before — it now just works.
|
||||
|
||||
**Verified against the real image, not a stand-in** (see §8 for full results): image builds →
|
||||
container starts → k3s reaches `Ready` → backend starts (`API server running on :4000`, no
|
||||
crash) → nginx starts → browser UI returns 200 → terminal WebSocket opens and shows the
|
||||
RootNode banner → `kubectl get nodes` run through the terminal returns real cluster state →
|
||||
`deploy-nginx` scenario solved via the terminal and validated via
|
||||
`POST /api/scenarios/deploy-nginx/validate` → all 5 checks pass.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gateway architecture
|
||||
|
||||
New component: `platform/gateway/`. The only student-facing path to disposable Lab
|
||||
environments — browsers never talk to a Lab container, the Provisioner, or Postgres directly.
|
||||
|
||||
```
|
||||
Browser
|
||||
│ (one stable origin — the Gateway)
|
||||
▼
|
||||
Gateway
|
||||
├── /api/environment/* → calls the Provisioner internally (shared secret never
|
||||
│ leaves the Gateway process)
|
||||
├── /lab/<token>/* → HTTP + WS proxy into that specific student's Lab
|
||||
│ container, resolved by opaque token via the Provisioner
|
||||
└── everything else → proxied to the persistent "identity" backend (roster,
|
||||
login, curriculum, progress, and the platform-shell
|
||||
frontend) — OR to the student's own Lab, if a valid
|
||||
lab-session cookie is present (see §5 for why)
|
||||
```
|
||||
|
||||
**Why a persistent "identity" instance exists, and what it is:** the roster/curriculum/
|
||||
progress SQLite data (built in the pre-Phase-2A slice) needs to be reachable *before* a student
|
||||
has a Lab — they pick their name and see their curriculum before ever clicking Launch Lab. That
|
||||
data can't live inside an ephemeral, disposable-by-design Lab container. Phase 2A runs it as
|
||||
one plain, non-ephemeral instance of the existing, unmodified Lab image (`identity` in
|
||||
`platform/docker-compose.dev.yml`) — same image, same code, just never destroyed/reset by the
|
||||
Provisioner, the same way Postgres or the egress proxy are infrastructure rather than
|
||||
per-student resources. This is a deliberate zero-new-code choice (see §9 known limitations for
|
||||
its cost).
|
||||
|
||||
**Provisioner addition:** `GET /environments/by-token/:token` (small, internal-only, same
|
||||
shared-secret auth as every other Provisioner route) — resolves a routing token to its
|
||||
environment for the Gateway. This is the only change to `platform/provisioner/`.
|
||||
|
||||
**What the Gateway never does:** expose a Lab container's port publicly, expose the Docker
|
||||
API, expose the Provisioner's shared secret to a browser, or trust a client-supplied
|
||||
environment ID for anything without checking it against the Provisioner's own record.
|
||||
|
||||
---
|
||||
|
||||
## 3. Student flow
|
||||
|
||||
**A. Entry screen** (`frontend/src/platform/EntryScreen.jsx`) — cohort dropdown → name
|
||||
dropdown (existing `GET /api/roster/cohorts` / `GET /api/roster?cohort=`) → `POST /api/login`.
|
||||
No password. A visible note states this is pilot identity, not production auth.
|
||||
|
||||
**B. Session** — `student_id` stored in `localStorage` only (`frontend/src/platform/api.js`),
|
||||
sent as `X-Student-Id` on every subsequent platform request. Every authenticated call throws
|
||||
if no student is stored, rather than silently going out anonymously — this is what "prevent
|
||||
accidental anonymous progress writes" means in practice.
|
||||
|
||||
**C. Dashboard / "Up Next"** (`StudentDashboard.jsx`) — renders `GET /api/curriculum/path`
|
||||
as-is: the existing RootNode path (Foundation → Workloads → Networking → Configuration →
|
||||
Troubleshooting → Real Ops → Capstone), no new structure invented. Capstone stays the locked
|
||||
placeholder from the earlier slice.
|
||||
|
||||
**D. Launch Lab** — `POST /api/environment/launch` (Gateway): reuses a non-terminal
|
||||
environment if one exists, otherwise provisions one via the Provisioner. UI states map directly
|
||||
to environment status: none → provisioning/resetting (polled every 2.5s while in flight) →
|
||||
running → error, each rendered distinctly (`EnvironmentPanel` in `StudentDashboard.jsx`). On
|
||||
`running`, the browser navigates to `launch_path` (`/lab/<token>/`), with the student_id
|
||||
attached as a `?sid=` query param for the Gateway's ownership check (see §5).
|
||||
|
||||
**E. Lab session** — the existing, **completely unmodified** Lab UI (`App.jsx` and everything
|
||||
under `frontend/src/components/`). It has no student-identity awareness and needs none —
|
||||
identity and progress stay entirely in the Platform layer, per the explicit requirement not to
|
||||
inject student identity into the privileged Lab container.
|
||||
|
||||
**F. Reset/Recreate** — one button, "Recreate Lab," calling `POST /api/environment/reset`
|
||||
(destroy + provision against the same environment row, per the Phase 1 lifecycle — same
|
||||
`environment_id`, fresh container). No raw destroy-without-recreate control is exposed to
|
||||
students; that keeps the surface to exactly what's safe, per the explicit requirement.
|
||||
|
||||
---
|
||||
|
||||
## 4. API routes added
|
||||
|
||||
All on the Gateway (`platform/gateway/src/server.js`), all internal glue — the Provisioner's
|
||||
own API remains unreachable from the browser:
|
||||
|
||||
| Method & path | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /api/environment/status` | `X-Student-Id` | Current environment state for this student, or `{status:'none'}` |
|
||||
| `POST /api/environment/launch` | `X-Student-Id` | Reuse or provision; blocks until running/error (matches the Provisioner's own synchronous provisioning) |
|
||||
| `POST /api/environment/reset` | `X-Student-Id` | Destroy + recreate, same environment identity |
|
||||
| `GET/POST/* /lab/:token/*` | opaque token (+ optional `?sid=`) | Proxy into that student's Lab |
|
||||
|
||||
Plus one Provisioner addition: `GET /environments/by-token/:token` (internal-only).
|
||||
|
||||
Responses to the browser never include `internal_ip`, `container_id`, `network_id`,
|
||||
`lab_host_id`, `environment_id`, or the raw routing token beyond its one legitimate use as a
|
||||
navigation path segment (`launch_path`) — see §6.
|
||||
|
||||
---
|
||||
|
||||
## 5. Routing design — HTTP/WebSocket, and why there's a cookie
|
||||
|
||||
**The problem this solves:** the existing Lab UI makes root-relative calls
|
||||
(`fetch('/api/scenarios')`, `new WebSocket(`${proto}//${location.host}/shell-ws`)`) — it has no
|
||||
idea it might be served under a `/lab/<token>/` prefix, and it shouldn't have to (preserving
|
||||
the existing architecture unmodified was an explicit requirement). Naively prefix-stripping
|
||||
only the *first* request would leave every subsequent asset/API/WS call unrouted.
|
||||
|
||||
**Solution:** the Gateway sets an `HttpOnly`, `SameSite=Lax`, `Path=/` cookie (`rn_lab`,
|
||||
8h max-age) on the first `/lab/<token>/` response. Every later request from that browser —
|
||||
regardless of path — carries it automatically. Routing priority:
|
||||
|
||||
1. `/lab/<token>/*` (explicit) → resolve token, strip prefix, proxy, (re)set the cookie.
|
||||
2. `/api/environment/*` → always the Gateway's own routes, **never** affected by the cookie —
|
||||
this is what keeps "student is inside their lab" from accidentally hijacking the dashboard's
|
||||
own environment-status calls.
|
||||
3. Anything else, unprefixed → if the `rn_lab` cookie is present and resolves to a `running`
|
||||
environment, proxy there (unprefixed) — this is what makes the *existing, unmodified* Lab
|
||||
UI's own `/api/scenarios`, `/shell-ws`, static assets, etc. resolve correctly once a browser
|
||||
has navigated into a lab session, with zero changes to that UI's code. Otherwise, proxy to
|
||||
the identity backend (this is what serves `/`, the platform-shell bundle, `/api/roster`,
|
||||
`/api/curriculum/path`, etc.).
|
||||
|
||||
WebSocket upgrades follow the identical priority (`server.on('upgrade', ...)` in
|
||||
`gateway/src/server.js`), so the terminal's `/shell-ws` connection is routed the same way as
|
||||
any other request.
|
||||
|
||||
**Stable URL, even across recreate:** the student-facing URL is always `/lab/<routing_token>/`
|
||||
— the token lives on the `environments` row and does not change across `reset` (same row, new
|
||||
container/network/IP). A bookmarked or previously-loaded lab URL keeps working after a
|
||||
recreate without the student needing a new link.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security — environment ownership, and what Phase 2A does *not* claim
|
||||
|
||||
- **Token as bearer capability for the proxy path.** A `/lab/<token>/` request is authorized by
|
||||
possession of the token itself (CSPRNG, 32 bytes, never guessable) — the same trust model as
|
||||
a signed download link. This is a deliberate, stated design choice, not an oversight.
|
||||
- **`?sid=` as an additional check where it's available.** A full top-level browser navigation
|
||||
cannot carry a custom header, so the Dashboard's "Launch/Continue Lab" link appends
|
||||
`?sid=<student_id>` to the URL. If present, the Gateway compares it against the environment's
|
||||
actual owning `student_id` (from the Provisioner) and rejects (403) on mismatch. This is
|
||||
strictly additive security on top of token-alone — its absence (e.g., a bookmarked link
|
||||
without the param) falls back to token-only trust, not a hole opened by omitting it.
|
||||
- **`/api/environment/*` ownership is checked against the Provisioner's own record**, not
|
||||
assumed from the header — `listForStudent`/`reset` always re-verify `env.student_id`.
|
||||
- **Explicitly not production auth.** Per the governing requirement: routing-token-alone,
|
||||
student-supplied-environment-ID-alone, and `X-Student-Id`-alone are each individually
|
||||
insufficient and documented as such — Phase 2A's actual security property is the
|
||||
*combination*, appropriate for a supervised pilot, not a claim of production-grade identity.
|
||||
Production SSO/OIDC is explicitly future work (§9).
|
||||
- **Nothing sensitive reaches the browser**: no internal Docker IPs, no Docker/Provisioner
|
||||
endpoints, no shared secret, no Postgres credentials — verified by inspecting every Gateway
|
||||
response shape in `server.js`'s `publicEnvView()`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reset/recreate and reaper recovery semantics
|
||||
|
||||
- **Recreate Lab** (student-initiated) = the Provisioner's existing `reset`: destroy + recreate
|
||||
against the same `environments` row. Same `environment_id`/routing token, same
|
||||
`/lab/<token>/` URL, fresh container/network/IP. Academic progress is untouched because it
|
||||
never lived in the Lab container to begin with (Phase 1 property, unchanged).
|
||||
- **Reaper-caused expiry** (idle timeout, no student action): the environment transitions to
|
||||
`destroyed`. `GET /api/environment/status` correctly reports `none`/`destroyed` afterward —
|
||||
the Dashboard's "Launch Lab" button provisions a **new** environment (new `environment_id`,
|
||||
new token, new URL) rather than trying to resurrect the old row, matching the Provisioner's
|
||||
actual lifecycle model. Progress survives because, again, it was never in the Lab.
|
||||
- **Unexpected Lab death** (crash, not reaped): the Gateway's `isReachable()` check
|
||||
(`status==='running' && internal_ip` present) fails closed — a dead container never gets
|
||||
silently proxied to. The student sees the `error` state with a "Try Again" action.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing — results
|
||||
|
||||
*(Filled in after the full end-to-end run against `platform/docker-compose.dev.yml`; see the
|
||||
Phase 2A final report for the authoritative results table. Summary below.)*
|
||||
|
||||
- Lab image: node-pty load, k3s Ready, terminal, kubectl, scenario validation — **all pass**,
|
||||
§1 above.
|
||||
- Gateway HTTP/WS proxying, two-student isolation, cross-student rejection, stable URL across
|
||||
recreate, reaper recovery, existing single-container dev mode, scenario files untouched,
|
||||
frontend/backend builds, Provisioner test suite — see final report for the full table and
|
||||
exact commands run.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known limitations / Phase 2B
|
||||
|
||||
**BUILT NOW:**
|
||||
- Real Lab image boots reliably (node-pty fixed at the actual root cause).
|
||||
- Gateway: HTTP + WS proxying, environment-aware routing, ownership checks, no public Lab
|
||||
ports, no exposed Docker/Provisioner internals.
|
||||
- Student entry (roster pick-name) → dashboard (curriculum + environment status) → Launch Lab
|
||||
→ Lab session → Reset/Recreate, all wired through the Gateway as the single origin.
|
||||
- Reaper-recovery and reset semantics correctly reflected in the student-facing states.
|
||||
|
||||
**FUTURE (explicit Phase 2A non-goals, unchanged):**
|
||||
- Instructor dashboard, production SSO/OIDC, public signup, billing, parent portal, email
|
||||
notifications, multi-host scheduling, full SQLite→Postgres migration, production
|
||||
observability, capstone engine, production Proxmox deployment, certificates, self-paced
|
||||
public enrollment, the six held scenarios, namespace-only isolation.
|
||||
|
||||
**Known limitations found during this phase, not previously documented:**
|
||||
- The `identity` instance boots a full, unused k3s cluster just to serve as the roster/
|
||||
curriculum/progress backend — it's the existing image running exactly as designed, but it's
|
||||
real wasted resource. A Phase 2B optimization is a slimmed identity-only variant of the image
|
||||
(skip k3s entirely) — not done here, to keep this phase at zero changes to the Lab image's
|
||||
own runtime behavior beyond the node-pty fix.
|
||||
- `identity` requires `network_mode: bridge` explicitly in `docker-compose.dev.yml` — under
|
||||
Compose's own auto-created bridge network, k3s failed with "no default routes found in
|
||||
/proc/net/route" at startup. Root cause not fully chased down (a Compose-network-vs-classic-
|
||||
bridge routing-table timing/detection difference); the explicit `network_mode: bridge`
|
||||
workaround is confirmed reliable and is what's shipped. Worth a deeper look before this
|
||||
matters for a real deployment (bridge mode isn't compatible with a per-student isolated
|
||||
network model anyway — the production identity service placement needs its own design pass).
|
||||
- The Gateway's environment-lookup cache (5s TTL) is in-memory and per-process — fine for one
|
||||
Gateway instance (Phase 2A's actual topology), would need a shared cache (or none) if the
|
||||
Gateway is ever horizontally scaled.
|
||||
- `?sid=` ownership verification only covers the initial `/lab/<token>/` navigation, not every
|
||||
subsequent proxied request (by design, per §6) — acceptable for the stated pilot threat
|
||||
model, revisit if this graduates past pilot use.
|
||||
@@ -0,0 +1,213 @@
|
||||
# RootNode Academy — Phase 2B: Admin Portal & Platform Data
|
||||
|
||||
Status: **IMPLEMENTED and end-to-end tested, 2026-08-18.** Builds on
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (Phase 2A milestone
|
||||
`42dfb1c31b599d5e86b64915beb4b871b2e91055`). This doc covers: the platform/lab split, the
|
||||
Postgres academic schema and SQLite migration, server-controlled sessions, and the
|
||||
instructor/admin control plane.
|
||||
|
||||
---
|
||||
|
||||
## 1. Split: Platform API vs. Lab image
|
||||
|
||||
Phase 2A's "identity" instance was a full, `--privileged` Lab image booting an unused k3s
|
||||
cluster just to serve roster/curriculum/progress APIs. Phase 2B replaces it with
|
||||
`platform/api/` — a plain Node/Express service: no k3s, no `--privileged`, no terminal
|
||||
runtime, no nginx (serves the built frontend directly via `express.static`). Startup is
|
||||
instant instead of a 1-2 minute k3s cold start — directly observable in testing (see §8).
|
||||
|
||||
The Lab image (`Dockerfile`, `backend/server.js`, `scripts/entrypoint.sh`) is **completely
|
||||
unchanged in this phase** — it's still the disposable-per-student, nested-k3s, terminal +
|
||||
scenario-execution engine, exactly as Phase 1/2A built it. Platform API never runs
|
||||
setup/validate/teardown commands itself; it only relays outcomes the Lab already produced
|
||||
(see §7, "Progress relay," carried over from Phase 2A and extended).
|
||||
|
||||
Platform API owns: students, cohorts, enrollments, curriculum assignments, progress, scenario
|
||||
attempts, student/admin session identity, and — new in Phase 2B — **all** Provisioner calls,
|
||||
both student-initiated (launch/reset) and admin-initiated (prepare-labs, destroy-labs, reset,
|
||||
destroy). The Gateway no longer calls the Provisioner directly at all; it proxies
|
||||
`/api/environment/*` and `/api/admin/*` to Platform API, which is now the single place that
|
||||
holds the Provisioner's shared secret.
|
||||
|
||||
---
|
||||
|
||||
## 2. PostgreSQL academic schema
|
||||
|
||||
New tables in the existing `rootnode_platform` database (same instance the Provisioner's
|
||||
`lab_hosts`/`environments`/`environment_events` already live in — different migration
|
||||
directory, same Postgres):
|
||||
|
||||
- `students` — `student_id` stays the same opaque TEXT identifier used everywhere else
|
||||
(no breaking change to `environments.student_id`, which remains a plain reference with no
|
||||
FK, exactly as Phase 1 designed it).
|
||||
- `cohorts` / `cohort_enrollments` — replaces the old single `students.cohort` TEXT column
|
||||
with a real many-to-many model (a student can move between cohorts over time; the old
|
||||
model couldn't represent that at all).
|
||||
- `curriculum_assignments` — records which curriculum path a cohort or student is on. Only
|
||||
one path exists today (`rootnode-path`) — this table exists so assigning it is a real,
|
||||
auditable instructor action, not because multiple paths exist yet.
|
||||
- `progress` — same shape as the SQLite table it replaces.
|
||||
- `scenario_attempts` — **new capability**, not a migration of anything that existed before.
|
||||
One row per validate/answer outcome (result, validation count, timestamp, which
|
||||
environment), giving real per-attempt history instead of just an aggregate status +
|
||||
counter. `hint_count` is present but always NULL — the existing Lab engine's hints are
|
||||
static UI content only (`frontend/src/components/ScenarioPanel.jsx`), nothing server-side
|
||||
ever records a hint view. Documented here rather than inventing a number; the column is
|
||||
ready if hint-view tracking is ever added to the Lab engine.
|
||||
- `exam_sessions` — historical import of the old SQLite `sessions` table. **Scope note:**
|
||||
exam mode's *live* session bookkeeping (start/shuffle/submit/abandon) stays Lab-image-local
|
||||
for Phase 2B — productionizing it would need the same relay pattern progress got in Phase
|
||||
2A, and it isn't exercised by the Phase 2B acceptance test. This table makes historical exam
|
||||
records survive the migration and visible to instructors; it isn't written to by new exam
|
||||
sessions yet. Flagged as a real gap, not silently ignored — see §13.
|
||||
|
||||
## 3. SQLite → Postgres migration
|
||||
|
||||
`platform/api/migrate-sqlite.js`. Run once as the cutover:
|
||||
|
||||
```bash
|
||||
SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js
|
||||
```
|
||||
|
||||
- **Backs up first, unconditionally** — copies the SQLite file to
|
||||
`<path>.backup-<timestamp>` before reading anything. The backup is never deleted or
|
||||
modified by the script.
|
||||
- **Idempotent** — every write uses `ON CONFLICT`; re-running re-syncs from the SQLite source
|
||||
rather than duplicating or erroring.
|
||||
- **Never silently drops data** — prints and writes a JSON report
|
||||
(`migration-report-<timestamp>.json`) with exact counts (students, progress rows,
|
||||
exam_sessions, cohorts created, enrollments created) and anything that couldn't be read
|
||||
(e.g. a missing table), with the reason.
|
||||
- **Preserves the old `students.cohort` column** as a real cohort + enrollment row instead of
|
||||
discarding it.
|
||||
- **Rollback:** Postgres only becomes authoritative once this has run and the Platform API is
|
||||
pointed at it — nothing here modifies or deletes the SQLite source. To roll back: stop
|
||||
Platform API, point student/instructor traffic back at a SQLite-backed service (the old
|
||||
Phase 2A "identity" image is unmodified and still buildable/runnable — see
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`), and restore from the backup file if the live
|
||||
SQLite file was somehow touched (it shouldn't have been — the migration is read-only against
|
||||
it).
|
||||
|
||||
See the final report for actual migrated record counts from this implementation's test run.
|
||||
|
||||
---
|
||||
|
||||
## 3. Student session — productionized ownership
|
||||
|
||||
Phase 2A's `?sid=` was explicitly pilot-only: a client-supplied query parameter, trivially
|
||||
changeable. Phase 2B replaces it with a server-controlled session:
|
||||
|
||||
- `POST /api/login {student_id}` (unchanged trigger — pick-your-name, no password, still
|
||||
pilot-grade identity, stated as such) creates a session server-side
|
||||
(`platform/api/src/sessions.js`, in-memory, 8h TTL) and returns an opaque, CSPRNG token as
|
||||
an `HttpOnly`, `SameSite=Lax` cookie (`rn_session`). The browser cannot read or fabricate
|
||||
this value.
|
||||
- Platform API derives `student_id` **only** from this cookie
|
||||
(`studentSession` middleware) — never from a header or query parameter a client controls.
|
||||
- The Gateway resolves "who is this browser" by calling Platform API's
|
||||
`GET /api/session/whoami` (forwarding the cookie), then compares that student_id against
|
||||
the environment's actual owning `student_id` (from the Provisioner, via Platform API) —
|
||||
**on every proxied Lab request, HTTP and WebSocket, not just the initial navigation.** A
|
||||
short-TTL cache (`SESSION_CACHE_TTL_MS`, 5s) keeps the extra round trip cheap.
|
||||
- Changing the token, URL, query parameter, or environment ID in the browser has **no effect**
|
||||
on who the Gateway thinks is asking — that's determined entirely by the unforgeable cookie.
|
||||
Verified by testing: a valid session for Student A requesting Student B's `/lab/<token>/`
|
||||
gets **403**, not just an unset-parameter fallback.
|
||||
|
||||
**Explicitly not production SSO/OIDC.** Sessions are in-memory (a Platform API restart logs
|
||||
everyone out — acceptable for a pilot/classroom tool, not silently ignored, see §13), and
|
||||
login is still pick-your-name with no password. Production OIDC/Google Workspace SSO remains
|
||||
Phase 3+ (§13/§14).
|
||||
|
||||
### Routing-hint cookie — a real bug found while building this
|
||||
|
||||
The Gateway's unprefixed fallback route (needed so the *unmodified* Lab UI's root-relative
|
||||
`/api/scenarios`, `/shell-ws`, etc. resolve correctly once a browser is inside `/lab/<token>/`
|
||||
— see Phase 2A's cookie design) initially used "does this session currently own a running
|
||||
environment" as the signal for "should I try routing this to the Lab." That's wrong: it's
|
||||
true on the *dashboard* too, not just inside the lab page, and it hijacked
|
||||
`GET /api/session/whoami` (and would have hijacked `/api/environment/*`, `/api/admin/*`,
|
||||
`/api/roster*`) to the student's Lab container — which doesn't have those routes at all —
|
||||
whenever a student had ever launched a lab. Confirmed by testing (whoami returned the SPA's
|
||||
`index.html` instead of JSON).
|
||||
|
||||
Fixed with an explicit allowlist (`LAB_OWNED_PATHS` in `platform/gateway/src/server.js`) of
|
||||
the specific unprefixed paths the Lab UI actually calls
|
||||
(`/api/scenarios*`, `/api/bundles`, `/api/sessions*`, `/api/cache*`, `/api/health`,
|
||||
`/shell-ws`, `/assets/*`) — everything else always goes to Platform API regardless of whether
|
||||
the student has a running lab. `LAB_ACTIVE_COOKIE` (set only on an actual `/lab/<token>/`
|
||||
visit) is a secondary gate on top of that, not the sole signal.
|
||||
|
||||
---
|
||||
|
||||
## 4-9. Admin/Instructor portal
|
||||
|
||||
New `/admin` route in the same frontend bundle (`frontend/src/admin/`) — a separate React tree
|
||||
from the student Platform Shell, gated by its own login (`rn_admin` session cookie, completely
|
||||
separate cookie/token namespace from `rn_session` — a student session can never call an admin
|
||||
route and vice versa).
|
||||
|
||||
**Dashboard** (`AdminDashboard.jsx`) — active cohorts, total students, Labs by state
|
||||
(running/provisioning/error/idle), recent completions, recent environment failures. No
|
||||
Prometheus/Grafana — this is "is class ready / is anything on fire," not an observability
|
||||
platform (explicit non-goal).
|
||||
|
||||
**Cohorts** (`CohortsView.jsx`) — create/edit, roster (add/view students), curriculum
|
||||
assignment, per-stage progress bars aggregated across the cohort, and the classroom
|
||||
preparation workflow:
|
||||
|
||||
- **Prepare Labs** — finds enrolled active students, provisions anyone without a usable
|
||||
environment. Runs concurrently (`Promise.allSettled`) — sequential would make a real cohort
|
||||
prep take the better part of an hour, since each provision is a genuine k3s cold start.
|
||||
Returns a per-student readiness report.
|
||||
- **Destroy Cohort Labs** — destroys every enrolled student's environment. Academic progress
|
||||
lives entirely in Postgres, untouched by this — by construction, not by a separate
|
||||
safeguard, since the destroy path never touches `students`/`progress`/`scenario_attempts`.
|
||||
|
||||
**Students** (`StudentsView.jsx`) — per-student curriculum progress, recent attempts
|
||||
(scenario, result, validation count, timestamp), current environment status, and controls:
|
||||
provision / reset-recreate / destroy / view event history.
|
||||
|
||||
**Admin auth** (`platform/api/src/admin-auth.js`) — a single instructor credential
|
||||
(`ADMIN_PASSWORD` env var, the same secret-injection channel every other component here
|
||||
uses), verified via `scrypt` + `timingSafeEqual` rather than a plain string comparison — no
|
||||
plaintext password ever sits in a database or gets logged. Explicitly not OIDC; documented as
|
||||
Phase 3+ future work.
|
||||
|
||||
---
|
||||
|
||||
## 10-12. API routes, security boundary
|
||||
|
||||
See the route table in the final report. Boundary preserved throughout:
|
||||
|
||||
```
|
||||
Student browser → Gateway → Platform API → Provisioner → Lab Host
|
||||
Admin browser → Gateway → Platform API → Provisioner → Lab Host
|
||||
```
|
||||
|
||||
Browser JavaScript never reaches the Provisioner directly in either path — Platform API is
|
||||
now the *only* thing holding `PROVISIONER_SHARED_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Known limitations
|
||||
|
||||
- **Sessions are in-memory, single-process.** A Platform API restart invalidates every student
|
||||
and admin session. Acceptable at pilot/classroom scale; would need a shared store (Postgres
|
||||
or Redis) before this runs across multiple Platform API instances.
|
||||
- **Exam mode is not fully productionized** — live exam sessions stay Lab-local; only
|
||||
historical records migrate to Postgres. See §2.
|
||||
- **`identity`'s Phase 2A `network_mode: bridge` requirement no longer applies** — Platform
|
||||
API replaces it entirely and doesn't run k3s, so that whole class of problem is gone, not
|
||||
just documented around.
|
||||
- **Admin credential is a single shared password**, not per-instructor accounts — fine for a
|
||||
single-instructor pilot, not a multi-instructor deployment model.
|
||||
- **`?sid=` is gone entirely** (Phase 2A's mechanism) — nothing reads it anymore; a stale
|
||||
bookmarked URL containing it is harmless, the parameter is simply ignored.
|
||||
|
||||
## Future (Phase 3+)
|
||||
|
||||
Production OIDC/SSO, per-instructor accounts, full students/progress historical fidelity for
|
||||
exam mode, shared session store for horizontal scaling, everything already listed as
|
||||
out-of-scope in Phase 2A/2B (parent portal, billing, capstone engine, production Proxmox,
|
||||
full observability, multi-host scheduling, public enrollment).
|
||||
@@ -0,0 +1,344 @@
|
||||
# Phase 3A — Identity and Instructor Access
|
||||
|
||||
Productionizes identity for RootNode Academy: persistent sessions, real per-instructor accounts
|
||||
with roles, server-enforced RBAC, CSRF/rate-limiting hardening, and a platform-level audit log.
|
||||
Builds on `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (Platform API split, Postgres migration,
|
||||
first-generation session cookies, admin portal) — read that first if you haven't.
|
||||
|
||||
## 1. What changed, in one paragraph
|
||||
|
||||
Phase 2B's sessions lived in an in-memory `Map` (gone on every restart) and instructor access
|
||||
was one shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation). Phase 3A
|
||||
replaces both: sessions are now rows in Postgres (`sessions` table, survive a restart, are
|
||||
individually revocable), and instructors are real accounts (`instructors` table) with a role —
|
||||
`owner`, `admin`, or `instructor` — enforced server-side on every `/api/admin/*` route, with a
|
||||
plain `instructor` further scoped to the cohorts they're assigned to. Every security-relevant
|
||||
action is now recorded in a platform-level audit log (`audit_events`).
|
||||
|
||||
## 2. Schema added
|
||||
|
||||
```
|
||||
instructors instructor_id, email (unique), display_name, password_hash, role, status,
|
||||
created_at, updated_at, last_login_at
|
||||
auth_identities auth_identity_id, instructor_id, provider ('local' today), provider_subject,
|
||||
provider_email, created_at, last_login_at — one 'local' row per instructor;
|
||||
a future OIDC login adds a second row for the SAME instructor_id, no schema
|
||||
change needed then.
|
||||
sessions session_id, session_type ('student'|'instructor'), student_id, instructor_id,
|
||||
token_hash (SHA-256 of the bearer token — the raw token is NEVER stored),
|
||||
created_at, last_seen_at, expires_at (absolute), idle_expires_at (sliding,
|
||||
capped at expires_at), revoked_at, metadata
|
||||
cohort_instructors cohort_id, instructor_id, role_in_cohort — scopes the plain 'instructor'
|
||||
role; owner/admin never consult this table (global visibility by role alone)
|
||||
audit_events audit_event_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
result, source_ip, metadata, created_at
|
||||
```
|
||||
|
||||
All five are new migrations (`platform/api/migrations/007`–`011`), idempotent (the existing
|
||||
`schema_migrations` tracking table already guarantees that), and applied automatically on
|
||||
Platform API startup — no manual migration step. None of Phase 2B's tables
|
||||
(`students`/`cohorts`/`progress`/`scenario_attempts`/etc.) were touched; all Phase 2B data is
|
||||
preserved exactly as-is. `sessions.instructor_id` and `cohort_instructors.instructor_id` are
|
||||
real foreign keys to `instructors` (both tables live in the same Platform API-owned migration
|
||||
set) — unlike `environments.student_id` in the Provisioner's schema, there's no
|
||||
cross-service-database reason to keep this one an opaque reference.
|
||||
|
||||
## 3. Persistent session design
|
||||
|
||||
Browser holds one opaque, CSPRNG bearer token (32 random bytes, base64url) per session, in an
|
||||
`HttpOnly`, `SameSite=Lax` cookie (`Secure` too, once `COOKIE_SECURE=1`). The server stores only
|
||||
`SHA-256(token)` — a Postgres dump or backup can't be replayed as a working session, same
|
||||
principle as password hashing.
|
||||
|
||||
Two independent lifecycles (`sessions.session_type`), separate cookie names
|
||||
(`rn_session`/`rn_instructor`), separate token namespaces — a bug can't confuse a student
|
||||
session for an instructor one. Each session has:
|
||||
|
||||
- **Absolute expiry** (`expires_at`, fixed at creation) — 8h student / 12h instructor by default,
|
||||
configurable via `STUDENT_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_TTL_HOURS`.
|
||||
- **Idle expiry** (`idle_expires_at`, slides forward on every authenticated request, never past
|
||||
`expires_at`) — 2h default for both, configurable via `*_SESSION_IDLE_HOURS`.
|
||||
|
||||
Resolving a session (`sessions.js` `resolveSession`) is one atomic `UPDATE ... RETURNING` that
|
||||
checks `revoked_at IS NULL AND expires_at > now() AND idle_expires_at > now()` and slides the
|
||||
idle window forward in the same round trip — no separate read-then-write race.
|
||||
|
||||
**Restart behavior:** a Platform API restart no longer logs anyone out — verified in
|
||||
`platform/test/e2e-phase3a.js` by actually running `docker compose restart api` mid-test and
|
||||
confirming both an instructor session and a student session still resolve afterward.
|
||||
|
||||
**Role/status changes take effect immediately, no revocation needed:** the instructor session
|
||||
middleware re-fetches the instructor row from Postgres on *every* request rather than trusting
|
||||
anything cached in the session token — so demoting a role or deactivating an account is live on
|
||||
the very next request that account makes, with no separate "walk every session and revoke"
|
||||
step required. Explicit revocation (owner-triggered "revoke all sessions," and automatic on
|
||||
password change) exists too, for the case where you want to kill an active session immediately
|
||||
rather than wait for its next request.
|
||||
|
||||
## 4. Instructor accounts
|
||||
|
||||
`instructors.password_hash` is `scrypt` with a **per-instructor random salt** (Phase 2B's
|
||||
`admin-auth.js` used one fixed salt because there was exactly one shared password — that's gone,
|
||||
this is real per-row salting now), stored as `"<salt-hex>:<hash-hex>"`, verified with
|
||||
`crypto.timingSafeEqual`. No plaintext password ever touches a log line, a database column, or
|
||||
an API response body (`publicInstructor()` strips `password_hash` before any JSON response).
|
||||
|
||||
Bootstrap: `node platform/api/bootstrap-owner.js <email> <name> [password]` — the only supported
|
||||
way to create the first account, so a fresh deployment never requires editing Postgres by hand.
|
||||
Refuses to run if an active owner already exists unless the target email is an existing
|
||||
instructor (the intended zero-owners recovery path).
|
||||
|
||||
## 5. Role model (RBAC matrix)
|
||||
|
||||
| Action | owner | admin | instructor |
|
||||
|---|---|---|---|
|
||||
| Manage instructor accounts (create/edit/deactivate/reset password/revoke sessions) | ✅ | ❌ | ❌ |
|
||||
| View audit log | ✅ | ✅ | ❌ |
|
||||
| Create/edit cohorts, create/edit students, assign curriculum | ✅ | ✅ | ❌ |
|
||||
| Assign an instructor to a cohort | ✅ | ✅ | ❌ |
|
||||
| View dashboard, cohort list, cohort/student detail, progress | ✅ (global) | ✅ (global) | ✅ (**assigned cohorts only**) |
|
||||
| Prepare Labs / Destroy Cohort Labs, provision a student's Lab | ✅ (any cohort) | ✅ (any cohort) | ✅ (**assigned cohorts only**) |
|
||||
| Reset/destroy/view-events by raw environment id | ✅ | ✅ | ❌ — see §6 |
|
||||
|
||||
Enforced by `src/rbac.js`, applied per-route in `src/admin-routes.js` — never in the frontend
|
||||
alone (`frontend/src/admin/*.jsx` hides controls a role can't use, but that's UX; every route
|
||||
re-checks server-side regardless of what the browser sends or hides). A denied request gets a
|
||||
flat `403 {"error":"forbidden"}` — no distinction in the response between "wrong role" and
|
||||
"right role, wrong cohort," and no distinction between "resource doesn't exist" and "you can't
|
||||
see it," so a 403 never confirms anything about what exists to someone probing.
|
||||
|
||||
An unauthenticated request gets `401`, not `403` — the two are kept distinct throughout.
|
||||
|
||||
## 6. Cohort-scoped instructor access
|
||||
|
||||
`cohort_instructors` maps `instructor_id → cohort_id` (assigned by an owner/admin, never
|
||||
self-service). A plain `instructor` role's requests to cohort- or student-keyed routes go
|
||||
through `requireCohortAccess`/`requireStudentCohortAccess`, which check this table;
|
||||
owner/admin bypass it entirely (global visibility by role alone).
|
||||
|
||||
**Deliberate simplification:** the raw `/api/admin/environments/:id/{reset,DELETE,events}`
|
||||
routes (operate directly on a Provisioner environment id, no cohort context in the URL) stay
|
||||
owner/admin-only rather than resolving cohort scope from a bare environment id — that resolution
|
||||
would need an extra Provisioner round trip on every single call. A plain instructor still has
|
||||
full Lab control, just via the cohort/student-scoped routes (`/cohorts/:id/prepare-labs`,
|
||||
`/students/:id/environment/provision`, etc.), which already carry the scope check. Upgrade path
|
||||
if this ever needs to change: add a `student_id`/`cohort_id` lookup to the Provisioner's
|
||||
`GET /environments/:id` response and extend `requireCohortAccess` to resolve through it.
|
||||
|
||||
## 7. Student session hardening
|
||||
|
||||
The pick-your-name roster flow is unchanged UX — **this is classroom/pilot identification, not
|
||||
production authentication.** There is still no password or possession factor at the identity
|
||||
step; anyone who knows a display name (or can see the roster dropdown) can "become" that
|
||||
student. What Phase 3A hardens is everything *after* that pick:
|
||||
|
||||
- The resulting session is server-controlled and now **persistent** (survives a Platform API
|
||||
restart — verified, see §3).
|
||||
- A student cannot switch identity by editing client state — there's nothing client-side to
|
||||
edit; the cookie is `HttpOnly` and opaque.
|
||||
- Logout revokes the session server-side (`sessions.destroyStudentSession`), not just a
|
||||
client-side cookie clear.
|
||||
- Every Gateway-proxied Lab request (HTTP and WebSocket) re-verifies ownership against the
|
||||
session on every request, not just the initial navigation — unchanged from Phase 2B, still
|
||||
true here.
|
||||
- A student session can never call `/api/admin/*` — those routes require an `rn_instructor`
|
||||
session in a completely separate cookie/token namespace.
|
||||
- CSRF (see §8) now also protects student mutating routes (`/api/environment/launch`, `/reset`,
|
||||
`/api/progress/record`, `/api/logout`) the same way it protects instructor ones.
|
||||
|
||||
**Future upgrade path that doesn't require touching the session system:** one-time student
|
||||
enrollment codes, parent-issued codes, or institutional SSO would all still terminate in the
|
||||
exact same `sessions` table and `rn_session` cookie — only the *identity verification step*
|
||||
before `createStudentSession()` is called would change. This is why the session store was worth
|
||||
building as a shared, session-type-agnostic module rather than something bespoke to the current
|
||||
roster-picker.
|
||||
|
||||
## 8. CSRF protection
|
||||
|
||||
Double-submit cookie pattern, on top of (not instead of) `SameSite=Lax`. On login, a
|
||||
**non-HttpOnly** cookie is set — `rn_csrf_s` (student) / `rn_csrf_i` (instructor) — deliberately
|
||||
readable by the frontend's own JS via `document.cookie`. Every state-changing request
|
||||
(`POST`/`PATCH`/`DELETE`) must echo that value back as an `X-CSRF-Token` header; the server
|
||||
checks the header matches the cookie it's holding for that same request. A cross-origin
|
||||
attacker's page can trigger a cookie-carrying request but can't read the cookie's value (browser
|
||||
same-origin policy) or attach a matching custom header without CORS, which isn't granted to
|
||||
other origins — so it can never produce a request where the two match.
|
||||
|
||||
`GET`/`HEAD`/`OPTIONS` are exempt (nothing to protect). Applied uniformly, including to
|
||||
`/api/logout` and `/api/auth/logout` — a forged logout is low-severity, but consistent
|
||||
enforcement was simpler to reason about than a per-route exception.
|
||||
|
||||
**One real bug found while testing this:** the Gateway's own progress-relay call
|
||||
(`platform/gateway/src/server.js` `relayScenarioOutcome`, triggered when a student solves a
|
||||
scenario inside their Lab) is a server-to-server call to `POST /api/progress/record`, not a
|
||||
browser one — but it's relaying the browser's own session, so it needs to forward the CSRF
|
||||
token too. The first fix attempt forwarded only the `X-CSRF-Token` header and still 403'd,
|
||||
because the double-submit check needs BOTH the cookie *and* a matching header on the request
|
||||
Platform API actually receives — the fix forwards both.
|
||||
|
||||
## 9. Login rate limiting
|
||||
|
||||
In-memory sliding window (`src/security.js`), keyed by `ip:email`, max 8 attempts per 5 minutes,
|
||||
`429` past that. No new dependency — small enough to hand-roll. **Known limitation:** in-memory
|
||||
means it resets on a Platform API restart and doesn't share state across multiple instances; a
|
||||
Postgres- or Redis-backed limiter would be the upgrade if this ever needs to survive a restart
|
||||
or scale beyond one process. Not needed at pilot scale.
|
||||
|
||||
## 10. Audit log
|
||||
|
||||
`audit_events`, distinct from the Provisioner's `environment_events` (Lab-lifecycle-only).
|
||||
Records: instructor login success/failure, logout, password change, instructor
|
||||
created/updated/deactivated, role changes, session revocation, cohort create/update, roster
|
||||
changes, curriculum assignment, Prepare Labs, Lab reset/destroy, and every `403` authorization
|
||||
denial (recorded centrally by the RBAC middleware itself, not sprinkled per-route). Never
|
||||
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens —
|
||||
verified by an explicit test assertion (`e2e-phase3a.js` "audit log" case scans recorded
|
||||
metadata for anything password-shaped). Viewable at `/admin` → Audit (owner/admin only), latest
|
||||
200 events, no pagination UI yet — pilot-scale, not a SIEM.
|
||||
|
||||
## 11. API inventory (new/changed)
|
||||
|
||||
```
|
||||
POST /api/auth/instructor/login email+password login, rate-limited, no enumeration
|
||||
POST /api/auth/logout revokes the current instructor session
|
||||
GET /api/auth/session current instructor identity (role, etc.)
|
||||
POST /api/auth/change-password requires current password; revokes other sessions
|
||||
|
||||
POST /api/login UNCHANGED route, now backed by persistent sessions
|
||||
POST /api/logout UNCHANGED route, now CSRF-checked + persistent revoke
|
||||
GET /api/session/whoami UNCHANGED — Gateway's ownership-check dependency
|
||||
|
||||
GET /api/admin/instructors owner only
|
||||
POST /api/admin/instructors owner only
|
||||
GET /api/admin/instructors/:id owner only — includes session list
|
||||
PATCH /api/admin/instructors/:id owner only — role/status/name; blocks removing the last active owner
|
||||
POST /api/admin/instructors/:id/revoke-sessions owner only
|
||||
POST /api/admin/instructors/:id/reset-password owner only — also revokes that instructor's sessions
|
||||
GET /api/admin/audit-events admin/owner
|
||||
POST /api/admin/cohorts/:id/instructors owner/admin — assigns an instructor to a cohort
|
||||
```
|
||||
|
||||
Every pre-existing `/api/admin/*` route from Phase 2B still exists at the same path — behavior
|
||||
unchanged for owner/admin, newly scoped for the `instructor` role (see §5/§6). `/api/admin/login`,
|
||||
`/api/admin/logout`, `/api/admin/whoami` (the old shared-password routes) are gone — replaced by
|
||||
the `/api/auth/instructor/*` routes above.
|
||||
|
||||
## 12. Frontend/admin changes
|
||||
|
||||
- `AdminPortal.jsx` — email+password login form (was a bare password field); nav now
|
||||
conditionally shows Instructors (owner) and Audit (owner/admin) tabs; adds an Account tab.
|
||||
- `InstructorsView.jsx` (new) — create/list/edit instructors, role/status controls, session list
|
||||
+ revoke-all, password reset.
|
||||
- `AuditView.jsx` (new) — read-only audit event table.
|
||||
- `AccountView.jsx` (new) — change-password form for the signed-in instructor.
|
||||
- `CohortsView.jsx` — create-cohort form and curriculum-assignment button hidden for the
|
||||
`instructor` role (server already enforces this; this just avoids showing controls that would
|
||||
403).
|
||||
- `frontend/src/admin/api.js` / `frontend/src/platform/api.js` — both now read the relevant
|
||||
`rn_csrf_*` cookie and attach `X-CSRF-Token` on every mutating call.
|
||||
|
||||
## 13. Security hardening review (what was checked)
|
||||
|
||||
- **Cookie settings** — `HttpOnly` on session cookies, `SameSite=Lax`, `Secure` gated behind
|
||||
`COOKIE_SECURE=1` (off by default for local HTTP dev).
|
||||
- **CSRF** — §8.
|
||||
- **Login brute force** — §9.
|
||||
- **Session fixation** — every login issues a brand-new session row (never reuses/upgrades an
|
||||
existing token), so there's nothing to "fix" a pre-auth session into.
|
||||
- **Password handling** — §4.
|
||||
- **Account enumeration** — identical response for unknown-email and wrong-password (tested).
|
||||
- **Authorization bypass** — every `/api/admin/*` route re-checks server-side (§5); tested with
|
||||
both role-based and cohort-scoped negative cases.
|
||||
- **Inactive accounts** — rejected at the session-middleware level on every request, not just at
|
||||
login (a deactivation takes effect immediately, §3).
|
||||
- **Revoked sessions** — `resolveSession`'s `WHERE revoked_at IS NULL` makes a revoked session
|
||||
fail identically to an expired one.
|
||||
- **Role downgrade/upgrade session invalidation** — handled by the live re-fetch design (§3),
|
||||
not a separate revoke pass.
|
||||
- **WebSocket authentication** — unchanged from Phase 2A/2B: the Gateway's WS upgrade handler
|
||||
re-verifies ownership via the same session-resolution path as HTTP.
|
||||
- **Gateway ownership checks** — unchanged mechanism, tightened cache TTL (5000ms → 2000ms) so a
|
||||
revoked session stops working at the Gateway within a tighter, tested bound.
|
||||
- **`trust proxy`** — off by default, opt-in via `TRUST_PROXY=1`, so `req.ip` (used for audit
|
||||
`source_ip` and rate-limit keys) isn't trivially spoofable via `X-Forwarded-For` unless a real
|
||||
trusted proxy is actually in front.
|
||||
|
||||
## 14. Test results
|
||||
|
||||
- `platform/provisioner/test/run.js` (Phase 1, isolated stack) — **not re-run this phase**: zero
|
||||
source changes to `platform/provisioner/` (confirmed via `git diff`), and its own dev compose
|
||||
stack collides on port 5433 with the shared `platform/docker-compose.dev.yml` stack used for
|
||||
everything else in this phase. Provisioner was still exercised heavily and indirectly through
|
||||
every e2e suite below (provision/reset/destroy, concurrent Prepare Labs, cohort-scoped
|
||||
operations) — all passing.
|
||||
- `platform/test/e2e-phase2a.js` — **10/10 passed** (student flow, now under persistent sessions).
|
||||
- `platform/test/e2e-phase2b.js` — **12/12 passed** (classroom workflow, now under real
|
||||
instructor accounts instead of a shared password).
|
||||
- `platform/test/e2e-phase3a.js` — **21/21 passed**: no-enumeration, CSRF positive+negative,
|
||||
rate limiting, role creation, RBAC denial (instructor→owner-only, instructor→admin-only,
|
||||
admin→owner-only), cohort scoping (visibility + access), last-owner protection, live
|
||||
role/status re-evaluation, password change + old-password rejection + other-session
|
||||
revocation, audit log content (including the no-secrets assertion), student session RBAC,
|
||||
student logout revocation, and session persistence across a real `docker compose restart api`.
|
||||
- Real Lab image — rebuilt clean; standalone `--privileged` single-container mode boots and
|
||||
serves with zero Platform API/Postgres/Provisioner dependency (verified live).
|
||||
- Frontend production build — clean, no errors.
|
||||
- Scenario content / Real Ops bundle / Lab `Dockerfile`/`backend/`/`scripts/` — `git diff`
|
||||
against the Phase 2B milestone SHA is empty for all of them: untouched.
|
||||
|
||||
## 15. Known limitations
|
||||
|
||||
- **Login rate limiting is in-memory, single-process** (§9) — resets on restart, doesn't share
|
||||
state across multiple Platform API instances. Fine at pilot scale.
|
||||
- **Gateway session cache is a 2-second window** — a revoked/expired session can still work at
|
||||
the Gateway for up to ~2s after revocation (tightened from 5s in Phase 2B). Platform API
|
||||
itself always reflects revocation immediately; this bound is specific to the Gateway's
|
||||
whoami-result cache, which exists purely to avoid a Platform API round trip on every single
|
||||
proxied Lab byte.
|
||||
- **No distributed session revocation push** — revoking a session updates Postgres immediately;
|
||||
anything holding a *cached* copy of "this session is valid" (only the Gateway does this, and
|
||||
only for 2s) catches up on its next cache miss, not instantly.
|
||||
- **Audit log has no pagination UI** — latest 200 events only. Add pagination if usage ever
|
||||
exceeds that being useful.
|
||||
- **Roster name-picking is still not strong student identity** (§7) — explicitly documented,
|
||||
not silently glossed over.
|
||||
- **`cohort_instructors` role_in_cohort field exists but isn't consulted for finer-grained
|
||||
permissions yet** — currently any assignment grants the full `instructor` role's cohort-scoped
|
||||
actions; a co-teacher vs. lead-teacher distinction within a cohort would consume this field but
|
||||
isn't built.
|
||||
- **Exam mode's live bookkeeping is still Lab-local** — unchanged from Phase 2B, still a real gap
|
||||
for anyone relying on cross-restart exam state.
|
||||
|
||||
## 16. Future OIDC path
|
||||
|
||||
`auth_identities` (§2) already supports it structurally: a Google/OIDC login would create a
|
||||
second `auth_identities` row (`provider='google'`, `provider_subject=<their sub>`) pointing at
|
||||
an existing `instructor_id`, or create a new `instructors` row + `auth_identities` row for a
|
||||
first-time SSO login. The missing piece is the actual OIDC client flow (discovery, token
|
||||
exchange, JWT verification) — deliberately **not built this phase**, per the explicit scope
|
||||
instruction to stop at a documented adapter interface rather than pull in an OIDC client
|
||||
library and expand scope. `instructors.password_hash` staying nullable (already the case) is
|
||||
what allows an SSO-only account to exist with no local password at all, whenever that flow is
|
||||
built.
|
||||
|
||||
## 17. Production HTTPS / cookie expectations
|
||||
|
||||
Local dev and the current single-VM pilot deployment run over plain HTTP — `COOKIE_SECURE` is
|
||||
unset (cookies lack `Secure`) and `TRUST_PROXY` is unset by default. Before any real-HTTPS
|
||||
production deployment: set `COOKIE_SECURE=1` (cookies become HTTPS-only) and, if fronted by a
|
||||
reverse proxy/load balancer, set `TRUST_PROXY=1` so `req.ip` reflects the real client IP instead
|
||||
of the proxy's.
|
||||
|
||||
## 18. BUILT NOW vs. FUTURE
|
||||
|
||||
**Built now:** persistent Postgres-backed sessions (student + instructor), per-instructor
|
||||
accounts with scrypt password hashing, three-role RBAC enforced server-side, cohort-scoped
|
||||
instructor access, CSRF double-submit protection, login rate limiting, platform audit log,
|
||||
owner-safety invariant (never zero active owners), password change with other-session
|
||||
revocation, `auth_identities` table ready for future OIDC mapping.
|
||||
|
||||
**Future (not built):** real OIDC/SSO client flow, multi-instance-safe rate limiting, audit log
|
||||
pagination/export, cohort_instructors role-in-cohort permission granularity, distributed
|
||||
(non-Gateway-cache) session revocation push, one-time student enrollment codes /
|
||||
parent-issued codes, everything already on the Phase 2B "DO NOT BUILD YET" list (capstone
|
||||
engine, billing, production Proxmox deployment, etc.) — unchanged, still not in scope.
|
||||
@@ -0,0 +1,370 @@
|
||||
# Phase 3B — Learning & Assessment Engine
|
||||
|
||||
Turns the platform infrastructure built in Phases 1–3A (identity, sessions, RBAC, disposable
|
||||
Labs, Gateway routing, admin portal) into a real teaching and assessment system: detailed
|
||||
attempt telemetry, progressive hints, rule-driven curriculum progression, a proper exam engine,
|
||||
a RootNode Capstone, and persistent achievement records. Builds on
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` — read that first if you haven't.
|
||||
|
||||
## 1. What changed, in one paragraph
|
||||
|
||||
Phase 2B's `scenario_attempts` table was one row per validation click, written atomically at
|
||||
completion, with `hint_count` deliberately left unmeasured. Phase 3B evolves it into a real
|
||||
attempt lifecycle — one row per continuous working session on a scenario, with live counters
|
||||
(validation attempts/passes/fails, hint reveals, duration) updated in place as the student
|
||||
works. On top of that: a rule-driven curriculum-progression engine that actually locks/unlocks
|
||||
stages instead of a permanent capstone placeholder; a progressive hint system that's genuinely
|
||||
server-enforced (not just collapsed-by-default in the UI); a full exam/capstone assessment
|
||||
engine with server-authoritative timers and random scenario draws; and persistent achievement
|
||||
records for stage/curriculum/assessment/capstone completion.
|
||||
|
||||
## 2. Learning record model
|
||||
|
||||
`scenario_attempts` (migration `012`) gained: `cohort_id`, `curriculum_stage` (both snapshotted
|
||||
at attempt-start, so a later cohort/curriculum change never rewrites history), `status`
|
||||
(`in_progress`/`completed`/`abandoned`), `validation_attempt_count`, `validation_pass_count`,
|
||||
`validation_fail_count`, `highest_hint_level`, `duration_seconds`, `assessment_session_id`.
|
||||
`result` (Phase 2B) is now nullable — meaningful only once `status = 'completed'`.
|
||||
`hint_count` (Phase 2B, always `NULL`) finally gets a `DEFAULT 0` and real increments.
|
||||
|
||||
This is an **evolution, not a new table** — Phase 2B's historical rows are backfilled (one
|
||||
validation performed, matching their original one-row-per-click semantics), not dropped or
|
||||
reshaped. `platform/api/src/attempts.js` owns the lifecycle:
|
||||
|
||||
- `getOrCreateOpenAttempt` — finds the open (`in_progress`) attempt for
|
||||
`(student, scenario, environment)`, or starts one. Environment-scoped so a destroyed/recreated
|
||||
Lab never silently resumes an attempt tied to compute that no longer exists.
|
||||
- `recordValidation` — increments the attempt/pass/fail counters; closes the attempt
|
||||
(`status='completed'`, `duration_seconds` computed) only on a pass.
|
||||
- `recordHintReveal` — sequential only: level *N* can't be revealed before *N-1* has been.
|
||||
Returns a 409 once every hint is exhausted.
|
||||
|
||||
No telemetry is invented that the Lab doesn't actually emit — `duration_seconds` is measured
|
||||
server-side from `started_at` to completion (not client-reported), and hint reveals only exist
|
||||
because Phase 3B builds a real reveal endpoint (§3), not because the old static hint array was
|
||||
reinterpreted as "measured."
|
||||
|
||||
## 3. Validation telemetry & progressive hints
|
||||
|
||||
`POST /api/scenarios/:id/start` — fire-and-forget from the Lab UI when a scenario becomes
|
||||
active (both task and MCQ types); creates/reopens the attempt with a real `started_at`.
|
||||
|
||||
`POST /api/progress/record` (Gateway-relayed from the Lab's existing validate/answer calls,
|
||||
unchanged mechanism from Phase 2A/2B) now routes through `attempts.recordValidation` instead of
|
||||
a flat insert, and — on a pass — checks curriculum/capstone/assessment consequences (§4, §6).
|
||||
|
||||
`POST /api/scenarios/:id/hint` reveals hints **one at a time, server-enforced**. This required a
|
||||
real architectural change: Phase 2B's Lab backend (unmodified, per the "Lab image stays
|
||||
unmodified" convention through Phase 3A) returns the scenario's full `hints` array in
|
||||
`GET /api/scenarios/:id` — always did, since the Lab has no student/attempt concept at all. A
|
||||
client could already see hint 3 in the raw response even with the UI collapsed. Making the gate
|
||||
real meant the Gateway now **strips `hints` from that response** (replacing it with a bare
|
||||
`hint_count`) for exactly the multi-student, Platform-mediated deployment
|
||||
(`platform/gateway/src/server.js`, `GET /lab/:token/api/scenarios/:scenarioId`) — the Lab
|
||||
image's own bytes are still untouched; this is a response rewrite at the boundary, the same
|
||||
pattern already used for the `hints`-stripping shape as everything else the Gateway mediates.
|
||||
Standalone/single-container dev mode never goes through the Gateway, so it keeps the original
|
||||
fully-preloaded array unchanged — the frontend detects which mode it's in
|
||||
(`scenario.hints === undefined && typeof scenario.hint_count === 'number'`) and switches to
|
||||
`ProgressiveHints` (`frontend/src/components/ProgressiveHints.jsx`) only in the gated case.
|
||||
|
||||
Instructor-visible attempt summary matches the shape in the Phase 3B spec directly:
|
||||
|
||||
```
|
||||
Student: Alice Scenario: Probe Port Mismatch Attempts: 2
|
||||
Validation: Failed 2, Passed 1 Hints used: 1/3 Time: 14m 22s Final: Completed
|
||||
```
|
||||
|
||||
— all straight off `scenario_attempts` columns, no derived/estimated numbers, and explicitly
|
||||
**not** turned into a score. See §12 for where instructors see this.
|
||||
|
||||
## 4. Curriculum completion rules
|
||||
|
||||
`scenarios/rootnode-path.json` gained a `version` field and, per stage, a `completion_rule`:
|
||||
|
||||
```json
|
||||
{ "type": "all_required" } // every scenario_id in the stage must be completed (default)
|
||||
{ "type": "threshold", "percent": 80 } // at least N% completed
|
||||
{ "type": "prerequisites", "scenario_ids": [...] } // only these specific ids must be completed
|
||||
```
|
||||
|
||||
The capstone stage is no longer a hardcoded `"locked": true` placeholder — it carries
|
||||
`"type": "capstone"`, an `"unlock_rule"` (`{"type":"stages_complete","stage_ids":[...]}`), and a
|
||||
`"capstone_assessment_id"` pointing at the assessment definition that drives it (§6/§7).
|
||||
|
||||
`platform/api/src/curriculum-rules.js`'s `computeCurriculumState(path, progress, capstoneStatus)`
|
||||
is the rule engine — pure, synchronous, no DB access — answering: per-stage locked/complete/
|
||||
percent/next-scenario, the student's current stage, overall percent complete, and the capstone's
|
||||
unlock status (server route supplies `capstoneStatus` after checking for a
|
||||
`capstone_completed` achievement, since that's Postgres state this module doesn't touch).
|
||||
Exposed at `GET /api/curriculum/state` (new; `GET /api/curriculum/path` from Phase 2A/2B is
|
||||
unchanged and still used by the pre-existing dashboard summary).
|
||||
|
||||
Stage/curriculum completion also triggers achievement recording (§8) automatically, from inside
|
||||
`POST /api/progress/record` — no separate "check for completion" step the student or frontend
|
||||
has to remember to call.
|
||||
|
||||
## 5 & 6. Assessment model and exam engine
|
||||
|
||||
`PRACTICE` is just... practice: the existing curriculum flow, hints freely available (subject to
|
||||
the scenario actually having any), unrestricted retries, no timer, no session concept.
|
||||
|
||||
`ASSESSMENT` (exam or capstone — same underlying engine, §7 covers what makes a capstone
|
||||
different) is a **definition** (the reusable template: pool, duration, hint policy, completion
|
||||
rule) producing **sessions** (one per student attempt, immutable snapshot of what was drawn and
|
||||
under what rules). New tables (migration `013`):
|
||||
|
||||
```
|
||||
assessment_definitions slug PK, type ('exam'|'capstone'), title, duration_minutes,
|
||||
hint_policy ('free'|'disabled'|'flagged'), pool_spec JSONB,
|
||||
completion_rule JSONB, unlock_rule JSONB (capstone only),
|
||||
version INTEGER, status ('active'|'inactive')
|
||||
assessment_cohort_assignments which cohorts a definition is available to
|
||||
assessment_sessions one per student attempt — snapshots type/title/duration_minutes/
|
||||
hint_policy from the definition at draw time, plus
|
||||
definition_version, started_at, server-computed expires_at,
|
||||
submitted_at, random_seed, result JSONB
|
||||
assessment_session_scenarios the drawn set, ordered, with per-scenario status + a real FK
|
||||
back to the scenario_attempts row once worked
|
||||
```
|
||||
|
||||
`exam_sessions` (Phase 1/2B, historical-import-only from the old Lab-local SQLite exam
|
||||
bookkeeping) is **left untouched** — it was already scoped as dead historical data, never the
|
||||
live mechanism, so there was nothing to migrate into the tables above without inventing data
|
||||
that was never real. This is a deliberate non-duplication: the assessment tables above *are*
|
||||
the real thing Phase 2B's note said didn't exist yet.
|
||||
|
||||
`pool_spec` shape:
|
||||
|
||||
```json
|
||||
{ "required": ["scenario-id", ...], "random": [{ "count": 1, "from": ["a", "b", "c"] }, ...] }
|
||||
```
|
||||
|
||||
`platform/api/src/assessments.js`'s `drawScenarios` includes every `required` id, plus `count`
|
||||
random picks from each `random` group, shuffled together into presentation order. The draw uses
|
||||
a tiny deterministic PRNG (mulberry32, seeded from `crypto.randomBytes` — no new dependency)
|
||||
purely so the seed can be recorded for audit; a student never sees the seed or the undrawn pool
|
||||
before starting.
|
||||
|
||||
**One assessment at a time per student** — `startSession` refuses if the student already has a
|
||||
`not_started`/`in_progress` session for *any* definition. This keeps "which scenario is this
|
||||
validate call happening inside" unambiguous for hint-policy enforcement (§19) without needing
|
||||
the client to declare a mode.
|
||||
|
||||
**Timer**: `expires_at` is computed once, server-side, at `started_at + duration_minutes`, and
|
||||
never recomputed from anything the client sends. Expiry is checked **lazily** — whenever a
|
||||
session is read or written, if `now() > expires_at` and it's still `in_progress`, it flips to
|
||||
`expired` right then. No background cron (explicitly out of scope this phase — see the
|
||||
non-goals list). Verified live: a 1-minute test definition correctly transitioned to `expired`
|
||||
on the first request after the minute passed, and unblocked starting a new assessment.
|
||||
|
||||
## 7. RootNode Capstone
|
||||
|
||||
A capstone is an `assessment_definitions` row with `type='capstone'` — same engine as an exam,
|
||||
different framing and unlock gate. The shipped default (seeded in migration `013`, idempotent
|
||||
`INSERT ... ON CONFLICT DO NOTHING`) is `rootnode-capstone`:
|
||||
|
||||
- **Required** (always drawn): all four RootNode Real Ops scenarios
|
||||
(`rootnode-runaway-monitor`, `broken-deployment`, `rootnode-probe-port-mismatch`,
|
||||
`rootnode-enforce-limits`) — "feature prominently," per the spec, satisfied by making them
|
||||
non-optional rather than just likely.
|
||||
- **Random**: one Workloads task, one Configuration task, one Troubleshooting task — task-type
|
||||
scenarios preferred over MCQ, since the point is operational ability, not recall.
|
||||
- 90 minutes, `hint_policy: 'flagged'` (available, but every use is recorded — appropriate for
|
||||
a capstone that should mostly reflect independent work without being punitive about needing a
|
||||
nudge).
|
||||
- `unlock_rule`: every non-capstone curriculum stage complete (Foundation through Real Ops).
|
||||
|
||||
`GET /api/capstone` is a student-friendly view combining `computeCurriculumState`'s capstone
|
||||
block with the definition and the student's own session history —
|
||||
`locked` → `unlocked` → `completed`, with a prerequisite checklist while locked. Verified live,
|
||||
end to end: locked with all six blocking stage ids, unlocked to zero blocking ids the moment the
|
||||
last stage completed, a real draw (7 scenarios, all four Real Ops present), completion of all
|
||||
seven finalizing the session and recording a `capstone_completed` achievement.
|
||||
|
||||
## 8. Achievement / completion records
|
||||
|
||||
`achievements` (migration `014`): `student_id`, `achievement_type`
|
||||
(`stage_completed`/`curriculum_completed`/`assessment_completed`/`capstone_completed`),
|
||||
`curriculum_path_id`/`curriculum_version` (for stage/curriculum types),
|
||||
`assessment_session_id` (for assessment/capstone types), `achieved_at`, `metadata`.
|
||||
|
||||
`recordAchievement` is idempotent via a `WHERE NOT EXISTS` guard (not a unique-index/
|
||||
`ON CONFLICT` dance — simple and correct at this write volume) — calling it again for something
|
||||
already recorded is a safe no-op. Triggered automatically: `checkStageAndCurriculumAchievements`
|
||||
runs inside `POST /api/progress/record` after every pass; `recordAssessmentAchievement` runs
|
||||
after `assessments.recordScenarioOutcome`/`submitSession` finalizes a session, and only on an
|
||||
actual pass (defensive — today's only `completion_rule` is `all_scenarios_passed`, which already
|
||||
guarantees a `completed` session passed, but a future threshold-based rule shouldn't silently
|
||||
grant achievements for a partial result).
|
||||
|
||||
**Not a certificate.** No public/shareable artifact is generated — this is explicitly a Phase
|
||||
4/5 concern (see the non-goals list); a certificate can be rendered from these records later
|
||||
without needing new data.
|
||||
|
||||
## 9. Assessment/exam Lab behavior
|
||||
|
||||
An assessment doesn't provision a separate Lab — the student works through their drawn
|
||||
scenarios inside the **same** disposable environment their curriculum work already uses. This
|
||||
is what makes "Lab reset doesn't erase assessment state" nearly free: assessment state
|
||||
(`assessment_sessions`, `assessment_session_scenarios`, `scenario_attempts`) lives entirely in
|
||||
Platform Postgres; the Lab environment is purely compute. Resetting/recreating it changes the
|
||||
container, never the rows. Verified live: reset an environment mid-assessment,
|
||||
`expires_at`/`status` on the session were unchanged afterward.
|
||||
|
||||
**Assessment-mode detection is entirely server-derived** — `findActiveAssessmentContextForScenario`
|
||||
looks up whether the scenario a `validate`/`hint` call is happening against is currently in the
|
||||
student's own active session's drawn set. Nothing the client sends declares "I'm in assessment
|
||||
mode"; the server's own `assessment_session_scenarios` table is the only source of truth (see
|
||||
§19).
|
||||
|
||||
If Lab infrastructure fails mid-assessment, the assessment session itself is unaffected — an
|
||||
instructor can reset (or, if truly needed, destroy/reprovision) the student's Lab via the
|
||||
existing admin environment controls (Phase 2B) without touching assessment state at all; there
|
||||
is no code path that resets an assessment as a side effect of a Lab operation.
|
||||
|
||||
## 10 & 11. Student UI and Lab UX
|
||||
|
||||
`frontend/src/platform/StudentDashboard.jsx` is now tabbed: **My Learning** (the original
|
||||
Phase 2A/2B "Up Next" + Launch/Reset Lab flow, extracted unchanged into `MyLearningTab.jsx` and
|
||||
extended with an overall percent-complete bar from the new rule-driven curriculum state),
|
||||
**Practice** (scenario/stage status + recent attempt history), **Assessments** (available/
|
||||
active-with-live-countdown/history), **Capstone** (locked checklist → unlocked → start/resume →
|
||||
completed, with attempt history), **Achievements** (a flat, dated list — not a leaderboard).
|
||||
|
||||
Inside the Lab UI itself (`frontend/src/components/ScenarioPanel.jsx`, `App.jsx`):
|
||||
`ProgressiveHints` replaces the static hint array render when the Gateway has stripped hints
|
||||
(§3); an `AssessmentBanner` (new, top-level, polls `/api/assessments/active`) shows a live
|
||||
countdown and per-scenario completion count whenever an assessment is running — distinct from,
|
||||
and rendered alongside, the pre-existing Lab-local exam-mode UI (`ExamTimer`/`ExamSession` etc.,
|
||||
upstream KubeKosh feature, untouched). Both render nothing in standalone/single-container dev
|
||||
mode (no Platform API to poll there), so neither regresses that deployment path.
|
||||
|
||||
## 12. Instructor learning analytics
|
||||
|
||||
`/admin` → **Learning**: cohort summary (student count, 7-day active-learner count, average
|
||||
completion, per-stage completion counts), a **Needs Attention** list, and a per-student
|
||||
drill-down (curriculum state, full attempt history with validation/hint counts, assessment
|
||||
history). `/admin` → **Assessments**: create/edit definitions (admin/owner), assign to a cohort,
|
||||
view/cancel sessions — visible to every instructor role, cohort-scoped server-side for the plain
|
||||
`instructor` role the same way Phase 3A's other cohort-scoped routes are.
|
||||
|
||||
`GET /api/admin/scenario-analytics` (optional `?cohort_id=`): completions, distinct students,
|
||||
average validation attempts, total failures, total hints used, average completion time — one
|
||||
`GROUP BY scenario_id` query over `scenario_attempts`, nothing pre-aggregated or cached.
|
||||
|
||||
## 13. "Needs Attention"
|
||||
|
||||
`GET /api/admin/cohorts/:id/needs-attention` computes four signals per student, on read (no
|
||||
stored table, no background job): **Repeated Attempts** (an open attempt with ≥3 failed
|
||||
validations), **May Need Help** (an open attempt where a hint has been used but the scenario
|
||||
isn't done), **Needs Attention — inactive** (no activity in 7 days), and **Needs Attention —
|
||||
incomplete assessment** (their most recent assessment session expired without completing). All
|
||||
four thresholds are named constants in `admin-learning-routes.js`
|
||||
(`NEEDS_ATTENTION_FAIL_THRESHOLD`, `NEEDS_ATTENTION_STALE_DAYS`), not hardcoded inline. Wording
|
||||
is deliberately instructional (`Needs Attention`, `May Need Help`, `Repeated Attempts`) — an
|
||||
explicit test asserts none of the returned labels match punitive language.
|
||||
|
||||
## 14 & 15. Assessment/capstone management (admin)
|
||||
|
||||
A structured form (`frontend/src/admin/AssessmentsView.jsx`) — id, title, type, duration, hint
|
||||
policy, and a raw JSON `pool_spec` textarea. Not a drag-and-drop authoring tool, per the explicit
|
||||
scope line; the JSON textarea is honest about what's actually being edited rather than hiding it
|
||||
behind partial UI that can't express every pool shape. Editing a definition's
|
||||
duration/hint_policy/pool_spec/completion_rule bumps its `version` automatically
|
||||
(`admin-learning-routes.js` diffs the incoming fields against the current row) — cosmetic edits
|
||||
(title, description) don't. Create/edit is admin/owner only server-side; assign-to-cohort and
|
||||
session view/cancel are available to a scoped `instructor` too, matching the RBAC matrix (§18).
|
||||
|
||||
## 16. Versioning
|
||||
|
||||
Deliberately minimal, per the explicit "don't overbuild full CMS versioning" instruction:
|
||||
|
||||
- **Curriculum path**: a plain `"version": 1` integer in `rootnode-path.json` itself, recorded
|
||||
on `stage_completed`/`curriculum_completed` achievements as a snapshot.
|
||||
- **Assessment definitions**: a plain incrementing `version` integer, bumped only on a semantic
|
||||
change (duration/hint_policy/pool_spec/completion_rule — anything that would change what a
|
||||
session is graded against). `assessment_sessions.definition_version` snapshots which version
|
||||
was live at draw time, alongside a full redundant snapshot of the fields themselves
|
||||
(title/duration/hint_policy) — so a later definition edit, or even deletion, can never change
|
||||
what an already-started session is measured against.
|
||||
|
||||
No full version-history table exists for either — if that's ever needed, `version` is already a
|
||||
column to build on, not a retrofit.
|
||||
|
||||
## 17. RBAC matrix (Phase 3B additions)
|
||||
|
||||
| Action | owner | admin | instructor | student |
|
||||
|---|---|---|---|---|
|
||||
| Create/edit assessment definitions | ✅ | ✅ | ❌ | ❌ |
|
||||
| Assign an assessment to a cohort | ✅ | ✅ | ✅ (own cohorts) | ❌ |
|
||||
| View/cancel assessment sessions | ✅ (any) | ✅ (any) | ✅ (own cohorts) | ❌ |
|
||||
| View cohort learning summary / needs-attention / scenario analytics | ✅ (global) | ✅ (global) | ✅ (own cohorts) | ❌ |
|
||||
| View a student's learning detail | ✅ | ✅ | ✅ (if enrolled in an owned cohort) | own only |
|
||||
| Start/work an assessment, view own attempts/hints/achievements | ❌ | ❌ | ❌ | ✅ (own only) |
|
||||
|
||||
Enforced entirely server-side, reusing Phase 3A's `rbac.js` (`requireAdminOrOwner`,
|
||||
`requireAnyInstructor`, `requireCohortAccess`, `requireStudentCohortAccess`) — no new
|
||||
authorization primitives were needed, only new routes wired through the existing ones.
|
||||
|
||||
## 18. Security / integrity
|
||||
|
||||
- **Timer is server-authoritative** (§6) — `expires_at` fixed at draw time, never accepts a
|
||||
client value, checked lazily on every touch.
|
||||
- **Scenario draw is server-stored** — `assessment_session_scenarios` is the only source of
|
||||
truth for what a student is supposed to be working on; nothing the client sends can add,
|
||||
remove, or reorder it.
|
||||
- **A student cannot submit/read another student's session** — every assessment route filters
|
||||
by `student_id = req.studentId` server-side; a foreign session id returns 404, not 403 (same
|
||||
"don't confirm existence" posture as Phase 3A's RBAC denials) — verified by an explicit test.
|
||||
- **Gateway ownership** (Phase 2A/2B/3A) is unchanged and still enforced on every proxied Lab
|
||||
request, HTTP and WebSocket, including the new `/start` and `/hint` relays.
|
||||
- **Lab recreation never resets attempt/exam state** (§9) — verified live.
|
||||
- **Hidden solution/pool data**: undrawn pool alternatives are never returned to the student
|
||||
(only the actual draw); hints beyond the currently-unlocked level are never sent (§3, the
|
||||
actual mechanism, not just a UI convention).
|
||||
- **No invasive anti-cheat** — no browser surveillance, no lockdown mode. Explicitly out of
|
||||
scope, per the non-goals list.
|
||||
|
||||
## 19. Dev/test commands
|
||||
|
||||
```bash
|
||||
cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
(cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus")
|
||||
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2a.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2b.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3a.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3b.js
|
||||
```
|
||||
|
||||
`e2e-phase3b.js` additionally shells out to `docker compose restart api` for its restart-
|
||||
persistence case (set `SKIP_RESTART_TEST=1` to skip that one case if running somewhere that
|
||||
isn't safe to restart).
|
||||
|
||||
## 20. Known limitations
|
||||
|
||||
- **No automatic "abandoned" detection** — an attempt a student never returns to stays
|
||||
`in_progress` forever; there's no reaper tying Lab destruction back to attempt state. A
|
||||
documented, deliberate scope line (would need wiring to Provisioner `environment_events`,
|
||||
more machinery than the instructional-visibility goal needs).
|
||||
- **`cohort_instructors.role_in_cohort`** (Phase 3A) still isn't consulted for finer-grained
|
||||
assessment permissions — any cohort assignment grants the full scoped-instructor action set.
|
||||
- **Needs-attention thresholds are fixed constants**, not per-cohort configurable yet.
|
||||
- **No pagination** on the audit log (Phase 3A limitation, unchanged) or on scenario analytics.
|
||||
- **Assessment pool_spec authoring is raw JSON** in the admin UI — correct and complete, but not
|
||||
a guided form; a validation-assist UI is a natural but unbuilt next step.
|
||||
- **One assessment at a time per student** — intentional (§6), but means a student can't have a
|
||||
capstone and an exam "in flight" simultaneously even if that were ever desired.
|
||||
|
||||
## 21. Phase 4 boundary
|
||||
|
||||
Everything in this document runs on a single dev machine against
|
||||
`platform/docker-compose.dev.yml`. **Not built, and explicitly out of scope this phase:**
|
||||
production Hetzner/Proxmox deployment, production OIDC provider integration, a parent portal,
|
||||
payments, public signup, automated email, equipment inventory, a multi-host scheduler,
|
||||
Grafana/Prometheus production dashboards, public certificates, a marketplace/self-paced commerce
|
||||
layer, the six held KubeKosh scenarios, invasive proctoring, and AI-driven grading. All of that
|
||||
stays exactly where the Phase 3A doc's non-goals list and this document's §21 leave it — Phase 4
|
||||
territory.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Phase 4.1 — Pre-Production Hardening
|
||||
|
||||
**Status: PHASE 4.1 COMPLETE. PRE-PRODUCTION HARDENING COMPLETE. READY FOR HARDWARE DEPLOYMENT.**
|
||||
|
||||
Phase 4 built the production infrastructure package with the real Hetzner/Proxmox hardware not
|
||||
yet purchased, and flagged one genuine architectural gap: the Gateway's mechanism for reaching
|
||||
a student Lab assumed Gateway and Lab shared a Docker daemon, which is false the moment Labs run
|
||||
on a genuinely separate `rn-lab-host-01` VM. Phase 4.1's job was to close that gap and harden
|
||||
everything else discovered along the way, still without the real hardware.
|
||||
|
||||
## 1. The core fix: a Lab Host Router
|
||||
|
||||
**Before:** Gateway → `http://${env.internal_ip}:${env.internal_port}` directly — a Docker
|
||||
bridge-local address, unreachable across a real VM boundary.
|
||||
|
||||
**After:** Gateway → (mode-aware) → either `direct` (unchanged, dev/single-host) or `router`
|
||||
(production): a small new service, `platform/lab-router/`, runs on the Lab Host itself,
|
||||
authenticates the Gateway via mTLS (the same private CA the Docker TLS control plane already
|
||||
used, extended with two new leaf certs), resolves an opaque routing token to a local container
|
||||
purely from `docker ps` label state (no Postgres access, no call back to the Provisioner), and
|
||||
reverse-proxies HTTP and WebSocket traffic to it.
|
||||
|
||||
The decision of which mode a given `lab_host` uses is data-driven
|
||||
(`lab_hosts.routing_mode`/`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`),
|
||||
resolved once per request in `platform/gateway/src/lab-endpoint.js`'s `labTarget()` — the only
|
||||
place in the Gateway that knows this distinction exists. Every proxy/fetch call site in
|
||||
`platform/gateway/src/server.js` goes through it.
|
||||
|
||||
**Exact `env.internal_ip` replacement:** `labTarget(env, token)` returns `{ url, headers, agent,
|
||||
mode }`. In `direct` mode, `url` is exactly the old `http://internal_ip:internal_port`. In
|
||||
`router` mode, `url` is the lab_host's `router_endpoint`, `headers` carries
|
||||
`X-RootNode-Route-Token: <token>` (computed server-side from the Provisioner-verified
|
||||
environment row — never trusted from the browser), and `agent` is an `https.Agent` configured
|
||||
with the Gateway's mTLS client identity.
|
||||
|
||||
## 2. No per-student host port
|
||||
|
||||
Deliberately not solved by publishing a host port per Lab (the originally-flagged, rejected
|
||||
approach). The Router resolves entirely from Docker's own label state on its own host — no
|
||||
port-per-student anything, no dynamic firewall rule per Lab.
|
||||
|
||||
## 3. Lab Host Router — design
|
||||
|
||||
See `platform/lab-router/src/server.js`'s header comment for the full "narrow by design" list:
|
||||
one route (proxy-by-token), requires mTLS (no bearer-token fallback), never exposes Docker API/
|
||||
container IDs/network info to its caller, never provides student-to-student routing (each
|
||||
request resolves to exactly the one container matching its own token).
|
||||
|
||||
## 4. Multi-Lab-Host ready
|
||||
|
||||
Routing is resolved per `lab_host_id` — adding `rn-lab-host-02` later is one new `lab_hosts` row
|
||||
(its own `routing_mode`/`router_endpoint`/TLS material), no Gateway code change. No scheduler
|
||||
was built (explicitly out of scope) — the Provisioner still picks "the one enabled lab_host"
|
||||
(`selectLabHost`), unchanged from Phase 1.
|
||||
|
||||
## 5. Local development compatibility
|
||||
|
||||
`dev-local` (the Unix-socket lab_host used by `docker-compose.dev.yml`) stays `routing_mode:
|
||||
direct` — zero behavior change for the T480/any local dev setup, confirmed by the full existing
|
||||
regression suite passing unmodified (§29 below) and by every new drill in this phase running
|
||||
against the same dev stack. The standalone single-container Lab image build/boot is unaffected
|
||||
(it never touches the Gateway or Provisioner at all).
|
||||
|
||||
## 6-9. Simulation, HTTP/WS validation, security tests, routing token security
|
||||
|
||||
`infra/scripts/simulate-two-host/` — a real two-Docker-network simulation (not mocks): Gateway
|
||||
attached ONLY to an isolated bridge with no route to any Lab's bridge network, Lab Host Router
|
||||
host-networked (mirrors `egress-proxy`'s existing pattern) so it can reach whatever per-Lab
|
||||
bridge the Provisioner creates. 13/13 real assertions pass, including:
|
||||
|
||||
- Gateway genuinely cannot open a TCP connection to a Lab's Docker-internal IP (proven from
|
||||
inside the Gateway container's own network namespace via a raw connect attempt).
|
||||
- Gateway reaches the Lab correctly through the Router — HTTP AND a real WebSocket terminal
|
||||
session running a genuine `kubectl get nodes` inside the Lab.
|
||||
- Alice/Bob isolation (own lab ALLOW, other's DENY — HTTP and WebSocket both), garbage tokens,
|
||||
anonymous requests, and destroyed-environment tokens are all rejected through the real code
|
||||
path.
|
||||
- The Router refuses any connection with no client certificate — mTLS is the real
|
||||
authentication mechanism.
|
||||
|
||||
Routing token security reviewed: already CSPRNG since Phase 1 (`generateRoutingToken`), never
|
||||
logged in plaintext (the Router's `tokenFingerprint` truncates), redacted in every API response
|
||||
except the two narrow internal reads that legitimately need it, and — critically — the header
|
||||
the Router trusts is computed server-side from the Provisioner-verified environment row, never
|
||||
read from the browser's own request, so a browser cannot forge routing by supplying its own
|
||||
`X-RootNode-Route-Token`.
|
||||
|
||||
## 10. Control plane vs data plane
|
||||
|
||||
Kept strictly separate — see `docs/PRODUCTION_NETWORK_SECURITY.md`'s dedicated section. Student
|
||||
browser traffic is never proxied through the Docker API at any point.
|
||||
|
||||
## 11. Firewall update
|
||||
|
||||
`infra/firewall/rn-lab-host-01.rules`'s old speculative wide port-range allowance replaced with
|
||||
one narrow rule: `platform_ip` → the Router's port only (`8443` default, configurable).
|
||||
|
||||
## 12. Self-paced / remote delivery architecture check
|
||||
|
||||
`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`. Short version: student-driven provisioning,
|
||||
cohort-less students, and "idle reaper destroys → progress survives → student returns → fresh
|
||||
Lab → curriculum continues" all already worked before this phase and are now proven end-to-end
|
||||
by two new drills. The one real gap for actual remote/self-paced delivery is student
|
||||
authentication (today's bare `student_id` login assumes an instructor-controlled roster) — not
|
||||
built here, explicitly Phase 5 scope.
|
||||
|
||||
## 13. Abandoned attempt cleanup
|
||||
|
||||
`scenario_attempts.status = 'abandoned'` (already a valid value since Phase 3B, never
|
||||
previously set) now gets set on: environment destroy/reset (student- or instructor-triggered),
|
||||
logout, session expiry (piggybacked on the existing hourly session sweeper — no new background
|
||||
job), and switching to a different scenario while one is still open. Never a punitive marker —
|
||||
`result` stays NULL, history is preserved, instructors see an honest "abandoned (reason)"
|
||||
instead of a phantom "still in progress."
|
||||
|
||||
## 14. Gateway session cache
|
||||
|
||||
Reviewed — already a tight 2-second TTL since Phase 3A, specifically to bound the
|
||||
revocation-propagation window. Decision: **keep it, don't add push-based invalidation** — the
|
||||
added coupling (Platform API would need to know how to reach the Gateway) isn't justified by
|
||||
shrinking an already-2-second window further. Documented reasoning, not a silently-accepted gap.
|
||||
|
||||
## 15. Rate limiting
|
||||
|
||||
Login rate limiting moved from an in-memory `Map` (reset on every restart, not shareable across
|
||||
instances) to Postgres (`login_attempts` table, `migrations/016_login_attempts.sql`) —
|
||||
`platform/api/src/security.js`'s `tooManyAttempts()`. No Redis added. Restart behavior
|
||||
confirmed for real: `platform/test/e2e-phase3a.js`'s rate-limiting test passes after a genuine
|
||||
Platform API restart in the same run.
|
||||
|
||||
## 16. Pagination
|
||||
|
||||
Real server-side pagination (`platform/api/src/pagination.js`) on: the audit log (was a flat
|
||||
`LIMIT 200`), student attempt history (was `LIMIT 100`), and assessment session history (was
|
||||
**fully unbounded** — the one genuine growth risk found). Scenario analytics was deliberately
|
||||
left as-is — grouped by scenario id, naturally bounded around ~100 rows, no real growth risk to
|
||||
paginate against. Frontend (`AuditView.jsx`, `AssessmentsView.jsx`, `StudentsView.jsx`) updated
|
||||
to match; `frontend` build confirmed clean.
|
||||
|
||||
## 17. Needs-attention configuration
|
||||
|
||||
`NEEDS_ATTENTION_FAIL_THRESHOLD`/`NEEDS_ATTENTION_STALE_DAYS` moved from hardcoded constants to
|
||||
env-var-configurable (matching the existing session-TTL pattern), sensible defaults unchanged.
|
||||
|
||||
## 18. Assessment authoring UX
|
||||
|
||||
Raw-JSON `pool_spec` textarea replaced with a structured editor
|
||||
(`frontend/src/admin/AssessmentsView.jsx`'s `PoolSpecEditor`): required scenarios and random
|
||||
pools picked from a real scenario catalog (`GET /api/admin/scenario-catalog`, new), grouped by
|
||||
curriculum stage, via native multi-select — no new UI framework. An owner-only "advanced" raw
|
||||
JSON view remains for anything the structured editor doesn't cover.
|
||||
|
||||
## 19. Cohort role-in-cohort
|
||||
|
||||
`cohort_instructors.role_in_cohort` existed since Phase 2B but was never read. Implemented
|
||||
minimally: `teacher` (unchanged full capability) vs `viewer` (read-only — dashboards/analytics
|
||||
still visible, but Prepare/Destroy Labs and provisioning a student's environment are now 403'd
|
||||
server-side, `rbac.js`'s `requireCohortWriteAccess`/`requireStudentCohortWriteAccess`).
|
||||
|
||||
## 20. Backup / restore destructive drill
|
||||
|
||||
`infra/backup/test-destructive-restore-drill.sh` — real owner/admin/cohort/3-students/progress/
|
||||
attempts/assessment/achievements seeded through the real Platform API, encrypted-backed-up, the
|
||||
disposable Postgres container **genuinely destroyed** (not just restored into a second
|
||||
container), a fresh Postgres created from nothing, restored, and the real Platform API booted
|
||||
again against the restored database and used to log in as the restored admin over HTTP. All
|
||||
data verified present; never touches the developer's real database.
|
||||
|
||||
## 21. Lab Host rebuild drill
|
||||
|
||||
`infra/scripts/drills/test-lab-host-loss.js` — a student's Lab container is destroyed out from
|
||||
under the Provisioner (no clean destroy call, simulating total Lab Host loss). Proves: Platform
|
||||
stays up throughout, the stale environment is never presented as healthy once the Provisioner's
|
||||
real `reconcileOnStartup` reconciliation runs, academic progress is untouched, and the student
|
||||
can launch a genuinely new Lab and continue.
|
||||
|
||||
## 22. Deployment rollback test
|
||||
|
||||
`infra/scripts/drills/test-deployment-rollback.sh` — builds a known-good and a deliberately
|
||||
broken gateway image, "deploys" each via the real deterministic-tag mechanism, proves the smoke
|
||||
test catches the regression, computes the rollback target with the exact same
|
||||
`deploy-history.log` logic `rollback-production.sh` uses, and proves the rollback recovers.
|
||||
Documents explicitly that image rollback never implies a DB migration rollback.
|
||||
|
||||
## 23. CI/CD failure tests
|
||||
|
||||
`infra/scripts/drills/test-ci-fails-closed.sh` — proves all six required failure modes (test/
|
||||
syntax failure, frontend build failure, image build failure, config validation failure,
|
||||
migration preflight failure, staging smoke test failure) genuinely exit nonzero. **Found and
|
||||
fixed two real pre-existing bugs**: `staging-smoke-test.sh` and `production-smoke-test.sh` both
|
||||
had a classic `set -e` gotcha (`curl ... && echo "OK"` as one statement — a failing command
|
||||
that isn't the last in an `&&` chain doesn't trigger `-e`) that let an unreachable/broken
|
||||
endpoint fall through silently instead of failing the smoke test. Fixed in both files.
|
||||
|
||||
## 24. Security scanning
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml`'s new `security-scan` job — npm audit, gitleaks
|
||||
(secrets), hadolint (Dockerfiles), shellcheck (shell scripts), trivy (container image CVEs).
|
||||
All free/open-source. Severity policy documented in `docs/SECURITY_SCANNING.md`: CRITICAL/HIGH
|
||||
blocks the build, MEDIUM/LOW is reported only. `security-scan` gates `build-images` the same
|
||||
way `regression` does.
|
||||
|
||||
## 25. Version / release visibility
|
||||
|
||||
`GET /api/version` (Platform API) — app version (`package.json`), deployed Git SHA
|
||||
(`GIT_SHA` env var, set to the same value as the image tag in the production compose file), and
|
||||
the most recently applied migration as a schema-version stand-in. Unauthenticated, minimal, no
|
||||
infrastructure detail.
|
||||
|
||||
## 26. Admission control failure mode
|
||||
|
||||
`resource-policy.yaml`'s new (disabled-by-default) `admission_control.max_concurrent_labs` —
|
||||
when set, the Provisioner checks it BEFORE touching Docker at all, so a full host fails cleanly
|
||||
(503, `CAPACITY_EXCEEDED`) instead of failing mid-provision. Relayed by the Platform API as a
|
||||
clean "Lab capacity is temporarily full. Try again shortly." — never a raw 500. A new
|
||||
`GET /capacity` Provisioner endpoint feeds the instructor dashboard's capacity display.
|
||||
|
||||
## 27. Fake cohort dress rehearsal
|
||||
|
||||
`infra/scripts/drills/test-fake-cohort-rehearsal.js` — one continuous narrative: instructor
|
||||
login, cohort + 4 students, curriculum assignment, Prepare Labs, students working different
|
||||
scenarios, hints, a deliberate failed validation, instructor analytics, a Lab reset, a Lab
|
||||
reaped (same consequence as the real idle reaper — see the drill's own comment on why the real
|
||||
multi-minute timer isn't waited out), the student returning and continuing, an exam
|
||||
assigned/taken, and end-of-class Lab destruction — progress verified intact after every
|
||||
destructive step. 14/14 passing.
|
||||
|
||||
## 28. Documentation
|
||||
|
||||
This file, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`, `docs/SECURITY_SCANNING.md` (new).
|
||||
Updated: `docs/LAB_ARCHITECTURE.md`, `docs/PRODUCTION_NETWORK_SECURITY.md`,
|
||||
`docs/PRODUCTION_DEPLOYMENT.md`, `docs/PRODUCTION_VALIDATION_CHECKLIST.md`, `docs/BACKUP_DR.md`,
|
||||
`docs/ENGINEERING_SOP.md`, `README.md`, `platform/README.md`.
|
||||
|
||||
## 29. Regression
|
||||
|
||||
Re-ran the full existing suite after every application-code change in this phase:
|
||||
`e2e-phase2a.js` (10/10), `e2e-phase2b.js` (12/12), `e2e-phase3a.js` (21/21 — including a real
|
||||
audit-log pagination-shape fix and confirming rate-limit persistence across a genuine restart),
|
||||
`e2e-phase3b.js` (24/24). Frontend production build clean. Real Lab image builds/boots (both
|
||||
dev-compose and standalone single-container). `tofu validate`, `ansible-playbook
|
||||
--syntax-check`, production Compose config (both platform and lab-host), `caddy validate`,
|
||||
`promtool`/`amtool`, and the backup/restore mechanism all re-validated clean after the
|
||||
infrastructure changes in this phase (new `lab-router` service, new firewall rule, new Ansible
|
||||
tasks). No knowingly-unexplained regression was left in this codebase at the end of this phase.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT build
|
||||
|
||||
Production deployment itself, Hetzner/Proxmox installation, public course signup, payments,
|
||||
subscriptions, parent portal, marketing site, full LMS, video hosting, email automation,
|
||||
certificates, course marketplace, mobile app, a multi-host scheduler, an AI tutor, invasive
|
||||
proctoring, held scenario content, student email/password authentication (documented as the one
|
||||
real gap for self-paced delivery, not built). All explicit non-goals per this phase's
|
||||
authorization.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Phase 4 — Production Infrastructure Package
|
||||
|
||||
**Status: PHASE 4 BUILD COMPLETE. PRODUCTION DEPLOYMENT PENDING HARDWARE.**
|
||||
|
||||
The dedicated Hetzner server this is designed for hasn't been purchased yet. Everything in this
|
||||
document and the `infra/` tree it describes is buildable, testable (against local
|
||||
Docker/Postgres/disposable certs), and reviewable **now** — none of it requires the real
|
||||
hardware to exist. What genuinely can't happen until the hardware exists is called out
|
||||
explicitly throughout, never silently assumed to already work.
|
||||
|
||||
This document is the map. Detail docs: `docs/PRODUCTION_DEPLOYMENT.md` (step-by-step),
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md` (network/firewall/TLS design), `docs/BACKUP_DR.md`
|
||||
(backup/restore/disaster recovery), `docs/OBSERVABILITY.md` (metrics/logs/alerts),
|
||||
`docs/CAPACITY_PLANNING.md` (sizing model), and the `docs/RUNBOOK_*.md` files (14 topics,
|
||||
command-oriented).
|
||||
|
||||
## 1. What was built
|
||||
|
||||
```
|
||||
infra/
|
||||
├── proxmox/ manual bare-metal install docs + cloud-init template definition
|
||||
├── terraform/ VM creation (rn-platform-01, rn-lab-host-01) — real `tofu validate` pass
|
||||
├── ansible/ all host configuration — real `ansible-playbook --syntax-check` pass
|
||||
│ ├── roles/ common, firewall, docker-tls, platform, lab-host, monitoring-agent, backup
|
||||
│ ├── group_vars/ production.yml, staging.yml, vault template
|
||||
│ └── site.yml top-level playbook, correct role ordering
|
||||
├── docker/ production Compose files, Caddyfile, Postgres tuning — real `docker
|
||||
│ compose config` + `caddy validate` pass
|
||||
├── firewall/ iptables-restore rulesets for all 3 hosts — structural lint pass
|
||||
├── monitoring/ Prometheus/Alertmanager/Loki/Promtail/Grafana config — real `promtool`
|
||||
│ + `amtool` validation pass
|
||||
├── backup/ Postgres backup/restore/verify scripts — REAL end-to-end test against
|
||||
│ disposable local Postgres, including the actual production migrations
|
||||
├── scripts/ deploy/rollback/smoke-test/CI/loadtest/capacity-model tooling
|
||||
├── ci/ Gitea Actions workflow (build/test/stage automatic, prod manual)
|
||||
└── environments/ dev/staging/production config templates, no secrets committed
|
||||
```
|
||||
|
||||
18 new docs (this file + 5 detail docs + 12 runbooks), 4 updated (`LAB_ARCHITECTURE.md`,
|
||||
`ENGINEERING_SOP.md`, `README.md`, `platform/README.md`).
|
||||
|
||||
## 2. Architecture (unchanged from the design, now codified)
|
||||
|
||||
```
|
||||
Hetzner Dedicated Server
|
||||
└── Proxmox VE
|
||||
├── rn-platform-01 (frontend, Platform API, Gateway, Provisioner, Postgres,
|
||||
│ Prometheus/Alertmanager/Grafana/Loki, backup cron, Caddy)
|
||||
└── rn-lab-host-01 (Docker Engine w/ TLS, egress proxy, disposable student Labs,
|
||||
node-exporter/cadvisor/promtail)
|
||||
```
|
||||
|
||||
The Platform VM / Lab Host VM security boundary from `docs/LAB_ARCHITECTURE.md` is the one
|
||||
thing this phase was explicitly told never to weaken — it's codified, not just described, in
|
||||
`infra/firewall/` and `infra/ansible/roles/docker-tls/`.
|
||||
|
||||
## 3. VM specifications
|
||||
|
||||
| | rn-platform-01 | rn-lab-host-01 |
|
||||
|---|---|---|
|
||||
| Baseline (default in `infra/terraform/variables.tf`) | 4 cores, 16 GB RAM, 100 GB disk | 24 cores, 96 GB RAM, 500 GB disk |
|
||||
| Recommended (once real host is ~16c/32t/128GB) | same — Postgres/API/Gateway/Provisioner don't need much | majority of remaining host resources |
|
||||
| Max-growth | scale RAM with Postgres `shared_buffers` if instructor/cohort count grows a lot | scale with measured concurrent-Lab capacity (`docs/CAPACITY_PLANNING.md`) |
|
||||
| Configurable via | `terraform.tfvars`' `platform_vm`/`lab_host_vm` objects | same |
|
||||
|
||||
NUMA/CPU pinning: not configured — not useful at this scale (a handful of VMs on one physical
|
||||
host, not a dozen contending for cache locality). Revisit only if real load testing shows a
|
||||
reason to.
|
||||
|
||||
## 4. Storage decision framework
|
||||
|
||||
Not a final answer — `infra/environments/production/provisioner-config/README.md` and this
|
||||
section exist so the final ZFS layout is a deployment-time decision made against real disks,
|
||||
not guessed now. Candidate layouts (`infra/terraform/variables.tf`'s `vm_disk_datastore` names
|
||||
whichever one gets chosen):
|
||||
|
||||
- **4× NVMe**: mirrored vdevs (RAID10-equivalent) — two mirror pairs, one for
|
||||
Postgres/platform data (I/O-sensitive, small), one for Lab Host disposable data
|
||||
(throughput-sensitive, large, never needs redundancy since nothing on it is precious).
|
||||
- **2× SSD/NVMe**: one mirrored vdev, logical dataset separation (ZFS datasets, not separate
|
||||
pools) between platform (`{{ rootnode_data_root }}/postgres`, `/backups`) and Lab Host data.
|
||||
- **Mixed SSD/HDD**: only if the purchased server's actual disks require it — HDD for
|
||||
disposable Lab scratch space is acceptable (it's disposable), never for Postgres.
|
||||
|
||||
**Never single-disk for Postgres data**, in any layout — a mirror is the floor, not a nice-to-have.
|
||||
|
||||
Pre-deployment disk inspection checklist (model, size, SMART, power-on hours, wear/endurance,
|
||||
sector errors, temperature, firmware): `docs/RUNBOOK_DISK_FAILURE.md`'s inspection section —
|
||||
run once, against the real drives, before committing to a layout.
|
||||
|
||||
## 5-8. Network, firewall, Docker TLS, public ingress
|
||||
|
||||
Full detail: `docs/PRODUCTION_NETWORK_SECURITY.md`. Summary: two private VM networks, Docker TLS
|
||||
(mutual, already application-supported since Phase 1 — this phase only needed the certificate
|
||||
generation/deployment automation, not new app code), a single public origin (Caddy, automatic
|
||||
HTTPS) proxying only to the Gateway, and one genuinely new architectural item this phase
|
||||
surfaced and addressed at the network-policy layer: **Gateway-to-Lab reachability across two
|
||||
real VMs** — see that doc's dedicated section for what's built vs. what still needs a small,
|
||||
reviewed Provisioner/Gateway code change before real go-live.
|
||||
|
||||
## 9-10. Production auth/cookie config, secrets
|
||||
|
||||
`infra/environments/production/*.env.template` set `COOKIE_SECURE=1`/`TRUST_PROXY=1` explicitly
|
||||
(dev's unsafe defaults are never inherited). Secrets: Ansible Vault (already bundled with
|
||||
Ansible — no new tool to install/trust for this project's secret surface), template at
|
||||
`infra/ansible/group_vars/production.vault.yml.template`. Nothing is committed —
|
||||
`infra/.gitignore` covers `*.env`, TLS private key material, vault plaintext, Terraform state.
|
||||
|
||||
## 11-13. Postgres, backup, DR
|
||||
|
||||
Full detail: `docs/BACKUP_DR.md`. Summary: conservative tuning baseline (not hand-tuned before
|
||||
real RAM is known), daily encrypted backups with an off-host adapter point (Storage Box/S3/any
|
||||
SSH target), and a **real, tested** backup→encrypt→restore→verify cycle
|
||||
(`infra/backup/test-backup-restore.sh`) — run against disposable local Postgres with the actual
|
||||
production migrations applied, not a mock.
|
||||
|
||||
## 14-17. Observability, logging, alerting, uptime
|
||||
|
||||
Full detail: `docs/OBSERVABILITY.md`. Prometheus + Grafana + Loki/Promtail (the user's stated
|
||||
Prometheus/Grafana preference, honored) + Alertmanager, all self-hosted, no paid dependency.
|
||||
Deliberately does not duplicate the application's own instructor learning analytics
|
||||
(Phase 3B) — operational observability and learning analytics stay separate concerns with
|
||||
separate audiences, per the explicit instruction. Application-level metrics (active
|
||||
students/Labs, provisioning failures, Gateway error rate) are a documented, **not-yet-built**
|
||||
follow-up — see that doc's honest accounting of what's real today (host/container/Postgres
|
||||
metrics, HTTP healthz probes) vs. what needs app instrumentation later.
|
||||
|
||||
## 18-19. CI/CD, image strategy
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml` — automatic test/build/tag/staging-deploy,
|
||||
**manual** production promotion (`infra/scripts/deploy-production.sh`), matching the stated
|
||||
preference. SHA-tagged images always, semantic tags on deliberate releases, never `:latest` in
|
||||
staging/production. Full detail: `infra/docker/IMAGE_STRATEGY.md`.
|
||||
|
||||
## 20-21. Ansible, Terraform
|
||||
|
||||
`infra/ansible/site.yml` — idempotent roles, doesn't assume the VMs already exist (Terraform
|
||||
creates them first). Real `ansible-playbook --syntax-check` pass. `infra/terraform/` — real
|
||||
`tofu validate` pass against the actual `bpg/proxmox` provider schema. Neither installs Proxmox
|
||||
itself or builds the base VM template — both stay deliberate manual, documented, one-time steps
|
||||
(`infra/proxmox/README.md`), per the explicit "don't over-automate the fragile one-time step"
|
||||
instruction.
|
||||
|
||||
## 22-23. Environment config, staging
|
||||
|
||||
`infra/environments/{dev,staging,production}/` — explicit separation, production never
|
||||
inherits dev defaults. Staging is designed to run in the homelab or a resource-constrained
|
||||
slice of the real server later — no second dedicated server required.
|
||||
|
||||
## 24-25. Load testing, capacity model
|
||||
|
||||
`infra/scripts/loadtest/` (real Prepare-Labs-driven boot test + `docker stats` sampling) and
|
||||
`infra/scripts/capacity-model.js` (a real, working worksheet — tested against example inputs,
|
||||
produces CPU-vs-memory-bound recommended/warning/hard-admission-control thresholds). No
|
||||
concurrency number is hard-coded as a marketing figure; the example config in
|
||||
`infra/environments/production/capacity.yml.example` is explicitly flagged as placeholder data.
|
||||
|
||||
## 26-27. Validation checklist, runbooks
|
||||
|
||||
`docs/PRODUCTION_VALIDATION_CHECKLIST.md` — one master checklist spanning hardware through
|
||||
academy-workflow verification. 12 runbooks (`docs/RUNBOOK_*.md`) — first deployment, normal
|
||||
deploy, rollback, Postgres restore, VM rebuild (both hosts), Docker TLS rotation, TLS renewal,
|
||||
owner recovery, stuck Lab cleanup, failed migration, backup failure, disk failure, Lab Host
|
||||
resource pressure.
|
||||
|
||||
## What this phase deliberately did NOT touch
|
||||
|
||||
Learning system design, curriculum content, RBAC model, session architecture — all Phase 3A/3B,
|
||||
unchanged. No parent portal, billing, held scenarios, or production OIDC provider integration.
|
||||
RootNode production infrastructure is dedicated to RootNode Academy — never folded into
|
||||
existing Synthos production servers, and canonical source stays Gitea, never GitHub `kubekosh`.
|
||||
|
||||
## Known gap surfaced during this phase (not silently papered over) — RESOLVED in Phase 4.1
|
||||
|
||||
Building the real production Compose/network config surfaced that the Gateway's existing
|
||||
mechanism for reaching a Lab container (`env.internal_ip`, a Docker-bridge-local address) only
|
||||
works when Gateway and Lab share a Docker daemon — true in every dev/single-host setup so far,
|
||||
**not** true once Labs run on a genuinely separate `rn-lab-host-01`. At the time this phase
|
||||
closed, the network-policy half was built but the application half was flagged as an explicit,
|
||||
not-yet-built follow-up rather than guessed at mid-infra-phase.
|
||||
|
||||
**Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) built and proved the application
|
||||
half** — a Lab Host Router (`platform/lab-router/`) plus a `labTarget()` routing abstraction in
|
||||
the Gateway, real-tested against a genuine two-network Docker simulation
|
||||
(`infra/scripts/simulate-two-host/`), not just designed. See
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "Gateway-to-Lab reachability" section for the full
|
||||
resolution. This item no longer blocks real two-VM go-live.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Phase 5A — Student Identity & Remote Classroom
|
||||
|
||||
**Status: PHASE 5A COMPLETE. STUDENT IDENTITY COMPLETE. REMOTE CLASSROOM FOUNDATION COMPLETE.**
|
||||
|
||||
Phase 4.1 closed the last pre-production infrastructure gap without real hardware. Phase 5A's
|
||||
job — still without real hardware — was the last real *product* gap flagged along the way: pilot
|
||||
students identify themselves by picking a name from a list, and there was no way for an
|
||||
instructor to run a live, remote-capable class through the platform itself. This phase replaces
|
||||
pilot identification with real accounts and adds a remote classroom control center. It does
|
||||
**not** add video, voice, chat, or any A/V — those stay Meet/Zoom/Teams's job; RootNode owns
|
||||
identity, presence, curriculum guidance, Lab status, help requests, and instructor control.
|
||||
|
||||
## 1. Student identity model
|
||||
|
||||
`students` (migration `017_student_credentials.sql`) gained `email`, `login_identifier`,
|
||||
`password_hash`, `last_login_at`, and a single credential-token triple
|
||||
(`credential_token_hash`/`_purpose`/`_expires_at`) — all nullable, all additive. `email` and
|
||||
`login_identifier` are independently unique (partial unique indexes, `WHERE ... IS NOT NULL`)
|
||||
and both optional: an older/self-paced student gets an email, a younger instructor-managed
|
||||
student gets an instructor-assigned login ID, neither is required, and nothing here blocks a
|
||||
future `guardian_id` FK for a Parent Portal — deliberately not built this phase (§37).
|
||||
|
||||
A student with **neither** field set is unchanged from before Phase 5A: a plain pilot row,
|
||||
identified with `POST /api/login { student_id }`. Setting either field (via account creation or
|
||||
`/convert`) is what makes an account "real." `password_hash` reuses `platform/api/src/auth/
|
||||
password.js` (scrypt, `salt:hash`) verbatim — the same function instructors use, no second
|
||||
hashing scheme.
|
||||
|
||||
## 2. Credential lifecycle — one token mechanism, not two
|
||||
|
||||
Activation ("first login") and password reset are the same problem: a time-limited, single-use
|
||||
secret that lets someone set a new password. `students-auth.js`'s `issueCredentialToken`/
|
||||
`consumeCredentialToken` implement it once (SHA-256-hashed at rest, plaintext returned exactly
|
||||
once in the API response, never logged), with `purpose` (`activation` | `reset`) distinguishing
|
||||
the two callers. Activation tokens live 7 days, reset tokens 1 hour. `consumeCredentialToken` is
|
||||
atomic: token verification, password hashing, `password_hash` write, and token-clearing happen
|
||||
in one statement — there is no window where a token is "spent" but the password isn't set.
|
||||
|
||||
Instructors never see or choose a student's password (§5's "instructor creates account →
|
||||
temporary credential" flow): `POST /api/admin/students`, `/convert`, and `/reset-password` all
|
||||
return a token (activation or reset), which the instructor relays out of band (printed, read
|
||||
aloud, handed over). The admin UI surfaces the resulting `?activate=<token>`/`?reset=<token>`
|
||||
link once and never again — there is no "view credentials" screen anywhere.
|
||||
|
||||
## 3. Password reset without email delivery
|
||||
|
||||
No email-sending infrastructure exists yet, and this phase deliberately doesn't fake one.
|
||||
`POST /api/student/request-password-reset` always returns an identical generic response
|
||||
regardless of whether the identifier exists (anti-enumeration) and never echoes the token in
|
||||
production. In non-production (`isProduction` false, i.e. `COOKIE_SECURE !== '1'`) it also
|
||||
returns `dev_reset_token` so local/CI flows can complete the loop without a real inbox — a
|
||||
documented dev/test path, not a production feature. The realistic production path today is
|
||||
instructor-triggered: `POST /api/admin/students/:id/reset-password`. Real email delivery is a
|
||||
named, not-yet-built follow-up, not something silently promised.
|
||||
|
||||
## 4. Session and login security — reused, not duplicated
|
||||
|
||||
Students and instructors share the exact same `sessions` table and mechanism
|
||||
(`sessions.js`: opaque tokens, HttpOnly, SameSite, hashed at rest, revocable), differentiated
|
||||
only by `session_type`/`student_id` vs `instructor_id`. `studentSession` middleware now
|
||||
re-verifies `status = 'active'` on **every** request (previously only checked session
|
||||
existence) — deactivating a student takes effect immediately, matching `instructorSession`'s
|
||||
existing behavior, not just at next login (verified in `e2e-phase5a.js`).
|
||||
|
||||
Rate limiting reuses Phase 4.1's Postgres-backed `tooManyAttempts` (`security.js`) with a
|
||||
`student:` key prefix — 8 attempts / 5 minutes, survives a Platform API restart because it's a
|
||||
table, not an in-memory Map (verified with a real restart in both `e2e-phase5a.js` and the
|
||||
rehearsal drill). CSRF reuses the existing double-submit cookie pattern (`rn_csrf_s`).
|
||||
|
||||
**A real bug this closed:** the legacy `POST /api/login` (bare `student_id`, no password) only
|
||||
checked `password_hash` to decide whether an account had "moved on" from pilot mode. An account
|
||||
mid-conversion — instructor has issued an activation token, student hasn't set a password yet —
|
||||
has `credential_token_hash` set but `password_hash` still `NULL`, and was reachable through the
|
||||
old insecure path for that whole window. Found via testing, fixed by checking
|
||||
`student.password_hash || student.credential_token_hash` instead (`server.js`).
|
||||
|
||||
## 5. Pilot migration
|
||||
|
||||
No destructive migration. `017_student_credentials.sql` only adds nullable columns — every
|
||||
existing pilot row, its progress, attempts, achievements, and cohort membership are completely
|
||||
untouched at migration time. `POST /api/admin/students/:id/convert` assigns email/login_
|
||||
identifier and issues an activation token without touching `student_id` (the FK every other
|
||||
table hangs off of) or anything downstream of it. Verified directly, not just asserted: the
|
||||
Phase 5A rehearsal drill (`infra/scripts/drills/test-phase5a-rehearsal.js`) seeds real pre-
|
||||
existing progress/attempt/achievement rows for a pilot student, converts them, activates them,
|
||||
logs in the new way, and confirms `/api/progress` shows the exact same completed scenario —
|
||||
nothing orphaned, nothing re-created.
|
||||
|
||||
## 6. Remote classroom — a fourth session concept
|
||||
|
||||
Distinct from cohort membership, assessment sessions, and Lab environments: a scheduled,
|
||||
bounded teaching event. `018_classroom_sessions.sql` adds `classroom_sessions` (lifecycle
|
||||
`scheduled → live → ended`, or `cancelled`), `classroom_participants`, `classroom_events`
|
||||
(append-only audit), `classroom_announcements`, and `help_requests`. A partial unique index
|
||||
(`WHERE status = 'live'`) enforces at most one live classroom per cohort at the database level —
|
||||
the app layer catches the resulting `23505` and returns a clean `ALREADY_LIVE` (409), not a
|
||||
raw constraint error (verified in `e2e-phase5a.js`).
|
||||
|
||||
## 7. Presence — derived, not stored
|
||||
|
||||
`classroom.derivePresence(lastSeenAt, leftAt)` is a pure function computing `online` /
|
||||
`recently_active` / `offline` from a timestamp at read time — there's no status column that can
|
||||
go stale or need a background job to sweep. Heartbeats (`POST /api/classroom/:id/heartbeat`,
|
||||
client fires every 20-30s while the Classroom tab is open and joined) are throttled server-side
|
||||
(`WHERE last_seen_at IS NULL OR last_seen_at < now() - interval`), never depend on Lab terminal
|
||||
activity, and stop the moment the tab is closed or the student leaves — no invasive tracking.
|
||||
|
||||
## 8. The classroom dashboard — real data, not a second state system
|
||||
|
||||
`GET /api/admin/classroom-sessions/:id` is the one place that reaches beyond `classroom.js`: it
|
||||
joins participant/presence data with the Provisioner's actual `environments` state (same enum
|
||||
every other Lab-status view uses — `none`/`requested`/`provisioning`/`running`/`resetting`/
|
||||
`error`/`destroying`/`destroyed`) and a lightweight academic snapshot from the existing
|
||||
`progress`/`scenario_attempts` tables (current stage, most recent scenario, validation-fail
|
||||
count, hints used). Never inspects terminal command history. Nothing here is a second Lab-state
|
||||
or academic-state system — it's the existing ones, read the same way every other admin view
|
||||
already reads them.
|
||||
|
||||
## 9. Help requests — a queue, not a chat
|
||||
|
||||
`POST /api/classroom/:id/help-request { category, message }` → `open` → (instructor)
|
||||
`acknowledge` → `resolve`. That's the entire surface — explicitly no chat, no video, no remote
|
||||
shell takeover (§37). The instructor dashboard's help queue polls the same dashboard endpoint
|
||||
everything else does.
|
||||
|
||||
## 10. Guidance, never control
|
||||
|
||||
`setCurrentActivity` (current scenario) and announcements are exactly that — a link the student
|
||||
can choose to open, a message they can read. Nothing server-side redirects a student's browser
|
||||
or marks work complete on their behalf. The external meeting URL (`classroom_sessions.
|
||||
meeting_url`) is a generic field the student sees as a link — RootNode never proxies or embeds
|
||||
Meet/Zoom/Teams/anything else, so it isn't coupled to a single vendor.
|
||||
|
||||
## 11. CSV roster import — whole-file, transactional
|
||||
|
||||
`student-csv.js` hand-parses (no CSV dependency — the file format here is small and simple
|
||||
enough that pulling in a library would just be one more thing to audit) and validates the
|
||||
**entire** file before any database write. `POST /api/admin/students/import` then does every
|
||||
insert inside one Postgres transaction — one bad row rolls back the whole import rather than
|
||||
leaving a half-imported roster, which is the more dangerous failure mode of the two. Never
|
||||
accepts a `password`/`password_hash`/`credential_token_hash` column — activation tokens are
|
||||
always generated server-side, never taken from input.
|
||||
|
||||
## 12. Observability
|
||||
|
||||
`GET /metrics` is a hand-rolled Prometheus text-exposition endpoint (no `prom-client`
|
||||
dependency — a handful of counters/gauges don't need one) with real in-process counters for
|
||||
login success/failure and on-demand gauges for live-classroom/help-request/Lab-state counts.
|
||||
No student or instructor ID ever appears as a label value — every label is a bounded enum
|
||||
(status, presence, result), per the explicit no-PII/no-high-cardinality-labels instruction. The
|
||||
Prometheus scrape job for the Platform API (`infra/monitoring/prometheus/prometheus.yml.j2`),
|
||||
previously commented out, is now active.
|
||||
|
||||
## 13. Frontend
|
||||
|
||||
**Student side** (`frontend/src/platform/`): `LoginScreen` (credentialed, now the default entry
|
||||
point) with links to the legacy pilot entry and forgot-password; `SetPasswordScreen` (shared
|
||||
activate/reset UI, driven by `?activate=`/`?reset=` query params — no router library, matching
|
||||
this app's existing state-machine-per-shell convention); `ForgotPasswordScreen`; a `Classroom`
|
||||
tab (live-class banner, join, guidance-only current-activity link, own Lab status/launch reusing
|
||||
the My Learning tab's exact API, announcements, Need Help form); a `Profile` tab (identity,
|
||||
cohort, progress, achievements, active-session count, change-password).
|
||||
|
||||
**Admin side** (`frontend/src/admin/`): `StudentsView` gained credential status in the roster
|
||||
table, a create-student form (email/login-ID/cohort), convert/reset-password/revoke-sessions
|
||||
controls per student, and a bulk CSV import form. A new `ClassroomView` is the control center:
|
||||
cohort picker → schedule/list classes → a live dashboard (participants with presence/Lab-status/
|
||||
current-work, help queue with acknowledge/resolve, current-activity setter, announcement
|
||||
poster, start/end/cancel, Prepare Labs).
|
||||
|
||||
## 14. Testing — real, not asserted
|
||||
|
||||
- `platform/test/e2e-phase5a.js` (21 checks): activation single-use, password security (§32 —
|
||||
correct/wrong/unknown-identifier/deactivated/immediate-deactivation/rate-limit/rate-limit-
|
||||
survives-restart/change-password/session-revocation/forgot-password anti-enumeration/admin
|
||||
reset/CSRF), and the two-student RBAC matrix (§31 — student can't reach `/api/admin/*`,
|
||||
assigned/unassigned/viewer instructor access), plus a classroom lifecycle smoke pass.
|
||||
- `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks): 5 pilot students, real seeded
|
||||
progress/attempt/achievement history, a live classroom, help requests, announcements, **a real
|
||||
Platform API restart mid-rehearsal** proving classroom/session/presence recovery, then
|
||||
converting two students mid-rehearsal and confirming every bit of their prior history survived
|
||||
under the new login.
|
||||
- Both run against a live `docker-compose.dev.yml` stack over real HTTP, not mocks.
|
||||
- The full pre-existing regression suite (Phase 1/2A/2B/3A/3B/4.1) was re-run unmodified given
|
||||
how much shared code (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`) this phase
|
||||
touched — see the Phase 5A completion report for results.
|
||||
|
||||
## 15. What was deliberately not built
|
||||
|
||||
Per §37: video/voice conferencing, student-to-student chat, remote instructor shell takeover,
|
||||
screen recording or proctoring, public registration or course catalog, payments/Stripe, a Parent
|
||||
Portal, school/SIS integrations, real email delivery infrastructure, certificates, an AI tutor,
|
||||
a mobile app, a multi-Lab-host scheduler, and production deployment itself. Each is a plausible
|
||||
"while we're in here" addition and each was left alone on purpose — the schema and API surface
|
||||
built this phase don't block any of them later.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Phase 5B — Self-Paced Learning & Enrollment Foundation
|
||||
|
||||
**Status: PHASE 5B COMPLETE. SELF-PACED LEARNING FOUNDATION COMPLETE. WEBSITE ENROLLMENT BOUNDARY READY.**
|
||||
|
||||
Phase 5A gave students real identity and instructors a remote classroom. Phase 5B's job was
|
||||
the last piece needed for an authenticated student to be enrolled in a RootNode Academy program
|
||||
and complete it entirely on their own — plus the clean, provider-neutral boundary a *separate*
|
||||
public website/commerce system will call to create those enrollments later. This phase does
|
||||
**not** build that website or any payment processing — see §12.
|
||||
|
||||
## 1. The RootNode program ladder
|
||||
|
||||
`init_` (ages ~5-10, robotics/block coding), `compile_` (ages ~11-14, ESP32/MicroPython/IoT),
|
||||
`root_` (ages ~15-18+, Linux/containers/Kubernetes/DevOps). The existing KubeKosh-derived
|
||||
curriculum belongs to `root_`. This phase does not write init_/compile_ curriculum content — it
|
||||
makes the platform capable of holding those programs later without another schema change (the
|
||||
`programs.family` enum already accepts `init`/`compile`/`root`).
|
||||
|
||||
## 2. Programs — a first-class object, not curriculum JSON
|
||||
|
||||
`programs` (migration `019_programs.sql`) is the commercial/academic unit a student enrolls in.
|
||||
It is deliberately **not** the curriculum file itself — `programs.curriculum_path` just points at
|
||||
one (today, always `rootnode-path`, the only curriculum that exists). `program_id` is the slug
|
||||
primary key, matching `assessment_definitions`/`cohorts`' existing convention rather than a
|
||||
separate id+slug pair. `family` (`init`/`compile`/`root`) and `delivery_mode`
|
||||
(`self_paced`/`cohort`/`hybrid`) are both real CHECK-constrained enums, not free text — small,
|
||||
stable vocabularies, matching how this codebase already treats `assessment_definitions.type` and
|
||||
`hint_policy`. The phase seeds exactly one program: `root-kubernetes-foundations`, wrapping the
|
||||
existing RootNode/Kubernetes curriculum and the existing `rootnode-capstone` as its completion
|
||||
criterion — no new curriculum content invented.
|
||||
|
||||
`programs.version` bumps automatically whenever an edit changes a field an enrollment snapshots
|
||||
(`curriculum_path`, `delivery_mode`) — same versioning principle `assessment_definitions`
|
||||
already established, so editing a program later never silently changes what an already-enrolled
|
||||
student is being held to.
|
||||
|
||||
## 3. Enrollments — real academic access, separate from cohorts
|
||||
|
||||
`enrollments` (migration `020_enrollments.sql`) represents a student's access to a program:
|
||||
`pending → active → completed`, or `pending`/`active → cancelled`. All transitions are
|
||||
server-side only (`enrollment.js`) — nothing accepts a client-supplied status. `expired` is
|
||||
**derived**, not stored: `deriveEnrollmentAccess(enrollment, now)` is a pure function (matching
|
||||
Phase 5A's `classroom.js` `derivePresence` pattern) that computes it from `access_ends_at` at
|
||||
read time, so there's no background sweep and nothing to go stale.
|
||||
|
||||
A self-paced enrollment has **no cohort at all** — `cohort_enrollments` (Phase 1) is completely
|
||||
untouched. A student can hold a cohort enrollment, a self-paced enrollment, and multiple
|
||||
historical enrollments simultaneously without any of those concepts colliding; re-enrollment
|
||||
after completion/cancellation always creates a new row rather than reviving a historical one (a
|
||||
partial unique index enforces at most one `pending`/`active` enrollment per student+program at a
|
||||
time — DB-level, not app-level "check then insert").
|
||||
|
||||
## 4. Curriculum assignment — the schema Phase 4.1 flagged, now wired
|
||||
|
||||
Phase 4.1's `curriculum_assignments` table (schema-ready since Phase 2B, never read by
|
||||
anything) is **not** reused as-is — it models cohort/student curriculum assignment
|
||||
independent of any commercial concept, which is a different problem than "this enrollment in
|
||||
this program uses this curriculum." Instead, `enrollments.curriculum_path` is itself the
|
||||
snapshot: set from `programs.curriculum_path` at enrollment time, so a later edit to the
|
||||
program never changes what an existing enrollment means. `curriculum_assignments` remains
|
||||
exactly as Phase 4.1 left it — still available for a future direct per-student assignment need
|
||||
outside the program/enrollment model, not duplicated or removed.
|
||||
|
||||
The resolution chain is real: `enrollment.student_id → enrollment.program_id →
|
||||
enrollment.curriculum_path → curriculum.loadCurriculumPath() → stages → scenarios`
|
||||
(`program-status.js`'s `buildProgramStatus`). Only one curriculum path exists today, so this
|
||||
always resolves to `rootnode-path` — the extension point for a second path is
|
||||
`enrollments.curriculum_path` itself, already snapshotted per-enrollment.
|
||||
|
||||
## 5. Self-paced dashboard and resume — reused, not reinvented
|
||||
|
||||
`GET /api/student/programs` returns every enrollment a student holds (never a public catalog —
|
||||
no public course browsing exists), each annotated with real access state, progress, and a
|
||||
deterministic next activity. The heavy lifting is **entirely existing Phase 3B machinery**:
|
||||
`curriculum-rules.js`'s `computeCurriculumState` already returns `current_stage` and each
|
||||
stage's `next_scenario` — `program-status.js` just wraps that per-enrollment. "Continue
|
||||
Learning" resume is not a guess based on the last page rendered; it's the same rule-driven
|
||||
current-stage/next-scenario computation the My Learning tab has used since Phase 3B, now scoped
|
||||
per program. Destroying a Lab never touches this — `progress`/`scenario_attempts` live in
|
||||
Postgres, completely independent of any container's lifecycle (verified for real, not just
|
||||
asserted — see §9).
|
||||
|
||||
## 6. Entitlement — enforced server-side, not by hiding a button
|
||||
|
||||
`POST /api/student/programs/:enrollmentId/launch` is the only self-paced entry point into a
|
||||
Lab. It resolves the enrollment (404 if it isn't the caller's own), calls
|
||||
`deriveEnrollmentAccess`, and 403s with a `reason` (`pending_activation` / `not_yet_started` /
|
||||
`access_expired` / `cancelled`) if `can_launch` is false — **before** ever touching the
|
||||
Provisioner. A pending, future-dated, expired, or cancelled enrollment cannot provision a Lab no
|
||||
matter what the browser sends. The existing `POST /api/environment/launch` (cohort/pilot path)
|
||||
is completely unchanged — this is a new, additive gate in front of the exact same disposable-Lab
|
||||
sequence (`environment-view.js`, extracted from `server.js` so both routes share one
|
||||
implementation), not a second kind of Lab.
|
||||
|
||||
## 7. Completion — server-derived and idempotent
|
||||
|
||||
`enrollment.maybeCompleteEnrollment` checks, for every `active` enrollment whose program has a
|
||||
`completion_assessment_id`, whether a matching `capstone_completed` achievement already exists
|
||||
(the achievement `recordAssessmentAchievement` already writes when a capstone session finalizes
|
||||
— Phase 3B, unmodified). If so, it transitions the enrollment to `completed` — an `UPDATE ...
|
||||
WHERE status = 'active'`, so calling it again once already completed is a guaranteed no-op, not
|
||||
a duplicate transition or an error. This runs opportunistically on every program-status read
|
||||
(`program-status.js`), which means completion is detected on the next read after the qualifying
|
||||
event, not pushed instantly — acceptable latency for a status field, and it means there is no
|
||||
"mark complete" button anywhere in the frontend to accidentally trust.
|
||||
|
||||
## 8. Pacing and availability
|
||||
|
||||
`starts_at`/`access_ends_at` are the entire pacing model this phase builds — no drip scheduling,
|
||||
no per-lesson unlock dates. `starts_at` in the future: the enrollment is visible
|
||||
(`effective_status: 'active'`) but `can_launch: false`, reason `not_yet_started`. `access_ends_at`
|
||||
in the past: `effective_status: 'expired'`, prior progress/achievements remain fully visible,
|
||||
`can_launch: false`. Both are enforced in `deriveEnrollmentAccess`, called from the same
|
||||
server-side gate every launch goes through — never a frontend-only check.
|
||||
|
||||
## 9. Real Lab-persistence proof
|
||||
|
||||
`platform/test/e2e-phase5b.js` and `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js`
|
||||
both drive this against a real stack: launch a Lab, complete a scenario, destroy the environment
|
||||
via the admin API, relaunch, confirm identical progress and resume target; separately, kill the
|
||||
Lab container directly (bypassing the Provisioner, exactly the idle reaper's failure mode from
|
||||
the Provisioner's point of view) and restart the Provisioner to run its real
|
||||
`reconcileOnStartup` reconciliation (the same technique `infra/scripts/drills/
|
||||
test-lab-host-loss.js` established in Phase 4.1) before relaunching — progress is identical
|
||||
both times.
|
||||
|
||||
## 10. Instructor/admin management
|
||||
|
||||
`/admin` → Programs: list/create/edit/activate-deactivate (admin/owner only, matching
|
||||
`assessment_definitions`' authoring convention — every role can view), per-program enrollment
|
||||
list and manual-enroll form, per-enrollment activate/cancel. The existing student detail view
|
||||
gained an "Enrolled programs" card (status, delivery mode, progress, last activity).
|
||||
|
||||
**RBAC** (`rbac.js`'s new `requireEnrollmentAccess`): owner/admin have global management, as
|
||||
everywhere else. A plain instructor may view/manage an enrollment if they created it
|
||||
(`enrollments.created_by`) **or** the student shares one of their cohorts — never blanket
|
||||
visibility into every self-paced student, since a self-paced enrollment can have no cohort
|
||||
relationship at all. Enrollment *creation* stays admin/owner only, matching the existing
|
||||
roster-creation precedent (`POST /api/admin/students` is the same restriction).
|
||||
|
||||
## 11. The website/commerce integration boundary
|
||||
|
||||
`POST /api/integrations/enrollments` — completely separate from student/instructor session
|
||||
auth (no cookie is ever read), authenticated by a pre-shared secret header
|
||||
(`X-RootNode-Integration-Key`), the exact same trust-boundary pattern this codebase already
|
||||
uses for Gateway↔Provisioner (`X-Provisioner-Key`/`PROVISIONER_SHARED_SECRET`), not a new
|
||||
scheme. Full contract for the separate website team: `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
|
||||
Idempotent by construction: a unique index on `enrollments.external_event_id`, not an
|
||||
app-level "check then insert" — proven under real concurrency (three simultaneous identical
|
||||
requests produce exactly one enrollment, one `created: true`, two `created: false`) in both the
|
||||
permanent suite and manually during development. Student matching is create-or-match by email
|
||||
**or** login_identifier (§13's minor/no-email requirement) — reuses Phase 5A's exact
|
||||
`students-auth.js` credential-issuing path, not a second account-creation mechanism, and this is
|
||||
a trusted server-to-server caller (the website's own verified-payment backend), not a public
|
||||
signup form.
|
||||
|
||||
## 12. What was deliberately not built (§22)
|
||||
|
||||
Stripe/payment processing, checkout UI, the public website itself, public self-registration, a
|
||||
Parent Portal or full guardian accounts, email delivery infrastructure, certificates, complex
|
||||
drip scheduling, and full init_/compile_ curriculum content. Each is a plausible "while we're in
|
||||
here" addition; each was left alone on purpose. Nothing built this phase blocks any of them —
|
||||
`enrollments.external_reference`/`metadata` and the create-or-match student resolution are the
|
||||
deliberate extension points for a future guardian-purchases-for-minor flow, without inventing a
|
||||
fake guardian record now.
|
||||
|
||||
## 13. A real bug found and fixed
|
||||
|
||||
`POST /api/progress/record` predated the `wrap()` async-error-handling convention (it's a
|
||||
Phase 1/2A route, never updated). An unexpected internal error there — including Postgres
|
||||
rejecting a malformed `environment_id` — became an unhandled promise rejection that **crashed
|
||||
the entire Platform API process**, not just that one request. Found by this phase's own test
|
||||
suite. Fixed: the handler is now wrapped like every route added since Phase 3B, plus explicit
|
||||
UUID-shape validation so a bad `environment_id` is a clean 400 and never reaches Postgres as a
|
||||
type error at all. Regression-tested in `e2e-phase5b.js`.
|
||||
|
||||
## 14. Testing
|
||||
|
||||
- `platform/test/e2e-phase5b.js` (32 checks): program seed, entitlement gating
|
||||
(pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/reaper/
|
||||
recreate persistence, server-derived idempotent completion, the full integration boundary
|
||||
(auth, idempotency, concurrency, unknown/inactive program rejection, minor/no-email path, no
|
||||
card data), RBAC scoping, Phase 5A regression smoke, and a real Platform API restart.
|
||||
- `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks): one continuous
|
||||
realistic story — activation, a simulated website enrollment event, real Lab work, Lab
|
||||
destroy, a new session later, Continue Learning, a fresh Lab, full capstone completion,
|
||||
program moves to Completed, and a second uninvolved student proven unable to reach any of it.
|
||||
- Both against a live `docker-compose.dev.yml` stack, real HTTP, real k3s Lab boots.
|
||||
- Migration safety proven explicitly: a fresh Postgres taken through Phase 1-5A's migrations
|
||||
only, seeded with realistic pre-5B data (credentialed student, cohort membership, progress,
|
||||
scenario attempts, achievements, classroom history), then 5B's migrations applied — every
|
||||
prior record intact and correctly associated with the same `student_id`; a new enrollment
|
||||
correctly links to that same pre-existing student; running the migration set a second time
|
||||
applies nothing.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Phase 5C — Enrollment Operations & Communications
|
||||
|
||||
**Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.**
|
||||
**LIVE GOOGLE WORKSPACE DELIVERY VALIDATED** — real activation and password-reset emails
|
||||
delivered end to end via `[email protected]` (a Workspace alias), DKIM/SPF passing,
|
||||
inbox delivery confirmed. See §14.
|
||||
|
||||
Phase 5B built the enrollment/entitlement model and the website integration boundary. What was
|
||||
still missing for a real student: nobody ever told them. This phase closes that gap — real
|
||||
email delivery for activation, enrollment confirmation, and password reset — plus finally
|
||||
completing the production password-reset delivery Phase 5A explicitly left unfinished.
|
||||
|
||||
## 1. Architecture: provider-neutral by design
|
||||
|
||||
```
|
||||
Enrollment/Auth code (integration-routes.js, admin-routes.js, student-auth-routes.js)
|
||||
↓ (mint token if needed, then) notifications.enqueue()
|
||||
notification_events row, committed in the SAME request — no network I/O yet
|
||||
↓
|
||||
notification-worker.js (a setInterval poller, same pattern as sessions.js's sweeper)
|
||||
↓
|
||||
email/provider.js — EMAIL_PROVIDER env var picks the implementation
|
||||
↓
|
||||
email/dev-provider.js (default) OR email/gws-provider.js
|
||||
(never a real network call) (Gmail API, service account)
|
||||
```
|
||||
|
||||
No enrollment/auth code anywhere imports a concrete provider. Swapping Google Workspace for a
|
||||
different provider later means writing one new file under `platform/api/src/email/` and
|
||||
changing `EMAIL_PROVIDER` — nothing else changes.
|
||||
|
||||
## 2. Google Workspace delivery mechanism: Gmail API + service account domain-wide delegation
|
||||
|
||||
**Selected mechanism:** the Gmail API's `users.messages.send`, authenticated as a Google Cloud
|
||||
service account using Workspace domain-wide delegation to impersonate one dedicated sending
|
||||
mailbox.
|
||||
|
||||
**Why, not SMTP or a human login:**
|
||||
- **No human password, ever.** Basic SMTP auth (or an "app password") means storing credentials
|
||||
that log in as an actual person's Workspace account — anyone with that value could read that
|
||||
mailbox's mail, change its settings, or use it to send as that person. A service account has
|
||||
no login of its own and no inbox; domain-wide delegation grants it exactly one narrow OAuth
|
||||
scope (`gmail.send`) against one specific mailbox, nothing more.
|
||||
- **Least privilege.** `gmail.send` cannot read mail, cannot manage the mailbox, cannot act as
|
||||
any address other than the one configured. Compare to SMTP relay, which typically wants either
|
||||
a static allow-listed sending IP (fragile for infrastructure that isn't pinned yet) or a
|
||||
Workspace login.
|
||||
- **This is Google's own documented pattern** for "an application needs to send mail as a
|
||||
specific Workspace address" — see `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the exact
|
||||
setup steps and links.
|
||||
|
||||
**No `googleapis`/`google-auth-library` dependency.** The JWT-bearer OAuth flow (RS256-sign a
|
||||
short JWT, exchange it for an access token, POST one REST call) is ~120 lines using only Node's
|
||||
built-in `crypto` and `fetch` — consistent with this codebase's established "hand-roll before
|
||||
adding a dependency" convention (CSRF tokens, Prometheus exposition, and CSV parsing all do the
|
||||
same). See `platform/api/src/email/gws-provider.js`.
|
||||
|
||||
## 3. RootNode email identity
|
||||
|
||||
`EMAIL_FROM_NAME` / `EMAIL_FROM_ADDRESS` / `EMAIL_REPLY_TO` — all environment-configured, no
|
||||
default mailbox address baked into the code (`EMAIL_FROM_NAME` defaults to the display string
|
||||
"RootNode Academy"; `EMAIL_FROM_ADDRESS` has no default at all — sending fails closed with a
|
||||
clean `gws_not_configured` error until it's set, rather than guessing a real mailbox that
|
||||
doesn't exist in this repository's configuration). `EMAIL_FROM_ADDRESS` doubles as the Gmail API
|
||||
adapter's delegated-user (`sub`) claim — it must be a real mailbox in the Workspace domain,
|
||||
authorized for delegation (see the runbook).
|
||||
|
||||
DNS/domain requirements before any of this can send real mail in production — SPF, DKIM, DMARC,
|
||||
and Workspace's own sender-authorization step — are **entirely external to this repository**
|
||||
and are documented, not performed, in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (marked
|
||||
`REQUIRES GOOGLE WORKSPACE ADMIN ACTION`).
|
||||
|
||||
## 4. Notification model
|
||||
|
||||
`notification_events` (migration `021_notification_events.sql`): `student_id` (required — every
|
||||
notification this phase sends is about a specific student), `enrollment_id` (nullable —
|
||||
password-reset/changed notifications have none), `notification_type`
|
||||
(`activation`/`enrollment_confirmation`/`password_reset`/`password_changed`), `recipient_email`
|
||||
(a snapshot, not a live join — see §10), `status`
|
||||
(`queued`→`sending`→`sent`/`failed`, or `cancelled`, unused this phase but reserved),
|
||||
`attempt_count`/`next_attempt_at` for bounded exponential backoff, `last_error_code` (a
|
||||
classified string, never a raw stack trace), and `metadata` JSONB.
|
||||
|
||||
**Privacy note on `recipient_email`:** duplicates PII already stored elsewhere (`students.email`
|
||||
or an enrollment's `contact_email`) purely so the worker and any later delivery audit don't need
|
||||
to reconstruct "what address would this have gone to" after the student record potentially
|
||||
changes. Same sensitivity class as data already in this database — never a new exposure, and
|
||||
never returned unmasked through any admin API (`notifications.js`'s `maskEmail`/`toAdminView`
|
||||
are the only shapes any route returns).
|
||||
|
||||
## 5. The transient-plaintext-token design (read this before touching token handling)
|
||||
|
||||
Phase 5A's invariant — only a HASH of an activation/reset token is ever persisted long-term
|
||||
(`students.credential_token_hash`) — is unchanged and non-negotiable. But an activation/reset
|
||||
*email* needs the real token to build a working link, and the whole point of this phase is that
|
||||
the actual send happens later, asynchronously, in the worker — not in the request that
|
||||
originally minted the token.
|
||||
|
||||
The resolution: the triggering request mints the token exactly as Phase 5A already does
|
||||
(synchronously, in-request), and hands the plaintext to `notifications.enqueue()`, which stores
|
||||
it in that one row's `metadata.token` **only until the row reaches a terminal state**
|
||||
(`markSent`/`markFailedOrRetry` both scrub it — see their comments in `notifications.js`). This
|
||||
is a deliberate, narrow, documented exception for the row that exists specifically to deliver
|
||||
the plaintext once — not a second credential store, and never returned by any admin API
|
||||
regardless of whether it's still present (`toAdminView` never includes `metadata` at all).
|
||||
|
||||
Activation notifications from a **new enrollment** are the one case where no token is minted
|
||||
ahead of time and handed in — `notifyForEnrollment()` mints it itself, at enqueue time, because
|
||||
there's no other synchronous caller already holding one. Everywhere else (admin create/convert/
|
||||
reset-password, self-service forgot-password, admin resend), the existing Phase 5A minting call
|
||||
site is reused unchanged and its plaintext is simply also passed to `enqueue()`.
|
||||
|
||||
## 6. Activation flow
|
||||
|
||||
Reuses Phase 5A's activation token mechanism entirely — no competing invitation-token system was
|
||||
built. `issueCredentialToken` already unconditionally invalidates any previous outstanding token
|
||||
on mint (Phase 5A), which means **"resend" and "regenerate" are mechanically the same
|
||||
operation**: there is never a moment with two simultaneously valid activation links, so a second
|
||||
button that did something different would have nothing different to do. `POST /api/admin/
|
||||
students/:id/resend-activation` is the one action that covers both.
|
||||
|
||||
```
|
||||
Enrollment event/admin enrollment → student matched/created
|
||||
→ credentials already active? → enrollment_confirmation (§7)
|
||||
→ else → issueCredentialToken('activation') → notifications.enqueue()
|
||||
→ worker delivers via the configured provider
|
||||
→ student opens the link → existing Set Password flow (Phase 5A, unchanged)
|
||||
→ token consumed atomically with setting the password
|
||||
→ account active, student can log in
|
||||
```
|
||||
|
||||
## 7. Enrollment confirmation flow
|
||||
|
||||
Fires whenever a **new enrollment** is created (integration event or admin manual enroll) for a
|
||||
student who **already has credentials** (`students.password_hash IS NOT NULL`). No token, no
|
||||
link beyond "go sign in" — `notifyForEnrollment()` (shared by both creation paths) is the single
|
||||
decision point, so a website-triggered and an admin-triggered enrollment behave identically.
|
||||
|
||||
## 8. Password reset flow — now actually delivered
|
||||
|
||||
Phase 5A's `POST /api/student/request-password-reset` already minted a token and, only outside
|
||||
production, echoed it back for local/test use. Phase 5C adds exactly one thing: the same
|
||||
minted token is now also handed to `notifications.enqueue()`, so a real email goes out. Every
|
||||
other behavior is unchanged: the response is the same generic anti-enumeration message
|
||||
regardless of whether the account exists, is inactive, or was never activated (this phase
|
||||
additionally excludes never-activated accounts, a real fix — see §13); production never returns
|
||||
the token, dev/test still can. `POST /api/student/reset-password` (consuming the token) now also
|
||||
enqueues a `password_changed` security-notice email once the new password is set.
|
||||
|
||||
## 9. Password-changed / security notice
|
||||
|
||||
Fires after both `POST /api/student/change-password` (self-service) and `POST /api/student/
|
||||
reset-password` (token consumption) succeed — no token, no link, purely "this happened, if it
|
||||
wasn't you here's what to do."
|
||||
|
||||
## 10. Minor/no-email communication design
|
||||
|
||||
Phase 5A/5B's minor-safe model (a student may have `login_identifier` with no `email` at all) is
|
||||
preserved exactly. `enrollments.metadata.contact_email` (an existing JSONB column, not a new
|
||||
migration) holds an OPTIONAL "who to notify about THIS enrollment" address — a guardian's email
|
||||
collected at checkout, for example. **It is never written to `students.email`** — that field
|
||||
stays reserved for the student's own identity/login credential; a guardian is not a student.
|
||||
`enrollment.js`'s `enrollmentContactEmail()` is the one accessor with that one meaning; recipient
|
||||
resolution (`notifications.js`'s `resolveRecipient`) tries the student's own email first, falls
|
||||
back to the enrollment's contact email only for enrollment-scoped notification types, and
|
||||
correctly has no fallback at all for password-reset/changed (which require the student to
|
||||
already be able to log in themselves). This is deliberately transitional — not a guardian
|
||||
account, not a linked identity, no relationship table — and does not block a future real
|
||||
Guardian Portal from being built on top of it later.
|
||||
|
||||
## 11. Website → enrollment → communication
|
||||
|
||||
`POST /api/integrations/enrollments` (Phase 5B, unchanged) now also decides and enqueues the
|
||||
right notification, but **only when its own enrollment-creation call reports `created: true`**
|
||||
(a genuinely new enrollment, not an idempotent replay). This is the entire duplicate-webhook
|
||||
safety net for notifications: since Phase 5B's DB-level idempotency already guarantees at most
|
||||
one caller ever sees `created: true` for a given event/race, at most one notification is ever
|
||||
queued, with zero new dedup mechanism needed.
|
||||
|
||||
## 12. Retry/failure handling
|
||||
|
||||
`markFailedOrRetry` classifies every provider error as `temporary` or `permanent` (both
|
||||
providers' `send()` throw an `Error` with `.errorClass`/`.errorCode` set). Permanent failures
|
||||
(bad recipient, missing/invalid Google config) go straight to `failed` on the first attempt —
|
||||
retrying can't fix them. Temporary failures (network/provider hiccups, rate limiting) requeue
|
||||
with bounded exponential backoff (30s base, doubling, capped at 1 hour) until `MAX_ATTEMPTS` (5)
|
||||
is exhausted, then `failed` too. Never an infinite loop; every attempt is persisted
|
||||
(`attempt_count`, `last_error_code`).
|
||||
|
||||
## 13. Real bugs found and fixed
|
||||
|
||||
- `request-password-reset` would previously mint a `reset` token for a student who had **never
|
||||
been activated** (`password_hash IS NULL`) — a "reset" for an account with no password to
|
||||
reset doesn't make semantic sense, and would have sent a confusing "reset your password" email
|
||||
to someone who needs activation, not reset. Fixed: that branch now also checks
|
||||
`student.password_hash` and falls through to the same generic anti-enumeration response,
|
||||
exactly as if the account didn't exist.
|
||||
- **HTML injection in email templates.** `displayName` (which can come straight from a website
|
||||
integration event's `student.display_name` — genuinely external, caller-controlled text, not
|
||||
admin-typed) was interpolated directly into the HTML email body with no escaping. `templates.js`
|
||||
now escapes every user-controlled value before it reaches HTML (`escapeHtml`); the plain-text
|
||||
bodies are correctly left unescaped, since there's no markup there to break. Both fixes are
|
||||
regression-tested in `e2e-phase5c.js`.
|
||||
|
||||
## 14. Testing
|
||||
|
||||
- `platform/test/e2e-phase5c.js` (28 checks): activation vs enrollment-confirmation decision,
|
||||
duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry classification,
|
||||
resend/regenerate token invalidation, activation/reset single-use and expiration, production
|
||||
token-secrecy gating (a real container recreate with `COOKIE_SECURE=1`), minor/contact-email
|
||||
resolution, RBAC scoping, metrics/audit hygiene, and a real Platform API restart.
|
||||
- `infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks): one continuous
|
||||
story — website enrollment → activation email rendered and its URL extracted exactly like a
|
||||
real inbox click → password set → real Lab launched → real scenario completed → a second
|
||||
enrollment gets confirmation not activation → full password-reset round trip with session
|
||||
revocation → the minor/contact-email path.
|
||||
- Both against a live `docker-compose.dev.yml` stack, using the deterministic dev email provider
|
||||
exclusively — **no test in this phase ever makes a real network call to Google or anyone
|
||||
else.**
|
||||
- Migration safety proven explicitly against a fresh Postgres taken through every prior phase's
|
||||
migrations, seeded with realistic Phase 5B data (credentialed student, minor/no-email student,
|
||||
cohort, enrollment with a contact-email, progress, attempts, achievements, classroom history),
|
||||
then this phase's migration applied — every prior record intact; a new notification correctly
|
||||
links to the pre-existing student/enrollment; re-running the migration set is a no-op.
|
||||
|
||||
**Live Google Workspace delivery has been validated for real**, both in isolation
|
||||
(`infra/scripts/gws-email-test.js`, one message, DKIM/SPF passing, delivered) and through the
|
||||
full genuine flow: a real student created, enrolled, receiving a real activation email, setting
|
||||
their password from the real link, logging in, seeing their enrolled program, requesting and
|
||||
receiving a real password-reset email, and completing the reset — every step against the live
|
||||
Platform API, `EMAIL_PROVIDER=gws`, nothing simulated.
|
||||
|
||||
**One real Google Workspace limitation found and handled:** newly-authorized domain-wide
|
||||
delegation does not take effect instantly across all of Google's auth infrastructure — the
|
||||
identical token request succeeded, then failed with `unauthorized_client`, then succeeded again,
|
||||
over the span of about a minute, before settling. This is Google-side propagation delay, not a
|
||||
configuration error, and it self-resolved. `gws-provider.js` now classifies `unauthorized_client`
|
||||
as a *temporary* failure (retried with the existing bounded backoff) rather than permanent —
|
||||
this is exactly the failure mode that backoff exists for, and it means a real deployment doesn't
|
||||
need a human to notice and manually retry during that propagation window. See
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting section.
|
||||
|
||||
**A second real-world detail found during setup, not a bug:** the sending address
|
||||
(`[email protected]`) is a Workspace *alias* of an existing account rather than its
|
||||
own paid seat — a deliberate cost-saving choice. Domain-wide delegation authorizes a real
|
||||
account, not an arbitrary alias string, so `GOOGLE_DELEGATED_USER` (the alias's real owner) was
|
||||
added as a setting distinct from `EMAIL_FROM_ADDRESS` (the visible sending address) — see §2 and
|
||||
the runbook §5.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Production Deployment
|
||||
|
||||
Step-by-step, for when the real Hetzner host exists. See
|
||||
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` for the architecture this executes.
|
||||
|
||||
## 0. One-time, manual, not automated (deliberately)
|
||||
|
||||
1. Install Proxmox VE on the bare-metal host — `infra/proxmox/README.md`.
|
||||
2. Inspect the real disks, pick a ZFS layout — `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4,
|
||||
`docs/RUNBOOK_DISK_FAILURE.md`'s inspection checklist.
|
||||
3. Build the cloud-init VM template Terraform will clone —
|
||||
`infra/proxmox/cloud-init/user-data.yaml` + `infra/proxmox/README.md`.
|
||||
4. Fill in `infra/terraform/terraform.tfvars` (copy from `.tfvars.example`) with the real
|
||||
Proxmox API endpoint, node name, storage/datastore names, and chosen VM sizes.
|
||||
5. Fill in `infra/ansible/inventory/production.ini` with the real VM IPs, and
|
||||
`infra/ansible/group_vars/production.yml` with `platform_ip`, `lab_host_ip`,
|
||||
`management_cidr`, `proxmox_mgmt_cidr`.
|
||||
6. Copy `infra/ansible/group_vars/production.vault.yml.template` →
|
||||
`production.vault.yml`, fill in every `vault_*` secret with real generated values, then
|
||||
`ansible-vault encrypt group_vars/production.vault.yml`. Never commit the plaintext.
|
||||
|
||||
## 1. Create the VMs
|
||||
|
||||
```bash
|
||||
cd infra/terraform
|
||||
tofu init
|
||||
tofu plan # review before applying against real hardware
|
||||
tofu apply
|
||||
```
|
||||
|
||||
Creates `rn-platform-01` and `rn-lab-host-01` from the cloud-init template. Confirm both boot
|
||||
and are SSH-reachable from the management network before continuing.
|
||||
|
||||
## 2. Generate TLS certificates (offline, once)
|
||||
|
||||
```bash
|
||||
export ROOTNODE_TLS_BUNDLE_DIR=/secure/offline/path # never inside the repo
|
||||
./infra/scripts/docker-tls/generate-docker-tls.sh
|
||||
./infra/scripts/docker-tls/verify-docker-tls.sh
|
||||
```
|
||||
|
||||
Generates FOUR leaf certs under one CA (Phase 4.1 extended this from two): the CONTROL-plane
|
||||
pair (`server-*`/`provisioner-client-*`, for Provisioner↔dockerd) and the DATA-plane pair
|
||||
(`router-server-*`/`gateway-client-*`, for Gateway↔Lab Host Router — see
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "control plane vs data plane" section). The CA private
|
||||
key stays in `ROOTNODE_TLS_BUNDLE_DIR` and never touches either VM — only the CA cert (public)
|
||||
and each host's own leaf cert+key go to each host, both pairs deployed by the same `docker-tls`
|
||||
Ansible role into two separate directories (`/etc/rootnode/docker-tls/` and
|
||||
`/etc/rootnode/lab-router-tls/`).
|
||||
|
||||
## 3. Configure both hosts
|
||||
|
||||
```bash
|
||||
cd infra/ansible
|
||||
ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass
|
||||
```
|
||||
|
||||
Role order matters and is fixed in `site.yml`: `common` → `firewall` → `docker-tls` →
|
||||
`platform`/`lab-host` → `monitoring-agent` → `backup`. Firewall and TLS land before anything
|
||||
that depends on them.
|
||||
|
||||
## 4. First application deploy
|
||||
|
||||
```bash
|
||||
./infra/scripts/deploy-production.sh <image-tag>
|
||||
```
|
||||
|
||||
Pulls the tagged images, runs the compose stack, applies Postgres migrations (automatic, on
|
||||
Platform API/Provisioner startup — see `docs/RUNBOOK_FAILED_MIGRATION.md` if one fails).
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
./infra/scripts/production-smoke-test.sh
|
||||
```
|
||||
|
||||
Curls `/healthz` on the Gateway and Platform API, confirms Postgres is reachable, confirms
|
||||
Prometheus/Grafana/Alertmanager are up. Then walk
|
||||
`docs/PRODUCTION_VALIDATION_CHECKLIST.md` in full before calling the deployment live.
|
||||
|
||||
## Normal (non-first) deploys
|
||||
|
||||
`docs/RUNBOOK_NORMAL_DEPLOY.md`. Rollback: `docs/RUNBOOK_ROLLBACK.md`.
|
||||
|
||||
## What this document does NOT cover
|
||||
|
||||
DNS/Cloudflare setup for the public origin (a one-time manual step outside this repo's scope —
|
||||
point the A/AAAA record at the real host and confirm Caddy issues its certificate). Which
|
||||
storage layout to pick (decision framework only, §4 of the master doc — the real answer depends
|
||||
on the real disks).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Production Network & Security
|
||||
|
||||
The Platform VM / Lab Host VM boundary is the core production control. This document is where
|
||||
every network decision that touches it gets written down. Nothing here weakens it.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
Internet ──HTTPS(443)──▶ Caddy (rn-platform-01) ──▶ Gateway (internal bridge)
|
||||
│
|
||||
management_cidr ──SSH──▶ rn-platform-01 │
|
||||
management_cidr ──SSH──▶ rn-lab-host-01 │
|
||||
proxmox_mgmt_cidr ──22/8006──▶ Proxmox host │
|
||||
│
|
||||
rn-platform-01 (Provisioner) ──TLS:2376──▶ rn-lab-host-01 (dockerd) [CONTROL plane]
|
||||
rn-platform-01 (Gateway) ──mTLS:8443──▶ rn-lab-host-01 (Lab Host Router) [DATA plane]
|
||||
rn-lab-host-01 (promtail) ──3100──▶ rn-platform-01 (Loki)
|
||||
rn-lab-host-01 (node-exporter/cadvisor) ◀──scrape── rn-platform-01 (Prometheus)
|
||||
```
|
||||
|
||||
Firewall source of truth: `infra/firewall/{proxmox-host,rn-platform-01,rn-lab-host-01}.rules`
|
||||
(iptables-restore format, deployed by the `firewall` Ansible role). Validate with
|
||||
`infra/firewall/validate-firewall.sh`.
|
||||
|
||||
## What is explicitly denied
|
||||
|
||||
- Student Lab → another student Lab (no student-to-student traffic, ever).
|
||||
- Student Lab → Proxmox management (both the host firewall and the `lab_subnet_range` DENY
|
||||
rule in `rn-lab-host-01.rules`).
|
||||
- Student Lab → Platform Postgres / Platform API / Gateway internals (logged DENY in
|
||||
`rn-platform-01.rules` for anything from `lab_host_ip` to ports 5432/4100).
|
||||
- rn-lab-host-01 → Postgres (same rule — the Lab Host itself has no legitimate reason to reach
|
||||
Postgres; only the Provisioner on rn-platform-01 does, and it talks to Postgres over the
|
||||
internal Docker bridge, never across the VM boundary).
|
||||
- Any student egress except through the approved egress proxy (enforced by the Provisioner's
|
||||
existing per-Lab `DOCKER-USER` chain manipulation from Phase 1 — unchanged by this phase).
|
||||
- Direct public exposure of the Provisioner, Postgres, Docker TLS port, Proxmox UI, or Grafana
|
||||
(Grafana is reachable only via Caddy's `/grafana/*` path, itself restricted to
|
||||
`management_cidr` by a `caddy` matcher — see `infra/docker/platform/Caddyfile.j2`).
|
||||
|
||||
## Control plane vs data plane (Phase 4.1 §10)
|
||||
|
||||
Two separate, never-conflated mTLS connections between the two VMs, deliberately kept apart
|
||||
both conceptually and operationally:
|
||||
|
||||
| | Control plane | Data plane |
|
||||
|---|---|---|
|
||||
| What | Provisioner manages Lab containers | Gateway serves student browser traffic |
|
||||
| Path | Provisioner → dockerd (port 2376) | Gateway → Lab Host Router (port 8443) |
|
||||
| Who initiates | Provisioner only | Gateway only |
|
||||
| What it can do | Create/start/stop/remove containers, full Docker API | Reverse-proxy one HTTP/WS request to one already-running container by opaque token — nothing else |
|
||||
| Certs | `provisioner-client-*` / `server-*` | `gateway-client-*` / `router-server-*` |
|
||||
| Exposes Docker API to caller? | Yes (that's its job — Provisioner IS a Docker client) | **Never** — the Router never exposes Docker API, container IDs, or network info to its caller |
|
||||
|
||||
The student browser's traffic is proxied at the Gateway/Router layer and never touches the
|
||||
Docker API at any point — the Router deliberately has no route that echoes back anything
|
||||
Docker-internal (see `platform/lab-router/src/server.js`'s header comment for the full "narrow
|
||||
by design" list).
|
||||
|
||||
## Docker TLS (Provisioner ↔ Lab Host)
|
||||
|
||||
Mutual TLS on port 2376, restricted at the firewall to `platform_ip` only. Application support
|
||||
already existed since Phase 1 (`platform/provisioner/src/docker.js`'s `buildClient()`); this
|
||||
phase added the operational half: `infra/scripts/docker-tls/generate-docker-tls.sh` (CA +
|
||||
server + client cert generation), the `docker-tls` Ansible role (deploys certs, configures
|
||||
`dockerd`'s `daemon.json`), and `docs/RUNBOOK_DOCKER_TLS_ROTATION.md` for renewal. The CA
|
||||
private key is generated offline and never committed or deployed to either VM — only the CA
|
||||
cert (to verify the peer) and each host's own leaf cert+key.
|
||||
|
||||
## Public ingress
|
||||
|
||||
One origin, Caddy, automatic HTTPS. `reverse_proxy gateway:4200` — Caddy talks only to the
|
||||
Gateway; nothing else is proxied. WebSocket upgrade works automatically (Caddy's default
|
||||
`reverse_proxy` behavior, no special config needed for the terminal WebSocket path). Whether to
|
||||
front this with Cloudflare or expose the origin directly is left as a deployment-time decision
|
||||
(both work with Caddy's ACME flow; Cloudflare adds DDoS absorption at the cost of Cloudflare
|
||||
being able to see plaintext unless "Full (strict)" mode is used) — not decided here since it
|
||||
doesn't affect anything else in this document.
|
||||
|
||||
## Gateway-to-Lab reachability across two real VMs — RESOLVED in Phase 4.1
|
||||
|
||||
Phase 4 flagged this as the single most important open item; Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) closed it. Kept here as the historical record of
|
||||
what the gap was and how it was closed, since the reasoning matters for anyone extending this
|
||||
later (e.g. adding a second Lab Host).
|
||||
|
||||
**The gap, as originally found:** the Gateway used to reach a running Lab container via
|
||||
`env.internal_ip` — a Docker bridge-local address recorded by the Provisioner when it creates
|
||||
the container. That only works when Gateway and the Lab share a Docker daemon, true in dev and
|
||||
any single-host setup, false the moment Labs run on a genuinely separate `rn-lab-host-01`.
|
||||
|
||||
**The fix — a Lab Host Router (`platform/lab-router/`):** a small, narrow service that runs on
|
||||
rn-lab-host-01 (host-networked, like `egress-proxy`, so it can reach whatever per-Lab bridge
|
||||
network the Provisioner creates) and does exactly one thing: given an opaque routing token over
|
||||
mTLS, find the ONE local container labeled with that token (`docker ps --filter
|
||||
label=rootnode.routing-token=<token>` — resolved purely from local Docker state, no Postgres,
|
||||
no call back to the Provisioner) and reverse-proxy the request to it, HTTP and WebSocket both.
|
||||
|
||||
The Gateway now decides `direct` vs `router` per `lab_host` (`lab_hosts.routing_mode` +
|
||||
`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`) via
|
||||
`platform/gateway/src/lab-endpoint.js` — a single function, `labTarget(env, token)`, that every
|
||||
proxy/fetch call site in `server.js` goes through instead of building
|
||||
`http://${env.internal_ip}:${env.internal_port}` itself. `dev-local` stays `direct` (T480/local
|
||||
dev unaffected, zero behavior change there); a real `rn-lab-host-01` is configured `router`.
|
||||
|
||||
**Firewall (`infra/firewall/rn-lab-host-01.rules`):** the old speculative
|
||||
`lab_gateway_port_range` allowance (which assumed option (a), publishing per-Lab ports) was
|
||||
replaced with a single, narrow rule: `platform_ip` → port `{{ lab_router_port | default('8443')
|
||||
}}` only. No wide port range, no per-Lab port publishing at all — the Router is the only thing
|
||||
the Gateway ever talks to on rn-lab-host-01 for Lab traffic.
|
||||
|
||||
**mTLS:** the Gateway↔Router pair uses the SAME private CA as the Provisioner↔dockerd pair
|
||||
(`infra/scripts/docker-tls/generate-docker-tls.sh`, extended in Phase 4.1 to also issue
|
||||
`router-server`/`gateway-client` leaf certs), deployed to a separate directory
|
||||
(`/etc/rootnode/lab-router-tls/`) by the same `docker-tls` Ansible role.
|
||||
|
||||
**Proven, not just designed:** `infra/scripts/simulate-two-host/` builds a real two-network
|
||||
Docker simulation (Gateway genuinely isolated on its own bridge, no route to any Lab's bridge)
|
||||
and runs 13 real assertions against it — Gateway cannot reach a Lab's Docker-internal IP
|
||||
directly, but reaches it correctly through the Router (HTTP and a real WebSocket
|
||||
`kubectl get nodes`), Alice/Bob isolation holds, garbage/destroyed tokens are rejected, and the
|
||||
Router itself refuses any connection without the Gateway's client certificate. See that
|
||||
directory's README for exactly what is and isn't proven by a same-daemon simulation versus real
|
||||
separate hardware.
|
||||
|
||||
## Cookie / auth production settings
|
||||
|
||||
`infra/environments/production/*.env.template`: `COOKIE_SECURE=1`, `TRUST_PROXY=1`. Dev's
|
||||
insecure defaults (`COOKIE_SECURE` unset, no proxy trust) are never inherited — production env
|
||||
files are separate files, not a dev file with overrides layered on.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Production Validation Checklist
|
||||
|
||||
Walk this once, top to bottom, before calling a production deployment live. Every item here
|
||||
needs the real hardware — this is the "DEPLOY LATER" half of Phase 4, not something this repo
|
||||
can prove in advance.
|
||||
|
||||
## Hardware / Proxmox
|
||||
- [ ] Disk inspection done, layout chosen and matches what's actually in the server (`docs/RUNBOOK_DISK_FAILURE.md` inspection checklist, `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4)
|
||||
- [ ] Proxmox VE installed, reachable only from `management_cidr`/`proxmox_mgmt_cidr`
|
||||
- [ ] VM template built from real cloud-init (`infra/proxmox/README.md`)
|
||||
- [ ] `tofu apply` created both VMs; both boot and are SSH-reachable
|
||||
|
||||
## Platform VM
|
||||
- [ ] `ansible-playbook site.yml` completed clean, all roles
|
||||
- [ ] `docker compose ps` shows every service healthy (postgres, provisioner, api, gateway, reverse-proxy, prometheus, alertmanager, grafana, loki, promtail, exporters)
|
||||
- [ ] `curl https://<origin>/healthz` (via Gateway) returns 200
|
||||
- [ ] `curl http://localhost:4300/healthz` (Platform API, from the VM) returns `{"ok":true,"db":"ok"}`
|
||||
- [ ] TLS certificate issued and valid for the real domain
|
||||
|
||||
## Lab Host VM
|
||||
- [ ] Docker daemon TLS verified: `docker --tlsverify --tlscacert=... -H tcp://<lab_host_ip>:2376 info` succeeds from the Platform VM, fails from anywhere else
|
||||
- [ ] Lab Host Router mTLS verified: a request to `https://<lab_host_ip>:8443` with no client cert is rejected; with the Gateway's real client cert it isn't (mirrors `infra/scripts/simulate-two-host/test-remote-routing.js`'s "direct public access: NOT AVAILABLE" case, now against the real host)
|
||||
- [ ] Provisioner successfully creates a real Lab against the real Lab Host
|
||||
- [ ] Gateway can actually reach that Lab's terminal (HTTP UI load + WebSocket `kubectl` command) — application mechanism built and simulation-tested in Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1/§6/§7); this line is the real-hardware confirmation that the same result holds across the actual two VMs, not two Docker networks on one daemon
|
||||
- [ ] Egress proxy correctly restricts a Lab's outbound traffic (Provisioner's per-Lab `DOCKER-USER` chain)
|
||||
|
||||
## Security
|
||||
- [ ] `infra/firewall/validate-firewall.sh` run against the real hosts with real `iptables-restore --test`, not just the structural lint
|
||||
- [ ] Confirm from an unauthorized IP: Postgres, Provisioner, Docker TLS port, Proxmox UI are all unreachable
|
||||
- [ ] Confirm Student Lab → another Lab is blocked; confirm Student Lab → Proxmox is blocked
|
||||
- [ ] `COOKIE_SECURE`/`TRUST_PROXY` active (check a real Set-Cookie header over HTTPS)
|
||||
|
||||
## Academy (application-level smoke test)
|
||||
- [ ] Owner bootstrap login works
|
||||
- [ ] Instructor can create a cohort, roster a student
|
||||
- [ ] Student can log in, boot a Lab, complete a scenario, see progress
|
||||
- [ ] An assessment can be assigned, taken, and graded end-to-end
|
||||
- [ ] Instructor analytics dashboards populate with real data
|
||||
|
||||
## Backup
|
||||
- [ ] Manual `backup-postgres.sh` run succeeds against real Postgres, real off-host target reachable
|
||||
- [ ] A real restore (`restore-postgres.sh` + `verify-restore.sh`) has been rehearsed at least once against a throwaway target, not just the local test harness — Phase 4.1's `infra/backup/test-destructive-restore-drill.sh` proved the full mechanism (realistic data, real storage destruction, restore, real Platform API boot) locally; this line is the real-hardware confirmation
|
||||
- [ ] Cron job confirmed scheduled (`crontab -l`)
|
||||
|
||||
## Observability
|
||||
- [ ] Grafana dashboard shows real data for both VMs
|
||||
- [ ] Trigger one alert deliberately (e.g. stop a service) and confirm it fires and reaches the webhook
|
||||
- [ ] Loki has logs flowing from both VMs
|
||||
|
||||
## Pre-production hardening (Phase 4.1 — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md)
|
||||
- [ ] `resource-policy.yaml`'s `admission_control.max_concurrent_labs` set from a real measured
|
||||
`infra/scripts/capacity-model.js` run against the real Lab Host, not left disabled
|
||||
- [ ] A deliberate over-capacity attempt returns the clean 503 "Lab capacity is temporarily
|
||||
full" message, not a 500 — confirms the real host's admission control matches the local test
|
||||
- [ ] `docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md`'s steps still make sense once capacity numbers are real
|
||||
|
||||
## Student identity & remote classroom (Phase 5A — see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md)
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms `/api/student/
|
||||
request-password-reset` never returns `dev_reset_token` outside of dev/CI
|
||||
- [ ] A real activation/reset link (`?activate=`/`?reset=`) round-trips against the production
|
||||
domain, not just `localhost` — query-param parsing, no router library in play
|
||||
- [ ] Student login rate limiting still 429s after a restart against the real production
|
||||
Postgres, same as the local `e2e-phase5a.js` check
|
||||
- [ ] `GET /metrics` is reachable by the real Prometheus instance (`infra/monitoring/
|
||||
prometheus/prometheus.yml.j2`'s `rootnode-platform-api` job) and no student/instructor ID
|
||||
appears in any label value
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
|
||||
still make sense walked against the real admin portal
|
||||
|
||||
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
|
||||
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
|
||||
value) and is stored only in the Platform API's own server-side environment — never
|
||||
committed, never in any browser-reachable config
|
||||
- [ ] The website team's production integration key was delivered out of band (not plaintext
|
||||
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
|
||||
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
|
||||
clean `401` against the real production endpoint, not a 500 or an information leak
|
||||
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
|
||||
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
|
||||
external_reference ever appears in a real production scrape
|
||||
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
|
||||
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
|
||||
actually integrated against (update it if reality drifted during that integration)
|
||||
|
||||
## Enrollment communications / Google Workspace email (Phase 5C — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md)
|
||||
- [ ] Every step in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` marked REQUIRES GOOGLE WORKSPACE
|
||||
ADMIN ACTION has actually been completed (sending mailbox, service account, domain-wide
|
||||
delegation scoped to `gmail.send` only, SPF/DKIM/DMARC)
|
||||
- [ ] `EMAIL_PROVIDER=gws` set in the real production environment — confirm it is NOT left at
|
||||
the dev-provider default, which would silently mean no real student ever receives email
|
||||
- [ ] `GOOGLE_SERVICE_ACCOUNT_JSON`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`,
|
||||
and `PUBLIC_APP_ORIGIN` are all set from the real production secret store, not left at any
|
||||
dev/example value
|
||||
- [ ] `infra/scripts/gws-email-test.js` (opt-in, `RUN_GWS_EMAIL_TEST=true`) run once against a
|
||||
real `TEST_EMAIL_RECIPIENT` and confirmed delivered — the one and only "does this actually
|
||||
work" proof this checklist can't fake
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms the dev-preview
|
||||
notification route (`GET /api/admin/notifications/:id/dev-preview`) is unreachable and no
|
||||
activation/reset token is ever returned by any API response
|
||||
- [ ] `GET /metrics`'s notification gauges/counters contain no student_id/email/token/
|
||||
notification_id/external_event_id in any label value on a real production scrape
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACTIVATION.md`'s steps still make sense walked against the real
|
||||
admin portal, including a real (not dev-provider) activation email actually arriving
|
||||
|
||||
Only once every box above is checked against the real server does the deployment graduate from
|
||||
"infrastructure package built" to "actually in production."
|
||||
@@ -0,0 +1,97 @@
|
||||
# Remote & Self-Paced Delivery Architecture
|
||||
|
||||
Phase 4.1 §12. This is an architecture review, not a new product — no self-registration,
|
||||
payments, or public enrollment is built here (explicit non-goal). The question this document
|
||||
answers: does the infrastructure and application assume "an instructor always provisions the
|
||||
Lab," and if so, where, and what would need to change for self-paced delivery to work.
|
||||
|
||||
## The short answer: less of that assumption exists than expected
|
||||
|
||||
RootNode Academy's Lab lifecycle was built student-driven from Phase 1 — `POST
|
||||
/api/environment/launch` is called by the STUDENT'S OWN session, not by an instructor action.
|
||||
"Prepare Labs" (`POST /api/admin/cohorts/:id/prepare-labs`) is a classroom **convenience** that
|
||||
front-loads several students' launches at once before a synchronous class session, not the only
|
||||
path to a running Lab. This was true before Phase 4.1 and confirmed still true by this phase's
|
||||
own regression suites (`platform/test/e2e-phase2a.js` roster's two students with `cohort_id:
|
||||
null` and takes them through login → curriculum → launch → scenario work with zero cohort
|
||||
involvement) and by two new Phase 4.1 drills built specifically to exercise the remote/returning
|
||||
-student narrative:
|
||||
|
||||
- `infra/scripts/drills/test-lab-host-loss.js` — student launches, makes progress, their Lab is
|
||||
destroyed out from under them (standing in for an idle-reaper destroy), Platform stays up,
|
||||
progress is untouched, they launch again unprompted and continue.
|
||||
- `infra/scripts/drills/test-fake-cohort-rehearsal.js` — the same "returns later, fresh Lab,
|
||||
curriculum continues" narrative as part of a fuller classroom rehearsal.
|
||||
|
||||
Both pass today, with zero application code changes needed for the self-paced-relevant parts of
|
||||
this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
|
||||
## What already works, unmodified
|
||||
|
||||
- **Student-initiated provisioning** — no instructor step is required between "student wants to
|
||||
work" and "Lab exists." (`platform/api/src/server.js`'s `/api/environment/launch`.)
|
||||
- **Cohort-less students** — `cohort_id` is nullable on student creation
|
||||
(`POST /api/admin/students`); a student with no cohort logs in, sees the curriculum, launches
|
||||
Labs, and makes progress identically to a cohorted one. Confirmed by inspection AND by the
|
||||
existing regression suite, not just by schema reading.
|
||||
- **Idle reaper → academic state survives → return → continue** — the exact lifecycle this
|
||||
section describes as the target self-paced experience is what Phase 1's reaper +
|
||||
Phase 2B's Postgres-backed academic data (separate from the disposable Lab) were already
|
||||
designed to produce. Phase 4.1 proved it end-to-end, twice (see above).
|
||||
- **Curriculum content itself is not cohort-gated** — `GET /api/curriculum/path` reads the one
|
||||
curriculum path file directly (`curriculum.loadCurriculumPath()`); it never checks
|
||||
`curriculum_assignments` or cohort membership at all. A self-paced student sees the full
|
||||
curriculum with no instructor action required to "unlock" it.
|
||||
|
||||
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
|
||||
|
||||
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
|
||||
built the boundary a *separate* public website will call
|
||||
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
|
||||
row can now be created automatically by a trusted server-to-server enrollment event, but there
|
||||
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
|
||||
|
||||
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
|
||||
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
|
||||
`instructors`' exact `src/auth/password.js`), a first-login/reset token flow, and Postgres-
|
||||
backed rate-limited/CSRF-protected sessions on the same `sessions` table instructors use — see
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. The old bare-`student_id` `POST
|
||||
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
|
||||
an account has real credentials or a pending activation token.
|
||||
|
||||
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
|
||||
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
|
||||
the real problem it was meant to eventually solve (which curriculum a given student sees) is
|
||||
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
|
||||
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
|
||||
future direct per-student assignment need outside the program/enrollment model — it wasn't
|
||||
removed, just not the mechanism Phase 5B ended up using. See
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
|
||||
|
||||
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
|
||||
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
|
||||
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
|
||||
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
|
||||
is modeled, not infrastructure.
|
||||
|
||||
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
|
||||
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
|
||||
self-paced student dashboard.
|
||||
|
||||
6. ~~**No enrollment communication.**~~ **Closed in Phase 5C.** An enrolled student now actually
|
||||
gets told — real activation/enrollment-confirmation/password-reset email, delivered
|
||||
asynchronously through a provider-neutral notification worker (Google Workspace via the
|
||||
Gmail API in production, a deterministic dev provider everywhere else). See
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT do here
|
||||
|
||||
No signup flow, no student password/email auth implementation, no per-student curriculum
|
||||
assignment route, no payments, no public course catalog, no held/premium scenario content. All
|
||||
explicit non-goals per this phase's authorization. Student authentication (Phase 5A), the
|
||||
self-paced enrollment/entitlement model (Phase 5B), and enrollment/account-lifecycle
|
||||
communication (Phase 5C) have all since been built; a public self-registration *page*, real
|
||||
payment processing, and a Parent/Guardian portal remain deliberately out of scope — see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12 and `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Runbook: Backup Failure
|
||||
|
||||
Triggered by the `BackupFailed`/`BackupAging` alerts (`infra/monitoring/prometheus/rules/rootnode.yml`).
|
||||
|
||||
```bash
|
||||
# 1. See what actually happened — the cron wrapper logs to syslog
|
||||
journalctl -u rootnode-backup --since "24 hours ago" -o cat
|
||||
# or, if the cron job never even ran:
|
||||
grep rootnode-postgres-backup /var/log/syslog
|
||||
|
||||
# 2. Run it manually to see the failure live
|
||||
source /etc/rootnode/backup.env
|
||||
/opt/rootnode/backup/backup-postgres.sh
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
- Disk full on `{{ rootnode_data_root }}/backups` — check `df -h`, prune more aggressively
|
||||
(`BACKUP_RETENTION_DAYS`) or the ZFS dataset needs more space (see `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4).
|
||||
- Off-host target unreachable (`BACKUP_OFFSITE_TARGET`) — check the SSH key/credentials
|
||||
haven't expired, the target host/bucket is reachable: `ssh <offsite-target-host> echo ok` or
|
||||
the equivalent for whatever adapter is configured (`docs/BACKUP_DR.md`).
|
||||
- `BACKUP_ENCRYPTION_PASSPHRASE` missing/wrong in `/etc/rootnode/backup.env` — re-check against
|
||||
the value in Ansible Vault (`vault_backup_encryption_passphrase`); a mismatch here means even
|
||||
a "successful" old backup becomes unrestorable, so treat this specific failure mode as urgent.
|
||||
- The dump itself is suspiciously small (script's own sanity check tripped) — Postgres may be
|
||||
unhealthy; check `docker exec rootnode-platform-postgres-1 pg_isready` first.
|
||||
|
||||
**After fixing:** run the backup manually once to confirm success (metric updates immediately,
|
||||
alert clears within one Prometheus scrape interval), then confirm the cron job is still
|
||||
correctly scheduled (`crontab -l` as root, or re-run the `backup` Ansible role if the cron entry
|
||||
itself is suspect).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Runbook: Disk Failure
|
||||
|
||||
Applies once real hardware/ZFS exists — see `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4 for
|
||||
the storage decision framework this assumes (mirrored vdevs, never single-disk for Postgres).
|
||||
|
||||
```bash
|
||||
# 1. Confirm the failure
|
||||
zpool status -v
|
||||
smartctl -a /dev/sdX # the suspect drive
|
||||
|
||||
# 2. If it's a mirrored vdev (the required layout for anything holding Postgres data — see §4),
|
||||
# the pool stays online in DEGRADED state. No immediate data-loss risk, but no redundancy
|
||||
# either until replaced — treat DEGRADED as urgent, not routine.
|
||||
zpool status # confirm DEGRADED, not FAULTED/UNAVAIL (those mean act immediately, possibly
|
||||
# restore from backup if the redundant mirror side is also compromised)
|
||||
|
||||
# 3. Replace the physical drive, then:
|
||||
zpool replace <pool> <old-device> <new-device>
|
||||
zpool status # watch the resilver progress
|
||||
|
||||
# 4. If a whole pool was lost (both mirror sides failed simultaneously — the scenario mirroring
|
||||
# exists to make vanishingly unlikely, but plan for it anyway):
|
||||
# - rn-lab-host-01's disposable data: nothing to recover, just rebuild the VM
|
||||
# (docs/RUNBOOK_VM_REBUILD.md)
|
||||
# - rn-platform-01's Postgres data: restore from the latest off-host backup
|
||||
# (docs/RUNBOOK_POSTGRES_RESTORE.md) — this is exactly the scenario off-host backup
|
||||
# copies exist for; a local-only backup on the SAME failed pool would be useless here
|
||||
```
|
||||
|
||||
**Pre-deployment disk inspection checklist** (run once when the real drives are known, before
|
||||
they go into service — `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4):
|
||||
|
||||
```bash
|
||||
for d in /dev/nvme0n1 /dev/nvme1n1 /dev/nvme2n1 /dev/nvme3n1; do # adjust to real device names
|
||||
echo "== $d =="
|
||||
smartctl -i "$d" | grep -E 'Model|Serial|Capacity|Firmware'
|
||||
smartctl -A "$d" | grep -iE 'power_on_hours|reallocated|pending|uncorrectable|wear|percentage.used|temperature'
|
||||
smartctl -H "$d" | grep -i overall
|
||||
done
|
||||
```
|
||||
Record model, size, power-on hours, wear/endurance percentage, reallocated/pending sector
|
||||
counts, temperature, and firmware version for every drive before committing to a ZFS layout —
|
||||
a drive already showing wear or reallocated sectors shouldn't be trusted with Postgres data
|
||||
even in a mirror.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Runbook: Docker TLS Rotation
|
||||
|
||||
Rotate on a schedule (recommend yearly, well before the ~2.25-year cert lifetime
|
||||
`generate-docker-tls.sh` defaults to) or immediately if a client cert may have leaked.
|
||||
|
||||
```bash
|
||||
# 1. Generate a fresh bundle (new CA too — simplest, avoids any question about whether the old
|
||||
# CA's private key was ever exposed)
|
||||
cd infra/scripts/docker-tls
|
||||
./generate-docker-tls.sh --out-dir ./out-$(date +%Y%m%d) --lab-host-ip <real-ip> --lab-host-dns rn-lab-host-01.internal
|
||||
./verify-docker-tls.sh ./out-$(date +%Y%m%d)
|
||||
|
||||
# 2. Redeploy — this restarts dockerd on rn-lab-host-01 (brief interruption to any in-flight
|
||||
# Docker API call from the Provisioner, NOT to already-running student Labs — their
|
||||
# containers keep running through a dockerd restart)
|
||||
cd ../../ansible
|
||||
ROOTNODE_TLS_BUNDLE_DIR=../scripts/docker-tls/out-$(date +%Y%m%d) \
|
||||
ansible-playbook site.yml -i inventory/production.ini --tags docker-tls --ask-vault-pass
|
||||
|
||||
# 3. Verify the Provisioner can still reach rn-lab-host-01
|
||||
curl -s http://localhost:4100/health # from rn-platform-01
|
||||
```
|
||||
|
||||
**Troubleshooting a failed rotation:**
|
||||
- `x509: certificate signed by unknown authority` — the new ca.pem wasn't deployed to both
|
||||
sides, or an old one is cached; re-run the docker-tls role on BOTH hosts.
|
||||
- Provisioner logs `ECONNREFUSED` on port 2376 — dockerd didn't actually restart with the new
|
||||
daemon.json; check `systemctl status docker` on rn-lab-host-01 and the ExecStart override
|
||||
(`infra/ansible/roles/docker-tls/templates/docker-override.conf.j2`) actually applied.
|
||||
- Firewall — confirm `infra/firewall/rn-lab-host-01.rules`' 2376 rule still matches
|
||||
rn-platform-01's actual IP (didn't change as part of this rotation, but worth a sanity check).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Runbook: Failed Migration
|
||||
|
||||
Both Platform API and Provisioner apply their own migrations automatically on startup
|
||||
(`platform/api/src/db.js`, `platform/provisioner/src/db.js`), file-tracked in
|
||||
`schema_migrations`, wrapped in a transaction per file — a migration either fully applies or
|
||||
fully rolls back, it can't leave a table half-altered.
|
||||
|
||||
**If a deploy fails because a migration errored:**
|
||||
|
||||
```bash
|
||||
# 1. Check what actually happened
|
||||
docker logs rootnode-platform-api-1 --tail 50 # the error is right there — migration file + message
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 10;"
|
||||
|
||||
# 2. Since the failing migration's transaction rolled back, the schema is still in its
|
||||
# PREVIOUS valid state — the app just won't start until the migration succeeds. Fix the
|
||||
# root cause (usually a real data issue the migration didn't anticipate, or the
|
||||
# cross-service ordering dependency — Provisioner's migrations must run before Platform
|
||||
# API's, since scenario_attempts has a real FK to environments; see
|
||||
# docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md).
|
||||
|
||||
# 3. Redeploy once fixed — the migration runner is idempotent and will just pick up where it
|
||||
# left off; already-applied migrations are skipped.
|
||||
./infra/scripts/deploy-production.sh <same-or-fixed-tag>
|
||||
```
|
||||
|
||||
**If you need to roll back the CODE but the migration already succeeded** (schema changed,
|
||||
code that depended on it is being reverted): rolling back application images
|
||||
(`docs/RUNBOOK_ROLLBACK.md`) does NOT undo a Postgres migration — there is no automatic
|
||||
down-migration mechanism (deliberate, matches the existing check-before-alter, additive-only
|
||||
migration convention documented in `docs/ENGINEERING_SOP.md` §6). If the old code genuinely
|
||||
can't run against the new schema, restore from the pre-migration backup instead
|
||||
(`docs/RUNBOOK_POSTGRES_RESTORE.md`) rather than trying to hand-write a down-migration under
|
||||
pressure.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Runbook: First Production Deployment
|
||||
|
||||
Prerequisites: Proxmox installed (`infra/proxmox/README.md`), VMs created
|
||||
(`infra/terraform/`), DNS pointed at rn-platform-01's public IP.
|
||||
|
||||
```bash
|
||||
# 1. Generate Docker TLS material (offline, on your own machine — never on either VM)
|
||||
cd infra/scripts/docker-tls
|
||||
./generate-docker-tls.sh --out-dir ./out --lab-host-ip <real-lab-host-ip> --lab-host-dns rn-lab-host-01.internal
|
||||
./verify-docker-tls.sh ./out
|
||||
|
||||
# 2. Fill in real values
|
||||
cd ../../ansible
|
||||
cp group_vars/production.vault.yml.template group_vars/production.vault.yml
|
||||
$EDITOR group_vars/production.vault.yml # real passwords/secrets — openssl rand -hex 32 etc.
|
||||
ansible-vault encrypt group_vars/production.vault.yml
|
||||
$EDITOR group_vars/production.yml # real IPs, domain, CIDRs
|
||||
$EDITOR inventory/production.ini # real IPs
|
||||
|
||||
# 3. Deploy
|
||||
ROOTNODE_TLS_BUNDLE_DIR=../scripts/docker-tls/out \
|
||||
ansible-playbook site.yml -i inventory/production.ini --ask-vault-pass
|
||||
|
||||
# 4. Bootstrap the owner account (once, by a human)
|
||||
ssh rootnode-deploy@rn-platform-01
|
||||
docker exec -it rootnode-platform-api-1 node bootstrap-owner.js [email protected] "Your Name"
|
||||
# (password comes from BOOTSTRAP_OWNER_PASSWORD in the environment, or the CLI prompt — see
|
||||
# platform/api/README.md)
|
||||
|
||||
# 5. Verify
|
||||
../../infra/scripts/production-smoke-test.sh
|
||||
```
|
||||
|
||||
Then work through `docs/PRODUCTION_VALIDATION_CHECKLIST.md` in full before calling this done.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Runbook: Google Workspace Email Configuration
|
||||
|
||||
Everything in this document happens OUTSIDE this repository, in the Google Admin Console and
|
||||
Google Cloud Console, and is marked **REQUIRES GOOGLE WORKSPACE ADMIN ACTION**. Nothing here can
|
||||
be scripted or automated from the codebase — Chase (or whoever holds Workspace super-admin) has
|
||||
to do these steps by hand, once, before RootNode Academy can send real email.
|
||||
|
||||
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §2 for why this mechanism was chosen over SMTP
|
||||
or a human login.
|
||||
|
||||
## 1. Selected mechanism
|
||||
|
||||
**Gmail API (`users.messages.send`) via a Google Cloud service account using Workspace
|
||||
domain-wide delegation**, impersonating one dedicated sending mailbox. No human password is
|
||||
ever stored by RootNode. The delegated scope is exactly `https://www.googleapis.com/auth/
|
||||
gmail.send` — nothing broader.
|
||||
|
||||
## 2. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the sending mailbox
|
||||
|
||||
Decide the address RootNode sends as — e.g. `[email protected]` or
|
||||
`[email protected]`. This repository does not assume or hard-code one; it's whatever
|
||||
you set `EMAIL_FROM_ADDRESS` to (§5).
|
||||
|
||||
1. In Google Admin Console (admin.google.com) → Directory → Users, create this mailbox as a
|
||||
real Workspace user (or a Google Group with "Collaborative Inbox" if you'd rather route
|
||||
replies to a shared team inbox — either works with this mechanism, but a Group needs its own
|
||||
delegation setup; a plain user mailbox is simpler and is what this runbook assumes).
|
||||
2. Consider giving it a strong, unique, never-reused password and enabling 2-Step Verification
|
||||
on it anyway, even though RootNode's application will never use that password — this account
|
||||
still exists as a real Workspace identity and should be secured like any other.
|
||||
|
||||
**If the sending address is an ALIAS of an existing account (a real cost-saving option — you
|
||||
don't need a whole extra paid seat just for this):** that's fine and supported, but note it for
|
||||
step 4 below. Domain-wide delegation authorizes the service account to act as a real Workspace
|
||||
**user account**, not an arbitrary alias string — if `[email protected]` is an alias of,
|
||||
say, `[email protected]`, the delegation in step 4 is granted for `chase@`'s account
|
||||
(the alias's owner), and RootNode's config keeps the alias as the visible sending address
|
||||
separately (see §5's `GOOGLE_DELEGATED_USER` vs. `EMAIL_FROM_ADDRESS`). Gmail already lets any
|
||||
account send as its own verified aliases, so this works cleanly — you just need both pieces of
|
||||
information (the alias, and which account owns it) when configuring RootNode.
|
||||
|
||||
## 3. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the service account (Google Cloud Console)
|
||||
|
||||
1. In Google Cloud Console (console.cloud.google.com), create or choose a GCP project associated
|
||||
with your Workspace organization.
|
||||
2. Enable the **Gmail API** for that project (APIs & Services → Library → search "Gmail API" →
|
||||
Enable).
|
||||
3. IAM & Admin → Service Accounts → Create Service Account. Name it something identifiable
|
||||
(e.g. `rootnode-academy-mailer`). No project-level IAM roles are needed for this — it only
|
||||
ever calls the Gmail API as the impersonated user, not as itself.
|
||||
4. On that service account, create a JSON key (Keys → Add Key → Create new key → JSON). This
|
||||
downloads a file containing `client_email` and `private_key` — **this is the sensitive
|
||||
credential**, treat it exactly like a password (see §8).
|
||||
5. Copy the service account's **Client ID** (a long numeric string, on the service account's
|
||||
detail page) — needed for the next step.
|
||||
|
||||
## 4. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: authorize domain-wide delegation
|
||||
|
||||
1. Google Admin Console → Security → Access and data control → API controls → Domain-wide
|
||||
Delegation → Add new.
|
||||
2. Client ID: the numeric ID from step 3.5 above.
|
||||
3. OAuth Scopes: `https://www.googleapis.com/auth/gmail.send` — **only this scope**. Do not add
|
||||
broader Gmail scopes (readonly, modify, etc.) — RootNode's adapter never needs them and
|
||||
granting them would violate least-privilege for no benefit.
|
||||
4. Authorize.
|
||||
|
||||
This grants the service account permission to act as **one specific Workspace user account**.
|
||||
If the sending address is that account's own primary login, that's the account. If the sending
|
||||
address is an ALIAS (§2), this authorization is inherently for the alias's owner account — there
|
||||
is no separate "authorize an alias" step, because delegation only ever targets real accounts.
|
||||
|
||||
## 5. RootNode-side environment variables
|
||||
|
||||
Set these on the Platform API's environment (production: real secret storage — see §8; local
|
||||
dev: `platform/.env`, already gitignored):
|
||||
|
||||
| Variable | Value | Notes |
|
||||
|---|---|---|
|
||||
| `EMAIL_PROVIDER` | `gws` | Unset/anything else defaults to the dev provider — never real mail. |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_JSON` | the full JSON key file's contents, as one string | The sensitive credential — see §8. |
|
||||
| `EMAIL_FROM_ADDRESS` | the address students see mail arrive from, e.g. `[email protected]` | Always the visible `From:` header. |
|
||||
| `GOOGLE_DELEGATED_USER` | **only needed if `EMAIL_FROM_ADDRESS` is an alias** — the real account that alias belongs to, e.g. `[email protected]` | This is what domain-wide delegation actually authorized in step 4. If `EMAIL_FROM_ADDRESS` is itself a primary account, leave this unset — the code falls back to `EMAIL_FROM_ADDRESS` automatically. |
|
||||
| `EMAIL_FROM_NAME` | e.g. `RootNode Academy` | Display name only, not sensitive. |
|
||||
| `EMAIL_REPLY_TO` | optional, e.g. `[email protected]` | Where a student's reply actually goes, if different from the sending address. |
|
||||
| `PUBLIC_APP_ORIGIN` | e.g. `https://learn.rootnodeacademy.com` | The origin activation/reset links are built from (§7 of the Phase 5C authorization) — required for any token-bearing email to render a working link. |
|
||||
|
||||
**Concrete example matching the alias setup described above:** `[email protected]`
|
||||
(what students see), `[email protected]` (whose account actually
|
||||
owns that alias and was granted delegation in step 4).
|
||||
|
||||
## 6. DNS: SPF, DKIM, DMARC (REQUIRES DNS ADMIN ACTION — not performed from this repository)
|
||||
|
||||
Google Workspace's own documentation is authoritative here; this is a summary of what must
|
||||
exist before mail sent via the Gmail API from a Workspace mailbox reliably lands in inboxes
|
||||
instead of spam:
|
||||
|
||||
- **SPF**: your domain's TXT record must include Google's SPF include
|
||||
(`include:_spf.google.com`) if Workspace is your MX provider — check Admin Console → Apps →
|
||||
Google Workspace → Gmail → Authenticate email for the exact current record Google recommends
|
||||
for your domain.
|
||||
- **DKIM**: Admin Console → Apps → Google Workspace → Gmail → Authenticate email → generate a
|
||||
DKIM key for the domain, then publish the TXT record it gives you, then return to that page
|
||||
and turn DKIM signing ON. Gmail API sends from a Workspace mailbox are signed the same way
|
||||
regular Gmail sends are once this is on.
|
||||
- **DMARC**: publish a `_dmarc` TXT record (start with `p=none` to monitor before enforcing) once
|
||||
SPF and DKIM both pass consistently.
|
||||
|
||||
**Do not change DNS from this repository or these instructions** — this is exactly the kind of
|
||||
external, admin-console/DNS-provider action that stays outside the codebase. Verify current
|
||||
exact record values in Google's own Workspace admin help before publishing anything.
|
||||
|
||||
## 7. Testing procedure
|
||||
|
||||
**Local/dev — no live credentials needed at all.** `EMAIL_PROVIDER` unset (or anything other
|
||||
than `gws`) uses the deterministic dev provider (`email/dev-provider.js`) — every notification
|
||||
still goes through the full queue/worker/retry pipeline, but nothing ever reaches a real
|
||||
network. `platform/test/e2e-phase5c.js` and the Phase 5C drills run entirely this way.
|
||||
|
||||
**Live delivery — opt-in, one message, once credentials exist:**
|
||||
```bash
|
||||
RUN_GWS_EMAIL_TEST=true \
|
||||
TEST_EMAIL_RECIPIENT=[email protected] \
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \
|
||||
EMAIL_FROM_ADDRESS=[email protected] \
|
||||
GOOGLE_DELEGATED_USER=[email protected] \
|
||||
EMAIL_FROM_NAME="RootNode Academy" \
|
||||
node infra/scripts/gws-email-test.js
|
||||
```
|
||||
(Omit `GOOGLE_DELEGATED_USER` entirely if `EMAIL_FROM_ADDRESS` is itself a primary account
|
||||
rather than an alias — see §5.) Sends exactly one clearly-marked test message to
|
||||
`TEST_EMAIL_RECIPIENT` — never inferred from production/student data, never run automatically as
|
||||
part of any regression suite. Without `RUN_GWS_EMAIL_TEST=true` it does nothing and exits
|
||||
cleanly.
|
||||
|
||||
## 8. Production secret handling
|
||||
|
||||
- `GOOGLE_SERVICE_ACCOUNT_JSON` is exactly as sensitive as a password — store it in whatever
|
||||
secret manager/environment-injection mechanism the production deployment uses (see
|
||||
`docs/PRODUCTION_DEPLOYMENT.md` for the general pattern this repo follows for other secrets
|
||||
like `PROVISIONER_SHARED_SECRET`). **Never commit it, never put it in a Docker image layer,
|
||||
never log it.** `gws-provider.js` reads it fresh from the environment on every send and never
|
||||
writes it to disk.
|
||||
- Nothing in this codebase ever returns `GOOGLE_SERVICE_ACCOUNT_JSON`'s contents, an access
|
||||
token, or any part of it through an API response, log line, or audit event.
|
||||
|
||||
## 9. Credential rotation
|
||||
|
||||
1. Google Cloud Console → the service account → Keys → Add Key → Create new key (JSON) — this
|
||||
creates an ADDITIONAL valid key without invalidating the old one yet.
|
||||
2. Update `GOOGLE_SERVICE_ACCOUNT_JSON` in production's secret store to the new key, redeploy/
|
||||
restart the Platform API.
|
||||
3. Confirm delivery still works (§7's live test, or just watch `/api/admin/notifications` for a
|
||||
real send after the restart).
|
||||
4. Google Cloud Console → the service account → Keys → delete the OLD key.
|
||||
|
||||
Domain-wide delegation authorization (§4) is tied to the service account's Client ID, not to any
|
||||
individual key — rotating keys does not require re-authorizing delegation.
|
||||
|
||||
## 10. Revocation (compromised credential / offboarding)
|
||||
|
||||
1. Google Cloud Console → the service account → Keys → delete the affected key immediately —
|
||||
this invalidates it instantly, before anything else.
|
||||
2. If the whole service account should stop being able to send at all: Google Admin Console →
|
||||
Security → API controls → Domain-wide Delegation → remove that Client ID's authorization.
|
||||
3. Rotate `EMAIL_FROM_ADDRESS`'s own Workspace login password as a precaution even though the
|
||||
application never used it — a compromised service-account key doesn't imply the mailbox's own
|
||||
credentials were exposed, but treat it as a "review this account" trigger.
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| Every send fails with `gws_not_configured` | `GOOGLE_SERVICE_ACCOUNT_JSON` or `EMAIL_FROM_ADDRESS` unset/malformed in the Platform API's environment. |
|
||||
| `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. **If this happens intermittently right after newly authorizing delegation** (the exact same request succeeds, then fails with `unauthorized_client`, then succeeds again, over roughly a minute) — that's Google's own auth infrastructure propagation delay, not a config error. It self-resolves; the notification worker's existing retry/backoff already absorbs it (`gws-provider.js` classifies `unauthorized_client` as retryable), so no action is needed beyond waiting. |
|
||||
| `gws_http_403` | The delegated `sub` (EMAIL_FROM_ADDRESS) isn't a real mailbox in this Workspace domain, or the Gmail API isn't enabled on the GCP project (§3.2). |
|
||||
| `gws_rate_limited` (HTTP 429) | Gmail API per-user sending limits hit — see §12. The notification worker retries this automatically with backoff. |
|
||||
| Mail sends successfully but lands in spam | SPF/DKIM/DMARC (§6) not fully configured/passing yet — this is a deliverability issue, not an application error. |
|
||||
|
||||
## 12. Google Workspace quotas/limits relevant to this mechanism
|
||||
|
||||
Gmail API sending inherits the same per-user daily sending limits as regular Gmail/Workspace
|
||||
sending (limits vary by Workspace edition — check current figures in Google's own Workspace
|
||||
admin documentation, as these change over time and are not something to hard-code here). For
|
||||
RootNode Academy's transactional volume (activation/enrollment/reset emails, not bulk/marketing
|
||||
— explicitly out of scope, see the Phase 5C authorization §5/§28), this should sit comfortably
|
||||
under any Workspace edition's per-user limit; revisit if enrollment volume grows substantially.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Runbook: High Lab Host Resource Pressure
|
||||
|
||||
Triggered by the `LabHostHighMemory`/`HighDiskUsage` warning alerts.
|
||||
|
||||
```bash
|
||||
# 1. How many Labs are actually running, and against what admission-control threshold?
|
||||
curl -s http://localhost:4100/environments | jq '[.[] | select(.status=="running")] | length'
|
||||
node infra/scripts/capacity-model.js # recommended/warning/hard thresholds
|
||||
|
||||
# 2. If genuinely over capacity: this is expected pressure from real usage, not a bug.
|
||||
# - Destroy idle/abandoned Labs early via /admin (per-cohort "Destroy Labs" after a class
|
||||
# session ends, rather than waiting for the idle reaper's default window)
|
||||
# - If a specific cohort's class just ended, use /admin -> Cohorts -> [cohort] -> Destroy
|
||||
# Cohort Labs immediately rather than waiting
|
||||
|
||||
# 3. If pressure is high with FEWER Labs running than capacity-model.js's threshold: something
|
||||
# is consuming more per-Lab resources than the model assumes (a scenario doing something
|
||||
# unusually heavy, a memory leak in a long-running Lab, zombie processes from a crashed
|
||||
# reset). Check per-container usage:
|
||||
docker stats --no-stream | grep rootnode-lab-
|
||||
|
||||
# 4. Update infra/environments/production/capacity.yml with fresh measured numbers
|
||||
# (infra/scripts/loadtest/) if the per-Lab footprint has genuinely changed, so the
|
||||
# admission-control threshold reflects reality going forward.
|
||||
```
|
||||
|
||||
Do not silently raise resource limits in `provisioner-config/resource-policy.yaml` to make an
|
||||
alert go away without understanding why usage is higher than modeled — that just moves the
|
||||
same pressure onto host RAM/CPU directly instead of onto individual Lab limits.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Runbook: Normal Deploy
|
||||
|
||||
For a routine code change already merged to `main` on the canonical Gitea repo.
|
||||
|
||||
1. CI (`infra/ci/gitea-workflows/build-and-test.yml`) runs automatically: tests, image build/tag/push, staging deploy, staging smoke test.
|
||||
2. Review the staging smoke test result and, ideally, poke around `https://staging.<domain>` yourself.
|
||||
3. Promote to production (manual, deliberate):
|
||||
|
||||
```bash
|
||||
cd infra/scripts
|
||||
./deploy-production.sh sha-<12-char-sha> # or a release tag, e.g. v1.3.0
|
||||
./production-smoke-test.sh
|
||||
```
|
||||
|
||||
4. Watch Grafana (`/grafana/`) for 10-15 minutes — error rates, Postgres connections, Lab Host
|
||||
memory — before considering the deploy "settled."
|
||||
5. If anything looks wrong: `docs/RUNBOOK_ROLLBACK.md`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Runbook: Owner Account Recovery
|
||||
|
||||
**Locked out with zero working owner accounts** (all deactivated, forgot the only owner's
|
||||
password, etc.):
|
||||
|
||||
```bash
|
||||
ssh rootnode-deploy@rn-platform-01
|
||||
docker exec -it rootnode-platform-api-1 sh
|
||||
node bootstrap-owner.js [email protected] "Your Name"
|
||||
# bootstrap-owner.js refuses to run if an active owner already exists UNLESS the target email
|
||||
# belongs to an EXISTING instructor row — in that case it promotes them to owner. Point it at
|
||||
# any known instructor email to recover through them, or a brand-new email to create a fresh
|
||||
# owner outright if the instructors table itself is in an unknown state.
|
||||
```
|
||||
|
||||
**Forgot password, account still active and you have SSH access:**
|
||||
|
||||
```bash
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"SELECT instructor_id, email, role, status FROM instructors WHERE role='owner';"
|
||||
# Then use another owner's account to reset via /admin -> Instructors -> [account] -> Reset
|
||||
# Password, OR re-run bootstrap-owner.js targeting that email (also resets the password).
|
||||
```
|
||||
|
||||
**Never** hand-edit `password_hash` in the database — it's `scrypt(salt):hash` in a specific
|
||||
format (`platform/api/src/auth/password.js`); a malformed value just breaks login rather than
|
||||
setting a usable password. Always go through `bootstrap-owner.js` or the admin UI's reset flow.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Runbook: Postgres Restore
|
||||
|
||||
**When to use:** Postgres corruption/data loss on rn-platform-01, or verifying a backup is
|
||||
actually good (do this periodically even without an incident — an untested backup is a
|
||||
hypothesis, not a backup).
|
||||
|
||||
```bash
|
||||
# 1. Find the backup to restore
|
||||
ls -la /srv/rootnode-data/backups/ # or wherever backup_offsite_target points, if local copies are gone
|
||||
|
||||
# 2. Create a FRESH target database — never restore over the live one directly
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"CREATE DATABASE rootnode_platform_restore_test;"
|
||||
|
||||
# 3. Restore into it
|
||||
source /etc/rootnode/backup.env # for BACKUP_ENCRYPTION_PASSPHRASE
|
||||
/opt/rootnode/backup/restore-postgres.sh \
|
||||
/srv/rootnode-data/backups/rootnode-platform-<timestamp>.sql.gz.gpg \
|
||||
"postgres://rootnode_platform:$PGPASS@localhost:5432/rootnode_platform_restore_test"
|
||||
# type RESTORE when prompted
|
||||
|
||||
# 4. Verify
|
||||
/opt/rootnode/backup/verify-restore.sh \
|
||||
"postgres://rootnode_platform:$PGPASS@localhost:5432/rootnode_platform_restore_test"
|
||||
|
||||
# 5a. If this was a drill: drop the test database
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"DROP DATABASE rootnode_platform_restore_test;"
|
||||
|
||||
# 5b. If this was a REAL incident: stop the app stack, rename the broken DB, rename the
|
||||
# restored one into its place, restart.
|
||||
systemctl stop rootnode-platform
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"ALTER DATABASE rootnode_platform RENAME TO rootnode_platform_broken_$(date +%s);"
|
||||
docker exec -it rootnode-platform-postgres-1 psql -U rootnode_platform -c \
|
||||
"ALTER DATABASE rootnode_platform_restore_test RENAME TO rootnode_platform;"
|
||||
systemctl start rootnode-platform
|
||||
```
|
||||
|
||||
Local, non-production dry run of this exact flow: `infra/backup/test-backup-restore.sh` (uses
|
||||
disposable containers, safe to run anytime, no real data involved).
|
||||
|
||||
**Recovery point objective:** up to 24 hours of academic data (daily backup cadence) — see
|
||||
`docs/BACKUP_DR.md` for the full RPO/RTO discussion and why that's the honest number, not an
|
||||
aspirational one.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Runbook: Running a Remote Classroom
|
||||
|
||||
Covers scheduling, running, and recovering a live class through `/admin` → Classroom. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design. RootNode does
|
||||
**not** provide video/voice — the meeting URL field just links out to Meet/Zoom/Teams/whatever
|
||||
you already use for that.
|
||||
|
||||
**Before class: schedule it**
|
||||
|
||||
`/admin` → Classroom → pick the cohort → Schedule a class. Title is required; scheduled start,
|
||||
description, and a meeting URL are optional. Only one class can be `live` per cohort at a time —
|
||||
scheduling a second one is fine, starting it while another is live is rejected.
|
||||
|
||||
**Right before class: Prepare Labs**
|
||||
|
||||
Open the classroom → Prepare Labs. This provisions/warms every enrolled student's Lab ahead of
|
||||
time (same underlying flow as the cohort-level Prepare Labs button) so nobody is waiting on a
|
||||
cold start once class begins. If a Lab host is at capacity, that student shows as
|
||||
`capacity_waiting` in the results rather than failing silently — check back or ease off other
|
||||
concurrent load.
|
||||
|
||||
**Start class**
|
||||
|
||||
Open the classroom → Start class. Status moves to `live`; students now see a "LIVE NOW" banner
|
||||
on their Classroom tab and can join.
|
||||
|
||||
**During class**
|
||||
|
||||
- **Participants table**: presence (`online`/`recently_active`/`offline`, derived from each
|
||||
student's last heartbeat — not a hard real-time signal, allow ~30s of lag), Lab status, and a
|
||||
one-line current-work snapshot (stage, scenario, validation failures, hints used).
|
||||
- **Set current activity**: enter a scenario ID and Set. This only shows students a link — it
|
||||
never redirects their browser or marks anything complete. Use it to say "everyone open this
|
||||
next," not to force navigation.
|
||||
- **Post an announcement**: short messages, shown newest-first on the student side.
|
||||
- **Help queue**: students flag `lab_problem` / `dont_understand` / `validation_failing` /
|
||||
`other` with an optional note. Acknowledge when you've seen it, Resolve when it's handled.
|
||||
There is no chat here — if it needs back-and-forth, use your actual video call.
|
||||
|
||||
**End of class**
|
||||
|
||||
Open the classroom → End class. Status moves to `ended`; this does not touch Labs or student
|
||||
progress — destroy Labs separately from the cohort view if you're done with them for the day.
|
||||
|
||||
**If you need to cancel a scheduled (not yet started) class:** Cancel, from the classroom
|
||||
detail view. A `live` class can only be ended, not cancelled.
|
||||
|
||||
**If the Platform API restarts mid-class** (deploy, crash, host reboot): nothing to do. Sessions,
|
||||
the live classroom row, and participant presence all live in Postgres, not in the API process's
|
||||
memory — students' browsers keep their existing session and heartbeat right through it, and the
|
||||
dashboard shows the same state a few seconds after the API comes back. This is exercised for
|
||||
real in `infra/scripts/drills/test-phase5a-rehearsal.js`, which restarts the API mid-rehearsal
|
||||
and asserts on exactly this.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Runbook: Rollback
|
||||
|
||||
```bash
|
||||
cd infra/scripts
|
||||
./rollback-production.sh # rolls back to the previous deployed tag automatically
|
||||
# or, to go back further than one step:
|
||||
./rollback-production.sh sha-<older-sha>
|
||||
|
||||
./production-smoke-test.sh
|
||||
```
|
||||
|
||||
This rolls back **application images only**. If the bad deploy included a database migration
|
||||
that needs undoing too, schema changes aren't automatically reversible — see
|
||||
`docs/RUNBOOK_FAILED_MIGRATION.md` instead (or in addition).
|
||||
|
||||
After rolling back: file an issue / note in the deploy log (`/opt/rootnode/deploy-history.log`
|
||||
already has the rollback recorded) about what went wrong before re-attempting the forward
|
||||
deploy.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Runbook: Self-Paced Programs & Enrollment
|
||||
|
||||
Covers managing programs and enrollments from `/admin` → Programs. See
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for the underlying design and
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` for the separate website-driven path.
|
||||
|
||||
**Create a program** (Programs → Create program, admin/owner only):
|
||||
|
||||
Program ID is a slug (e.g. `root-linux-fundamentals`) and can't be changed later. Pick a
|
||||
family (`init_`/`compile_`/`root_`) and delivery mode. New programs default to
|
||||
`curriculum_path: rootnode-path` — the only curriculum content that exists today. A program for
|
||||
`init_`/`compile_` can be created now as a placeholder and wired to real content later without a
|
||||
schema change; it just won't have real lessons behind it yet.
|
||||
|
||||
**Activate/deactivate a program** (Programs → \[program\] → Activate/Deactivate):
|
||||
|
||||
An inactive program can't accept new enrollments (admin-manual or via the website integration
|
||||
endpoint) but existing enrollments in it are untouched — students already enrolled keep their
|
||||
access exactly as it was.
|
||||
|
||||
**Manually enroll a student** (Programs → \[program\] → Enroll a student):
|
||||
|
||||
Enter the student's ID (find it on the Students tab). Choose `pending` (the default — the
|
||||
student sees the enrollment but can't start until you Activate it) or `active` (skip that step).
|
||||
Optional start/end dates enforce access windows server-side — a future start date hides Lab
|
||||
access until then; an end date cuts off new Lab launches after it while leaving prior progress
|
||||
visible.
|
||||
|
||||
As of Phase 5C, this also automatically emails the student — an activation link if they've never
|
||||
had credentials, or an enrollment confirmation if they already do. See
|
||||
`docs/RUNBOOK_STUDENT_ACTIVATION.md` for checking delivery status and resending.
|
||||
|
||||
**Activate a pending enrollment** (Programs → \[program\] → Enrollments table → Activate):
|
||||
|
||||
Only needed if you enrolled as `pending`. This is the moment a student actually gains entry.
|
||||
|
||||
**Cancel an enrollment** (Programs → \[program\] → Enrollments table → Cancel, or Enrollments
|
||||
list on a student's own page):
|
||||
|
||||
Immediately blocks new Lab launches. Does not touch the student's progress/achievements/attempt
|
||||
history — those are permanent regardless of enrollment status. Cancelling is final for that
|
||||
enrollment; if the student should have access again later, enroll them again (a new enrollment
|
||||
row) rather than trying to "reactivate" a cancelled one.
|
||||
|
||||
**Check a student's enrollment history** (Students → \[student\] → "Enrolled programs" card):
|
||||
|
||||
Shows every program the student has ever been enrolled in — pending, active, completed,
|
||||
cancelled, expired — with progress and last activity. A plain instructor sees this scoped to
|
||||
enrollments they created or that belong to a student sharing one of their cohorts; owner/admin
|
||||
see everything.
|
||||
|
||||
**A student says "Continue Learning" isn't working / shows the wrong thing:**
|
||||
|
||||
1. Check their enrollment status on the Programs or Student page. `pending` → activate it.
|
||||
`expired`/`cancelled` → that's working as intended; enroll them again if they should continue.
|
||||
2. If `active` and still stuck, check `/api/admin/students/:id/environment` for a stuck Lab —
|
||||
same troubleshooting as any other Lab issue (`docs/RUNBOOK_STUCK_LAB_CLEANUP.md`). Destroying
|
||||
the Lab never loses their program progress; a fresh launch resumes from the same place.
|
||||
|
||||
**A student's completion didn't register:**
|
||||
|
||||
Completion is detected the next time their program status is *read* (their dashboard, or an
|
||||
admin viewing their enrollment) after they finish the capstone — not pushed instantly. Refresh
|
||||
the page or reopen their student detail in `/admin`; if it still doesn't show `completed` after
|
||||
that, check `GET /api/achievements` for that student for a `capstone_completed` record matching
|
||||
the program's configured capstone — if that's missing, the capstone itself wasn't actually
|
||||
finished, not an enrollment bug.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runbook: Stuck Lab Cleanup
|
||||
|
||||
A Lab that's stuck `provisioning`/`resetting` past its expected boot window, or a container
|
||||
that outlived its Postgres `environments` row (crash during teardown, etc.).
|
||||
|
||||
**Via the admin UI (preferred, no host access needed):** `/admin` -> Students -> [student] ->
|
||||
Environment -> Destroy, then Provision again if they need a fresh one immediately.
|
||||
|
||||
**Via the API directly** (same effect, useful for scripting a batch cleanup):
|
||||
```bash
|
||||
curl -s -b <owner-session-cookie> -X DELETE https://academy.example.com/api/admin/environments/<environment_id>
|
||||
```
|
||||
|
||||
**A container the Provisioner itself has lost track of** (rare — e.g. a crash during
|
||||
provisioning left an orphaned container/network/firewall chain with no matching `environments`
|
||||
row, or vice versa):
|
||||
|
||||
```bash
|
||||
# On rn-lab-host-01 — list what's actually running
|
||||
docker ps -a --format '{{.Names}}\t{{.Status}}' | grep '^rootnode-lab-'
|
||||
|
||||
# Cross-reference against the Provisioner's own view
|
||||
curl -s http://localhost:4100/environments | jq '.[] | {environment_id, status, container_name}'
|
||||
|
||||
# Provisioner already does this automatically on its own restart (reconcileOnStartup,
|
||||
# platform/provisioner/src/lifecycle.js) — a simple `systemctl restart rootnode-platform`
|
||||
# (which restarts the provisioner container as part of the stack) is often the fastest fix for
|
||||
# a batch of orphans rather than hand-cleaning each one.
|
||||
|
||||
# Manual last resort — remove the container/network/firewall chain directly:
|
||||
docker rm -f rootnode-lab-<id>
|
||||
docker network rm rootnode-lab-net-<id>
|
||||
iptables -D DOCKER-USER -s <subnet> -j ROOTNODE-ENV-<id> # remove the jump rule FIRST
|
||||
iptables -F ROOTNODE-ENV-<id> && iptables -X ROOTNODE-ENV-<id>
|
||||
```
|
||||
|
||||
Never manually delete an `environments` row from Postgres directly unless you've confirmed the
|
||||
container/network/firewall chain are already gone — the Provisioner treats that table as
|
||||
authoritative and will behave unpredictably against real Docker/iptables state it doesn't know
|
||||
matches.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Runbook: Student Account Lifecycle
|
||||
|
||||
Covers creating, converting, resetting, and revoking student accounts. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design and
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`/`docs/RUNBOOK_STUDENT_ACTIVATION.md` for the
|
||||
automatic-email side of this, added in Phase 5C.
|
||||
|
||||
**Create a new credentialed student** (`/admin` → Students → Add student):
|
||||
|
||||
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
|
||||
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
|
||||
link **once**: `https://<domain>/?activate=<token>`. As of Phase 5C, if the student has an
|
||||
email, RootNode also sends it to them automatically — the on-screen link remains a manual-relay
|
||||
fallback, useful when there's no deliverable address or the automatic email needs backup. Copy
|
||||
it if you might need it (print it, read it aloud, hand it over) — it is never shown again and
|
||||
never stored in plaintext anywhere. The student opens the link and sets their own password; the
|
||||
instructor never sees it.
|
||||
|
||||
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
|
||||
|
||||
Same as creation, but for a student who already exists with progress/attempts/achievements —
|
||||
none of that is touched. Give an email or login ID, get a one-time activation link the same way.
|
||||
|
||||
**Bulk import via CSV** (Students → Bulk import):
|
||||
|
||||
Columns: `display_name, email, login_identifier, cohort_id` (only `display_name` is required per
|
||||
row). The whole file is validated before anything is written — one bad row rejects the entire
|
||||
file with a list of row errors, rather than importing some rows and skipping others. Never
|
||||
include a password column; it will always be rejected if present. Activation links for every
|
||||
created row are shown once after a successful import.
|
||||
|
||||
**Reset a student's password** (Students → \[student\] → Reset password):
|
||||
|
||||
Only for already-credentialed accounts. Issues a fresh reset link (`?reset=<token>`, valid 1
|
||||
hour) and immediately revokes every existing session for that student — the instructor still
|
||||
never sees or sets the actual password.
|
||||
|
||||
**Revoke a student's sessions** (Students → \[student\] → Revoke all sessions):
|
||||
|
||||
Immediately invalidates every active login for that student without touching their password.
|
||||
Use this if a device is lost/shared or a session looks compromised.
|
||||
|
||||
**Deactivate a student** (Students → \[student\] → PATCH status: inactive, via the existing
|
||||
students list — deactivation takes effect on their very next request, not just their next
|
||||
login, because `studentSession` middleware re-checks `status='active'` every time.
|
||||
|
||||
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
|
||||
time-limited (activation 7 days, reset 1 hour). Use Students → \[student\] → **Resend
|
||||
activation** (or Reset password, for an already-activated account) to issue a new one — there's
|
||||
no way to "resend" the same token, and that's intentional (only the most recent link is ever
|
||||
valid, so there's never ambiguity about which one to use). See `docs/RUNBOOK_STUDENT_
|
||||
ACTIVATION.md` for checking whether the automatic email actually delivered.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Runbook: Student Activation Pipeline
|
||||
|
||||
Covers the day-to-day operation of the enrollment → activation → login pipeline from `/admin`.
|
||||
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` for the underlying design and
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the email delivery mechanism itself.
|
||||
|
||||
## What happens automatically
|
||||
|
||||
Every time a student account gains a new enrollment — whether the website's checkout triggered
|
||||
it (`POST /api/integrations/enrollments`) or an instructor enrolled them manually from
|
||||
`/admin` → Programs — RootNode decides and sends the right email on its own:
|
||||
|
||||
- **Never activated before** → an activation email, with a one-time link to set their password.
|
||||
- **Already has real credentials** → an enrollment confirmation email pointing them back to
|
||||
sign in — no link/token involved.
|
||||
|
||||
Nothing needs to be triggered by hand for the normal case. The controls below are for when the
|
||||
normal case didn't work.
|
||||
|
||||
## Checking delivery status
|
||||
|
||||
**Per student**: Students → \[student\] → the "Notifications" card shows every notification ever
|
||||
sent to (or attempted for) that student, with type/status/attempt count. `sent` means it
|
||||
actually reached the provider successfully; `failed` means it gave up (see below); `queued`/
|
||||
`sending` means it's still in flight — check back in a few seconds, delivery happens through a
|
||||
background worker, not the same request that triggered it.
|
||||
|
||||
**Everywhere**: `/admin` → Notifications (owner/admin only) — the same information across every
|
||||
student, most recent first.
|
||||
|
||||
## If a student never got their activation email
|
||||
|
||||
1. Check Students → \[student\] → Notifications. If it shows `failed`, note the error — a
|
||||
`no_recipient_address` failure means the student truly has no email or contact address on
|
||||
file and can only be activated by an instructor manually relaying a link (see below); other
|
||||
error codes usually mean a delivery-infrastructure issue worth checking
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting table for.
|
||||
2. Students → \[student\] → Credentials → **Resend activation**. This invalidates whatever link
|
||||
was outstanding and sends a fresh one — safe to click as many times as needed; only the most
|
||||
recent link is ever valid, so there's never a "which link do I use" confusion for the student.
|
||||
3. If the student genuinely has no deliverable address at all (a younger student with a
|
||||
login-only account and no guardian contact email on file), convert/create them without email
|
||||
in the first place (see `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md`) and relay the one-time
|
||||
activation link shown in the admin UI directly (print it, read it aloud) — this manual path
|
||||
still works exactly as it did before Phase 5C added automatic email.
|
||||
|
||||
## If a student says they didn't get an enrollment confirmation
|
||||
|
||||
Check Students → \[student\] → Notifications for the `enrollment_confirmation` row. If it's
|
||||
`sent`, the email genuinely went out — check their spam folder, or confirm the address on file
|
||||
(Students → \[student\]) is actually correct. If none exists for a given enrollment,
|
||||
Programs → \[program\] → Enrollments → find the enrollment → (student detail page also works)
|
||||
and use **Send enrollment confirmation** to trigger one explicitly.
|
||||
|
||||
## Password reset
|
||||
|
||||
Students should use "Forgot password" on the login screen themselves wherever possible — it's
|
||||
delivered the same way as everything else here and the flow is self-service end to end.
|
||||
|
||||
If a student can't complete self-service reset (e.g. they no longer have access to the email a
|
||||
guardian originally provided and it needs an instructor to re-trigger with a corrected address),
|
||||
Students → \[student\] → Credentials → **Send password reset** issues a fresh reset link and
|
||||
revokes all of that student's existing sessions immediately. In production, the instructor
|
||||
never sees the token itself — the response is a confirmation that an email was queued, not the
|
||||
link. If automatic delivery still can't reach the student, use `docs/RUNBOOK_STUDENT_ACCOUNT_
|
||||
LIFECYCLE.md`'s manual-relay procedure, which is unaffected by any of this.
|
||||
|
||||
## What "resend" does NOT do
|
||||
|
||||
Resending activation does not touch the student's enrollment status, progress, achievements, or
|
||||
attempt history — it only affects the credential/token side of their account. It's always safe.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Runbook: Public TLS Certificate Renewal
|
||||
|
||||
If using Caddy's built-in Let's Encrypt (the default in `infra/docker/platform/Caddyfile.j2`):
|
||||
**nothing to do** — Caddy renews automatically well before expiry, as long as port 80 stays
|
||||
reachable for the ACME HTTP-01 challenge (`infra/firewall/rn-platform-01.rules` already allows
|
||||
it). `infra/scripts/production-smoke-test.sh` checks the live expiry date on every run as an
|
||||
early warning if auto-renewal ever silently breaks.
|
||||
|
||||
If using Cloudflare origin certs instead (see `docs/PRODUCTION_NETWORK_SECURITY.md` §8 for
|
||||
that decision): Cloudflare-issued origin certs are typically 15-year validity — this is a
|
||||
once-per-network-topology-change concern, not a recurring one. Re-issue from the Cloudflare
|
||||
dashboard (SSL/TLS -> Origin Server) and redeploy the cert/key via the `platform` Ansible role
|
||||
if it ever needs replacing (domain change, suspected compromise).
|
||||
|
||||
**Manual check:**
|
||||
```bash
|
||||
echo | openssl s_client -connect academy.example.com:443 -servername academy.example.com 2>/dev/null \
|
||||
| openssl x509 -noout -enddate
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# Runbook: Platform VM / Lab Host VM Rebuild
|
||||
|
||||
Both VMs are designed to be disposable and rebuildable from code — nothing irreplaceable
|
||||
should ever live only on either VM's disk outside of what's backed up (`docs/BACKUP_DR.md`).
|
||||
|
||||
## rn-platform-01 lost
|
||||
|
||||
Academic data survives (it's in Postgres backups, off-host). Instructor/student accounts and
|
||||
progress are recoverable; anything since the last backup is not (`docs/BACKUP_DR.md`'s RPO).
|
||||
|
||||
```bash
|
||||
cd infra/terraform && terraform apply # recreates the VM from the template
|
||||
cd ../ansible && ansible-playbook site.yml -i inventory/production.ini --limit platform --ask-vault-pass
|
||||
# Then restore Postgres — docs/RUNBOOK_POSTGRES_RESTORE.md, step 5b variant (fresh DB this time,
|
||||
# no need to rename since it's a fresh instance)
|
||||
./infra/scripts/production-smoke-test.sh
|
||||
```
|
||||
|
||||
## rn-lab-host-01 lost
|
||||
|
||||
**No academic data is lost** — this host only ever holds disposable Lab containers by design
|
||||
(`docs/BACKUP_DR.md`'s Tier distinction). Every enrolled student's Lab needs re-provisioning
|
||||
after this, which is a normal "Prepare Labs" click, not a recovery operation.
|
||||
|
||||
```bash
|
||||
cd infra/terraform && terraform apply
|
||||
cd ../ansible && ansible-playbook site.yml -i inventory/production.ini --limit lab_host --ask-vault-pass
|
||||
# Re-generate Docker TLS material for the new host if its identity (IP/DNS) changed —
|
||||
# docs/RUNBOOK_DOCKER_TLS_ROTATION.md
|
||||
# From /admin: Prepare Labs again for any cohort that needs it.
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
# Security Scanning (Phase 4.1 §24)
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml`'s `security-scan` job. All free, open-source,
|
||||
no account/signup required for any of them — no paid scanner was added.
|
||||
|
||||
## What's scanned
|
||||
|
||||
| Tool | Checks | Scope |
|
||||
|---|---|---|
|
||||
| `npm audit --audit-level=high` | Known-vulnerable dependencies | Every first-party Node service |
|
||||
| [gitleaks](https://github.com/gitleaks/gitleaks) | Committed secrets (keys, tokens, credentials) | Whole repo |
|
||||
| [hadolint](https://github.com/hadolint/hadolint) | Dockerfile best-practice/security lint | Every first-party Dockerfile |
|
||||
| [shellcheck](https://www.shellcheck.net/) | Shell script correctness/security | `infra/**/*.sh` |
|
||||
| [trivy](https://github.com/aquasecurity/trivy) | OS/library CVEs in a built container image | `platform-api` image (representative — same base/layers as the other Node services) |
|
||||
|
||||
## Severity policy
|
||||
|
||||
- **CRITICAL / HIGH** — fails the build (`security-scan` blocks `build-images`, which blocks
|
||||
`deploy-staging`, which blocks production promotion — see the job dependency chain in the
|
||||
workflow file).
|
||||
- **MEDIUM / LOW** — reported in the job output, never blocks. A build that's permanently red
|
||||
over a low-severity transitive dependency finding nobody can immediately fix trains everyone
|
||||
to ignore red builds, which is worse than not scanning at all.
|
||||
|
||||
`npm audit --audit-level=high` and trivy's `--severity HIGH,CRITICAL` both implement this
|
||||
directly at the tool level, not via manual triage.
|
||||
|
||||
## What's NOT in the automated job (and why)
|
||||
|
||||
- **Dedicated IaC security linting** (checkov/tflint) — not added. `infra/scripts/validate-compose.sh`,
|
||||
`infra/firewall/validate-firewall.sh`, `ansible-playbook --syntax-check`, and `tofu validate`
|
||||
(all in the `regression` job) already catch structural/syntax problems in the infra code;
|
||||
a dedicated security-policy linter for Terraform/Ansible is a reasonable Phase 5 addition once
|
||||
there's a real cloud footprint to misconfigure, not before.
|
||||
- **DAST/penetration testing** — out of scope for CI; this is a build-time gate, not a running-system
|
||||
test. Covered separately by `docs/PRODUCTION_VALIDATION_CHECKLIST.md`'s security section once
|
||||
real hardware exists.
|
||||
- **SAST beyond hadolint/shellcheck** — the codebase is small enough (a handful of first-party
|
||||
Node services) that `npm audit` + secret scanning + Dockerfile/shell lint cover the realistic
|
||||
risk surface; a dedicated SAST tool (Semgrep, CodeQL) is a reasonable addition once the
|
||||
codebase or team grows enough to justify the added CI time.
|
||||
|
||||
## Local reproduction
|
||||
|
||||
Every check above can be run locally with the exact same free tools — no CI-only dependency.
|
||||
See the `security-scan` job's steps in `infra/ci/gitea-workflows/build-and-test.yml` for the
|
||||
exact commands (they're copy-pasteable).
|
||||
@@ -0,0 +1,220 @@
|
||||
# Website → RootNode Academy Enrollment Integration
|
||||
|
||||
Audience: whoever builds `rootnodeacademy.com` (marketing, pricing, checkout) and needs to turn
|
||||
a successful purchase/signup into a real RootNode Academy student enrollment. This document is
|
||||
meant to be enough on its own — you should not need to read the Platform API's source to
|
||||
integrate against it.
|
||||
|
||||
## Responsibility boundary
|
||||
|
||||
**The website is responsible for:** marketing, program selection, pricing, checkout/payment (now
|
||||
or in the future), collecting enrollment/customer information, and sending ONE verified
|
||||
enrollment event to the endpoint below once payment/signup is confirmed.
|
||||
|
||||
**RootNode's platform is responsible for everything after that:** student identity, program
|
||||
entitlement, account activation, the entire password lifecycle, all operational enrollment
|
||||
email (activation, enrollment confirmation, password reset — see
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`), learning, Labs, progress, assessment, and
|
||||
completion. **The website does not send email for platform activation or any other account
|
||||
lifecycle event — RootNode owns that entirely, as of Phase 5C.** Do not build a parallel
|
||||
"welcome" or "here's how to log in" email on the website side; it would either duplicate or
|
||||
conflict with what RootNode already sends automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
rootnodeacademy.com (marketing, pricing, checkout — built separately, not in this repo)
|
||||
|
|
||||
| your backend verifies payment/signup with your payment provider
|
||||
v
|
||||
your backend calls the RootNode Enrollment API (below) — server-to-server only
|
||||
v
|
||||
RootNode Academy Platform API
|
||||
creates/matches a student account
|
||||
creates or reuses (idempotently) an enrollment
|
||||
v
|
||||
the student logs in at the RootNode Academy platform and sees their program
|
||||
```
|
||||
|
||||
**RootNode never talks to your payment provider, and your payment provider never talks to
|
||||
RootNode.** Your backend is the only thing that calls this endpoint, after it has already
|
||||
confirmed payment (or whatever your enrollment trigger is) on its own. This endpoint knows
|
||||
nothing about Stripe, PayPal, or any other provider's object model — it only understands a
|
||||
small, generic enrollment event described below.
|
||||
|
||||
## The endpoint
|
||||
|
||||
```
|
||||
POST /api/integrations/enrollments
|
||||
```
|
||||
|
||||
Reachable at the RootNode Academy platform's public origin (the same host your students'
|
||||
browsers already use), via HTTPS in any real deployment. This is the **only** endpoint your
|
||||
backend needs.
|
||||
|
||||
## Authentication
|
||||
|
||||
A pre-shared secret, sent as a header:
|
||||
|
||||
```
|
||||
X-RootNode-Integration-Key: <the secret>
|
||||
```
|
||||
|
||||
- This is **not** a student or admin login. It has nothing to do with cookies, sessions, or
|
||||
browser auth of any kind.
|
||||
- **The secret must live only in your backend's server-side configuration.** It must never be
|
||||
embedded in any browser-served JavaScript, mobile app bundle, or client-side config — anyone
|
||||
who obtains it can create enrollments.
|
||||
- A request with a missing or wrong key gets `401 { "error": "unauthorized" }` and nothing else
|
||||
happens — no student is looked up, nothing is created.
|
||||
- RootNode will provide you with the production secret out of band (not over email/chat in
|
||||
plaintext — ask the RootNode team for the current secure-sharing process). Rotate it by
|
||||
asking RootNode to issue a new one; there is no self-service rotation yet.
|
||||
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "order_9f8e7d6c5b4a",
|
||||
"source": "website",
|
||||
"program_slug": "root-kubernetes-foundations",
|
||||
"status": "paid",
|
||||
"student": {
|
||||
"email": "[email protected]",
|
||||
"login_identifier": null,
|
||||
"display_name": "Jamie Rivera",
|
||||
"contact_email": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `event_id` | **yes** | A string, unique per real-world event, from *your* system (an order ID, a payment intent ID — anything stable). This is the idempotency key — see below. Not a RootNode-generated value. |
|
||||
| `source` | no | Free text, e.g. `"website"`. Recorded for audit only, defaults to `"integration"` if omitted. |
|
||||
| `program_slug` | **yes** | The RootNode program's id, e.g. `"root-kubernetes-foundations"`. Ask RootNode for the current list — there is no public program-catalog endpoint. |
|
||||
| `status` | **yes** | One of: `"paid"`, `"active"`, `"confirmed"` (all mean "grant access now") or `"pending"`, `"awaiting_payment"` (means "record the enrollment but don't grant access yet"). This is a small, fixed, provider-neutral vocabulary — translate whatever your payment provider says into one of these five values before calling. Anything else is rejected. |
|
||||
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
|
||||
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
|
||||
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
|
||||
| `student.contact_email` | no | **Only** for a student with no email of their own (§ below) — where to send THIS enrollment's activation/confirmation email (typically a guardian's address collected at checkout). Never treated as the student's own identity/login. |
|
||||
|
||||
### What you must NOT send
|
||||
|
||||
- No card numbers, CVVs, bank details, or any other payment-card data. This endpoint has no
|
||||
field for it and never will — RootNode never touches payment-card data at all.
|
||||
- No raw provider webhook payloads (Stripe event objects, PayPal IPN bodies, etc.) — translate
|
||||
to the shape above first. This endpoint is deliberately provider-neutral.
|
||||
- No password. RootNode issues its own credential/activation flow (see "What the student
|
||||
experiences" below) — you cannot set a student's password through this endpoint, and should
|
||||
never try to invent one.
|
||||
|
||||
## Response
|
||||
|
||||
**Success — a new enrollment was created** (`201`):
|
||||
```json
|
||||
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": true }
|
||||
```
|
||||
|
||||
**Success — this exact `event_id` was already processed** (`200`, idempotent replay — see below):
|
||||
```json
|
||||
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": false }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body `error` | Meaning |
|
||||
|---|---|---|
|
||||
| `401` | `unauthorized` | Missing/wrong `X-RootNode-Integration-Key`. |
|
||||
| `400` | `event_id is required` | — |
|
||||
| `400` | `program_slug is required` | — |
|
||||
| `400` | `status must be one of: paid, active, confirmed, pending, awaiting_payment` | — |
|
||||
| `400` | `student.email or student.login_identifier is required` | — |
|
||||
| `404` | `PROGRAM_NOT_FOUND` | `program_slug` doesn't exist. |
|
||||
| `409` | `PROGRAM_INACTIVE` | The program exists but isn't currently open for enrollment. |
|
||||
| `500` | `internal_error` | Something unexpected broke server-side. Safe to retry (see below). |
|
||||
|
||||
## Idempotency and retries
|
||||
|
||||
**`event_id` is the whole mechanism.** Send the exact same `event_id` again (your payment
|
||||
provider retried a webhook, your own backend retried a timed-out request, anything) and you get
|
||||
back the **same enrollment**, `created: false`, `200` instead of `201` — never a duplicate. This
|
||||
is enforced at the database level (a unique constraint), not just "best effort," so it's safe
|
||||
under concurrent/parallel retries too, not just sequential ones.
|
||||
|
||||
**Retry policy:** on a `5xx` or a network error/timeout, retry with the *same* `event_id`
|
||||
(standard exponential backoff is fine — a few attempts over a couple of minutes is plenty). On a
|
||||
`4xx`, do not blindly retry — fix the request (see the error table above) or, for `401`, check
|
||||
your key.
|
||||
|
||||
**Never generate a new `event_id` for a retry of the same real-world event.** Doing so would
|
||||
create a second enrollment. Reserve a fresh `event_id` only for a genuinely new enrollment event
|
||||
(e.g. a renewal, a second purchase).
|
||||
|
||||
## Student matching rules
|
||||
|
||||
RootNode looks for an existing student by `email` (case-insensitive) or `login_identifier`
|
||||
(exact match), in that priority. If found, that student is enrolled. If not found, a new student
|
||||
account is created automatically — this is a trusted server-to-server call from your backend,
|
||||
equivalent to an instructor adding a student through the admin portal, not a public signup form.
|
||||
|
||||
## Minor / no-email students
|
||||
|
||||
RootNode explicitly supports students without their own email address (younger students,
|
||||
enrolled by a parent/guardian). Send `student.login_identifier` instead of (or in addition to)
|
||||
`email` — any stable string you control, such as an internal account/order reference. You must
|
||||
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
|
||||
|
||||
If the enrolled student has no email at all, send `student.contact_email` too (e.g. the
|
||||
guardian's address collected at checkout) so RootNode has somewhere to deliver the activation
|
||||
email — otherwise the enrollment is created but nobody is notified automatically, and an
|
||||
instructor has to relay the activation link manually. `contact_email` is stored only against
|
||||
that specific enrollment, never copied into the student's own identity record.
|
||||
|
||||
There is no guardian/parent account system yet — the enrollment is created directly against the
|
||||
student identity you provide. If you need to support "a parent manages multiple children's
|
||||
enrollments" as a first-class concept later, talk to the RootNode team before building around a
|
||||
workaround; the schema has room for it but it isn't implemented.
|
||||
|
||||
## What the student experiences after you call this (as of Phase 5C)
|
||||
|
||||
If `status` mapped to "active": RootNode sends the student (or their enrollment's
|
||||
`contact_email`) a real email automatically —an activation email with a one-time setup link if
|
||||
they're new, or an enrollment confirmation if they already have a RootNode account — and they
|
||||
can log in as soon as they act on it. **You do not need to send any email yourself for this** —
|
||||
see "Responsibility boundary" above. If `status` mapped to "pending": the enrollment exists but
|
||||
the student can't start until a RootNode instructor activates it — useful if your flow needs a
|
||||
manual review step before access is granted; no email goes out until activation happens.
|
||||
|
||||
If the student has neither an `email` nor a `contact_email` on file, RootNode still creates the
|
||||
account and enrollment, but nothing gets emailed automatically — an instructor has to relay the
|
||||
activation link by hand. Send whichever address you have whenever you have one.
|
||||
|
||||
## Local/staging testing
|
||||
|
||||
Set `INTEGRATION_SHARED_SECRET` in your test environment's config (in local dev,
|
||||
`platform/.env`; example dev value used by this repo's own test suite:
|
||||
`dev-integration-secret-not-for-production` — never use this value anywhere real). Then:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:4200/api/integrations/enrollments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
|
||||
-d '{
|
||||
"event_id": "test-order-001",
|
||||
"source": "website",
|
||||
"program_slug": "root-kubernetes-foundations",
|
||||
"status": "paid",
|
||||
"student": { "email": "[email protected]", "display_name": "Test Student" }
|
||||
}'
|
||||
```
|
||||
|
||||
All example data on this page is fake — do not reuse these values against a real deployment.
|
||||
|
||||
## Production secret handling
|
||||
|
||||
- The production `X-RootNode-Integration-Key` value must be requested from the RootNode team
|
||||
and stored in your backend's secret manager/environment configuration — never in source
|
||||
control, never in a browser-reachable config, never logged.
|
||||
- RootNode's own logs and audit trail never record the secret's value, only that an
|
||||
authenticated call happened (or a `401` if it didn't).
|
||||
Generated
+2996
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
|
||||
import ExamReport from './components/ExamReport'
|
||||
import ExamStartModal from './components/ExamStartModal'
|
||||
import ExamHistory from './components/ExamHistory'
|
||||
import AssessmentBanner from './components/AssessmentBanner'
|
||||
import styles from './App.module.css'
|
||||
|
||||
const MIN_SIDEBAR_W = 180
|
||||
@@ -303,6 +304,7 @@ export default function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||
<AssessmentBanner />
|
||||
|
||||
{/* Bundle navigation bar */}
|
||||
<BundleNav
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function AccountView({ instructor }) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try {
|
||||
await adminApi.changePassword(oldPassword, newPassword)
|
||||
setOldPassword(''); setNewPassword('')
|
||||
setNotice('Password changed. Any other sessions you had open elsewhere were signed out.')
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Account</h2>
|
||||
<p className={styles.muted}>{instructor.display_name} · {instructor.email} · role: {instructor.role}</p>
|
||||
|
||||
<h3 style={{ marginTop: '1.5rem' }}>Change password</h3>
|
||||
<form onSubmit={submit} className={styles.formRow}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Current password</label>
|
||||
<input className={styles.input} type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} autoComplete="current-password" required /></div>
|
||||
<div><label className={styles.label}>New password (12+ chars)</label>
|
||||
<input className={styles.input} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" minLength={12} required /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>{busy ? 'Changing…' : 'Change password'}</button></div>
|
||||
</form>
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Admin portal — visually distinct from the student shell (a muted accent instead of
|
||||
--green) so instructors and students never mistake which mode they're in, while still
|
||||
reusing the same design tokens for everything else. */
|
||||
|
||||
.wrap { min-height: 100vh; background: var(--bg); color: var(--text); font-family: var(--sans); }
|
||||
|
||||
.loginWrap { height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.loginCard { width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; }
|
||||
|
||||
.brand { font-family: var(--mono); font-size: 1.2rem; font-weight: 600; color: var(--blue); display: flex; align-items: center; gap: 0.5rem; }
|
||||
.subtitle { color: var(--text-2); font-size: 0.85rem; margin: 0.4rem 0 1.25rem; }
|
||||
|
||||
.input, .select {
|
||||
width: 100%; padding: 0.55rem 0.6rem; margin-top: 0.3rem;
|
||||
background: var(--surface2); border: 1px solid var(--border2); border-radius: var(--radius);
|
||||
color: var(--text); font-family: var(--sans); font-size: 0.9rem;
|
||||
}
|
||||
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin-top: 0.9rem; }
|
||||
|
||||
.btn {
|
||||
padding: 0.55rem 0.9rem; background: var(--blue); color: #04141c; border: none;
|
||||
border-radius: var(--radius); font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btnSecondary { padding: 0.5rem 0.8rem; background: transparent; color: var(--text); border: 1px solid var(--border2); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
|
||||
.btnDanger { padding: 0.5rem 0.8rem; background: transparent; color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
|
||||
.nav { display: flex; gap: 1.25rem; }
|
||||
.navLink { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.3rem 0; }
|
||||
.navLinkActive { color: var(--blue); border-bottom: 2px solid var(--blue); }
|
||||
|
||||
.content { max-width: 1000px; margin: 1.5rem auto; padding: 0 1.5rem; }
|
||||
|
||||
.statGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.statCard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
|
||||
.statValue { font-size: 1.6rem; font-weight: 700; }
|
||||
.statLabel { color: var(--text-2); font-size: 0.78rem; margin-top: 0.2rem; }
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.25rem; }
|
||||
.card h2, .card h3 { margin-bottom: 0.75rem; font-size: 1rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border2); font-size: 0.75rem; text-transform: uppercase; }
|
||||
td { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
tr:hover td { background: var(--surface2); }
|
||||
|
||||
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
|
||||
.pillOk { background: var(--green-dim); color: var(--green); }
|
||||
.pillWarn { background: var(--amber-dim); color: var(--amber); }
|
||||
.pillErr { background: var(--red-dim); color: var(--red); }
|
||||
.pillMuted { background: var(--surface3); color: var(--text-2); }
|
||||
|
||||
.formRow { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.formRow > div { flex: 1; min-width: 140px; }
|
||||
|
||||
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
|
||||
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.muted { color: var(--text-2); font-size: 0.85rem; }
|
||||
|
||||
/* Phase 4.1 §16 — shared pagination control (AuditView, StudentsView attempts). */
|
||||
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Operational overview — active cohorts, students, Lab states, recent activity. Deliberately
|
||||
// lean (no Prometheus/Grafana here, see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §9) — this is
|
||||
// "is class ready / is anything on fire," not a metrics platform.
|
||||
export default function AdminDashboard() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.dashboard().then(setData).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.statGrid}>
|
||||
<Stat label="Active cohorts" value={data.active_cohorts} />
|
||||
<Stat label="Total students" value={data.total_students} />
|
||||
<Stat label="Labs running" value={data.labs.running} />
|
||||
<Stat label="Labs provisioning" value={data.labs.provisioning} />
|
||||
<Stat label="Labs in error" value={data.labs.error} />
|
||||
<Stat label="Labs idle/destroyed" value={data.labs.idle_or_destroyed} />
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Recent completions</h3>
|
||||
{data.recent_completions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Scenario</th><th>Completed</th></tr></thead>
|
||||
<tbody>
|
||||
{data.recent_completions.map((c, i) => (
|
||||
<tr key={i}><td>{c.display_name}</td><td>{c.scenario_id}</td><td>{new Date(c.completed_at).toLocaleString()}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Recent environment failures</h3>
|
||||
{data.recent_failures.length === 0 ? <p className={styles.muted}>None.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Reason</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
{data.recent_failures.map((f, i) => (
|
||||
<tr key={i}><td>{f.student_id}</td><td>{f.error_reason || '—'}</td><td>{f.started_at ? new Date(f.started_at).toLocaleString() : '—'}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import AdminDashboard from './AdminDashboard'
|
||||
import CohortsView from './CohortsView'
|
||||
import StudentsView from './StudentsView'
|
||||
import InstructorsView from './InstructorsView'
|
||||
import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import ProgramsView from './ProgramsView'
|
||||
import NotificationsView from './NotificationsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
// separate rn_instructor session cookie, separate login). A student session cannot reach any
|
||||
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
|
||||
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
// Learning/Assessments (Phase 3B) are visible to every instructor role — cohort scoping for a
|
||||
// plain 'instructor' happens server-side per route, not by hiding the whole tab.
|
||||
const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
|
||||
export default function AdminPortal() {
|
||||
const [instructor, setInstructor] = useState(null) // null = checking, false = need login, object = signed in
|
||||
const [view, setView] = useState('dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.session().then((r) => setInstructor(r.instructor)).catch(() => setInstructor(false))
|
||||
}, [])
|
||||
|
||||
if (instructor === null) return null
|
||||
if (!instructor) return <InstructorLogin onLogin={setInstructor} />
|
||||
|
||||
const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(instructor.role))
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||
<nav className={styles.nav}>
|
||||
{visibleNav.map((item) => (
|
||||
<button key={item.key} className={`${styles.navLink} ${view === item.key ? styles.navLinkActive : ''}`} onClick={() => setView(item.key)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button className={`${styles.navLink} ${view === 'account' ? styles.navLinkActive : ''}`} onClick={() => setView('account')}>
|
||||
{instructor.display_name}
|
||||
</button>
|
||||
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setInstructor(false))}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && <NotificationsView />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||
{view === 'account' && <AccountView instructor={instructor} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstructorLogin({ onLogin }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const { instructor } = await adminApi.login(email, password)
|
||||
onLogin(instructor)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Sign-in failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.loginWrap}>
|
||||
<form className={styles.loginCard} onSubmit={submit}>
|
||||
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||
<p className={styles.subtitle}>Instructor sign-in. Not connected to student accounts.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus autoComplete="username" />
|
||||
<label className={styles.label} style={{ marginTop: '0.75rem' }}>Password</label>
|
||||
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !email || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Exam and Capstone definition/session management. Definition authoring (create/edit) is
|
||||
// admin/owner only server-side (RBAC) — a plain instructor can still assign an existing
|
||||
// definition to their own cohort and view/cancel sessions within it, so the form below is
|
||||
// always shown but the server will 403 a create/edit attempt from an instructor role; this
|
||||
// mirrors CohortsView's approach of not pre-emptively hiding every control, since assessments
|
||||
// are viewed by all roles and only definition-authoring is restricted.
|
||||
export default function AssessmentsView({ role }) {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
adminApi.cohorts().then(setCohorts).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <AssessmentDetail definition={selected} cohorts={cohorts} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create assessment</h2>
|
||||
<CreateAssessmentForm onCreated={reload} role={role} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Exams & Capstones</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Type</th><th>Duration</th><th>Hints</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{assessments.map((a) => (
|
||||
<tr key={a.assessment_definition_id}>
|
||||
<td>{a.title}</td>
|
||||
<td><span className={styles.pill}>{a.type}</span></td>
|
||||
<td>{a.duration_minutes} min</td>
|
||||
<td>{a.hint_policy}</td>
|
||||
<td><span className={`${styles.pill} ${a.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{a.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(a)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 4.1 §18 — pool_spec is built from structured picks (required scenarios + named random
|
||||
// pools), not hand-typed JSON. `catalog` ({ stages: [{ id, name, scenarios: [{id,title}] }] })
|
||||
// comes from GET /api/admin/scenario-catalog. An owner can still drop into a raw-JSON view for
|
||||
// anything the structured editor doesn't cover — see the toggle below.
|
||||
function PoolSpecEditor({ catalog, required, setRequired, pools, setPools }) {
|
||||
const allScenarios = (catalog?.stages || []).flatMap((s) => s.scenarios.map((sc) => ({ ...sc, stageName: s.name })))
|
||||
const titleFor = (id) => allScenarios.find((s) => s.id === id)?.title || id
|
||||
|
||||
const addPool = () => setPools([...pools, { count: 1, from: [] }])
|
||||
const removePool = (i) => setPools(pools.filter((_, idx) => idx !== i))
|
||||
const updatePool = (i, patch) => setPools(pools.map((p, idx) => (idx === i ? { ...p, ...patch } : p)))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={styles.label}>Required scenarios (always included)</label>
|
||||
<select className={styles.select} multiple size={6} style={{ width: '100%' }}
|
||||
value={required} onChange={(e) => setRequired([...e.target.selectedOptions].map((o) => o.value))}>
|
||||
{catalog?.stages.map((stage) => (
|
||||
<optgroup key={stage.id} label={stage.name}>
|
||||
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<p className={styles.muted}>{required.length ? `${required.length} selected: ${required.map(titleFor).join(', ')}` : 'None selected — cmd/ctrl-click to pick multiple.'}</p>
|
||||
|
||||
<label className={styles.label} style={{ marginTop: '1rem', display: 'block' }}>Random pools (draw N at random from a set, every attempt)</label>
|
||||
{pools.map((pool, i) => (
|
||||
<div key={i} className={styles.card} style={{ marginBottom: '0.5rem' }}>
|
||||
<div className={styles.formRow}>
|
||||
<div style={{ maxWidth: '100px' }}><label className={styles.label}>Draw count</label>
|
||||
<input className={styles.input} type="number" min="1" value={pool.count}
|
||||
onChange={(e) => updatePool(i, { count: Number(e.target.value) })} /></div>
|
||||
<div><button type="button" className={styles.linkBtn} onClick={() => removePool(i)}>Remove pool</button></div>
|
||||
</div>
|
||||
<label className={styles.label}>Draw from</label>
|
||||
<select className={styles.select} multiple size={5} style={{ width: '100%' }}
|
||||
value={pool.from} onChange={(e) => updatePool(i, { from: [...e.target.selectedOptions].map((o) => o.value) })}>
|
||||
{catalog?.stages.map((stage) => (
|
||||
<optgroup key={stage.id} label={stage.name}>
|
||||
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{pool.count > pool.from.length && <p className={styles.muted} style={{ color: 'var(--amber, #b58900)' }}>Draw count ({pool.count}) exceeds the pool size ({pool.from.length}) — every draw will use every scenario, not a random subset.</p>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className={styles.linkBtn} onClick={addPool}>+ Add random pool</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAssessmentForm({ onCreated, role }) {
|
||||
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [required, setRequired] = useState([])
|
||||
const [pools, setPools] = useState([])
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const [rawPoolSpec, setRawPoolSpec] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => { adminApi.scenarioCatalog().then(setCatalog).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
// Owner's raw-JSON view, when open, is the source of truth — otherwise built from the
|
||||
// structured pickers above.
|
||||
const pool_spec = advanced
|
||||
? JSON.parse(rawPoolSpec || '{"required":[],"random":[]}')
|
||||
: { required, random: pools.filter((p) => p.count > 0 && p.from.length > 0) };
|
||||
if (!advanced && !required.length && !pool_spec.random.length) {
|
||||
throw new Error('Pick at least one required scenario or one random pool.')
|
||||
}
|
||||
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
|
||||
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
|
||||
setRequired([]); setPools([]); setRawPoolSpec('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message?.includes('JSON') ? 'Raw pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>ID (slug)</label>
|
||||
<input className={styles.input} value={form.id} onChange={(e) => setForm({ ...form, id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Type</label>
|
||||
<select className={styles.select} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="exam">exam</option><option value="capstone">capstone</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Duration (min)</label>
|
||||
<input className={styles.input} type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Hint policy</label>
|
||||
<select className={styles.select} value={form.hint_policy} onChange={(e) => setForm({ ...form, hint_policy: e.target.value })}>
|
||||
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
{role === 'owner' && (
|
||||
<p className={styles.muted}>
|
||||
<button type="button" className={styles.linkBtn} onClick={() => setAdvanced(!advanced)}>
|
||||
{advanced ? '← Back to the structured editor' : 'Advanced: edit pool_spec as raw JSON →'}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{advanced ? (
|
||||
<>
|
||||
<label className={styles.label}>Pool spec (JSON)</label>
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
|
||||
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
|
||||
value={rawPoolSpec} onChange={(e) => setRawPoolSpec(e.target.value)} required />
|
||||
</>
|
||||
) : !catalog ? <p className={styles.muted}>Loading scenario catalog…</p> : (
|
||||
<PoolSpecEditor catalog={catalog} required={required} setRequired={setRequired} pools={pools} setPools={setPools} />
|
||||
)}
|
||||
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AssessmentDetail({ definition, cohorts, onBack }) {
|
||||
// Phase 4.1 §16 — assessmentSessions is now paginated (was unbounded); `result` holds the
|
||||
// full { items, page, total, total_pages } envelope, `sessions` stays the plain array for
|
||||
// the table below so its render logic doesn't need to change.
|
||||
const [result, setResult] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessmentSessions(definition.assessment_definition_id, page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [definition, page])
|
||||
useEffect(reload, [reload])
|
||||
const sessions = result?.items || null
|
||||
|
||||
const assign = async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.assignAssessment(definition.assessment_definition_id, cohortId); setNotice('Assigned.') }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const cancelSession = async (sessionId) => {
|
||||
if (!window.confirm('Cancel this session?')) return
|
||||
try { await adminApi.cancelAssessmentSession(sessionId); reload() } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to assessments</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{definition.title} <span className={styles.muted}>({definition.type})</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Assign to cohort</h3>
|
||||
<div className={styles.formRow}>
|
||||
<div><select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
<div><button className={styles.btn} disabled={busy || !cohortId} onClick={assign}>Assign</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Sessions</h3>
|
||||
{!sessions ? <p className={styles.muted}>Loading…</p> : sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th><th>Started</th><th>Result</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : s.status === 'expired' ? styles.pillErr : styles.pillWarn}`}>{s.status}</span></td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
<td>{['not_started', 'in_progress'].includes(s.status) && <button className={styles.linkBtn} onClick={() => cancelSession(s.assessment_session_id)}>Cancel</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{result && result.total_pages > 1 && (
|
||||
<div className={styles.pager}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
|
||||
<span className={styles.muted}>Page {result.page} of {result.total_pages} ({result.total} total)</span>
|
||||
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// admin/owner only (enforced server-side too). Phase 4.1 §16 — real server-side pagination
|
||||
// (platform/api/src/pagination.js) replaces the old flat "latest 200, nothing older" cap.
|
||||
export default function AuditView() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setResult(null)
|
||||
adminApi.auditEvents(page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [page])
|
||||
|
||||
const events = result?.items || []
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Audit log</h2>
|
||||
<p className={styles.muted}>{result ? `${result.total} total events` : 'Security/admin events, most recent first.'}</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!result ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.audit_event_id}>
|
||||
<td>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
|
||||
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.pager}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
|
||||
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
|
||||
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
const DASHBOARD_POLL_MS = 5000
|
||||
|
||||
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
|
||||
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
|
||||
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
|
||||
export default function ClassroomView() {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [classrooms, setClassrooms] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!cohortId) return
|
||||
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cohortId && (
|
||||
<div className={styles.card}>
|
||||
<h3>Schedule a class</h3>
|
||||
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Classes</h3>
|
||||
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{classrooms.map((c) => (
|
||||
<tr key={c.classroom_session_id}>
|
||||
<td>{c.title}</td>
|
||||
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
|
||||
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateClassroomForm({ cohortId, onCreated }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [scheduledStart, setScheduledStart] = useState('')
|
||||
const [meetingUrl, setMeetingUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createClassroom(cohortId, {
|
||||
title: title.trim(), description: description.trim() || undefined,
|
||||
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
|
||||
})
|
||||
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Scheduled start</label>
|
||||
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
|
||||
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
|
||||
</div>
|
||||
<label className={styles.label}>Description (optional)</label>
|
||||
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ClassroomDashboard({ classroomId, onBack }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [activityScenario, setActivityScenario] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
|
||||
}, [classroomId])
|
||||
useEffect(() => {
|
||||
reload()
|
||||
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
|
||||
return () => clearInterval(t)
|
||||
}, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
|
||||
const { classroom, students, announcements, help_requests: helpRequests } = data
|
||||
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to classes</button>
|
||||
<div className={styles.card} style={{ marginTop: '1rem' }}>
|
||||
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<div className={styles.formRow}>
|
||||
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
|
||||
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
|
||||
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { results } = await adminApi.prepareClassroomLabs(classroomId)
|
||||
setNotice(`Labs prepared: ${results.length} student(s).`)
|
||||
})}>Prepare Labs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Participants ({students.length})</h3>
|
||||
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
|
||||
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
|
||||
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
|
||||
<td className={styles.muted}>{s.hints_used}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
|
||||
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{helpRequests.map((h) => (
|
||||
<tr key={h.help_request_id}>
|
||||
<td>{h.display_name || h.student_id}</td>
|
||||
<td className={styles.muted}>{h.category}</td>
|
||||
<td className={styles.muted}>{h.message || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
|
||||
<td>
|
||||
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
|
||||
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Current activity</h3>
|
||||
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
|
||||
<div className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
|
||||
)}
|
||||
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
|
||||
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function CohortsView({ role }) {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canManage = role !== 'instructor' // owner/admin only — a plain instructor works within cohorts already assigned to them
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <CohortDetail cohortId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create cohort</h2>
|
||||
<CreateCohortForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Cohorts</h2>
|
||||
{!cohorts ? <p className={styles.muted}>Loading…</p> : cohorts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Tier</th><th>Students</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{cohorts.map((c) => (
|
||||
<tr key={c.cohort_id}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.tier || '—'}</td>
|
||||
<td>{c.student_count}</td>
|
||||
<td><span className={`${styles.pill} ${c.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{c.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(c.cohort_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateCohortForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createCohort(form)
|
||||
setForm({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Cohort ID (slug)</label>
|
||||
<input className={styles.input} placeholder="root-fall-2026" value={form.cohort_id} onChange={(e) => setForm({ ...form, cohort_id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} placeholder="Root Fall 2026" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Tier</label>
|
||||
<input className={styles.input} placeholder="root" value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>Start date</label>
|
||||
<input className={styles.input} type="date" value={form.start_date} onChange={(e) => setForm({ ...form, start_date: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>End date</label>
|
||||
<input className={styles.input} type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>{busy ? 'Creating…' : 'Create cohort'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function CohortDetail({ cohortId, canManage, onBack }) {
|
||||
const [students, setStudents] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [prepResults, setPrepResults] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newStudentName, setNewStudentName] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.cohortStudents(cohortId).then(setStudents).catch((e) => setError(e.message))
|
||||
adminApi.cohortProgress(cohortId).then(setProgress).catch(() => {})
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const addStudent = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!newStudentName.trim()) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
|
||||
setNewStudentName('')
|
||||
reload()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const prepareLabs = async () => {
|
||||
setBusy(true); setError(null); setPrepResults(null)
|
||||
try {
|
||||
const { results } = await adminApi.prepareLabs(cohortId)
|
||||
setPrepResults(results)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const destroyLabs = async () => {
|
||||
if (!window.confirm(`Destroy all Labs for this cohort? Academic progress is not affected.`)) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const { results } = await adminApi.destroyLabs(cohortId)
|
||||
setPrepResults(results)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const assignCurriculum = async () => {
|
||||
setBusy(true)
|
||||
try { await adminApi.assignCohortCurriculum(cohortId, 'rootnode-path') } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const readyCount = prepResults?.filter((r) => r.status === 'running').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to cohorts</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{cohortId}</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Roster ({students?.length ?? '…'})</h3>
|
||||
{canManage && (
|
||||
<form onSubmit={addStudent} className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
|
||||
</form>
|
||||
)}
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Last active</th></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum</h3>
|
||||
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
|
||||
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className={styles.card}>
|
||||
<h3>Cohort progress ({progress.student_count} students)</h3>
|
||||
{progress.stages.map((s) => (
|
||||
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem' }}>
|
||||
<span>{s.name}</span><span className={styles.muted}>{s.pct}%</span>
|
||||
</div>
|
||||
<div style={{ background: 'var(--surface3)', borderRadius: 4, height: 6 }}>
|
||||
<div style={{ background: 'var(--green)', borderRadius: 4, height: 6, width: `${s.pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Classroom preparation</h3>
|
||||
<div className={styles.formRow}>
|
||||
<button className={styles.btn} onClick={prepareLabs} disabled={busy}>{busy ? 'Working…' : 'Prepare Labs'}</button>
|
||||
<button className={styles.btnDanger} onClick={destroyLabs} disabled={busy}>Destroy Cohort Labs</button>
|
||||
</div>
|
||||
{prepResults && (
|
||||
<>
|
||||
{readyCount !== undefined && <p className={styles.muted}>{readyCount}/{prepResults.length} READY</p>}
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{prepResults.map((r) => (
|
||||
<tr key={r.student_id}>
|
||||
<td>{r.display_name || r.student_id}</td>
|
||||
<td><StatusPill status={r.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPill({ status }) {
|
||||
const cls = status === 'running' ? styles.pillOk
|
||||
: status === 'error' ? styles.pillErr
|
||||
: ['provisioning', 'requested', 'resetting'].includes(status) ? styles.pillWarn
|
||||
: styles.pillMuted
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Owner-only — the server enforces this too (requireOwner on every /api/admin/instructors*
|
||||
// route); this component is only ever mounted for an owner, but the backend never trusts that.
|
||||
export default function InstructorsView({ currentInstructorId }) {
|
||||
const [instructors, setInstructors] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.instructors().then(setInstructors).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <InstructorDetail instructorId={selected} isSelf={selected === currentInstructorId} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Add instructor</h2>
|
||||
<CreateInstructorForm onCreated={reload} />
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<h2>Instructors</h2>
|
||||
{!instructors ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{instructors.map((i) => (
|
||||
<tr key={i.instructor_id}>
|
||||
<td>{i.display_name}</td>
|
||||
<td>{i.email}</td>
|
||||
<td><span className={styles.pill}>{i.role}</span></td>
|
||||
<td><span className={`${styles.pill} ${i.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{i.status}</span></td>
|
||||
<td>{i.last_login_at ? new Date(i.last_login_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(i.instructor_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInstructorForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ email: '', display_name: '', role: 'instructor', password: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createInstructor(form)
|
||||
setForm({ email: '', display_name: '', role: 'instructor', password: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Role</label>
|
||||
<select className={styles.select} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Temporary password (12+ chars)</label>
|
||||
<input className={styles.input} type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={12} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>{busy ? 'Adding…' : 'Add instructor'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function InstructorDetail({ instructorId, isSelf, onBack }) {
|
||||
const [instructor, setInstructor] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.instructor(instructorId).then(setInstructor).catch((e) => setError(e.message))
|
||||
}, [instructorId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const updateRole = async (role) => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.updateInstructor(instructorId, { role }); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const toggleStatus = async () => {
|
||||
const status = instructor.status === 'active' ? 'inactive' : 'active'
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.updateInstructor(instructorId, { status }); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeSessions = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await adminApi.revokeInstructorSessions(instructorId); setNotice('All sessions revoked.'); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.resetInstructorPassword(instructorId, newPassword); setNewPassword(''); setNotice('Password reset. All of their sessions were revoked.') }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!instructor) return error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to instructors</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{instructor.display_name} <span className={styles.muted}>({instructor.email})</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Role & status</h3>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Role</label>
|
||||
<select className={styles.select} value={instructor.role} onChange={(e) => updateRole(e.target.value)} disabled={busy}>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select></div>
|
||||
<div><button className={instructor.status === 'active' ? styles.btnDanger : styles.btnSecondary} onClick={toggleStatus} disabled={busy}>
|
||||
{instructor.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
</button></div>
|
||||
</div>
|
||||
{isSelf && <p className={styles.muted}>This is your own account — the platform refuses to remove the last active owner, whichever account that is.</p>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Sessions</h3>
|
||||
<p className={styles.muted}>Last login: {instructor.last_login_at ? new Date(instructor.last_login_at).toLocaleString() : 'never'}</p>
|
||||
{instructor.sessions?.length ? (
|
||||
<table>
|
||||
<thead><tr><th>Created</th><th>Last active</th><th>Expires</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{instructor.sessions.map((s) => (
|
||||
<tr key={s.session_id}>
|
||||
<td>{new Date(s.created_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.last_seen_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.expires_at).toLocaleString()}</td>
|
||||
<td>{s.revoked_at ? <span className={`${styles.pill} ${styles.pillMuted}`}>revoked</span> : <span className={`${styles.pill} ${styles.pillOk}`}>active</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <p className={styles.muted}>No sessions on record.</p>}
|
||||
<button className={styles.btnDanger} style={{ marginTop: '0.75rem' }} onClick={revokeSessions} disabled={busy}>Revoke all sessions</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Reset password</h3>
|
||||
<form onSubmit={resetPassword} className={styles.formRow}>
|
||||
<div><input className={styles.input} type="password" placeholder="New password (12+ chars)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={12} required /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>Set new password</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor learning analytics — cohort summary, "needs attention," per-scenario analytics,
|
||||
// and a student drill-down. Deliberately no leaderboards or scoring language — this is
|
||||
// instructional visibility, not a ranking (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §13).
|
||||
export default function LearningView() {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [cohortId, setCohortId] = useState(null)
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [needsAttention, setNeedsAttention] = useState(null)
|
||||
const [scenarioStats, setScenarioStats] = useState(null)
|
||||
const [selectedStudent, setSelectedStudent] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then((c) => { setCohorts(c); if (c[0]) setCohortId(c[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!cohortId) return
|
||||
adminApi.cohortLearning(cohortId).then(setSummary).catch((e) => setError(e.message))
|
||||
adminApi.needsAttention(cohortId).then(setNeedsAttention).catch((e) => setError(e.message))
|
||||
adminApi.scenarioAnalytics(cohortId).then(setScenarioStats).catch((e) => setError(e.message))
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selectedStudent) return <StudentLearningDetail studentId={selectedStudent} onBack={() => setSelectedStudent(null)} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohortId || ''} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className={styles.card}>
|
||||
<h2>Cohort Summary</h2>
|
||||
<div className={styles.statGrid}>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.student_count}</div><div className={styles.statLabel}>Students</div></div>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.active_learners}</div><div className={styles.statLabel}>Active (7d)</div></div>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.overall_completion_pct}%</div><div className={styles.statLabel}>Avg. Completion</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Stage</th><th>Students Complete</th></tr></thead>
|
||||
<tbody>
|
||||
{summary.stages.map((s) => (
|
||||
<tr key={s.id}><td>{s.name}</td><td>{s.completedBy}/{summary.student_count} ({s.pct}%)</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Needs Attention</h2>
|
||||
{!needsAttention ? <p className={styles.muted}>Loading…</p> : needsAttention.length === 0 ? <p className={styles.muted}>No signals right now.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Signal</th><th>Detail</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{needsAttention.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${styles.pillWarn}`}>{s.label}</span></td>
|
||||
<td className={styles.muted}>{s.detail}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelectedStudent(s.student_id)}>View</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Scenario Analytics</h2>
|
||||
{!scenarioStats ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Scenario</th><th>Completions</th><th>Avg Attempts</th><th>Failures</th><th>Hints Used</th><th>Avg Time</th></tr></thead>
|
||||
<tbody>
|
||||
{scenarioStats.map((s) => (
|
||||
<tr key={s.scenario_id}>
|
||||
<td>{s.scenario_id}</td><td>{s.completions}</td><td>{s.avg_attempts}</td>
|
||||
<td>{s.total_failures}</td><td>{s.total_hints_used}</td>
|
||||
<td>{s.avg_duration_seconds ? `${Math.round(s.avg_duration_seconds / 60)}m` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentLearningDetail({ studentId, onBack }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.studentLearning(studentId).then(setData).catch((e) => setError(e.message)) }, [studentId])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||
|
||||
{data.curriculum && (
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum ({data.curriculum.percent_complete}%)</h3>
|
||||
{data.curriculum.stages.map((s) => (
|
||||
<div key={s.id} style={{ fontSize: '0.85rem', padding: '0.2rem 0' }}>
|
||||
{s.complete ? '✅' : s.locked ? '🔒' : '🟡'} {s.name} — {s.completed}/{s.total}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Attempts</h3>
|
||||
<table>
|
||||
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints (max level)</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{data.attempts.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td><span className={`${styles.pill} ${a.status === 'completed' ? styles.pillOk : styles.pillWarn}`}>{a.status}</span></td>
|
||||
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
|
||||
<td>{a.hint_count} ({a.highest_hint_level})</td>
|
||||
<td>{a.duration_seconds ? `${Math.round(a.duration_seconds / 60)}m` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Assessment History</h3>
|
||||
{data.assessment_sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Assessment</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{data.assessment_sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.title}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 5C §16 — owner/admin only (enforced server-side too; a plain instructor's notification
|
||||
// visibility is per-student, on the Students tab, never this global view — see
|
||||
// rbac.js's requireStudentRelationshipAccess). No recipient email in full, no token, no
|
||||
// metadata — everything here comes from notifications.js's toAdminView.
|
||||
export default function NotificationsView() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setResult(null)
|
||||
adminApi.notifications(page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [page])
|
||||
|
||||
const items = result?.items || []
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Notifications</h2>
|
||||
<p className={styles.muted}>{result ? `${result.total} total` : 'Activation, enrollment, and password-reset emails — most recent first.'}</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!result ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Attempts</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td>{new Date(n.created_at).toLocaleString()}</td>
|
||||
<td>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.attempt_count}</td>
|
||||
<td className={styles.muted}>{n.last_error_code || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.pager}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
|
||||
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
|
||||
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 5B §11 — Programs area. Authoring (create/edit/activate-deactivate) is admin/owner
|
||||
// only, matching AssessmentsView's "viewed by all roles, only authoring restricted" pattern —
|
||||
// canAuthor gates the create form and the activate/deactivate toggle only, not the list itself.
|
||||
export default function ProgramsView({ role }) {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.programs().then(setPrograms).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <ProgramDetail programId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create program</h2>
|
||||
<CreateProgramForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Programs</h2>
|
||||
{!programs ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Family</th><th>Delivery</th><th>Enrollments</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{programs.map((p) => (
|
||||
<tr key={p.program_id}>
|
||||
<td>{p.name}</td>
|
||||
<td><span className={styles.pill}>{p.family}</span></td>
|
||||
<td className={styles.muted}>{p.delivery_mode.replace('_', ' ')}</td>
|
||||
<td>{p.enrollment_count}</td>
|
||||
<td><span className={`${styles.pill} ${p.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{p.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(p.program_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateProgramForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createProgram(form)
|
||||
setForm({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Program ID (slug)</label>
|
||||
<input className={styles.input} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Family</label>
|
||||
<select className={styles.select} value={form.family} onChange={(e) => setForm({ ...form, family: e.target.value })}>
|
||||
<option value="init">init_ (ages ~5-10)</option>
|
||||
<option value="compile">compile_ (ages ~11-14)</option>
|
||||
<option value="root">root_ (ages ~15-18+)</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Delivery mode</label>
|
||||
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
|
||||
<option value="self_paced">self-paced</option>
|
||||
<option value="cohort">cohort</option>
|
||||
<option value="hybrid">hybrid</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label className={styles.label}>Description</label>
|
||||
<input className={styles.input} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<p className={styles.muted} style={{ marginTop: '0.5rem' }}>
|
||||
New programs default to curriculum_path 'rootnode-path' — the only curriculum content that exists today. A program for init_/compile_ can be created now and wired to real content later without a schema change.
|
||||
</p>
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramDetail({ programId, canAuthor, onBack }) {
|
||||
const [program, setProgram] = useState(null)
|
||||
const [enrollments, setEnrollments] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.program(programId).then(setProgram).catch((e) => setError(e.message))
|
||||
adminApi.programEnrollments(programId).then(setEnrollments).catch(() => {})
|
||||
}, [programId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to programs</button>
|
||||
{!program ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{program.name} <span className={`${styles.pill} ${program.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{program.status}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<div className={styles.card}>
|
||||
<h3>Overview</h3>
|
||||
<p className={styles.muted}>{program.description || 'No description.'}</p>
|
||||
<p>Family: <span className={styles.pill}>{program.family}</span> — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})</p>
|
||||
<p className={styles.muted}>
|
||||
Enrollments — pending: {program.enrollment_counts.pending || 0}, active: {program.enrollment_counts.active || 0},
|
||||
{' '}completed: {program.enrollment_counts.completed || 0}, cancelled: {program.enrollment_counts.cancelled || 0}, expired: {program.enrollment_counts.expired || 0}
|
||||
</p>
|
||||
{canAuthor && (
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.updateProgram(programId, { status: program.status === 'active' ? 'inactive' : 'active' }))}>
|
||||
{program.status === 'active' ? 'Deactivate' : 'Activate'} program
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h3>Enroll a student</h3>
|
||||
<EnrollForm programId={programId} onEnrolled={() => { setNotice('Enrolled.'); reload() }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrollments</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th><th>Source</th><th>Enrolled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.student_display_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.status === 'active' ? styles.pillOk : e.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{e.status}</span></td>
|
||||
<td className={styles.muted}>{e.source}</td>
|
||||
<td className={styles.muted}>{new Date(e.enrolled_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{e.status === 'pending' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.activateEnrollment(e.enrollment_id))}>Activate</button>}
|
||||
{['pending', 'active'].includes(e.status) && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.cancelEnrollment(e.enrollment_id))}>Cancel</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrollForm({ programId, onEnrolled }) {
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [startsAt, setStartsAt] = useState('')
|
||||
const [accessEndsAt, setAccessEndsAt] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createEnrollment({
|
||||
student_id: studentId.trim(), program_id: programId, status,
|
||||
starts_at: startsAt || undefined, access_ends_at: accessEndsAt || undefined,
|
||||
})
|
||||
setStudentId(''); setStartsAt(''); setAccessEndsAt('')
|
||||
onEnrolled()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Student ID</label>
|
||||
<input className={styles.input} value={studentId} onChange={(e) => setStudentId(e.target.value)} placeholder="student-001" required /></div>
|
||||
<div><label className={styles.label}>Initial status</label>
|
||||
<select className={styles.select} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="pending">pending</option>
|
||||
<option value="active">active (skip activation step)</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Starts (optional)</label>
|
||||
<input className={styles.input} type="date" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Access ends (optional)</label>
|
||||
<input className={styles.input} type="date" value={accessEndsAt} onChange={(e) => setAccessEndsAt(e.target.value)} /></div>
|
||||
</div>
|
||||
<button className={styles.btn} disabled={busy || !studentId.trim()}>{busy ? 'Enrolling…' : 'Enroll'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import { StatusPill } from './CohortsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that
|
||||
// were already here. canManage gates create/convert/reset/revoke/import the same way
|
||||
// CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary,
|
||||
// this just avoids showing controls a plain instructor would get a 403 from.
|
||||
export default function StudentsView({ role }) {
|
||||
const [students, setStudents] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canManage = role !== 'instructor'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then(setStudents).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <StudentDetail studentId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Add student</h2>
|
||||
<CreateStudentForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Bulk import (CSV)</h2>
|
||||
<ImportForm onImported={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Students ({students?.length ?? '…'})</h2>
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>ID</th><th>Login</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td className={styles.muted}>{s.student_id}</td>
|
||||
<td>
|
||||
{s.has_credentials
|
||||
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
|
||||
</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateStudentForm({ onCreated }) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setCreated(null)
|
||||
try {
|
||||
const result = await adminApi.createStudent({
|
||||
display_name: displayName.trim(), cohort_id: cohortId || undefined,
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
setCreated(result)
|
||||
setDisplayName(''); setEmail(''); setLoginIdentifier('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{created?.activation_token && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
|
||||
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Display name</label>
|
||||
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Email (older/self-paced)</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID (younger/managed)</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Cohort (optional)</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
|
||||
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportForm({ onImported }) {
|
||||
const [csv, setCsv] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowErrors, setRowErrors] = useState(null)
|
||||
const [created, setCreated] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setRowErrors(null); setCreated(null)
|
||||
try {
|
||||
const { created: rows } = await adminApi.importStudents(csv)
|
||||
setCreated(rows); setCsv('')
|
||||
onImported()
|
||||
} catch (e) {
|
||||
if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below
|
||||
setError(e.message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{rowErrors && (
|
||||
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
|
||||
)}
|
||||
{created && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
|
||||
{created.filter((c) => c.activation_token).map((c) => (
|
||||
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
|
||||
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
|
||||
value={csv} onChange={(e) => setCsv(e.target.value)} required />
|
||||
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentDetail({ studentId, canManage, onBack }) {
|
||||
const [student, setStudent] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [env, setEnv] = useState(null)
|
||||
const [events, setEvents] = useState(null)
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [enrollments, setEnrollments] = useState(null)
|
||||
const [notifications, setNotifications] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
|
||||
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
|
||||
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
|
||||
// recent page (labeled "Recent attempts," not a full history browser — see
|
||||
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
|
||||
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
|
||||
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
||||
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
|
||||
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
|
||||
// instructor only sees enrollments they created or that share a cohort with them.
|
||||
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
|
||||
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
|
||||
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
|
||||
}, [studentId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const loadEvents = async () => {
|
||||
if (!env) return
|
||||
try { setEvents(await adminApi.environmentEvents(env.environment_id)) } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to students</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Credentials</h3>
|
||||
{student?.has_credentials ? (
|
||||
<>
|
||||
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.</p>
|
||||
<div className={styles.formRow}>
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { reset_token } = await adminApi.resetStudentPassword(studentId)
|
||||
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
|
||||
})}>Send password reset</button>
|
||||
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
|
||||
</div>
|
||||
</>
|
||||
) : student?.email || student?.login_identifier ? (
|
||||
<>
|
||||
<p className={styles.muted}>Not yet activated. An activation link was queued — use this if the student never got it or it expired.</p>
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resendActivation(studentId))}>Resend activation</button>
|
||||
</>
|
||||
) : (
|
||||
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Notifications</h3>
|
||||
{!notifications ? <p className={styles.muted}>Loading…</p> : notifications.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Type</th><th>Status</th><th>Attempts</th></tr></thead>
|
||||
<tbody>
|
||||
{notifications.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td>{new Date(n.created_at).toLocaleString()}</td>
|
||||
<td>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.attempt_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Environment</h3>
|
||||
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
||||
<>
|
||||
<p>Status: <StatusPill status={env.status} /> — Lab host: {env.lab_host_id} — created {new Date(env.created_at).toLocaleString()}</p>
|
||||
{env.last_active_at && <p className={styles.muted}>Last active: {new Date(env.last_active_at).toLocaleString()}</p>}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.formRow} style={{ marginTop: '0.75rem' }}>
|
||||
{!env && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.provisionStudentLab(studentId))}>Provision Lab</button>}
|
||||
{env && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resetEnvironment(env.environment_id))}>Reset/Recreate</button>}
|
||||
{env && (
|
||||
<button className={styles.btnDanger} disabled={busy} onClick={withBusy(async () => {
|
||||
if (window.confirm('Destroy this Lab? Progress is not affected.')) await adminApi.destroyEnvironment(env.environment_id)
|
||||
})}>Destroy Lab</button>
|
||||
)}
|
||||
{env && <button className={styles.linkBtn} onClick={loadEvents}>View event history</button>}
|
||||
</div>
|
||||
{events && (
|
||||
<table style={{ marginTop: '0.75rem' }}>
|
||||
<thead><tr><th>Event</th><th>Actor</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
{events.map((e) => <tr key={e.id}><td>{e.event_type}</td><td>{e.actor}</td><td>{new Date(e.created_at).toLocaleString()}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrolled programs</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.program_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
|
||||
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
|
||||
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
|
||||
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum progress</h3>
|
||||
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
||||
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{s.name}</strong>
|
||||
<div className={styles.muted}>
|
||||
{s.scenarios.filter((sc) => sc.status === 'completed').length}/{s.scenarios.length} complete
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Recent attempts</h3>
|
||||
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Scenario</th><th>Result</th><th>Checks</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
{attempts.map((a) => (
|
||||
<tr key={a.attempt_id}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td><span className={`${styles.pill} ${a.result === 'passed' ? styles.pillOk : styles.pillWarn}`}>{a.result}</span></td>
|
||||
<td className={styles.muted}>{a.validation_count ?? '—'}</td>
|
||||
<td>{new Date(a.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertForm({ studentId, onDone }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const { activation_token } = await adminApi.convertStudent(studentId, {
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Admin API client. Uses the rn_instructor session cookie exclusively (same-origin, sent
|
||||
// automatically) — completely separate credential/cookie from student sessions (see
|
||||
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
|
||||
// React state; nothing here reads or writes localStorage.
|
||||
//
|
||||
// rn_csrf_i is a deliberately non-HttpOnly cookie (double-submit CSRF pattern, mirrored on the
|
||||
// student side in frontend/src/platform/api.js) — read here and echoed back as a header on
|
||||
// every state-changing request.
|
||||
|
||||
const CSRF_COOKIE = 'rn_csrf_i'
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function withCsrf(init) {
|
||||
return { ...init, headers: { ...init.headers, 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' } }
|
||||
}
|
||||
function post(path, body) {
|
||||
return fetch(path, withCsrf({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })).then(json)
|
||||
}
|
||||
function patch(path, body) {
|
||||
return fetch(path, withCsrf({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).then(json)
|
||||
}
|
||||
function del(path) {
|
||||
return fetch(path, withCsrf({ method: 'DELETE' })).then(json)
|
||||
}
|
||||
function get(path) {
|
||||
return fetch(path).then(json)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
// No CSRF token yet at login (nothing authenticated to forge into).
|
||||
login: (email, password) => fetch('/api/auth/instructor/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }),
|
||||
}).then(json),
|
||||
logout: () => post('/api/auth/logout'),
|
||||
session: () => get('/api/auth/session'),
|
||||
changePassword: (old_password, new_password) => post('/api/auth/change-password', { old_password, new_password }),
|
||||
|
||||
dashboard: () => get('/api/admin/dashboard'),
|
||||
|
||||
students: () => get('/api/admin/students'),
|
||||
createStudent: (fields) => post('/api/admin/students', fields),
|
||||
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
|
||||
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
|
||||
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
|
||||
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
|
||||
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
|
||||
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
|
||||
importStudents: (csv) => post('/api/admin/students/import', { csv }),
|
||||
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
|
||||
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
|
||||
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
|
||||
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
|
||||
removeStudentCohort: (id, cohortId) => del(`/api/admin/students/${id}/cohort/${cohortId}`),
|
||||
|
||||
cohorts: () => get('/api/admin/cohorts'),
|
||||
createCohort: (fields) => post('/api/admin/cohorts', fields),
|
||||
updateCohort: (id, fields) => patch(`/api/admin/cohorts/${id}`, fields),
|
||||
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
|
||||
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
|
||||
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
|
||||
assignCohortInstructor: (id, instructor_id, role_in_cohort) => post(`/api/admin/cohorts/${id}/instructors`, { instructor_id, role_in_cohort }),
|
||||
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
|
||||
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
|
||||
|
||||
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
|
||||
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
|
||||
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
|
||||
|
||||
instructors: () => get('/api/admin/instructors'),
|
||||
createInstructor: (fields) => post('/api/admin/instructors', fields),
|
||||
instructor: (id) => get(`/api/admin/instructors/${id}`),
|
||||
updateInstructor: (id, fields) => patch(`/api/admin/instructors/${id}`, fields),
|
||||
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: (page = 1) => get(`/api/admin/audit-events?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
|
||||
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
|
||||
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
|
||||
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
|
||||
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
|
||||
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
|
||||
scenarioCatalog: () => get('/api/admin/scenario-catalog'), // Phase 4.1 §18 — structured assessment editor
|
||||
|
||||
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
|
||||
assessments: () => get('/api/admin/assessments'),
|
||||
createAssessment: (fields) => post('/api/admin/assessments', fields),
|
||||
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
|
||||
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
|
||||
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
|
||||
|
||||
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
|
||||
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
|
||||
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
|
||||
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
|
||||
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
|
||||
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
|
||||
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
|
||||
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
|
||||
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
|
||||
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
|
||||
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
|
||||
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
|
||||
|
||||
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
|
||||
programs: () => get('/api/admin/programs'),
|
||||
program: (id) => get(`/api/admin/programs/${id}`),
|
||||
createProgram: (fields) => post('/api/admin/programs', fields),
|
||||
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
|
||||
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
|
||||
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
|
||||
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
|
||||
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
|
||||
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
|
||||
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
|
||||
|
||||
// ── Phase 5C §15/§16: enrollment operations & communications ────────────────────────────
|
||||
notifications: (page = 1) => get(`/api/admin/notifications?page=${page}`),
|
||||
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
|
||||
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
|
||||
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Phase 3B — shows when the student has an active Platform-level assessment (exam or capstone)
|
||||
// running, distinct from the pre-existing Lab-local exam mode (ExamTimer/ExamSession above,
|
||||
// which predates this and stays untouched). Polls /api/assessments/active, which 404s/errors
|
||||
// harmlessly in standalone/single-container dev mode (no Platform API there at all) — so this
|
||||
// component renders nothing in that mode, by design.
|
||||
export default function AssessmentBanner() {
|
||||
const [active, setActive] = useState(null)
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
function poll() {
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (!cancelled) setActive(d.active ? d : null)
|
||||
}).catch(() => { if (!cancelled) setActive(null) })
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, 15000)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 1rem',
|
||||
background: 'var(--amber-dim)', color: 'var(--amber)', fontSize: '0.85rem', fontFamily: 'var(--mono)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>🎯 Assessment Mode: {active.title}</span>
|
||||
<span>{completedCount}/{active.scenarios.length} scenarios complete</span>
|
||||
<span>⏱ {m}m {s}s remaining</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
|
||||
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
|
||||
// the scenario payload (the multi-student, Platform-mediated deployment; see
|
||||
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
|
||||
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
|
||||
// 3), and every reveal is recorded against the student's attempt — none of that is possible
|
||||
// with the old fully-preloaded array this replaces for this deployment mode.
|
||||
//
|
||||
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
|
||||
// still the full array and ScenarioPanel never renders this component at all — see the
|
||||
// `hintCount != null` check at the call site.
|
||||
export default function ProgressiveHints({ scenarioId, totalHints }) {
|
||||
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
|
||||
|
||||
// A scenario/environment change resets local reveal state — the server is still the source
|
||||
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
|
||||
// rendered locally this session. Also checks whether this scenario is part of an active
|
||||
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
|
||||
// the policy regardless, on every reveal call — this is UX, not the security boundary).
|
||||
useEffect(() => {
|
||||
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
|
||||
}).catch(() => {})
|
||||
}, [scenarioId])
|
||||
|
||||
const revealNext = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
|
||||
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
|
||||
} catch {
|
||||
setError('Could not reach the platform to reveal a hint.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scenarioId])
|
||||
|
||||
function copyCmd(cmd, idx) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
|
||||
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
|
||||
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
|
||||
{revealed.map((hint, i) => (
|
||||
<div key={i} className={styles.hintCard}>
|
||||
<div className={styles.hintHeader}>
|
||||
<div className={styles.hintLeft}>
|
||||
<span className={styles.hintNum}>Hint {hint.level}</span>
|
||||
<span className={styles.hintTitle}>{hint.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hintBody}>
|
||||
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
|
||||
{hint.command && (
|
||||
<div className={styles.cmdBlock}>
|
||||
<pre className={styles.cmdPre}>{hint.command}</pre>
|
||||
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.validateError}>⚠ {error}</div>}
|
||||
{revealed.length < totalHints && (
|
||||
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
|
||||
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
import ProgressiveHints from './ProgressiveHints'
|
||||
|
||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||
const inlineComponents = {
|
||||
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
setHintsRevealed([])
|
||||
}, [scenario?.id])
|
||||
|
||||
// Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
|
||||
// a real started_at for this working session. Only meaningful under the Platform-multiplied
|
||||
// deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
|
||||
// mode, where there's no Platform API to reach at all.
|
||||
useEffect(() => {
|
||||
if (!scenario?.id) return
|
||||
fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
|
||||
}, [scenario?.id])
|
||||
|
||||
const timeSpentRef = useRef(0)
|
||||
const lastScenarioIdRef = useRef(null)
|
||||
|
||||
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'problem' ? '📄 Problem'
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
|
||||
: '✓ Validate'}
|
||||
</button>
|
||||
))}
|
||||
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
{/* HINTS TAB */}
|
||||
{tab === 'hints' && (
|
||||
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
|
||||
{/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
|
||||
multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
|
||||
as the signal to switch to server-gated progressive reveal instead of the old
|
||||
fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
|
||||
rewrite happens there) and keeps the original behavior untouched below. */}
|
||||
{scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
|
||||
<ProgressiveHints scenarioId={scenario.id} totalHints={scenario.hint_count} />
|
||||
) : (
|
||||
<>
|
||||
{scenario.hints?.length === 0 && (
|
||||
<div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
)}
|
||||
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+12
-1
@@ -1,14 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import PlatformShell from './platform/PlatformShell.jsx'
|
||||
import AdminPortal from './admin/AdminPortal.jsx'
|
||||
import './index.css'
|
||||
|
||||
// Apply saved theme before first paint to avoid flash
|
||||
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
|
||||
// This bundle is served both by the lightweight Platform API (root paths + /admin) and by
|
||||
// every ephemeral per-student Lab container (reached via the Gateway at /lab/<token>/) — same
|
||||
// build, same code, split purely by the URL path at runtime. See
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Student/admin flow" for why: it lets the existing
|
||||
// Lab UI (<App/>) stay completely unmodified while the Platform Shell and Admin Portal live
|
||||
// alongside it in the same bundle.
|
||||
const path = window.location.pathname
|
||||
const view = path.startsWith('/lab/') ? 'lab' : path.startsWith('/admin') ? 'admin' : 'platform'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
{view === 'lab' ? <App /> : view === 'admin' ? <AdminPortal /> : <PlatformShell />}
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const LABELS = {
|
||||
stage_completed: 'Stage completed', curriculum_completed: 'Curriculum completed',
|
||||
assessment_completed: 'Assessment completed', capstone_completed: 'Capstone completed',
|
||||
}
|
||||
const ICONS = {
|
||||
stage_completed: '✅', curriculum_completed: '🎓', assessment_completed: '📝', capstone_completed: '🏁',
|
||||
}
|
||||
|
||||
// Not a public certificate — a durable internal record. Phase 4/5 can render a certificate from
|
||||
// these later (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §16); nothing here is shareable yet.
|
||||
export default function AchievementsTab() {
|
||||
const [achievements, setAchievements] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.achievements().then(setAchievements).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Achievements & History</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!achievements ? <p className={styles.muted}>Loading…</p> : achievements.length === 0 ? <p className={styles.muted}>Nothing yet — complete a stage to see it here.</p> : (
|
||||
achievements.map((a) => (
|
||||
<div key={a.achievement_id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>{ICONS[a.achievement_type] || '🏅'}</span>
|
||||
<div>
|
||||
<div>{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}</div>
|
||||
<div className={styles.muted} style={{ fontSize: '0.78rem' }}>{new Date(a.achieved_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available exams (assigned to the student's cohort), an active session if one is running (with
|
||||
// a live countdown), and completed history. Starting an exam draws its scenario set
|
||||
// server-side — the student works through it inside their existing Lab, the same one My
|
||||
// Learning launches (see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §9 for why a separate Lab
|
||||
// per assessment isn't needed).
|
||||
export default function AssessmentsTab() {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [active, setActive] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
api.activeAssessment().then(setActive).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async (definitionId) => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(definitionId); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{active?.active && (
|
||||
<section className={styles.card}>
|
||||
<h2>Active Assessment</h2>
|
||||
<p><strong>{active.title}</strong></p>
|
||||
<CountdownLine expiresAt={active.expires_at} />
|
||||
<table className={styles.dataTable} style={{ marginTop: '0.75rem' }}>
|
||||
<thead><tr><th>#</th><th>Scenario</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{active.scenarios.map((s) => (
|
||||
<tr key={s.scenario_id}>
|
||||
<td>{s.position + 1}</td><td>{s.scenario_id}</td>
|
||||
<td>
|
||||
{s.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>{s.status}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className={styles.hint} style={{ marginTop: '0.75rem' }}>Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Available Assessments</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : assessments.length === 0 ? <p className={styles.muted}>None assigned to your cohort yet.</p> : (
|
||||
assessments.map((a) => {
|
||||
const completedSessions = a.sessions.filter((s) => s.status === 'completed')
|
||||
return (
|
||||
<div key={a.assessment_definition_id} style={{ padding: '0.75rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<p><strong>{a.title}</strong> <span className={styles.muted}>· {a.duration_minutes} min</span></p>
|
||||
{a.description && <p className={styles.muted} style={{ fontSize: '0.85rem' }}>{a.description}</p>}
|
||||
{completedSessions.length > 0 && (
|
||||
<p className={styles.hint}>Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}</p>
|
||||
)}
|
||||
{!active?.active && (
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={() => handleStart(a.assessment_definition_id)}>
|
||||
{busy ? 'Starting…' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>History</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Assessment</th><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{assessments.flatMap((a) => a.sessions).filter((s) => s.status !== 'not_started').sort((a, b) => new Date(b.started_at) - new Date(a.started_at)).map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{assessments.find((a) => a.sessions.includes(s))?.title}</td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td><StatusPill status={s.status} /></td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ status }) {
|
||||
const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function CountdownLine({ expiresAt }) {
|
||||
const [remaining, setRemaining] = useState(() => Math.max(0, new Date(expiresAt) - Date.now()))
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setRemaining(Math.max(0, new Date(expiresAt) - Date.now())), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAt])
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> Time remaining: {m}m {s}s</p>
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const STAGE_NAMES = {
|
||||
foundation: 'Foundation', workloads: 'Workloads', networking: 'Networking',
|
||||
configuration: 'Configuration', troubleshooting: 'Troubleshooting', 'real-ops': 'RootNode: Real Ops',
|
||||
}
|
||||
|
||||
export default function CapstoneTab() {
|
||||
const [capstone, setCapstone] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.capstone().then(setCapstone).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(capstone.assessment_definition_id); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!capstone) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
if (capstone.status === 'locked') {
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone</h2>
|
||||
<div className={styles.lockedBox}>
|
||||
<p style={{ fontSize: '2rem' }}>🔒</p>
|
||||
<p>Complete the rest of the curriculum to unlock the RootNode Capstone.</p>
|
||||
</div>
|
||||
<h3 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Prerequisites</h3>
|
||||
{capstone.blocking_stage_ids.map((id) => (
|
||||
<div key={id} className={styles.checklistItem}><span>⬜</span> {STAGE_NAMES[id] || id}</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
|
||||
const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone {capstone.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>completed</span> : <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>unlocked</span>}</h2>
|
||||
{capstone.definition && (
|
||||
<>
|
||||
<p><strong>{capstone.definition.title}</strong> <span className={styles.muted}>· {capstone.definition.duration_minutes} min</span></p>
|
||||
<p className={styles.muted} style={{ fontSize: '0.85rem' }}>{capstone.definition.description}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{completedSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>
|
||||
Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
|
||||
</p>
|
||||
) : activeSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>Your Capstone is in progress — launch your Lab from My Learning to continue.</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} style={{ marginTop: '1rem' }} disabled={busy} onClick={handleStart}>
|
||||
{busy ? 'Starting…' : 'Start Capstone'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{capstone.sessions.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>Attempt History</h3>
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{capstone.sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.status}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
|
||||
const POLL_MS = 5000
|
||||
|
||||
const HELP_CATEGORIES = [
|
||||
{ value: 'lab_problem', label: 'Lab problem' },
|
||||
{ value: 'dont_understand', label: "Don't understand the task" },
|
||||
{ value: 'validation_failing', label: 'Validation failing' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
|
||||
// right now" state. Live -> title, current activity (guidance only — a link into the student's
|
||||
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
|
||||
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
|
||||
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
|
||||
export default function ClassroomTab() {
|
||||
const [live, setLive] = useState(null)
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [helpCategory, setHelpCategory] = useState('lab_problem')
|
||||
const [helpMessage, setHelpMessage] = useState('')
|
||||
const [helpSent, setHelpSent] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const heartbeatRef = useRef(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadLive = useCallback(() => {
|
||||
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(() => { loadLive() }, [loadLive])
|
||||
|
||||
const loadDetail = useCallback((id) => {
|
||||
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
|
||||
api.environmentStatus().then(setEnvStatus).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return
|
||||
loadDetail(live.classroom_session_id)
|
||||
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [live, loadDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!joined || !live) return
|
||||
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
|
||||
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
|
||||
return () => clearInterval(heartbeatRef.current)
|
||||
}, [joined, live])
|
||||
|
||||
const join = async () => {
|
||||
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
|
||||
catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const sendHelp = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
|
||||
setHelpSent(true); setHelpMessage('')
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const launchLab = async () => {
|
||||
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
if (!live) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<p className={styles.livePill}>LIVE NOW</p>
|
||||
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
|
||||
{live.description && <p className={styles.muted}>{live.description}</p>}
|
||||
{live.meeting_url && (
|
||||
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
|
||||
)}
|
||||
{!joined ? (
|
||||
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
|
||||
) : (
|
||||
<p className={styles.muted}>You're in this classroom.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{joined && detail && (
|
||||
<>
|
||||
{detail.classroom.current_scenario_id && (
|
||||
<div className={styles.card}>
|
||||
<h3>Current class activity</h3>
|
||||
<p>{detail.classroom.current_scenario_id}</p>
|
||||
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Your Lab</h3>
|
||||
{envStatus?.status === 'running'
|
||||
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
|
||||
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
|
||||
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>
|
||||
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Need help?</h3>
|
||||
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
|
||||
<form onSubmit={sendHelp}>
|
||||
<label className={styles.label}>What's going on?</label>
|
||||
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
|
||||
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<label className={styles.label}>Details (optional)</label>
|
||||
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
|
||||
<button className={styles.primaryBtn} type="submit">Need Help</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// A. Entry screen — RootNode Academy branding, choose your name from roster, no password.
|
||||
// Explicitly pilot/dev identity, not production auth — see
|
||||
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation."
|
||||
export default function EntryScreen({ onLogin }) {
|
||||
const [cohorts, setCohorts] = useState([])
|
||||
const [cohort, setCohort] = useState('')
|
||||
const [roster, setRoster] = useState([])
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.cohorts().then(setCohorts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!cohort) { setRoster([]); return }
|
||||
api.roster(cohort).then(setRoster).catch((e) => setError(e.message))
|
||||
}, [cohort])
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!studentId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const student = await api.login(studentId)
|
||||
onLogin(student)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Real-world Kubernetes labs. Pick your name to continue.</p>
|
||||
<p className={styles.pilotNote}>Pilot identity — no password. Not production auth.</p>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohort} onChange={(e) => { setCohort(e.target.value); setStudentId('') }}>
|
||||
<option value="">Select a cohort…</option>
|
||||
{cohorts.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
|
||||
{cohort && (
|
||||
<>
|
||||
<label className={styles.label}>Your name</label>
|
||||
<select className={styles.select} value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
||||
<option value="">Select your name…</option>
|
||||
{roster.map((s) => <option key={s.student_id} value={s.student_id}>{s.display_name}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className={styles.primaryBtn} disabled={!studentId || loading} onClick={handleLogin}>
|
||||
{loading ? 'Signing in…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet
|
||||
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic
|
||||
// message regardless of whether the identifier exists (anti-enumeration, matches the server's
|
||||
// own response shape exactly). In dev/test, the server includes dev_reset_token in its
|
||||
// response so this screen can offer a direct link — a real deployment omits it, and completing
|
||||
// the flow needs an instructor to run the admin-side reset instead (see
|
||||
// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md).
|
||||
export default function ForgotPasswordScreen({ onCancel, onHaveToken }) {
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [sent, setSent] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.requestPasswordReset(identifier)
|
||||
setSent(result)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Reset your password</p>
|
||||
|
||||
{!sent ? (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
|
||||
{sent.dev_reset_token && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
|
||||
Dev/test only (never shown in production):{' '}
|
||||
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §6 — the real, credentialed student login. This is now the default entry point
|
||||
// (see PlatformShell.jsx); EntryScreen (pilot, no password) is still reachable for
|
||||
// not-yet-converted accounts via the link at the bottom, not removed — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7.
|
||||
export default function LoginScreen({ onLogin, onShowPilotEntry, onShowForgotPassword }) {
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!identifier || !password) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const student = await api.studentLogin(identifier, password)
|
||||
onLogin(student)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Sign in to continue your learning.</p>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" autoComplete="username" value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required />
|
||||
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowForgotPassword}>Forgot password?</button>
|
||||
</p>
|
||||
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowPilotEntry}>Pick your name instead (pilot cohorts)</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
// The original Phase 2A/2B "Up Next" + Launch Lab / Reset flow, now extended with overall
|
||||
// percent-complete (Phase 3B's rule-driven curriculum state, GET /api/curriculum/state, instead
|
||||
// of the older /api/curriculum/path stats-only shape — same stage list, richer per-stage data).
|
||||
export default function MyLearningTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadCurriculum = useCallback(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
const loadEnvStatus = useCallback(() => {
|
||||
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
|
||||
if (inFlight && !pollRef.current) {
|
||||
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
|
||||
} else if (!inFlight && pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
|
||||
}, [envStatus, loadEnvStatus])
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchEnvironment()
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) window.location.href = result.launch_path
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
const handleReset = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { setEnvStatus(await api.resetEnvironment()) } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const currentStage = curriculum?.stages?.find((s) => s.id === curriculum.current_stage)
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Current Stage</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
|
||||
<>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Overall progress</span><span className={styles.muted}>{curriculum.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${curriculum.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
<div className={styles.stageRow}>
|
||||
{curriculum.stages.map((s) => (
|
||||
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.current_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
|
||||
<span>{s.icon}</span> {s.name}
|
||||
<span className={styles.stageCount}>{s.completed}/{s.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{currentStage ? (
|
||||
<p className={styles.nextUp}>
|
||||
Up next: <strong>{currentStage.name}</strong>
|
||||
{currentStage.next_scenario && <> — {currentStage.next_scenario}</>}
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.nextUp}>All curriculum stages complete — your Capstone is waiting.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Your Lab</h2>
|
||||
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
|
||||
const s = status?.status || 'none'
|
||||
|
||||
if (s === 'none' || s === 'destroyed') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.muted}>No lab running yet.</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Launch Lab'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (['requested', 'provisioning', 'resetting'].includes(s)) {
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
|
||||
}
|
||||
if (s === 'running') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
|
||||
<div className={styles.btnRow}>
|
||||
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
|
||||
{busy ? 'Working…' : 'Recreate Lab'}
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s === 'error') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Try Again'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <p className={styles.muted}>Unknown state ({s}).</p>
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* Platform Shell styling — reuses the existing design tokens from index.css (--bg, --surface,
|
||||
--green, --text, etc.) so the entry/dashboard screens feel like the same product as the Lab
|
||||
UI, not a bolted-on separate app. */
|
||||
|
||||
.entryWrap {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.entryCard {
|
||||
width: 380px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brandIcon { font-size: 1.4rem; }
|
||||
|
||||
.tagline { color: var(--text-2); margin: 0.5rem 0 0.25rem; font-size: 0.9rem; }
|
||||
.pilotNote { color: var(--text-3); font-size: 0.75rem; margin-bottom: 1.25rem; }
|
||||
|
||||
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
|
||||
|
||||
.select, .input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.6rem;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
margin-top: 1.25rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--green);
|
||||
color: #05130a;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.primaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.secondaryBtn {
|
||||
padding: 0.65rem 1rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.secondaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
background: var(--red-dim);
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Dashboard ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.dashWrap {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
padding: 0 0 3rem;
|
||||
}
|
||||
|
||||
.dashHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.1rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: 1rem; }
|
||||
.studentName { font-weight: 600; }
|
||||
|
||||
.card {
|
||||
max-width: 760px;
|
||||
margin: 1.5rem auto 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text); }
|
||||
|
||||
.muted { color: var(--text-2); font-size: 0.9rem; }
|
||||
|
||||
.stageRow { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.stagePill {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.stagePillActive { border-color: var(--green); color: var(--text); }
|
||||
.stagePillLocked { opacity: 0.5; }
|
||||
.stageCount { color: var(--text-3); font-family: var(--mono); font-size: 0.75rem; }
|
||||
|
||||
.nextUp { font-size: 0.9rem; color: var(--text); }
|
||||
|
||||
.statusLine { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; margin-bottom: 0.9rem; }
|
||||
.btnRow { display: flex; gap: 0.75rem; }
|
||||
.hint { color: var(--text-3); font-size: 0.78rem; margin-top: 0.75rem; }
|
||||
|
||||
.dotOk, .dotErr { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dotOk { background: var(--green); }
|
||||
.dotErr { background: var(--red); }
|
||||
|
||||
.spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--border2);
|
||||
border-top-color: var(--green);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Learning tabs (Phase 3B) ─────────────────────────────────────────── */
|
||||
|
||||
.tabNav { display: flex; gap: 1.25rem; padding: 0 2rem; border-bottom: 1px solid var(--border); }
|
||||
.tab { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.75rem 0; }
|
||||
.tabActive { color: var(--green); border-bottom: 2px solid var(--green); }
|
||||
|
||||
.progressBarTrack { background: var(--surface3); border-radius: 4px; height: 8px; overflow: hidden; }
|
||||
.progressBarFill { background: var(--green); height: 8px; }
|
||||
|
||||
table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.dataTable th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border2); font-size: 0.72rem; text-transform: uppercase; }
|
||||
.dataTable td { padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
|
||||
.pillOk { background: var(--green-dim); color: var(--green); }
|
||||
.pillWarn { background: var(--amber-dim); color: var(--amber); }
|
||||
.pillErr { background: var(--red-dim); color: var(--red); }
|
||||
.pillMuted { background: var(--surface3); color: var(--text-2); }
|
||||
|
||||
/* Phase 5A — classroom/profile screens */
|
||||
.livePill {
|
||||
display: inline-block; background: var(--red-dim); color: var(--red); font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
}
|
||||
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
|
||||
|
||||
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
|
||||
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api, getCachedDisplay } from './api'
|
||||
import LoginScreen from './LoginScreen'
|
||||
import EntryScreen from './EntryScreen'
|
||||
import SetPasswordScreen from './SetPasswordScreen'
|
||||
import ForgotPasswordScreen from './ForgotPasswordScreen'
|
||||
import StudentDashboard from './StudentDashboard'
|
||||
|
||||
// Top-level Platform Shell. Phase 5A: the credentialed LoginScreen is now the default entry
|
||||
// point (was EntryScreen's pilot name-picker); an activation/reset link
|
||||
// (?activate=<token> or ?reset=<token>) short-circuits straight to SetPasswordScreen before
|
||||
// any session check, since neither flow requires an existing session. Session validity is
|
||||
// still decided by the server (GET /api/session/whoami against the rn_session cookie), not by
|
||||
// anything read from localStorage/sessionStorage.
|
||||
export default function PlatformShell() {
|
||||
const [student, setStudent] = useState(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const [view, setView] = useState(
|
||||
params.get('activate') ? 'activate' : params.get('reset') ? 'reset' : 'login'
|
||||
)
|
||||
const [resetToken, setResetToken] = useState(params.get('reset') || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'activate' || view === 'reset') { setChecking(false); return }
|
||||
const cached = getCachedDisplay()
|
||||
if (cached) setStudent(cached) // optimistic paint while we confirm with the server
|
||||
api.whoami()
|
||||
.then(({ student_id }) => {
|
||||
if (!cached || cached.student_id !== student_id) setStudent({ student_id, display_name: cached?.display_name || student_id })
|
||||
})
|
||||
.catch(() => setStudent(null))
|
||||
.finally(() => setChecking(false))
|
||||
}, [view])
|
||||
|
||||
if (checking) return null
|
||||
|
||||
if (view === 'activate') {
|
||||
return <SetPasswordScreen token={params.get('activate')} purpose="activation"
|
||||
onDone={(s) => { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'reset') {
|
||||
return <SetPasswordScreen token={resetToken} purpose="reset"
|
||||
onDone={() => { setView('login'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'forgot') {
|
||||
return <ForgotPasswordScreen onCancel={() => setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} />
|
||||
}
|
||||
if (view === 'pilot') {
|
||||
return <EntryScreen onLogin={(s) => { setStudent(s); setView('dashboard') }} />
|
||||
}
|
||||
if (!student) {
|
||||
return <LoginScreen onLogin={(s) => { setStudent(s); setView('dashboard') }}
|
||||
onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} />
|
||||
}
|
||||
return <StudentDashboard student={student} onLogout={() => { setStudent(null); setView('login') }} />
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available/completed scenarios (from curriculum state) + recent attempt history
|
||||
// (GET /api/attempts/recent) — purely informational, no controls here; the actual work happens
|
||||
// inside the Lab itself once launched from My Learning.
|
||||
export default function PracticeTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
api.recentAttempts().then(setAttempts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
const allScenarios = curriculum?.stages.flatMap((s) => Array(s.total).fill(null).map((_, i) => i)) || []
|
||||
const completedCount = curriculum?.stages.reduce((n, s) => n + s.completed, 0) || 0
|
||||
const totalCount = allScenarios.length
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<section className={styles.card}>
|
||||
<h2>Scenarios</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Stage</th><th>Completed</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{curriculum.stages.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.icon} {s.name}</td>
|
||||
<td>{s.completed}/{s.total}</td>
|
||||
<td>
|
||||
{s.locked ? <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>
|
||||
: s.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{curriculum && <p className={styles.hint} style={{ marginTop: '0.75rem' }}>{completedCount}/{totalCount} scenarios completed overall.</p>}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Recent Attempts</h2>
|
||||
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>No attempts yet — launch your Lab and pick a scenario.</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{attempts.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td>
|
||||
{a.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
|
||||
<td>{a.hint_count || 0}</td>
|
||||
<td>{a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §10 — minimal account/profile page. Deliberately narrow: identity, cohort, progress
|
||||
// summary, achievements, active session count, and change-password. No social/profile features.
|
||||
export default function ProfileTab() {
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { api.profile().then(setProfile).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!profile) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<h2>Account</h2>
|
||||
<p><strong>{profile.student.display_name}</strong></p>
|
||||
<p className={styles.muted}>{profile.student.email || profile.student.login_identifier || 'No login identifier set'}</p>
|
||||
<p className={styles.muted}>
|
||||
{profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'}
|
||||
</p>
|
||||
<p className={styles.muted}>{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Progress</h2>
|
||||
<p>{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed</p>
|
||||
<div className={styles.progressBarTrack}>
|
||||
<div className={styles.progressBarFill} style={{ width: `${profile.progress_summary.total ? Math.round(100 * profile.progress_summary.completed / profile.progress_summary.total) : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Achievements</h2>
|
||||
{!profile.achievements.length ? <p className={styles.muted}>None yet — keep going!</p> : (
|
||||
<ul>
|
||||
{profile.achievements.map((a, i) => (
|
||||
<li key={i}>{a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordCard hasCredentials={profile.student.has_credentials} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChangePasswordCard({ hasCredentials }) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
if (!hasCredentials) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
<p className={styles.muted}>This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword)
|
||||
setNotice('Password updated.'); setOldPassword(''); setNewPassword('')
|
||||
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Current password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={busy}>{busy ? 'Saving…' : 'Update password'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
const BLOCKED_MESSAGES = {
|
||||
pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
|
||||
not_yet_started: 'This program opens on the date below. Check back then.',
|
||||
access_expired: 'Access to this program has ended. Your progress is still here for reference.',
|
||||
cancelled: 'This enrollment was cancelled.',
|
||||
}
|
||||
|
||||
// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
|
||||
// already carries real access/progress/next-activity per enrollment — see program-status.js),
|
||||
// split into Active and Completed. Continue Learning delegates to the entitlement-gated
|
||||
// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
|
||||
// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
|
||||
export default function ProgramsTab() {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.programs().then(setPrograms).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
|
||||
const completed = programs.filter((p) => p.effective_status === 'completed')
|
||||
const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
|
||||
|
||||
if (!programs.length) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
<p className={styles.muted}>You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{active.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{completed.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Completed Programs</h2>
|
||||
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{cancelled.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Other</h2>
|
||||
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramCard({ program, onChange }) {
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const poll = useCallback(() => {
|
||||
api.environmentStatus().then((s) => {
|
||||
setEnvStatus(s)
|
||||
if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
|
||||
clearInterval(pollRef.current); pollRef.current = null
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleContinue = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchProgram(program.enrollment_id)
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
|
||||
if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
|
||||
pollRef.current = setInterval(poll, POLL_MS)
|
||||
}
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const p = program.progress
|
||||
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<strong>{program.program_name}</strong>
|
||||
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
|
||||
{program.effective_status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{p && (
|
||||
<div style={{ margin: '0.6rem 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{program.effective_status === 'completed' ? (
|
||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||
) : program.blocked_reason ? (
|
||||
<p className={styles.muted}>
|
||||
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
|
||||
{error && <p className={styles.errorBox}>{error}</p>}
|
||||
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves:
|
||||
// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same
|
||||
// UI, different API call and success copy.
|
||||
export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) {
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message))
|
||||
}, [token, purpose])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (password.length < 10) { setError('Password must be at least 10 characters.'); return }
|
||||
if (password !== confirm) { setError('Passwords do not match.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (purpose === 'activation') {
|
||||
const student = await api.activate(token, password)
|
||||
onDone(student)
|
||||
} else {
|
||||
await api.resetPassword(token, password)
|
||||
onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
|
||||
|
||||
{previewError && (
|
||||
<div className={styles.errorBox}>
|
||||
This link is invalid or has expired. Ask your instructor for a new one.
|
||||
</div>
|
||||
)}
|
||||
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{!previewError && (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
|
||||
<label className={styles.label}>Confirm password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
import MyLearningTab from './MyLearningTab'
|
||||
import PracticeTab from './PracticeTab'
|
||||
import AssessmentsTab from './AssessmentsTab'
|
||||
import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
import ClassroomTab from './ClassroomTab'
|
||||
import ProfileTab from './ProfileTab'
|
||||
import ProgramsTab from './ProgramsTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'programs', label: 'My Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'capstone', label: 'Capstone' },
|
||||
{ key: 'achievements', label: 'Achievements' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
]
|
||||
|
||||
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
|
||||
// Learning (the original Phase 2A/2B "Up Next" + Lab launch flow, unchanged in behavior, just
|
||||
// extracted into its own component), Practice, Assessments, Capstone, Achievements/History.
|
||||
export default function StudentDashboard({ student, onLogout }) {
|
||||
const [tab, setTab] = useState('learning')
|
||||
const handleLogout = () => { api.logout().then(onLogout) }
|
||||
|
||||
return (
|
||||
<div className={styles.dashWrap}>
|
||||
<header className={styles.dashHeader}>
|
||||
<div className={styles.brand}><span className={styles.brandIcon}>⎈</span> RootNode Academy</div>
|
||||
<div className={styles.headerRight}>
|
||||
<span className={styles.studentName}>{student.display_name}</span>
|
||||
<button className={styles.linkBtn} onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className={styles.tabNav}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'programs' && <ProgramsTab />}
|
||||
{tab === 'classroom' && <ClassroomTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
{tab === 'profile' && <ProfileTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Platform Shell API client. Identity is a server-controlled session cookie (rn_session,
|
||||
// HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically
|
||||
// on every same-origin request). Roster name-picking is classroom/pilot identification, not
|
||||
// production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's
|
||||
// hardened here is the session that results from it: persistent (survives an API restart),
|
||||
// unforgeable, and checked on every request.
|
||||
//
|
||||
// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi,
|
||||
// Alice" without an extra round trip) — it carries no authority; every actual API call is
|
||||
// authorized by the cookie alone, not by anything read from here.
|
||||
//
|
||||
// rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code
|
||||
// reads it and echoes it back as a header on every state-changing request, proving the request
|
||||
// came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read
|
||||
// its value or attach a matching custom header without CORS, which we don't grant elsewhere).
|
||||
|
||||
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
|
||||
const CSRF_COOKIE = 'rn_csrf_s'
|
||||
|
||||
export function getCachedDisplay() {
|
||||
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
|
||||
}
|
||||
function cacheDisplay(student) {
|
||||
sessionStorage.setItem(DISPLAY_CACHE_KEY, JSON.stringify(student))
|
||||
}
|
||||
function clearCachedDisplay() {
|
||||
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mutate(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(json)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
cohorts: () => fetch('/api/roster/cohorts').then(json),
|
||||
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
|
||||
login: async (student_id) => {
|
||||
// No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the
|
||||
// instructor login route.
|
||||
const student = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
logout: async () => {
|
||||
await mutate('/api/logout').catch(() => {})
|
||||
clearCachedDisplay()
|
||||
},
|
||||
whoami: () => fetch('/api/session/whoami').then(json),
|
||||
|
||||
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
||||
curriculumState: () => fetch('/api/curriculum/state').then(json),
|
||||
|
||||
environmentStatus: () => fetch('/api/environment/status').then(json),
|
||||
launchEnvironment: () => mutate('/api/environment/launch'),
|
||||
resetEnvironment: () => mutate('/api/environment/reset'),
|
||||
|
||||
recentAttempts: () => fetch('/api/attempts/recent').then(json),
|
||||
|
||||
assessments: () => fetch('/api/assessments').then(json),
|
||||
activeAssessment: () => fetch('/api/assessments/active').then(json),
|
||||
assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json),
|
||||
startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`),
|
||||
submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`),
|
||||
|
||||
capstone: () => fetch('/api/capstone').then(json),
|
||||
|
||||
achievements: () => fetch('/api/achievements').then(json),
|
||||
|
||||
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
|
||||
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
|
||||
// above: nothing authenticated exists yet to forge into.
|
||||
studentLogin: async (identifier, password) => {
|
||||
const { student } = await fetch('/api/student/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
activate: async (token, password) => {
|
||||
const { student } = await fetch('/api/student/activate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
|
||||
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
|
||||
}).then(json),
|
||||
resetPassword: (token, password) => fetch('/api/student/reset-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json),
|
||||
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
|
||||
profile: () => fetch('/api/student/profile').then(json),
|
||||
|
||||
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
|
||||
liveClassroom: () => fetch('/api/classroom/live').then(json),
|
||||
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
|
||||
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
|
||||
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
|
||||
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
||||
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
||||
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
|
||||
|
||||
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
|
||||
programs: () => fetch('/api/student/programs').then(json),
|
||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Never commit filled secrets — only *.env.template files are tracked.
|
||||
*.env
|
||||
!*.env.template
|
||||
|
||||
# Docker TLS material — CA/server/client certs and keys, generated by
|
||||
# infra/scripts/docker-tls/generate-ca.sh and friends. Templates/READMEs stay tracked.
|
||||
docker/tls/*.pem
|
||||
docker/tls/*.key
|
||||
docker/tls/*.crt
|
||||
docker/tls/*.srl
|
||||
!docker/tls/**/README.md
|
||||
|
||||
# Ansible Vault password files and any decrypted vars
|
||||
ansible/.vault_pass
|
||||
ansible/**/vault_plain.yml
|
||||
|
||||
# Terraform/OpenTofu state and generated vars (may contain resource IDs/IPs)
|
||||
# Phase 4.1: these patterns were previously written repo-root-relative (e.g.
|
||||
# "infra/terraform/...") inside THIS file, which git resolves relative to the .gitignore's own
|
||||
# directory (infra/) — so they silently matched nothing at all. Found via `git status` showing
|
||||
# a real .terraform/ provider cache as untracked when it should have been ignored. Fixed by
|
||||
# dropping the redundant leading "infra/" from every pattern below.
|
||||
terraform/**/*.tfstate
|
||||
terraform/**/*.tfstate.*
|
||||
terraform/**/.terraform/
|
||||
terraform/**/terraform.tfvars
|
||||
!terraform/**/terraform.tfvars.example
|
||||
|
||||
# Backup output and restore scratch space
|
||||
backup/output/
|
||||
backup/restore-test/
|
||||
|
||||
# Load test results (local artifacts, not source)
|
||||
scripts/loadtest/results/
|
||||
@@ -0,0 +1,10 @@
|
||||
[defaults]
|
||||
inventory = inventory/production.ini
|
||||
roles_path = roles
|
||||
host_key_checking = True
|
||||
retry_files_enabled = False
|
||||
stdout_callback = yaml
|
||||
|
||||
[privilege_escalation]
|
||||
become = True
|
||||
become_method = sudo
|
||||
@@ -0,0 +1,22 @@
|
||||
# Ansible Vault template — copy to production.vault.yml and encrypt it, NEVER commit the
|
||||
# plaintext or the encrypted file to git (infra/.gitignore already excludes
|
||||
# **/vault_plain.yml; the encrypted production.vault.yml itself is small/safe to commit IF
|
||||
# encrypted — see docs/PRODUCTION_DEPLOYMENT.md §10 for the actual decision made). Ansible
|
||||
# Vault chosen over SOPS/age here because it's already bundled with Ansible (no new tool to
|
||||
# install/trust) and this project's secret surface is small enough that Vault's simplicity
|
||||
# outweighs SOPS's per-key-value granularity — revisit if that changes.
|
||||
#
|
||||
# cp production.vault.yml.template production.vault.yml
|
||||
# # fill in real values below
|
||||
# ansible-vault encrypt production.vault.yml
|
||||
#
|
||||
# Reference a vaulted value from group_vars/production.yml or a role like:
|
||||
# postgres_password: "{{ vault_postgres_password }}"
|
||||
|
||||
vault_postgres_password: __REPLACE__
|
||||
vault_provisioner_shared_secret: __REPLACE_openssl_rand_hex_32__
|
||||
vault_owner_bootstrap_password: __REPLACE__ # used once, by a human, per docs/RUNBOOK_FIRST_DEPLOYMENT.md
|
||||
vault_postgres_exporter_password: __REPLACE__
|
||||
vault_backup_encryption_passphrase: __REPLACE_openssl_rand_base64_32__
|
||||
vault_backup_offsite_credentials: __REPLACE_e.g._Hetzner_Storage_Box_ssh_key_passphrase_or_S3_secret_key__
|
||||
vault_grafana_admin_password: __REPLACE__
|
||||
@@ -0,0 +1,33 @@
|
||||
# Production variables — referenced by every {{ }} placeholder in infra/docker/, infra/firewall/,
|
||||
# and the roles below. Fill in the __PLACEHOLDER__ values once the real server exists
|
||||
# (docs/PRODUCTION_DEPLOYMENT.md §1). Nothing here is a secret — real secrets live in
|
||||
# infra/environments/production/*.env (gitignored) and Ansible Vault (see
|
||||
# infra/ansible/group_vars/production.vault.yml.template), never in this plain-vars file.
|
||||
|
||||
env: production
|
||||
academy_domain: __REPLACE_e.g._academy.rootnode.example__
|
||||
|
||||
# ── Network topology (Phase 4 §5 — placeholders until real hardware exists) ────────────────
|
||||
platform_ip: __PLATFORM_VM_IP__
|
||||
lab_host_ip: __LAB_HOST_VM_IP__
|
||||
management_cidr: __YOUR_ADMIN_ACCESS_CIDR__ # e.g. a VPN/WireGuard subnet, never 0.0.0.0/0
|
||||
proxmox_mgmt_cidr: __PROXMOX_HOST_MGMT_CIDR__
|
||||
lab_subnet_range: "10.42.0.0/16" # matches NO_PROXY_CIDRS already used by the app
|
||||
lab_router_port: "8443" # Gateway -> Lab Host Router (data plane) — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3
|
||||
|
||||
# ── Storage (Phase 4 §4 — a decision framework, not a final layout; see docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §4)
|
||||
rootnode_data_root: /srv/rootnode-data # expected to be a ZFS dataset mountpoint on the real host
|
||||
|
||||
# ── Registry / images (infra/docker/IMAGE_STRATEGY.md) ─────────────────────────────────────
|
||||
registry: git.synthossystems.com
|
||||
image_tag: __GIT_SHA_OR_RELEASE_TAG__
|
||||
|
||||
postgres_exporter_user: rootnode_exporter
|
||||
|
||||
# ── Observability (infra/monitoring/) ───────────────────────────────────────────────────────
|
||||
prometheus_retention_days: 30
|
||||
alertmanager_webhook_url: __REPLACE_e.g._uptime_kuma_or_ntfy_webhook__
|
||||
|
||||
# ── Backup (infra/backup/) ───────────────────────────────────────────────────────────────────
|
||||
backup_retention_days: 30
|
||||
backup_offsite_target: __REPLACE_e.g._Hetzner_Storage_Box_path_or_S3_bucket__
|
||||
@@ -0,0 +1,22 @@
|
||||
# Staging variables — same shape as production.yml, lower stakes. See
|
||||
# infra/environments/staging/README.md.
|
||||
|
||||
env: staging
|
||||
academy_domain: __REPLACE_e.g._staging.academy.rootnode.example_or_unset_for_no_public_TLS__
|
||||
|
||||
platform_ip: __STAGING_PLATFORM_IP__
|
||||
lab_host_ip: __STAGING_LAB_HOST_IP__
|
||||
management_cidr: __YOUR_ADMIN_ACCESS_CIDR__
|
||||
proxmox_mgmt_cidr: __PROXMOX_HOST_MGMT_CIDR__
|
||||
lab_subnet_range: "10.42.0.0/16"
|
||||
|
||||
rootnode_data_root: /srv/rootnode-data-staging
|
||||
|
||||
registry: git.synthossystems.com
|
||||
image_tag: __STAGING_TAG_e.g._staging-<sha>__
|
||||
|
||||
prometheus_retention_days: 7
|
||||
alertmanager_webhook_url: __OPTIONAL_staging_can_skip_alerting__
|
||||
|
||||
backup_retention_days: 7
|
||||
backup_offsite_target: __OPTIONAL_staging_backups_are_lower_priority__
|
||||
@@ -0,0 +1,16 @@
|
||||
# Production inventory. IPs/hostnames are placeholders — fill in once the real VMs exist
|
||||
# (docs/PRODUCTION_DEPLOYMENT.md §1). This file is safe to commit (no secrets, just topology).
|
||||
|
||||
[platform]
|
||||
rn-platform-01 ansible_host=__PLATFORM_VM_IP__
|
||||
|
||||
[lab_host]
|
||||
rn-lab-host-01 ansible_host=__LAB_HOST_VM_IP__
|
||||
|
||||
[rootnode:children]
|
||||
platform
|
||||
lab_host
|
||||
|
||||
[rootnode:vars]
|
||||
ansible_user=rootnode-deploy
|
||||
ansible_ssh_common_args='-o StrictHostKeyChecking=yes'
|
||||
@@ -0,0 +1,16 @@
|
||||
# Staging inventory — same group shape as production, pointed at homelab VMs/containers or a
|
||||
# resource-constrained slice of the dedicated server once it exists (infra/environments/staging/README.md).
|
||||
|
||||
[platform]
|
||||
rn-platform-staging ansible_host=__STAGING_PLATFORM_IP__
|
||||
|
||||
[lab_host]
|
||||
rn-lab-host-staging ansible_host=__STAGING_LAB_HOST_IP__
|
||||
|
||||
[rootnode:children]
|
||||
platform
|
||||
lab_host
|
||||
|
||||
[rootnode:vars]
|
||||
ansible_user=rootnode-deploy
|
||||
env=staging
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
# Deploys the backup script + a daily cron job on rn-platform-01. The script itself
|
||||
# (infra/backup/backup-postgres.sh) is identical to what infra/backup/test-backup-restore.sh
|
||||
# exercises locally — same code path, just pointed at real production values here.
|
||||
|
||||
- name: Install postgresql-client + gpg (backup script dependencies)
|
||||
ansible.builtin.apt:
|
||||
name: [postgresql-client, gnupg]
|
||||
state: present
|
||||
|
||||
- name: Create backup directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
loop:
|
||||
- /opt/rootnode/backup
|
||||
- "{{ rootnode_data_root }}/backups"
|
||||
|
||||
- name: Deploy backup script
|
||||
ansible.builtin.copy:
|
||||
src: "{{ role_path }}/../../../backup/backup-postgres.sh"
|
||||
dest: /opt/rootnode/backup/backup-postgres.sh
|
||||
mode: "0750"
|
||||
|
||||
- name: Deploy restore + verify scripts (not run by cron, kept alongside for the runbook)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ role_path }}/../../../backup/{{ item }}"
|
||||
dest: "/opt/rootnode/backup/{{ item }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- restore-postgres.sh
|
||||
- verify-restore.sh
|
||||
|
||||
- name: Deploy the backup env file (PLATFORM_DB_URL, encryption passphrase, offsite target)
|
||||
ansible.builtin.template:
|
||||
src: backup.env.j2
|
||||
dest: /etc/rootnode/backup.env
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
|
||||
- name: Deploy cron wrapper (sources the env file, writes to syslog too)
|
||||
ansible.builtin.template:
|
||||
src: run-backup.sh.j2
|
||||
dest: /opt/rootnode/backup/run-backup.sh
|
||||
mode: "0750"
|
||||
|
||||
- name: Install daily backup cron job
|
||||
ansible.builtin.cron:
|
||||
name: rootnode-postgres-backup
|
||||
user: root
|
||||
minute: "15"
|
||||
hour: "3"
|
||||
job: /opt/rootnode/backup/run-backup.sh
|
||||
|
||||
- name: Ensure node-exporter's textfile collector directory exists (backup metrics land here)
|
||||
ansible.builtin.file:
|
||||
path: /var/lib/node-exporter/textfile
|
||||
state: directory
|
||||
mode: "0755"
|
||||
@@ -0,0 +1,6 @@
|
||||
PLATFORM_DB_URL=postgres://rootnode_platform:{{ vault_postgres_password }}@localhost:5432/rootnode_platform
|
||||
BACKUP_DIR={{ rootnode_data_root }}/backups
|
||||
BACKUP_ENCRYPTION_PASSPHRASE={{ vault_backup_encryption_passphrase }}
|
||||
BACKUP_RETENTION_DAYS={{ backup_retention_days }}
|
||||
BACKUP_OFFSITE_TARGET={{ backup_offsite_target }}
|
||||
METRICS_TEXTFILE_DIR=/var/lib/node-exporter/textfile
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cron wrapper — sources the deployed env file and runs the real backup script, logging to
|
||||
# syslog (visible via journalctl and shipped to Loki by promtail like every other unit) in
|
||||
# addition to whatever cron itself captures.
|
||||
set -euo pipefail
|
||||
set -a
|
||||
source /etc/rootnode/backup.env
|
||||
set +a
|
||||
exec /opt/rootnode/backup/backup-postgres.sh 2>&1 | systemd-cat -t rootnode-backup
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
common_timezone: UTC
|
||||
common_unattended_upgrades: true
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Shared Docker Engine install — used by both platform and lab-host roles (import_tasks from
|
||||
# common, not duplicated). Follows Docker's own official apt-repo installation method.
|
||||
|
||||
- name: Add Docker's GPG key
|
||||
ansible.builtin.get_url:
|
||||
url: https://download.docker.com/linux/ubuntu/gpg
|
||||
dest: /etc/apt/keyrings/docker.asc
|
||||
mode: "0644"
|
||||
register: docker_gpg
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: docker_gpg is succeeded
|
||||
|
||||
- name: Add Docker apt repository
|
||||
ansible.builtin.apt_repository:
|
||||
repo: "deb [arch={{ ansible_architecture | replace('x86_64','amd64') }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
|
||||
state: present
|
||||
filename: docker
|
||||
|
||||
- name: Install Docker Engine + Compose plugin
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Enable and start Docker
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
enabled: true
|
||||
state: started
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
# Baseline hardening applied to both rn-platform-01 and rn-lab-host-01 before anything
|
||||
# RootNode-specific — idempotent, safe to re-run (Phase 4 §20 requirement).
|
||||
|
||||
- name: Set timezone
|
||||
community.general.timezone:
|
||||
name: "{{ common_timezone }}"
|
||||
|
||||
- name: Install base packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- iptables
|
||||
- iptables-persistent
|
||||
- unattended-upgrades
|
||||
- fail2ban
|
||||
- jq
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Enable unattended security upgrades
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
content: |
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
mode: "0644"
|
||||
when: common_unattended_upgrades
|
||||
|
||||
- name: Create the rootnode-deploy service account (if not the connecting user already)
|
||||
ansible.builtin.user:
|
||||
name: rootnode-deploy
|
||||
shell: /bin/bash
|
||||
groups: docker
|
||||
append: true
|
||||
create_home: true
|
||||
register: deploy_user_result
|
||||
failed_when: false # non-fatal — some hosts may already use this as ansible_user
|
||||
|
||||
- name: Ensure fail2ban is enabled for SSH
|
||||
ansible.builtin.service:
|
||||
name: fail2ban
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Create /opt/rootnode and /etc/rootnode base directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
loop:
|
||||
- /opt/rootnode
|
||||
- /etc/rootnode
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
# Local path (on the machine running ansible-playbook, NOT on either VM) holding the cert
|
||||
# bundle generated by infra/scripts/docker-tls/generate-docker-tls.sh. The CA private key
|
||||
# (ca-key.pem) is deliberately never copied to either host by this role — only its public
|
||||
# ca.pem, plus the one cert/key pair each host actually needs.
|
||||
docker_tls_local_bundle_dir: "{{ lookup('env', 'ROOTNODE_TLS_BUNDLE_DIR') | default('/does/not/exist', true) }}"
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: restart docker with tls
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
# Phase 4.1 — these restart the whole per-host compose wrapper unit (no per-container systemd
|
||||
# units exist in this project's convention, see the `platform`/`lab-host` roles), which is
|
||||
# heavier than a surgical single-container restart but consistent with how every other
|
||||
# config-changed-so-restart-the-stack case is already handled here.
|
||||
- name: restart lab-router
|
||||
ansible.builtin.systemd:
|
||||
name: rootnode-lab-host.service
|
||||
state: restarted
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
|
||||
- name: restart gateway
|
||||
ansible.builtin.systemd:
|
||||
name: rootnode-platform.service
|
||||
state: restarted
|
||||
when: inventory_hostname in groups['platform']
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
# Deploys Docker TLS material generated OFFLINE by infra/scripts/docker-tls/generate-docker-tls.sh
|
||||
# — this role never generates key material itself, and the CA private key never touches either
|
||||
# VM (see infra/docker/tls/README.md). Fails loudly if the operator forgot to set
|
||||
# ROOTNODE_TLS_BUNDLE_DIR / run the generation script first, rather than silently skipping and
|
||||
# leaving the Provisioner unable to reach rn-lab-host-01.
|
||||
|
||||
- name: Verify the local TLS bundle directory exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ docker_tls_local_bundle_dir }}"
|
||||
register: bundle_dir
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Fail fast if the TLS bundle wasn't generated
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
ROOTNODE_TLS_BUNDLE_DIR ({{ docker_tls_local_bundle_dir }}) does not exist. Run
|
||||
infra/scripts/docker-tls/generate-docker-tls.sh first, then re-run with
|
||||
ROOTNODE_TLS_BUNDLE_DIR=<output dir> in the environment.
|
||||
when: not bundle_dir.stat.exists
|
||||
|
||||
- name: Create /etc/rootnode/docker-tls
|
||||
ansible.builtin.file:
|
||||
path: /etc/rootnode/docker-tls
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Deploy CA cert (public, both hosts)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/ca.pem"
|
||||
dest: /etc/rootnode/docker-tls/ca.pem
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0444"
|
||||
|
||||
- name: Deploy server cert/key (rn-lab-host-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/docker-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: server-cert.pem, dest: server-cert.pem, mode: "0444" }
|
||||
- { src: server-key.pem, dest: server-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart docker with tls
|
||||
|
||||
- name: Deploy Provisioner client cert/key (rn-platform-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/docker-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: provisioner-client-cert.pem, dest: provisioner-client-cert.pem, mode: "0444" }
|
||||
- { src: provisioner-client-key.pem, dest: provisioner-client-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['platform']
|
||||
|
||||
- name: Configure dockerd for TLS (rn-lab-host-01 only)
|
||||
ansible.builtin.template:
|
||||
src: daemon.json.j2
|
||||
dest: /etc/docker/daemon.json
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart docker with tls
|
||||
|
||||
- name: Override the Docker systemd unit's ExecStart (clears the default -H fd:// flag)
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/docker.service.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
|
||||
- name: Deploy the ExecStart override
|
||||
ansible.builtin.template:
|
||||
src: docker-override.conf.j2
|
||||
dest: /etc/systemd/system/docker.service.d/override.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart docker with tls
|
||||
|
||||
# ── Phase 4.1: the DATA-plane mTLS pair (Gateway <-> Lab Host Router). Same CA as the
|
||||
# CONTROL-plane pair above, separate directory (/etc/rootnode/lab-router-tls) so the two
|
||||
# concerns stay bind-mount-separable — the `lab-router`/`gateway` containers only ever need
|
||||
# their own pair mounted in, never the Docker TLS one. See
|
||||
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3.
|
||||
- name: Create /etc/rootnode/lab-router-tls
|
||||
ansible.builtin.file:
|
||||
path: /etc/rootnode/lab-router-tls
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Deploy CA cert for the data-plane pair (public, both hosts)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/ca.pem"
|
||||
dest: /etc/rootnode/lab-router-tls/ca.pem
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0444"
|
||||
|
||||
- name: Deploy Lab Host Router server cert/key (rn-lab-host-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: router-server-cert.pem, dest: router-server-cert.pem, mode: "0444" }
|
||||
- { src: router-server-key.pem, dest: router-server-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart lab-router
|
||||
|
||||
- name: Deploy Gateway client cert/key (rn-platform-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: gateway-client-cert.pem, dest: gateway-client-cert.pem, mode: "0444" }
|
||||
- { src: gateway-client-key.pem, dest: gateway-client-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['platform']
|
||||
notify: restart gateway
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"tls": true,
|
||||
"tlsverify": true,
|
||||
"tlscacert": "/etc/rootnode/docker-tls/ca.pem",
|
||||
"tlscert": "/etc/rootnode/docker-tls/server-cert.pem",
|
||||
"tlskey": "/etc/rootnode/docker-tls/server-key.pem",
|
||||
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||
|
||||
"storage-driver": "overlay2",
|
||||
"default-address-pools": [
|
||||
{ "base": "10.42.0.0/16", "size": 24 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Docker's systemd unit specifies `-H fd://` on ExecStart by default, which conflicts with
|
||||
# setting `hosts` in daemon.json ("specified both as a flag and in the configuration file").
|
||||
# This override clears ExecStart and re-specifies it with no -H flag, letting daemon.json's
|
||||
# `hosts` list be the only source of truth — the documented fix for this exact conflict.
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/dockerd
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- name: reload firewall
|
||||
ansible.builtin.command: netfilter-persistent reload
|
||||
changed_when: true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user