Author SHA1 Message Date
ced4568 896c230142 docs(phase5a): student identity and remote classroom documentation
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
- PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md: the full design doc.
- RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md, RUNBOOK_REMOTE_CLASSROOM.md: operational runbooks.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the 'no real student authentication' gap this
  doc previously flagged as the one item blocking self-paced delivery.
- ENGINEERING_SOP.md §5 rewritten (was describing a pre-Phase-2A state no longer true of any
  deployed version of this codebase) plus a new §14; roadmap item 3 updated.
- LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md:
  cross-references and a Phase 5A production-validation section.
2026-08-25 01:20:13 -05:00
ced4568 bfa16d9162 test(phase5a): permanent regression suite + dress rehearsal drill
- platform/test/e2e-phase5a.js (21 checks): the two-student RBAC matrix (student can't reach
  any /api/admin/* route, assigned/unassigned/viewer instructor access) and the full password
  security checklist (correct/wrong/unknown-identifier/deactivated/immediate-deactivation/
  rate-limit/rate-limit-survives-a-real-restart/change-password/session-revocation/
  forgot-password anti-enumeration/admin-issued reset/CSRF), plus a classroom lifecycle smoke
  pass. Replaces the throwaway smoke5a/smoke5b scripts used during development.
- infra/scripts/drills/test-phase5a-rehearsal.js (15 checks): 5 pilot students with real
  seeded pre-existing 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 zero data was orphaned.
- Both run against a live docker-compose.dev.yml stack over real HTTP, not mocks.
2026-08-25 01:20:02 -05:00
ced4568 7557fdc753 feat(admin): student credential management, CSV import UI, remote classroom control center
- StudentsView: credential status in the roster table, a create-student form
  (email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
  bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
  re-fetchable.
- ClassroomView: 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). Polls the dashboard endpoint
  rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
  for createStudent's new object-argument signature.
2026-08-25 01:19:54 -05:00
ced4568 5c7e49c399 feat(student): live student experience — real login, classroom, profile
- LoginScreen (credentialed, now the default entry point) with links to 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 (anti-enumeration, dev-only
  reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
  guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
  exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
  change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
  tabs; api.js/Platform.module.css extended to match.
2026-08-25 01:19:44 -05:00
ced4568 ee8ccb2ab6 feat(classroom): remote classroom backend — lifecycle, presence, help queue, dashboard
- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one
  live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/
  leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored
  status to go stale), help-request create/acknowledge/resolve, announcements, current-activity
  (guidance only, never redirects a browser or marks work complete), append-only event log.
- admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's
  requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves
  through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere
  else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines
  real Provisioner Lab state and real academic records with classroom.js's own data — no
  second Lab-state or academic-state system.
- classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request,
  announcements), requireEnrolledClassroom middleware.
- prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new
  classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission
  control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
2026-08-25 01:19:33 -05:00
ced4568 d34ddae458 feat(student): admin student-account management + CSV import, session hardening
- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions,
  /sessions, /import (transactional CSV import — whole file validated, one Postgres
  transaction, never accepts a password/hash column from input). Fixed GET
  /api/admin/students to select an explicit safe column list instead of SELECT * — the new
  password_hash/credential_token_hash columns must never be exposed through this route.
- server.js: /api/login now also blocks on credential_token_hash (not just password_hash) —
  closes a real window where a student mid-conversion stayed reachable via the old
  bare-student_id path. studentSession middleware now re-verifies status='active' on every
  request, not just at session creation, matching instructorSession's existing behavior.
- sessions.js: revokeAllStudentSessions, listStudentSessions.
- rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the
  new classroom/help-request RBAC (next commit) reuses the exact same authorization rule
  instead of a parallel copy.
- student-csv.js + test: hand-rolled parser (no new dependency), unit tested.
- prometheus.yml.j2: activated the platform-api scrape job.
2026-08-25 01:19:19 -05:00
ced4568 2f09b1a498 feat(student-auth): real student credentials — email/login-ID + password, token-based activation and reset
- migrations/017: email, login_identifier, password_hash, last_login_at, credential_token_*
  on students. Fully additive, both nullable/independent, no destructive change.
- students-auth.js: shared credential/token lifecycle (issue/consume/find), reusing
  instructors' exact scrypt password.js verbatim.
- student-auth-routes.js: /api/student/login, /activate, /change-password,
  /request-password-reset (anti-enumeration, dev-only token echo), /reset-password, /profile.
- metrics.js: hand-rolled /metrics Prometheus exporter, login success/failure counters, no
  student/instructor ID ever used as a label value.
- migrations/018: classroom_sessions/participants/events/announcements/help_requests, with a
  partial unique index enforcing at most one live classroom per cohort at the DB level.
2026-08-25 01:18:59 -05:00
ced4568 5bc0743b08 docs(phase4.1): pre-production hardening documentation
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
Master overview (PHASE4_1_PREPRODUCTION_HARDENING.md) covering all 27
substantive items, plus REMOTE_AND_SELF_PACED_ARCHITECTURE.md (reviews
what already works for remote/self-paced delivery - student-driven
provisioning, cohort-less students, idle-reaper-destroy-then-continue -
and the one real gap: student authentication, explicitly Phase 5 scope,
not built here).

Updated PRODUCTION_NETWORK_SECURITY.md (the Gateway-to-Lab gap is now
RESOLVED, with the full design writeup and a new control-plane-vs-data-plane
section), PRODUCTION_DEPLOYMENT.md (four-cert TLS bundle),
PRODUCTION_VALIDATION_CHECKLIST.md (Router mTLS check, admission control
checks), PHASE4_PRODUCTION_INFRASTRUCTURE.md and ENGINEERING_SOP.md and
LAB_ARCHITECTURE.md (stale 'known gap' pointers updated to point at the
resolution), BACKUP_DR.md (destructive drill), README.md and
platform/README.md (Phase 4.1 pointers, lab-router component, updated
known-limitations).

Also fixes a real pre-existing bug found while auditing what gets
committed: infra/.gitignore's patterns were written repo-root-relative
(e.g. "infra/terraform/...") but the file lives in infra/, so git resolved
them relative to THAT directory and none of them ever matched anything -
a real .terraform/ provider cache showed up as untracked when it should
have been ignored. Fixed by dropping the redundant leading "infra/" from
every pattern.

Status: PHASE 4.1 COMPLETE / PRE-PRODUCTION HARDENING COMPLETE / READY FOR
HARDWARE DEPLOYMENT.
2026-08-24 20:16:20 -05:00
ced4568 f3c356951c test(preprod): two-host routing simulation, drill suite, CI security scanning
- infra/scripts/simulate-two-host/: real two-Docker-network simulation
  (Gateway genuinely isolated from any Lab bridge network) proving the
  Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the
  Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router
  access).
- infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback,
  CI-fails-closed (6 required failure modes), fake cohort dress rehearsal
  (14/14, one continuous narrative through the real app).
- infra/ci/gitea-workflows/build-and-test.yml: new security-scan job
  (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source),
  severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks,
  MEDIUM/LOW reported only). Gates build-images alongside regression.

Found and fixed two real pre-existing bugs via the CI-fails-closed drill:
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 last in an && chain doesn't trigger -e) that let an
unreachable/broken endpoint fall through silently instead of failing the
smoke test.

platform/test/e2e-phase3a.js updated for the new paginated
/api/admin/audit-events response shape (Phase 4.1 pagination work).
2026-08-24 20:16:00 -05:00
ced4568 f5110d3c1b test(dr): destructive Postgres restore drill
infra/backup/test-destructive-restore-drill.sh - realistic academy data
(owner, admin instructor, cohort, 3 students, progress, attempts, an
assessment definition, achievements) seeded through the REAL Platform API,
encrypted-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. Never touches the developer's real database.
Confirmed passing.
2026-08-24 20:15:45 -05:00
ced4568 a8e543234c feat(platform): pagination, admission control, cohort roles, version endpoint, structured assessment editor
- 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). Frontend updated (AuditView, AssessmentsView, StudentsView).
- Admission control: resource-policy.yaml's new (disabled-by-default)
  admission_control.max_concurrent_labs, checked before touching Docker at
  all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision.
  Relayed as a clean student-facing message, never a raw 500. New
  GET /capacity endpoint feeds the instructor dashboard.
- cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is
  now enforced: teacher vs viewer, rbac.js's
  requireCohortWriteAccess/requireStudentCohortWriteAccess.
- GET /api/version - app version, deployed Git SHA, latest migration as a
  schema-version stand-in.
- Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor)
  replaces the raw-JSON pool_spec textarea, backed by a new
  GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback
  remains for anything the structured editor doesn't cover.
- NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration.

Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after
these changes; frontend production build confirmed clean.
2026-08-24 20:15:39 -05:00
ced4568 addf474b70 feat(security): Postgres-backed login rate limiting
Replaces the in-memory sliding-window Map (reset on every restart, not
shareable across instances) with a login_attempts table (migration 016).
No Redis added - login is not a hot path, and this is a single indexed
range-scan query. 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.
2026-08-24 20:15:03 -05:00
ced4568 6dda13b837 feat(learning): abandoned attempt handling
scenario_attempts.status = 'abandoned' (a valid value since Phase 3B,
never previously set by anything) now gets set on: environment
destroy/reset, 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 an academic failure marker -
result stays NULL, history is preserved. Call-site wiring in
server.js/admin-routes.js lands in the next commit alongside other
platform hardening in those same files.
2026-08-24 20:14:52 -05:00
ced4568 eae8e48d43 feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap
Adds platform/lab-router/ — a narrow service that runs on the Lab Host,
authenticates the Gateway via mTLS, and resolves an opaque routing token to
a local container purely from Docker label state (no Postgres access, no
callback to the Provisioner). Replaces the Gateway's env.internal_ip
assumption (only valid when Gateway and Lab share a Docker daemon) with a
data-driven direct/router decision per lab_host
(lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one
place (platform/gateway/src/lab-endpoint.js's labTarget()) that every
proxy/fetch call site in server.js now goes through.

Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh)
with a second mTLS leaf pair (router-server/gateway-client) for this new
data-plane connection, deployed by the docker-tls Ansible role into a
separate directory from the control-plane pair. Firewall
(rn-lab-host-01.rules) replaces the old speculative wide port-range
allowance with one narrow rule: platform_ip -> the Router's port only.

dev-local stays routing_mode: direct — zero behavior change for local dev,
confirmed by the full existing regression suite passing unmodified.
2026-08-24 20:14:39 -05:00
ced4568 bf1e1b5c76 docs(phase4): production infrastructure documentation + runbooks
Master overview (PHASE4_PRODUCTION_INFRASTRUCTURE.md) plus five detail docs
(PRODUCTION_DEPLOYMENT, PRODUCTION_NETWORK_SECURITY, BACKUP_DR,
OBSERVABILITY, CAPACITY_PLANNING), a master PRODUCTION_VALIDATION_CHECKLIST,
and 12 runbooks (first deployment, normal deploy, rollback, Postgres
restore, VM rebuild for both hosts, Docker TLS rotation, TLS renewal, owner
recovery, stuck Lab cleanup, failed migration, backup failure, disk
failure, Lab Host resource pressure).

PRODUCTION_NETWORK_SECURITY.md prominently documents the one genuine
architectural gap this phase surfaced: the Gateway's existing
env.internal_ip mechanism for reaching a Lab container assumes Gateway and
Lab share a Docker daemon, which breaks once Labs run on a genuinely
separate rn-lab-host-01 VM. The network-policy half is built (a firewall
allowance for Gateway->Lab-Host on a published port range); the
application half (Provisioner/Gateway address handling) is an explicit,
reviewed, NOT-YET-BUILT follow-up flagged there rather than silently
decided.

Updated LAB_ARCHITECTURE.md, ENGINEERING_SOP.md, README.md, and
platform/README.md with Phase 4 pointers, consistent with how every prior
phase updated these.

Status: PHASE 4 BUILD COMPLETE / PRODUCTION DEPLOYMENT PENDING HARDWARE.
2026-08-18 17:13:51 -05:00
ced4568 1d3afc736b feat(observability): add /healthz to Platform API and Gateway
Unauthenticated liveness/readiness probes for uptime monitoring and Ansible
deploy smoke tests (Phase 4). API's checks Postgres reachability via
SELECT 1; Gateway's answers locally without proxying anywhere, so it can
distinguish 'Gateway itself is down' from 'something behind it is down'.
No student/instructor data exposed.

Verified live against the real dev stack: both return 200 with the
expected JSON body. Full regression suite re-run after this change:
e2e-phase2a (10/10), e2e-phase2b (12/12), e2e-phase3a (21/21), e2e-phase3b
(24/24) - all passing, no regressions. Frontend build and the Lab image
(both the multi-service dev compose and the standalone single-container
Dockerfile) also confirmed still building/booting correctly.
2026-08-18 17:13:42 -05:00
ced4568 51a09241ad feat(loadtest): load-test tooling + capacity model
boot-labs.js drives the real admin Prepare-Labs endpoint (reusing the
existing session-helper.js from Phase 3A) and records boot time/failure
rate; sample-resources.sh samples real docker stats for running Labs.
capacity-model.js turns measured per-Lab CPU/memory into a recommended/
warning/hard-admission-control concurrency threshold - no concurrency
number is hardcoded as a marketing figure, every input is explicitly
placeholder data until measured against the real host.

Plus deploy/rollback/smoke-test scripts used by the CI/CD and deployment
runbooks. All syntax-checked; validate-compose.sh and capacity-model.js
actually run against real inputs.
2026-08-18 17:13:32 -05:00
ced4568 67e172c800 feat(cicd): Gitea Actions pipeline, deterministic image tagging
Automatic test/build/tag/staging-deploy on push to main; production
promotion stays a manual, human-run step (deploy-production.sh) - never
automated. SHA-tagged images always, semantic tags only on deliberate
release, never :latest alone in staging/production. No paid CI provider or
registry required (uses Gitea's own). Real regression job installs all
services, applies real migrations against a service Postgres, runs the
infra validation scripts.

Validated as real YAML (python3 yaml parse).
2026-08-18 17:13:25 -05:00
ced4568 b1b22b9285 feat(backup): encrypted Postgres backup/restore + off-host adapter
pg_dump -> gzip -> gpg AES-256 symmetric encryption, plaintext deleted
immediately, optional off-host rsync-over-SSH copy (Hetzner Storage Box or
any SSH target), retention pruning, Prometheus textfile-collector metrics
for backup success/age/size.

Genuinely tested end-to-end, not just written: real production migrations
(17 total, correct cross-service order) applied to a disposable Postgres,
sample data inserted, backed up, restored into a second disposable
Postgres, verified table-for-table and byte-for-byte. Exit 0.
2026-08-18 17:13:19 -05:00
ced4568 4e8fa1d413 feat(observability): Prometheus/Alertmanager/Grafana/Loki stack
Self-hosted, no paid SaaS. Host/container/Postgres metrics, uptime probes
against the new /healthz endpoints, alert rules split critical (pages) vs
warning (informational), one starter Grafana dashboard. Deliberately
separate from the application's instructor learning analytics (Phase 3B) -
different audience, different data.

Validated: promtool check config (valid), promtool check rules (11 rules,
valid), amtool check-config (1 inhibit rule, 2 receivers, valid).
2026-08-18 17:13:14 -05:00
ced4568 d2280d0f0d feat(security): production Compose topology, firewall rulesets, Docker mTLS
Named-bridge-network production Compose (not host networking, unlike dev)
for rn-platform-01; egress-proxy + agents for rn-lab-host-01. Caddy reverse
proxy, single public origin, Grafana restricted to management_cidr.
iptables-restore firewall rulesets for all three hosts (Proxmox host,
rn-platform-01, rn-lab-host-01) codifying the Platform/Lab-Host boundary:
no student-to-student traffic, no direct Proxmox exposure, no direct
Postgres/Provisioner exposure from the Lab Host.

Mutual TLS for the Provisioner<->Lab Host Docker connection: certificate
generation/verification scripts, dockerd TLS config, firewall-restricted
port 2376. Application support already existed since Phase 1; this is
purely the operational half.

Validated: docker compose config, caddy validate, firewall structural lint,
and a real openssl s_server/s_client mTLS handshake proving the cert chain
and mutual-auth rejection (legitimate client verified, rogue client
correctly rejected with verify code 19).
2026-08-18 17:13:09 -05:00
ced4568 507ac4c5d3 feat(ansible): idempotent role-based host config for both production VMs
common/firewall/docker-tls/platform/lab-host/monitoring-agent/backup roles,
orchestrated by site.yml with the ordering that matters (firewall/TLS land
before the services that depend on them). Ansible Vault for secrets, no new
tool introduced for this project's modest secret surface. Explicit
dev/staging/production environment separation, no secrets committed
(.gitignore covers vault plaintext, TLS keys, Terraform state, *.env).

Validated: ansible-playbook --syntax-check clean.
2026-08-18 17:13:01 -05:00
ced4568 cbf81b09e7 feat(proxmox): Proxmox VM template + Terraform/OpenTofu VM creation
Manual, documented bare-metal Proxmox install + cloud-init template build
(deliberately not automated — a fragile one-time step). Terraform/OpenTofu
config creates rn-platform-01/rn-lab-host-01 from that template via the
bpg/proxmox provider, with configurable (not hardcoded) VM sizing.

Validated: tofu fmt, tofu init -backend=false (real provider download),
tofu validate -> Success. No paid state backend required (local state).
2026-08-18 17:12:55 -05:00
ced4568 45a147a61f docs(phase3b): document the learning and assessment engine
New docs/PHASE3B_LEARNING_AND_ASSESSMENT.md covers the attempt/learning-record model,
progressive hints (including the Gateway response-rewrite mechanism), curriculum completion
rules, the assessment/exam engine and its timer/draw integrity guarantees, the RootNode
Capstone design, achievement records, the deliberately minimal versioning model, instructor
analytics, the Phase 3B RBAC matrix, security/integrity assumptions, dev/test commands, known
limitations, and the Phase 4 boundary.

Updates ENGINEERING_SOP.md (roadmap items 2/7/8 now done, new section 12, corrects a stale
capstone-placeholder description in section 4), LAB_ARCHITECTURE.md's status line,
README.md (corrects the 'Lab image unmodified' claim now that its frontend bundle carries
Phase 3B UX, plus new platform properties), platform/README.md, and platform/api/README.md.
2026-08-18 14:36:13 -05:00
ced4568 8956a95446 test(phase3b): add learning and assessment coverage
24 cases: attempt telemetry (start/fail/pass counters, duration), progressive hint sequencing
and exhaustion, hint persistence through a real Lab reset, curriculum stage lock/unlock,
capstone lock-before-prerequisites/unlock-after/random-draw-includes-required-Real-Ops/
cannot-double-start/completion-records-achievement, exam assignment/draw/timer/hint-policy,
cross-student session-read rejection (404, not 403 - consistent with the existing don't-confirm-
existence posture), two-student isolation, timer survives a Lab reset, an assessment session
survives a REAL docker compose restart api, and instructor analytics/needs-attention/RBAC.

Found and fixed two real product bugs in the process: result needed to become nullable (an
in-progress attempt has none yet) and hint_count needed a real DEFAULT 0 (Phase 2B left it
NULL/undefaulted since it wasn't measured yet - it is now, and NULL + 1 stays NULL in SQL).
2026-08-18 14:36:13 -05:00
ced4568 d4db8f99f5 feat(student): add learning tabs and Lab hint/assessment UX
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).

Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
2026-08-18 14:35:59 -05:00
ced4568 7e5f357b6f feat(admin): add instructor learning analytics and assessment management
New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).

New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
2026-08-18 14:35:59 -05:00
ced4568 4e3dacc043 feat(hints): wire student-facing learning API and progressive hint reveal
New learning-routes.js: scenario start/attempt-state/hint (sequential reveal, 409 once
exhausted, policy-aware under an active assessment), GET /api/curriculum/state (rule-driven,
alongside the unchanged /api/curriculum/path), assessments list/start/active/detail/submit,
GET /api/capstone, GET /api/achievements, GET /api/attempts/recent.

/api/progress/record now routes through attempts.recordValidation instead of a flat insert,
and, on a pass, checks curriculum/capstone achievement consequences and, if the scenario is
part of an active assessment, updates that session (assessment context is derived entirely
server-side from assessment_session_scenarios, never from anything the client asserts).

Gateway changes: two new relay routes (/lab/:token/api/scenarios/:id/start and .../hint,
mirroring the existing validate/answer pattern - pure Platform API operations, the Lab has no
attempt/hint concept) and a response rewrite on GET /lab/:token/api/scenarios/:id that strips
the Lab's own hints array (replacing it with a bare hint_count) so progressive reveal is
actually server-gated instead of just UI-collapsed - the Lab image's bytes stay untouched;
standalone/single-container dev mode never goes through the Gateway and keeps the full array
unaffected.
2026-08-18 14:35:44 -05:00
ced4568 0beb0e8f5e feat(learning): add persistent achievement records
New achievements table: stage_completed/curriculum_completed/assessment_completed/
capstone_completed, idempotent (WHERE NOT EXISTS guard, not a unique-index dance — simple and
correct at this write volume). Not a certificate — a durable internal record Phase 4/5 can
render one from later. recordAssessmentAchievement only fires on an actual pass, defensively,
even though today's only completion_rule already guarantees that.
2026-08-18 14:35:11 -05:00
ced4568 b908ade8e0 feat(assessment): add exam and capstone definition/session engine
New tables: assessment_definitions (reusable template — pool_spec, duration, hint_policy,
completion/unlock rules, a plain incrementing version bumped only on semantic edits),
assessment_cohort_assignments, assessment_sessions (one per student attempt, full snapshot of
what was drawn and under what rules — never affected by a later definition edit),
assessment_session_scenarios (the drawn set, ordered, with a real FK to scenario_attempts).
Deliberately does not touch/reuse exam_sessions (migration 006) — that was already scoped in
Phase 2B as historical-import-only, never the live mechanism; these tables are the real thing.

drawScenarios: every 'required' scenario id included, plus N random picks per pool group,
shuffled together (tiny seeded PRNG, mulberry32 — no new dependency; the seed is recorded for
audit, never shown to the student before the draw). One assessment at a time per student.
Timer is server-authoritative (expires_at fixed at draw time) and lazily expired — checked
whenever a session is touched, no background cron. Ships two seeded defaults: rootnode-capstone
(Real Ops scenarios required, weighted toward operational ability) and rootnode-quick-assessment
(a general example exam matching the spec's category-draw example).
2026-08-18 14:35:11 -05:00
ced4568 01a862e007 feat(learning): add scenario attempt telemetry and curriculum completion rules
Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
2026-08-18 14:35:11 -05:00
ced4568 e793d9476b docs(deps): document the dependency-lock convention
Was ambiguous — backend/frontend committed lockfiles, api/gateway/provisioner didn't, with
no stated reason either way. Now explicit: every first-party Node service commits its
package-lock.json, validated with npm ci before each commit that touches it.
2026-08-18 13:50:38 -05:00
ced4568 957a99645a chore(deps): track gateway and provisioner package locks
backend/ and frontend/ already had committed lockfiles; platform/api/ just gained one.
gateway/ and provisioner/ never had one generated at all — not a deliberate policy, an
inconsistency. Generated via npm install --package-lock-only and validated with a clean
npm ci for both (72 and 153 packages respectively, no resolution surprises).
2026-08-18 13:50:13 -05:00
ced4568 60a1e79930 chore(deps): track platform API package lock
Was generated locally during Phase 3A work but never committed. Verified it corresponds
to the current package.json via a clean npm ci (118 packages, no resolution changes).
2026-08-18 13:49:35 -05:00
ced4568 40692926ee docs(phase3a): document production identity architecture
New docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md covers the schema, persistent session
design, instructor accounts, the full RBAC matrix, cohort scoping (including the documented
raw-environment-id simplification), student session hardening (explicitly still
classroom/pilot identification, not production auth), CSRF, rate limiting, audit logging,
the API inventory, a security hardening checklist, test results, known limitations, the
future OIDC path, and production HTTPS/cookie expectations — with an explicit BUILT NOW vs.
FUTURE split.

Updates ENGINEERING_SOP.md (new §9/§10, closing a stale '(§9)' forward-reference that dated
back to Phase 2B), LAB_ARCHITECTURE.md status line, README.md, platform/README.md, and
platform/api/README.md (bootstrap-owner usage, new config vars, no more ADMIN_PASSWORD).
2026-08-18 13:36:45 -05:00
ced4568 face0ae132 test(phase3a): add identity and RBAC end-to-end coverage
New e2e-phase3a.js (21 cases): no account enumeration, CSRF positive/negative, login rate
limiting, RBAC across all three roles, cohort-scoped instructor access (visibility + route
access), last-owner protection, live role/status re-evaluation (no separate revoke needed),
password change (old password rejected after, other sessions revoked), audit log content
(including a no-secrets-in-metadata assertion), student session RBAC/logout, and session
persistence across a REAL docker compose restart api mid-test.

New session-helper.js — a shared cookie-jar test helper (Node's fetch doesn't persist
cookies across calls) that also auto-attaches X-CSRF-Token from whichever rn_csrf_*
cookie it's holding, replacing the single-cookie-string trackers e2e-phase2a.js and
e2e-phase2b.js each had their own copy of.

e2e-phase2a.js and e2e-phase2b.js updated for the new auth model: admin login is now a real
bootstrapped OWNER_EMAIL/OWNER_PASSWORD account instead of the old shared ADMIN_PASSWORD.
All cases still passing: 10/10 and 12/12 respectively.
2026-08-18 13:36:45 -05:00
ced4568 1ca5dd3995 chore(platform): retire ADMIN_PASSWORD from the dev stack
No more shared instructor password env var — the api service now needs only
PROVISIONER_SHARED_SECRET. First-run setup is npm run bootstrap-owner instead.
2026-08-18 13:36:29 -05:00
ced4568 777a1ef880 feat(admin): add instructor management, audit, and account UI
Login form is now email+password (was a bare shared password field). Nav conditionally
shows Instructors (owner only) and Audit (owner/admin) tabs based on the signed-in
instructor's role. New InstructorsView (create/list/edit accounts, role/status controls,
session list + revoke-all, password reset — owner only), AuditView (read-only audit event
table), AccountView (change-password form for the signed-in instructor). CohortsView hides
the create-cohort form and curriculum-assignment button for the 'instructor' role — the
server already enforces this; this just avoids showing controls that would 403.
2026-08-18 13:36:29 -05:00
ced4568 7ce435ad32 feat(security): add CSRF double-submit protection and login rate limiting
Non-HttpOnly rn_csrf_s/rn_csrf_i cookies, set on login, echoed back as X-CSRF-Token on every
state-changing request — defense in depth on top of (not instead of) SameSite=Lax. In-memory
sliding-window rate limiting on instructor login (8 attempts / 5 min per ip:email, no new
dependency).

Real bug found while wiring this up: the Gateway's own progress-relay call (triggered when a
student solves a scenario) is a server-to-server POST to /api/progress/record relaying the
browser's session — it needs to forward the CSRF token too, and needs to forward BOTH the
rn_csrf_s cookie and a matching X-CSRF-Token header (forwarding only the header still 403'd,
since the double-submit check needs both on the request Platform API actually receives).

Also tightens the Gateway's session-resolution cache from 5000ms to 2000ms, so a
revoked/expired session stops working at the Gateway within a tighter, tested bound.

Frontend (admin + student API clients) now reads the relevant rn_csrf_* cookie via
document.cookie and attaches it on every POST/PATCH/DELETE.
2026-08-18 13:36:20 -05:00
ced4568 62726455d5 feat(rbac): enforce owner/admin/instructor permissions across the admin API
Three roles (owner > admin > instructor), enforced server-side on every /api/admin/* route
via reusable middleware (requireOwner/requireAdminOrOwner/requireAnyInstructor) — the
frontend hides controls a role can't use, but that's UX, never the actual boundary. A plain
'instructor' is further scoped to cohorts assigned via the new cohort_instructors table
(requireCohortAccess/requireStudentCohortAccess); owner/admin bypass that check entirely.

Owner-only: instructor account lifecycle, audit log. Admin/owner: cohort/student CRUD,
curriculum assignment. Any instructor role (cohort-scoped for 'instructor'): dashboard,
cohort/student views, Prepare/Destroy Labs, provisioning. Deliberately NOT cohort-scoped:
the raw /api/admin/environments/:id/* routes (no cohort context in a bare environment id
without an extra Provisioner round trip on every call) — a plain instructor still has full
Lab control via the cohort/student-scoped routes instead, which already carry the check.

server.js wires the new persistent-session middleware (studentSession/instructorSession —
re-fetches the instructor row from Postgres every request, so a role change or
deactivation takes effect on the very next request with no separate revoke needed) and adds
a last-resort error handler so a DB error in any async route returns 500 instead of
crashing the process or leaking a stack trace.
2026-08-18 13:36:11 -05:00
ced4568 37230d4ef1 feat(audit): add platform security audit log
New audit_events table, distinct from the Provisioner's environment_events (Lab-lifecycle
only). recordAudit() is called from the auth/RBAC/admin layers added in this phase — login
success/failure, logout, password change, instructor CRUD, role changes, session
revocation, cohort/roster changes, curriculum assignment, Prepare/Destroy Labs, and every
403 authorization denial (recorded centrally by the RBAC middleware, not per-route). Never
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens.
2026-08-18 13:36:11 -05:00
ced4568 85fa920f96 feat(auth): add per-instructor accounts and local password login
Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table
(email/display_name/role/status), scrypt password hashing with a fresh random salt PER
INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password —
that assumption is gone), auth_identities table so a future OIDC login can map to the same
instructor_id without a schema change.

New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
POST /api/auth/change-password (old password required, revokes other sessions). No account
enumeration: identical response whether the email doesn't exist or the password is wrong.

bootstrap-owner.js is the only supported way to create the first account — no manual
database editing to stand up a fresh deployment. Refuses to run if an active owner already
exists unless the target email is an existing instructor (zero-owners recovery path).
2026-08-18 13:35:54 -05:00
ced4568 52c17c1907 feat(identity): add persistent Postgres-backed session store
Replaces the in-memory studentSessions/adminSessions Maps (Phase 2B — gone on every
restart) with a sessions table. Browser holds an opaque CSPRNG bearer token; only its
SHA-256 hash is ever stored server-side, same principle as password hashing. Each session
has an absolute expiry (fixed at creation) and a sliding idle expiry (pushed forward on
every authenticated request, capped at the absolute one) — one atomic UPDATE...RETURNING
per resolve, no read-then-write race. Student and instructor sessions stay fully separate
token namespaces, matching Phase 2B's cookie-name separation.

cookies.js centralizes Set-Cookie construction so the Secure attribute (COOKIE_SECURE=1,
for real HTTPS deployments) is applied consistently instead of hand-built per call site.
2026-08-18 13:35:54 -05:00
ced4568 08ff5315de docs(phase2b): document admin and platform-data architecture
New docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md covers the Platform API
split, Postgres schema and migration guarantees/rollback, the session
cookie architecture (including the whoami-routing-bug fix), the admin
portal, the API/security boundary, known limitations, and Phase 3+
future work.

Updates ENGINEERING_SOP.md §8, LAB_ARCHITECTURE.md, README.md, and
platform/README.md to reflect Platform API as the sole Provisioner
caller and Postgres as authoritative for identity/progress as of
Phase 2B.
2026-08-18 12:23:17 -05:00
ced4568 6f1d202242 test(phase2b): add instructor classroom workflow coverage
Full acceptance test: admin auth (+ student-can't-call-admin check),
cohort create, 3-student add, curriculum assign, concurrent Prepare Labs
(3/3 ready, instant-reuse timing check), two students completing
different scenarios, dashboard reflecting completions, admin reset with
progress-intact assertion, admin destroy-labs with progress-intact
assertion for everyone, and a regression check that no Lab container
publishes a host port. 12/12 passing.
2026-08-18 12:23:17 -05:00
ced4568 9b28d786de chore(platform): wire platform-api into dev compose, retire identity service
Removes the old identity service/volume, adds the api service (host
networking, Postgres URL, Provisioner shared secret, ADMIN_PASSWORD,
both required via :?). gateway now points PLATFORM_API_URL at it instead
of the old IDENTITY_BACKEND_URL.
2026-08-18 12:23:17 -05:00
ced4568 8e4ef9ca53 feat(admin): add cohort and student management portal
New /admin route (frontend/src/admin/), gated by a separate admin
session (scrypt + timingSafeEqual against ADMIN_PASSWORD, no plaintext
storage, no new dependency). Covers cohort CRUD + roster + curriculum
assignment, and student CRUD + cohort assignment + environment/progress/
attempt views, all under /api/admin/* on the Platform API.

main.jsx now does a three-way path split (lab / admin / platform) so the
existing, unmodified Lab UI, the student dashboard, and the new admin
portal all ship in one Vite bundle without a router dependency.
2026-08-18 12:22:58 -05:00
ced4568 ae75bb4cbe feat(auth): add server-controlled student and admin sessions
Replaces the Phase 2A pilot ?sid= query param with an HttpOnly,
SameSite=Lax session cookie. Gateway now derives student identity from
the session (via Platform API's /api/session/whoami) instead of trusting
a client-suppliable id, and checks ownership on every proxied HTTP and
WebSocket Lab request, not just the initial navigation.

Fixes a routing bug found during testing: the fallback route was using
'does this session own a running environment' to decide whether an
unprefixed /api/* call belonged to the Lab UI or the dashboard — true in
both places. Replaced with an explicit LAB_OWNED_PATHS allowlist of the
exact unprefixed paths the existing Lab UI actually calls.

e2e-phase2a.js rewritten to use a cookie jar instead of headers/query
params, with explicit impersonation-rejection tests added. 10/10 passing.
2026-08-18 12:22:58 -05:00
ced4568 6b52c9e1d2 feat(platform-api): split lightweight platform service from Lab image
New platform/api/ service owns students, cohorts, curriculum, progress,
scenario attempts, and student/admin sessions. No k3s, no --privileged,
no terminal runtime — starts instantly, unlike the old identity service
which booted an unused k3s cluster just to serve roster/progress APIs.

Provisioner calls (student and admin) are consolidated here too, in
provisioner-client.js, so it's the only service that talks to the
Provisioner directly.
2026-08-18 12:22:58 -05:00
ced4568andClaude Sonnet 5 42dfb1c31b docs(phase2a): document gateway and student launch architecture
- docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md — the full Phase 2A record: node-pty
  root cause and fix, Gateway architecture, student flow (entry through
  reset/recreate), API routes added, HTTP/WebSocket routing design (and why
  the lab-session cookie exists), security model (what's checked, and the
  explicit statement that this is pilot identity, not production auth),
  reset/recreate and reaper-recovery semantics, and known limitations found
  during implementation (the identity instance's k3s resource cost and its
  network_mode: bridge requirement, the Gateway's in-memory cache scope, the
  ?sid= check's coverage). BUILT NOW vs. FUTURE (Phase 2B) called out
  explicitly per the governing requirement.
- docs/LAB_ARCHITECTURE.md, docs/ENGINEERING_SOP.md — status updated to
  reflect Phase 2A is implemented and tested, not just Phase 1; roadmap item 5
  updated accordingly; ENGINEERING_SOP's Provisioner section corrected (it's
  no longer un-fronted — the Gateway is the only caller now).
- README.md — Multi-Student Deployment section updated to describe the
  Gateway and student flow, not just the Provisioner; clarifies the
  node-pty fix is the only change to the Lab image itself.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:18:43 -05:00
ced4568andClaude Sonnet 5 05421bcb0e test(phase2a): add end-to-end student environment coverage
platform/test/e2e-phase2a.js — 15 assert-based tests against a live
platform/docker-compose.dev.yml stack, exercising the full student flow
through the Gateway exactly as a browser would: roster -> login ->
curriculum -> launch (real k3s boot) -> Gateway HTTP routing -> invalid-token
rejection -> cross-student ownership rejection (?sid= mismatch) ->
same-student access -> progress relay (solve in one student's lab, confirm
it shows in their curriculum and NOT the other student's) -> reset (stable
launch_path, progress survives).

All 15 pass. This is slow (each real environment launch is a genuine k3s
cold start) and deliberately not meant for every commit — see
platform/README.md for when to run it.

Does not cover what needs a browser/WebSocket client — real Lab image
terminal (node-pty, kubectl execution, scenario validation) and idle-reaper
timing were verified manually; see
docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8 for exactly what and how.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:16:44 -05:00
ced4568andClaude Sonnet 5 fb2945069c feat(student): add curriculum dashboard and lab launch/reset/recreate flow
StudentDashboard.jsx — "Up Next" curriculum view + Launch Lab flow, in one
component since they share state (environment status affects what curriculum
actions make sense).

- 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.
- Launch Lab -> POST /api/environment/launch, polls status every 2.5s while
  provisioning/resetting, transitions through none -> provisioning -> running
  -> error with a distinct UI state for each (EnvironmentPanel). On running,
  navigates to launch_path with the student's own id as ?sid= — a
  defense-in-depth ownership check the Gateway verifies on that specific
  navigation (see the gateway commit).
- One "Recreate Lab" control, calling POST /api/environment/reset — no raw
  destroy-without-recreate exposed to students, matching "don't expose raw
  destroy/admin controls beyond what is safe for a student." A visible note
  states progress is saved separately and survives.

Platform.module.css reuses the existing design tokens from index.css
(--green, --surface, --text, etc.) so this reads as the same product as the
Lab UI, not a bolted-on separate app.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:16:35 -05:00
ced4568andClaude Sonnet 5 5e4f5518fd feat(student): wire roster identity and student session
New Platform Shell entry point (frontend/src/platform/) alongside the
existing, completely unmodified Lab UI (App.jsx and everything under
frontend/src/components/). main.jsx now splits on the current URL path —
/lab/<token>/ renders the existing <App/> exactly as before; anything else
renders the new <PlatformShell/>. Both live in the same built bundle (it's
served both by the persistent identity instance and by every ephemeral Lab
container), split purely at runtime.

- EntryScreen.jsx — cohort dropdown -> name dropdown (existing
  GET /api/roster/cohorts, GET /api/roster?cohort=) -> POST /api/login. No
  password. Visibly labeled pilot identity, not production auth, per the
  governing requirement that this be stated explicitly, not just true in the
  architecture docs.
- api.js — student_id lives in localStorage only, sent as X-Student-Id on
  every authenticated 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, not just
  a comment.
- PlatformShell.jsx — entry screen if no stored session, dashboard otherwise.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:16:24 -05:00
ced4568andClaude Sonnet 5 ce6abfe6c7 feat(platform): relay scenario progress from ephemeral labs to identity backend
Real architectural gap found and fixed during Phase 2A testing, not part of
the original plan: scenario validate/answer requests proxied through
/lab/<token>/api/scenarios/:id/{validate,answer} were writing progress into
that ephemeral Lab container's own SQLite — which is destroyed on every
reset/reap. Confirmed by testing: solving a scenario never showed up on the
dashboard's curriculum view, and would have been silently lost on lab
recreate, directly contradicting "academic progress must survive lab
reset/recreate."

Root tension: scenario validation must run against the student's own live
cluster (only exists inside their Lab container) — it can't be re-run
anywhere else. But the canonical progress record needs to live on the
persistent identity instance, which never has the student's actual cluster
state applied to it.

Fix: this endpoint (POST /api/progress/record) records an outcome WITHOUT
re-running validation — it exists purely so the Gateway can relay a
validate/answer result it already obtained from the real Lab. The Gateway
forwards the real request to the Lab, returns that real response to the
browser unchanged, and separately calls this endpoint with the outcome. See
platform/gateway/src/server.js's relayScenarioOutcome().

Verified end-to-end: a scenario solved through a student's Lab shows up in
their curriculum immediately, survives their lab being destroyed and reset,
and is correctly attributed only to that student (not visible to a second
student's own curriculum view).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:16:08 -05:00
ced4568andClaude Sonnet 5 c89e39c5ee feat(gateway): add environment-aware HTTP and websocket routing
New internal component (platform/gateway/) — the only student-facing path to
disposable Lab environments. Browsers never reach a Lab container, the
Provisioner, or Postgres directly.

Routes:
- /api/environment/{status,launch,reset} — student-facing glue (X-Student-Id),
  calls the Provisioner internally (shared secret never reaches the browser).
  Reuses an existing non-terminal environment on launch rather than always
  provisioning a new one; reset destroys+recreates the same environment row.
- /lab/<token>/* — HTTP+WS proxy into that one student's Lab container,
  resolved by opaque routing token via a new Provisioner endpoint
  (GET /environments/by-token/:token). No raw container IP/port ever reaches
  the browser. Rejects invalid tokens (404), unready environments (202/503),
  and cross-student access via an optional ?sid= ownership check (403) — see
  docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security" for why token-alone,
  student-id-alone, and env-id-alone are each individually insufficient and
  this is explicitly pilot-grade, not production auth.
- everything else — proxied to the persistent identity/curriculum backend
  (roster, login, curriculum, progress, platform-shell frontend), or to the
  student's own Lab if a valid lab-session cookie is present.

Routing design note: the existing Lab UI makes root-relative calls
(fetch('/api/scenarios'), the /shell-ws WebSocket) with no idea it might be
served under a /lab/<token>/ prefix — preserving that UI unmodified was a
hard requirement. Solved with an HttpOnly, SameSite=Lax cookie set on the
first /lab/<token>/ response: every later request from that browser carries
it, so the unmodified Lab UI's own unprefixed calls resolve correctly once a
student has navigated into a lab session, with zero changes to its code.
/api/environment/* is explicitly exempted from cookie-based routing so it
never gets hijacked by an active lab session.

Also added platform/docker-compose.dev.yml (the full local stack: Postgres +
egress-proxy + Provisioner + a persistent "identity" instance + Gateway) and
platform/README.md tying the components together. The "identity" instance
required an explicit `network_mode: bridge` — under Compose's own
auto-created bridge network, k3s failed at startup with "no default routes
found in /proc/net/route"; documented as a known limitation, not fully
root-caused.

Found and fixed during testing: express.json() applied globally broke
http-proxy's re-streaming of proxied POST bodies (e.g. POST /api/roster hung
forever, since the body had already been consumed by the parser before
proxy.web() tried to forward it). Removed — none of the Gateway's own routes
actually need a parsed body.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:15:55 -05:00
ced4568andClaude Sonnet 5 de03dbde9a fix(lab): restore node-pty terminal runtime
Root cause, confirmed empirically (not guessed):

1. node-gyp generates an incorrect build/config.gypi for this NodeSource-packaged
   Node 20 build — it reports node_shared: true (expects a shared libnode.so),
   but the actual installed Node is statically linked (no libnode.so.<abi>
   exists anywhere on the system; process.config.variables.node_shared on the
   real running Node is 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 (which collapses this into a generic
   "Cannot find module .../pty.node" across every path it checks).

2. A second, independent bug compounded it: no .dockerignore existed. A
   host-side `npm install` (run earlier for backend testing) left
   backend/node_modules on disk, built against the host's own environment. The
   Dockerfile's `COPY backend/ ./backend/` (placed after the npm-install RUN
   layer, for build-cache efficiency) silently overwrote the correctly-built
   node_modules with that host copy — including after a first .dockerignore
   fix using the pattern `node_modules/`, which only matches a node_modules
   directory at the build context ROOT in Docker's matching rules, not at any
   depth (unlike .gitignore, where the same-looking pattern does match any
   depth — a real, confirmed gotcha, not an assumption).

Fix:
- Dockerfile: `ENV npm_config_node_shared=false` (image-level, not a one-off
  RUN prefix, so it can't be silently lost if a native module ever gets
  rebuilt again for any reason) + a build-time `node -e "require(...)"` check
  that fails the build loudly if this regresses, instead of shipping a silent
  runtime failure.
- .dockerignore: `**/node_modules/` (recursive), not `node_modules/`.

No terminal architecture change, no fake shell, no changes to
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 end-to-end, not a stand-in: build -> container
starts -> k3s reaches Ready -> backend starts, no crash -> nginx starts ->
browser UI 200s -> terminal WebSocket opens showing the RootNode banner ->
`kubectl get nodes` run through the terminal returns real cluster state ->
deploy-nginx scenario solved via the terminal -> all 5 validation checks pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:15:26 -05:00
ced4568andClaude Sonnet 5 a15897c78f docs(platform): document ephemeral lab architecture
- docs/LAB_ARCHITECTURE.md — the full ephemeral-per-student design: two-VM
  production topology (Platform VM / Lab Host VM, correction A), lifecycle,
  data model, networking/egress model (correction B), resource model,
  routing/auth model, security boundaries, failure/recovery behavior,
  migration path from the current single container, and the lab-host
  abstraction (correction D). Status updated to reflect Phase 1 is now
  implemented, pointing at the two docs below for specifics.

- docs/PHASE1_PLATFORM_FOUNDATION.md — the Phase 1 build plan: exact Postgres
  schema, service boundaries, API routes, lifecycle state machine, Docker/
  firewall commands, security assumptions, file layout, and a 12-test plan.
  Updated with "Implementation notes" at the points where testing surfaced
  real deviations from the original design: the per-network (not static)
  egress-proxy address resolution, the COALESCE reaper fix, and the
  provisioning-failure cleanup fix.

- docs/ENGINEERING_SOP.md — new §8 mapping the platform/provisioner/ and
  platform/egress-proxy/ components, what's built vs. not (no Gateway, no
  frontend wiring — internal API only), and where identity still lives
  (unchanged: Lab image's own SQLite). Roadmap item 5 (per-student isolation)
  updated from "not started" to "Phase 1 done, not yet cleared for minors."

- README.md — Multi-Student Deployment section rewritten: the old per-student-
  volume persistence model is superseded by the Platform's registry (progress
  already lives outside the disposable container as of the prior curriculum/
  roster work); points at docs/LAB_ARCHITECTURE.md and platform/provisioner/
  for the real architecture instead of describing three delivery modes that
  were never actually built out.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:22:46 -05:00
ced4568andClaude Sonnet 5 17bd01045f test(platform): add Phase 1 integration coverage
platform/provisioner/test/run.js — 12 assert-based integration tests against a
live docker-compose.dev.yml stack (no framework, matching the project's
existing convention). Covers: health/auth, provision with unique
network/container/token, duplicate-provision rejection, distinct
subnets/IPs for concurrent students, the egress firewall ordering fix
specifically (proxy ACCEPT before the RFC1918 DROP for the same range),
student-to-student isolation, RFC1918 blocking, reset (same identity, fresh
container), destroy (deterministic cleanup), and the audit trail (all
expected events present, routing token never logged).

All 12 pass against the default lightweight test image. Several properties
were validated manually during implementation rather than automated here —
idle reaper timing (needs a real threshold wait), remote TCP+TLS lab-host
(needs a Docker-in-Docker sidecar, too heavy for routine runs),
reconciliation-on-crash (needs an out-of-band container kill + process
restart), and real-Lab-image k3s behavior (environment-specific, see the
Phase 1 final report and platform/provisioner/README.md's known limitations)
— each is noted at the bottom of the file with why it's not automated here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:22:29 -05:00
ced4568andClaude Sonnet 5 9359abc9cb feat(reaper): add idle environment cleanup
Background loop (src/reaper.js), config-driven idle/abandoned thresholds and
poll interval (no hard-coded numbers) — without this, "ephemeral by default"
is just a description: a closed browser tab does not stop a running k3s
cluster on its own.

Past the idle threshold: logs a warning event only. Past the abandoned
threshold: destroys via the same path DELETE /environments/:id uses, so
cleanup is identical either way, and logs event_type='reaped_idle'.
Failure-safe — one environment's teardown throwing never stops the reaper
from checking the rest, and a Postgres/Docker hiccup on one tick just means
"try again next tick," never a crashed process.

Uses COALESCE(last_active_at, created_at) rather than bare last_active_at.
Found during testing: an environment that fails during provisioning (before
ever reaching 'running') has a NULL last_active_at, and NULL < anything is
never true in SQL — without the fallback, a failed-and-never-cleaned-up
environment would never have aged out here regardless of how long it sat
there. This is the second, independent safety net on top of
provisionEnvironment's own best-effort cleanup on failure (see the
platform-provisioner commit).

Verified end-to-end: backdated an environment's last_active_at past the
abandoned threshold, waited for a real reaper tick, confirmed full
deterministic cleanup (container, network, firewall chain all gone) with the
correct actor='reaper' attribution in environment_events.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:22:19 -05:00
ced4568andClaude Sonnet 5 0116ee1522 feat(networking): enforce isolated lab egress policy
Egress enforcement is a firewall-layer control, not an application-layer
convention (correction B). HTTP_PROXY/HTTPS_PROXY configure containerd for a
clean pull path; they enforce nothing on their own.

- src/egress.js — per-environment dedicated iptables chain
  (ROOTNODE-ENV-<short-id>), built with -A (append, order-preserving) rather
  than the -I (insert-at-head) pattern an earlier design draft used, which
  would have installed rules in reverse order and let the default-deny shadow
  the proxy/DNS accepts. Required infrastructure (egress proxy, DNS) is
  explicitly permitted first, then RFC1918 ranges are dropped unconditionally,
  then a final default-deny catches the public internet. One jump rule per
  environment hooks its subnet into its chain from DOCKER-USER (the chain
  Docker guarantees it won't silently rewrite). Destroy is deterministic:
  unhook, flush, delete — no comment-grep matching.

  The ordering fix is empirically verified, not just reasoned about: the
  egress proxy's own address is itself an RFC1918 address (it's Platform-side
  infrastructure), and testing confirms it's reachable while the broader
  RFC1918 range is still blocked — exactly the scenario the original ordering
  bug would have broken.

- platform/egress-proxy/ — shared Squid instance (one per deployment, not
  per-student), destination-allowlisted (dstdomain ACL against a plain-text
  allowlist file), starting set covers docker.io/ghcr.io/gcr.io/quay.io/k8s.io.
  Proxy performs domain allowlisting; the firewall chain above is what makes
  it unbypassable, not the other way around.

  The egress proxy's address is resolved per-student-network (each network's
  Docker gateway IP, since the proxy runs with host networking and is present
  on every bridge gateway) rather than a fixed hostname — a static address
  wouldn't be reachable from more than one isolated student network at a time.
  Found while testing with two simultaneous environments.

Verified: student-to-student isolation (A cannot reach B's internal IP),
RFC1918 blocking (A cannot reach a simulated trusted-infra address), and the
proxy allowlist itself (CONNECT tunnel to an allowlisted host succeeds,
non-allowlisted gets a clean 403 from Squid).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:22:10 -05:00
ced4568andClaude Sonnet 5 c8dd33522f feat(provisioner): add disposable lab lifecycle
Internal-only service (platform/provisioner/) implementing the environment
lifecycle from docs/PHASE1_PLATFORM_FOUNDATION.md: provision -> running ->
(reset -> running | destroy -> destroyed), plus experimental suspend/resume
(decision #3 — never on the guaranteed path, never relied on for correctness).

- src/docker.js — the lab-host abstraction (correction D). Resolves a
  lab_hosts row into a connected dockerode client, unix:// socket or tcp://
  with TLS client-cert auth. Refuses to connect to a tcp:// endpoint with no
  TLS material configured rather than connecting unauthenticated. Validated
  against both a local socket and a real remote Docker-in-Docker TLS endpoint
  during testing — same code path either way, only the lab_hosts config
  differs, which is the entire point.

- src/lifecycle.js — provision/destroy/reset/suspend/resume orchestration,
  wired to docker.js + egress.js + the Postgres registry. Every Lab container
  is privileged (required for k3s, not avoidable), gets its own Docker network,
  no published host ports, deterministic naming (rootnode-lab-<short-id> /
  rootnode-lab-net-<short-id>), and a CSPRNG routing token. Includes
  reconcileOnStartup(), implementing the "don't trust stale state after a
  crash/restart" requirement from docs/LAB_ARCHITECTURE.md §8 — verified by
  killing a container out-of-band and confirming a Provisioner restart flags
  the mismatch instead of trusting the stale 'running' row.

  Found and fixed during testing: a provisioning failure (e.g. readiness
  timeout) left an orphaned container/network/firewall chain behind with only
  the DB row marked 'error'. provisionEnvironment's catch block now does
  best-effort teardown of whatever was already created before marking the row
  error — confirmed by deliberately failing a provision and checking no
  container/network/chain survived it.

- src/server.js — the internal HTTP API (X-Provisioner-Key shared-secret auth
  on every route, matching the instructor-passcode pattern already used for
  the roster endpoint). Input validation on student_id/environment_id,
  structured JSON errors with a request id, routing tokens redacted from every
  response.

- docker-compose.dev.yml — Postgres + egress-proxy + provisioner on one
  machine (the documented Phase 1 dev exception). provisioner and egress-proxy
  run with network_mode: host + NET_ADMIN/NET_RAW, which is what lets
  src/egress.js manipulate the host's DOCKER-USER chain without needing
  interactive host sudo.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:21:52 -05:00
ced4568andClaude Sonnet 5 62241d26d2 feat(platform-db): add lab host and environment registry
New Phase 1 Platform database — PostgreSQL, separate from the Lab image's own
SQLite (which keeps its current students/progress data untouched, decision #2 /
correction C in docs/LAB_ARCHITECTURE.md).

Schema (platform/provisioner/migrations/):
- lab_hosts: the abstraction that keeps Docker connectivity out of code —
  endpoint (unix:// or tcp://+TLS), enabled flag. Phase 1 ships one row.
- environments: environment_id (UUID), student_id (opaque external identifier,
  no FK — students table isn't in this database yet), lab_host_id (real FK),
  status, routing_token (CSPRNG via crypto.randomBytes, opaque), container/
  network identity, resource limits, lifecycle timestamps.
- environment_events: append-only audit trail, one row per lifecycle
  transition, never logs the routing token in plaintext (see
  lifecycle.js's redact()).

File-based migration runner (db.js) matching the check-before-alter idempotency
pattern already used in the Lab image's backend/server.js, applied on every
Provisioner boot.

Resource limits and the lab_hosts seed are config files
(config/resource-policy.yaml, config/lab-hosts.seed.yaml), not database tables —
there's no admin UI to manage them via a database yet, so a DB-backed policy
table would just be a write path nothing uses.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:21:31 -05:00
ced4568andClaude Sonnet 5 60fbf0c385 feat(curriculum): add RootNode learning path, student roster, and scenario isolation metadata
Pre-Phase-1 platform work, approved and tested earlier but not yet committed:

- Terminal banner: "RootNode Academy Lab — node: k8s-lab", upstream KubeKosh
  attribution left untouched in footer/README/LICENSE.
- RootNode curriculum mode: scenarios/rootnode-path.json (Foundation -> Workloads
  -> Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone),
  built from existing scenario content grouped by category, no new scenarios
  invented. New GET /api/curriculum/path endpoint.
- Student roster + per-student progress: instructor-roster, pick-your-name login
  (no password), students/environments-adjacent progress table migrated to a
  (student_id, scenario_id) composite key so practice progress and exam sessions
  are isolated per student instead of one global bucket. New /api/roster,
  /api/login endpoints.
- scenarios/data/*.json: added isolation_type metadata (shared_namespace vs
  ephemeral_cluster) from an audit of which scenarios execute cluster-scoped or
  node-level commands. This was Tier-1-isolation groundwork that got superseded
  same-day by the full-ephemeral-per-student architecture (see
  docs/LAB_ARCHITECTURE.md) — the field is kept as descriptive metadata only,
  per the note now in scenarios/SCHEMA.md. Not wasted: it's an accurate audit of
  which scenarios touch cluster-scoped resources, useful independent of isolation
  model.
- .gitignore added (node_modules/, frontend/dist/, *.db, /data/, .env) — none
  existed before; without it, npm installs during testing would have landed in
  a commit.

Known limitation still open: no frontend UI for any of this yet (login screen,
roster-add form, curriculum view) — API-only. No auth on the roster-add endpoint.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 01:21:16 -05:00
383 changed files with 30635 additions and 71 deletions
+17
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
frontend/dist/
*.db
*.db-journal
/data/
.env
.ansible/
+22 -1
View File
@@ -22,6 +22,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && 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) ──────────────────────────── # ── k3s (bundles kubectl, containerd, everything) ────────────────────────────
# We download the binary and the airgap images so the cluster starts offline. # We download the binary and the airgap images so the cluster starts offline.
RUN set -eux && \ RUN set -eux && \
@@ -41,8 +47,23 @@ RUN set -eux && \
WORKDIR /app WORKDIR /app
# Install backend dependencies # 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/ 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 # Build frontend
COPY frontend/package.json frontend/vite.config.js ./frontend/ COPY frontend/package.json frontend/vite.config.js ./frontend/
+79 -13
View File
@@ -127,22 +127,88 @@ Everything runs inside one image managed by `scripts/entrypoint.sh`.
## Multi-Student Deployment (RootNode Academy addition) ## Multi-Student Deployment (RootNode Academy addition)
The upstream project assumes one learner, one container. RootNode Academy runs it across three The upstream project assumes one learner, one container — this image is still exactly that.
delivery modes. **Never publish the raw privileged container** — front it with isolation. 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 | - `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
| ---- | ------------ | --------- | networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
| In-person camp | Pre-spun container per seat, torn down after | Lab VLAN, no public ingress | - `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress, detailed
| Live cohort | Container per enrolled student for the session window | Per-student auth at the proxy | scenario attempt telemetry, progressive hints, assessment/capstone engine, achievements,
| Self-paced | On-demand spin-up per session, idle-timeout teardown | Per-user volume + auth + resource limits | 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.
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. Key properties of the platform (not this image):
- 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. - One container **per student**, its own Docker network, its own firewall chain — never a
- Persist each student's `/data` to a dedicated volume. shared cluster between learners. Verified with two simultaneous students: separate clusters,
- Idle-timeout + auto-teardown for self-paced sessions to control cost. 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`.
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.
--- ---
+1184
View File
File diff suppressed because it is too large Load Diff
+237 -43
View File
@@ -14,6 +14,7 @@ app.use(express.static(path.join(__dirname, '../frontend/dist')));
const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data'); const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data');
const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles'); 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'; const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db';
// ── SQLite progress store ───────────────────────────────────────────────────── // ── SQLite progress store ─────────────────────────────────────────────────────
@@ -55,6 +56,51 @@ function getDb() {
console.error('Failed to run schema migrations on progress table:', e.message); 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(` _db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -75,6 +121,9 @@ function getDb() {
if (!cols.includes('scenario_ids')) { if (!cols.includes('scenario_ids')) {
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`); _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) { } catch (e) {
console.error('Failed to migrate sessions table:', e.message); console.error('Failed to migrate sessions table:', e.message);
} }
@@ -94,9 +143,26 @@ function getDb() {
return _db; 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 { 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, { return Object.fromEntries(rows.map(r => [r.scenario_id, {
status: r.status, status: r.status,
attempts: r.attempts, attempts: r.attempts,
@@ -109,13 +175,13 @@ function loadProgress() {
} catch { return {}; } } catch { return {}; }
} }
function saveProgress(progress) { function saveProgress(studentId, progress) {
try { try {
const db = getDb(); const db = getDb();
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds) INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scenario_id) DO UPDATE SET ON CONFLICT(student_id, scenario_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
attempts = excluded.attempts, attempts = excluded.attempts,
last_validated = excluded.last_validated, last_validated = excluded.last_validated,
@@ -127,6 +193,7 @@ function saveProgress(progress) {
const tx = db.transaction((entries) => { const tx = db.transaction((entries) => {
for (const [id, p] of entries) { for (const [id, p] of entries) {
upsert.run( upsert.run(
studentId,
id, id,
p.status || null, p.status || null,
p.attempts || 0, p.attempts || 0,
@@ -153,11 +220,15 @@ function loadJsonDir(dir) {
let scenariosCache = []; let scenariosCache = [];
let bundlesCache = []; let bundlesCache = [];
let curriculumPathCache = null;
function reloadCache() { function reloadCache() {
try { try {
scenariosCache = loadJsonDir(SCENARIOS_DIR); scenariosCache = loadJsonDir(SCENARIOS_DIR);
bundlesCache = loadJsonDir(BUNDLES_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.`); console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`);
} catch (e) { } catch (e) {
console.error('Failed to reload cache:', e.message); console.error('Failed to reload cache:', e.message);
@@ -175,6 +246,10 @@ function loadBundles() {
return bundlesCache; return bundlesCache;
} }
function loadCurriculumPath() {
return curriculumPathCache;
}
async function runCommand(cmd, timeoutMs = 15000) { async function runCommand(cmd, timeoutMs = 15000) {
try { try {
const { stdout } = await execAsync(cmd, { const { stdout } = await execAsync(cmd, {
@@ -223,31 +298,77 @@ function refreshPrompt(delayMs = 80) {
// ── Routes ──────────────────────────────────────────────────────────────────── // ── 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 // POST /api/progress/reset — reset progress for scenario | category | bundle
app.post('/api/progress/reset', (req, res) => { app.post('/api/progress/reset', (req, res) => {
const { scope, scenarioId, category, bundleId } = req.body; const { scope, scenarioId, category, bundleId } = req.body;
const studentId = getStudentId(req);
const db = getDb(); const db = getDb();
const resetProgressStmt = db.prepare(` const resetProgressStmt = db.prepare(`
UPDATE progress UPDATE progress
SET status = 'not_started', SET status = 'not_started',
attempts = 0, attempts = 0,
completed_at = NULL, completed_at = NULL,
last_validated = NULL last_validated = NULL
WHERE scenario_id = ? WHERE scenario_id = ? AND student_id = ?
`); `);
try { try {
if (scope === 'scenario') { if (scope === 'scenario') {
resetProgressStmt.run(scenarioId); resetProgressStmt.run(scenarioId, studentId);
} else if (scope === 'category') { } else if (scope === 'category') {
const scenarios = loadScenarios(); const scenarios = loadScenarios();
const ids = scenarios.filter(s => s.category === category).map(s => s.id); 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); tx(ids);
} else if (scope === 'bundle') { } else if (scope === 'bundle') {
const bundle = loadBundles().find(b => b.id === bundleId); const bundle = loadBundles().find(b => b.id === bundleId);
if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); 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); tx(bundle.scenario_ids);
} else { } else {
return res.status(400).json({ error: 'Invalid scope' }); 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') db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE status='active'`).run(); WHERE status='active'`).run();
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 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) const studentId = getStudentId(req);
VALUES (?, ?, datetime('now'), 'active', ?, ?)`) db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids, student_id)
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds)); VALUES (?, ?, datetime('now'), 'active', ?, ?, ?)`)
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds), studentId);
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds }); 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; for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
// Also pull time_spent_seconds from global progress (tracked per scenario during the exam) // 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 => ({ const snapshot = bundleScenarios.map(s => ({
id: s.id, title: s.title, weight: s.weight, 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=?`) duration_secs=?, snapshot=? WHERE id=?`)
.run(durationSecs, JSON.stringify(snapshot), req.params.id); .run(durationSecs, JSON.stringify(snapshot), req.params.id);
// Reset timers of all scenarios in this bundle // 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 = ?`); const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id))); const sid = session.student_id || 'unassigned';
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
tx(bundle.scenario_ids); tx(bundle.scenario_ids);
res.json({ ok: true, snapshot, durationSecs }); 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); WHERE id=? AND status='active'`).run(req.params.id);
if (bundle) { if (bundle) {
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`); const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id))); const sid = session.student_id || 'unassigned';
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
tx(bundle.scenario_ids); tx(bundle.scenario_ids);
} }
@@ -475,7 +600,7 @@ app.post('/api/scenarios/:id/context', async (req, res) => {
app.get('/api/bundles', (req, res) => { app.get('/api/bundles', (req, res) => {
const bundles = loadBundles(); const bundles = loadBundles();
const scenarios = loadScenarios(); const scenarios = loadScenarios();
const progress = loadProgress(); const progress = loadProgress(getStudentId(req));
const result = bundles.map(b => { const result = bundles.map(b => {
const total = b.scenario_ids.length; const total = b.scenario_ids.length;
const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').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); 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 // GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
app.get('/api/scenarios', (req, res) => { app.get('/api/scenarios', (req, res) => {
const scenarios = loadScenarios() const scenarios = loadScenarios()
const progress = loadProgress() const progress = loadProgress(getStudentId(req))
const { bundle, session: sessionId } = req.query const { bundle, session: sessionId } = req.query
// Session-scoped: return only session scenario_ids in their shuffled order // 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 scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id); const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
const progress = loadProgress(); const progress = loadProgress(getStudentId(req));
res.json({ res.json({
...scenario, ...scenario,
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 } 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 // 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') { if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
progress[scenario.id] = { progress[scenario.id] = {
...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), attempts: (progress[scenario.id]?.attempts || 0),
started_at: progress[scenario.id]?.started_at || new Date().toISOString() started_at: progress[scenario.id]?.started_at || new Date().toISOString()
}; };
saveProgress(progress); saveProgress(studentId, progress);
} }
res.json({ setup_results: results }); res.json({ setup_results: results });
@@ -602,7 +765,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
} }
// Update global practice progress // Update global practice progress
const progress = loadProgress(); const studentId = getStudentId(req);
const progress = loadProgress(studentId);
const prev = progress[scenario.id] || { attempts: 0 }; const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = { progress[scenario.id] = {
...prev, ...prev,
@@ -611,7 +775,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
last_validated: new Date().toISOString(), last_validated: new Date().toISOString(),
completed_at: allPassed ? new Date().toISOString() : prev.completed_at 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 // Also update exam_progress if there's an active session
const db = getDb(); const db = getDb();
@@ -649,7 +814,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
const correct = selected === scenario.correct_option; const correct = selected === scenario.correct_option;
// Update global practice progress // Update global practice progress
const progress = loadProgress(); const studentId = getStudentId(req);
const progress = loadProgress(studentId);
const prev = progress[scenario.id] || { attempts: 0 }; const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = { progress[scenario.id] = {
...prev, ...prev,
@@ -659,7 +825,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
last_validated: new Date().toISOString(), last_validated: new Date().toISOString(),
completed_at: correct ? new Date().toISOString() : prev.completed_at 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 // Also update exam_progress if there's an active session
const db = getDb(); const db = getDb();
@@ -702,7 +869,8 @@ app.post('/api/scenarios/:id/time', (req, res) => {
if (!activeSession) { if (!activeSession) {
return res.json({ ok: true, message: 'No active session' }); 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]) { if (!progress[req.params.id]) {
progress[req.params.id] = { progress[req.params.id] = {
status: 'in_progress', 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].started_at = new Date().toISOString();
} }
progress[req.params.id].time_spent_seconds = time_spent_seconds; 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 }); res.json({ ok: true });
}); });
// GET /api/progress — full progress summary // GET /api/progress — full progress summary
app.get('/api/progress', (req, res) => { app.get('/api/progress', (req, res) => {
const scenarios = loadScenarios(); const scenarios = loadScenarios();
const progress = loadProgress(); const progress = loadProgress(getStudentId(req));
const total = scenarios.length; const total = scenarios.length;
const completed = Object.values(progress).filter(p => p.status === 'completed').length; const completed = Object.values(progress).filter(p => p.status === 'completed').length;
const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0); const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0);
@@ -740,13 +934,13 @@ app.post('/api/progress/reset/:id', (req, res) => {
try { try {
const db = getDb(); const db = getDb();
db.prepare(` db.prepare(`
UPDATE progress UPDATE progress
SET status = 'not_started', SET status = 'not_started',
attempts = 0, attempts = 0,
completed_at = NULL, completed_at = NULL,
last_validated = NULL last_validated = NULL
WHERE scenario_id = ? WHERE scenario_id = ? AND student_id = ?
`).run(req.params.id); `).run(req.params.id, getStudentId(req));
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
+74
View File
@@ -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.
+59
View File
@@ -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).
+359
View File
@@ -0,0 +1,359 @@
# 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`).
+509
View File
@@ -0,0 +1,509 @@
# 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.
+64
View File
@@ -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`.
+483
View File
@@ -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.
+292
View File
@@ -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.
+213
View File
@@ -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.
+370
View File
@@ -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.
+254
View File
@@ -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.
+187
View File
@@ -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.
+90
View File
@@ -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).
+130
View File
@@ -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.
+71
View File
@@ -0,0 +1,71 @@
# 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
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,83 @@
# 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.** A student row is created only by an instructor
(`POST /api/admin/students`, admin/owner RBAC-gated). There is no public "sign up" surface.
This is almost certainly the right call to leave alone for now — it's explicitly a Phase 5+
product/business decision (course commerce, is it free, who's allowed to enroll), not an
infrastructure gap.
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.** The table has supported
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
(`CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT
NULL)`) — but nothing in the application ever reads or writes that column today; there's only
one curriculum path, so nothing has needed to differentiate yet. Not a gap that blocks
self-paced delivery now (every student already sees the one path with no gate), but the
moment a second curriculum path exists, this is where per-individual assignment lands — the
schema was designed for exactly this and doesn't need a migration to support it.
4. **No delivery-mode concept anywhere.** In-person, remote, and self-paced sessions aren't
distinguished in any data model today, and nothing currently needs them to be — capacity
planning, the reaper, and academic records all behave identically regardless of how a student
is attending. Worth being aware this is unmodeled, not worth building a taxonomy for before a
real product reason exists (YAGNI).
## 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 (item 2, above) was
closed in Phase 5A; the rest remain open, unmodeled by design (YAGNI) until a real product
reason exists.
+31
View File
@@ -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).
+44
View File
@@ -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.
+31
View File
@@ -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).
+35
View File
@@ -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.
+34
View File
@@ -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,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.
+17
View File
@@ -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`.
+27
View File
@@ -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.
+45
View File
@@ -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.
+53
View File
@@ -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.
+18
View File
@@ -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.
+40
View File
@@ -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.
+44
View File
@@ -0,0 +1,44 @@
# Runbook: Student Account Lifecycle
Covers creating, converting, resetting, and revoking student accounts. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design.
**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>`. Copy it immediately (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). Just issue a new one via Convert or Reset
password — there's no way to "resend" the same token, and that's intentional.
+19
View File
@@ -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
```
+31
View File
@@ -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.
```
+47
View File
@@ -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).
+2996
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport' import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal' import ExamStartModal from './components/ExamStartModal'
import ExamHistory from './components/ExamHistory' import ExamHistory from './components/ExamHistory'
import AssessmentBanner from './components/AssessmentBanner'
import styles from './App.module.css' import styles from './App.module.css'
const MIN_SIDEBAR_W = 180 const MIN_SIDEBAR_W = 180
@@ -303,6 +304,7 @@ export default function App() {
return ( return (
<div className={styles.app}> <div className={styles.app}>
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} /> <Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
<AssessmentBanner />
{/* Bundle navigation bar */} {/* Bundle navigation bar */}
<BundleNav <BundleNav
+39
View File
@@ -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>
)
}
+62
View File
@@ -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; }
+68
View File
@@ -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>
)
}
+110
View File
@@ -0,0 +1,110 @@
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 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: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ 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 === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{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>
)
}
+258
View File
@@ -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 &amp; 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}>&larr; 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>
</>
)
}
+49
View File
@@ -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>
)
}
+216
View File
@@ -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}>&larr; 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>
</>
)
}
+221
View File
@@ -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}>&larr; 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>
}
+180
View File
@@ -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}>&larr; 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 &amp; 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>
</>
)
}
+160
View File
@@ -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}>&larr; 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>
</>
)
}
+315
View File
@@ -0,0 +1,315 @@
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 [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(() => {})
}, [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}>&larr; 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).` : ''}</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): ${window.location.origin}/?reset=${reset_token}`)
})}>Reset password</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</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>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>
)
}
+119
View File
@@ -0,0 +1,119 @@
// 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 }),
}
@@ -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>
)
}
+22 -1
View File
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css' import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm' import { useConfirm } from './useConfirm'
import ProgressiveHints from './ProgressiveHints'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans // Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = { const inlineComponents = {
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
setHintsRevealed([]) setHintsRevealed([])
}, [scenario?.id]) }, [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 timeSpentRef = useRef(0)
const lastScenarioIdRef = useRef(null) const lastScenarioIdRef = useRef(null)
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
{t === 'problem' ? '📄 Problem' {t === 'problem' ? '📄 Problem'
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})` : t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
: '✓ Validate'} : '✓ Validate'}
</button> </button>
))} ))}
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
{/* HINTS TAB */} {/* HINTS TAB */}
{tab === 'hints' && ( {tab === 'hints' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}> <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 && ( {scenario.hints?.length === 0 && (
<div className={styles.noHints}>No hints available for this scenario.</div> <div className={styles.noHints}>No hints available for this scenario.</div>
)} )}
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
</div> </div>
) )
})} })}
</>
)}
</div> </div>
)} )}
+12 -1
View File
@@ -1,14 +1,25 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import PlatformShell from './platform/PlatformShell.jsx'
import AdminPortal from './admin/AdminPortal.jsx'
import './index.css' import './index.css'
// Apply saved theme before first paint to avoid flash // Apply saved theme before first paint to avoid flash
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark' const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
document.documentElement.setAttribute('data-theme', savedTheme) 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( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> {view === 'lab' ? <App /> : view === 'admin' ? <AdminPortal /> : <PlatformShell />}
</React.StrictMode> </React.StrictMode>
) )
+40
View File
@@ -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 &amp; 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>
)
}
+113
View File
@@ -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>
}
+89
View File
@@ -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>
)
}
+146
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+135
View File
@@ -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>
}
+191
View File
@@ -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; }
+59
View File
@@ -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') }} />
}
+77
View File
@@ -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`
}
+91
View File
@@ -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,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,56 @@
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'
const TABS = [
{ key: 'learning', label: 'My Learning' },
{ 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 === 'classroom' && <ClassroomTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
{tab === 'capstone' && <CapstoneTab />}
{tab === 'achievements' && <AchievementsTab />}
{tab === 'profile' && <ProfileTab />}
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
// 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 }),
}
+34
View File
@@ -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/
+10
View File
@@ -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__
+33
View File
@@ -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__
+22
View File
@@ -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__
+16
View File
@@ -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'
+16
View File
@@ -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
+63
View File
@@ -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
+58
View File
@@ -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
@@ -0,0 +1,28 @@
---
# Deploys the matching infra/firewall/*.rules file for this host's group and loads it. Same
# ruleset, same variables as infra/firewall/validate-firewall.sh renders locally — this task
# just uses Ansible's own templating instead of sed, against the real inventory values.
- name: Determine which ruleset applies to this host
ansible.builtin.set_fact:
firewall_ruleset_name: "{{ 'rn-platform-01' if inventory_hostname in groups['platform'] else 'rn-lab-host-01' }}"
- name: Render firewall ruleset for this host
ansible.builtin.template:
src: "{{ role_path }}/../../../firewall/{{ firewall_ruleset_name }}.rules"
dest: /etc/iptables/rules.v4.rootnode
owner: root
group: root
mode: "0600"
notify: reload firewall
- name: Point iptables-persistent at the RootNode ruleset
ansible.builtin.file:
src: /etc/iptables/rules.v4.rootnode
dest: /etc/iptables/rules.v4
state: link
force: true
notify: reload firewall
- name: Flush handlers so the firewall is active before later roles assume it
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,6 @@
---
- name: restart rootnode-lab-host
ansible.builtin.systemd:
name: rootnode-lab-host
state: restarted
daemon_reload: true
@@ -0,0 +1,72 @@
---
# rn-lab-host-01: Docker Engine (TLS config comes from the docker-tls role, run before this
# one), the egress proxy + monitoring agents compose stack, kernel prerequisites for nested
# k3s in privileged Lab containers, and Docker daemon storage/limits configuration.
- name: Install Docker Engine
ansible.builtin.import_tasks: "{{ role_path }}/../common/tasks/docker-engine.yml"
- name: Kernel prerequisites for nested k3s (privileged containers)
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_set: true
state: present
reload: true
loop:
- { name: net.ipv4.ip_forward, value: "1" }
- { name: fs.inotify.max_user_watches, value: "524288" } # k3s/kubelet watch a lot of files
- { name: fs.inotify.max_user_instances, value: "512" }
- { name: vm.max_map_count, value: "262144" } # matches upstream k3s/etcd guidance
- name: Ensure cgroup v2 controllers needed by nested k3s are available
ansible.builtin.command: cat /sys/fs/cgroup/cgroup.controllers
register: cgroup_controllers
changed_when: false
failed_when: false
- name: Warn if cgroup v2 controllers look incomplete (informational only)
ansible.builtin.debug:
msg: >-
cgroup.controllers reports: {{ cgroup_controllers.stdout | default('unreadable') }}.
Expect at least cpu, memory, pids, io for reliable nested k3s — verify manually if any
are missing (see docs/PRODUCTION_VALIDATION_CHECKLIST.md "Lab boot" section).
when: cgroup_controllers.rc == 0
- name: Create data/config directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: docker
mode: "0750"
loop:
- /opt/rootnode/lab-host
- "{{ rootnode_data_root }}/lab-work" # disposable — see docs/BACKUP_DR.md, never backed up
- name: Render production docker-compose.yml
ansible.builtin.template:
src: "{{ role_path }}/../../../docker/lab-host/docker-compose.production.yml"
dest: /opt/rootnode/lab-host/docker-compose.yml
owner: root
group: root
mode: "0640"
notify: restart rootnode-lab-host
- name: Deploy systemd unit
ansible.builtin.template:
src: rootnode-lab-host.service.j2
dest: /etc/systemd/system/rootnode-lab-host.service
mode: "0644"
notify: restart rootnode-lab-host
- name: Enable and start rootnode-lab-host
ansible.builtin.systemd:
name: rootnode-lab-host
enabled: true
state: started
daemon_reload: true
# Storage-driver/networking daemon options live in the docker-tls role's daemon.json.j2 —
# Docker only ever reads ONE /etc/docker/daemon.json, so that's the single source of truth for
# this host's whole daemon config, not just its TLS settings (see that role's template).
@@ -0,0 +1,17 @@
[Unit]
Description=RootNode Academy Lab Host services (egress proxy + monitoring agents)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/rootnode/lab-host
ExecStart=/usr/bin/docker compose -f docker-compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f docker-compose.yml down
ExecReload=/usr/bin/docker compose -f docker-compose.yml up -d --remove-orphans
TimeoutStartSec=180
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
---
- name: reload prometheus
ansible.builtin.command: docker compose -f /opt/rootnode/platform/docker-compose.yml restart prometheus
changed_when: true
- name: reload alertmanager
ansible.builtin.command: docker compose -f /opt/rootnode/platform/docker-compose.yml restart alertmanager
changed_when: true
- name: restart platform promtail
ansible.builtin.command: docker compose -f /opt/rootnode/platform/docker-compose.yml restart promtail
changed_when: true
- name: restart lab-host promtail
ansible.builtin.command: docker compose -f /opt/rootnode/lab-host/docker-compose.yml restart promtail
changed_when: true
@@ -0,0 +1,75 @@
---
# Deploys infra/monitoring/ configs to whichever host needs them. Named monitoring-agent for
# historical reasons in this repo's role list, but it deploys both the "agent" pieces
# (node-exporter/cadvisor/promtail configs, already referenced directly by the platform/
# lab-host compose files) and the central stack's config (Prometheus/Alertmanager/Loki/Grafana
# on rn-platform-01 only).
- name: Create /opt/rootnode/monitoring (rn-platform-01)
ansible.builtin.file:
path: /opt/rootnode/monitoring/rules
state: directory
mode: "0755"
when: inventory_hostname in groups['platform']
- name: Deploy Prometheus config
ansible.builtin.template:
src: "{{ role_path }}/../../../monitoring/prometheus/prometheus.yml.j2"
dest: /opt/rootnode/monitoring/prometheus.yml
mode: "0644"
when: inventory_hostname in groups['platform']
notify: reload prometheus
- name: Deploy alert rules
ansible.builtin.copy:
src: "{{ role_path }}/../../../monitoring/prometheus/rules/rootnode.yml"
dest: /opt/rootnode/monitoring/rules/rootnode.yml
mode: "0644"
when: inventory_hostname in groups['platform']
notify: reload prometheus
- name: Deploy blackbox_exporter config
ansible.builtin.copy:
src: "{{ role_path }}/../../../monitoring/prometheus/blackbox.yml"
dest: /opt/rootnode/monitoring/blackbox.yml
mode: "0644"
when: inventory_hostname in groups['platform']
- name: Deploy Alertmanager config
ansible.builtin.template:
src: "{{ role_path }}/../../../monitoring/alertmanager/alertmanager.yml.j2"
dest: /opt/rootnode/monitoring/alertmanager.yml
mode: "0644"
when: inventory_hostname in groups['platform']
notify: reload alertmanager
- name: Deploy Loki config
ansible.builtin.copy:
src: "{{ role_path }}/../../../monitoring/loki/loki-config.yml"
dest: /opt/rootnode/monitoring/loki-config.yml
mode: "0644"
when: inventory_hostname in groups['platform']
- name: Deploy Grafana provisioning + dashboards
ansible.builtin.copy:
src: "{{ role_path }}/../../../monitoring/grafana/{{ item }}/"
dest: "/opt/rootnode/monitoring/grafana/{{ item }}/"
mode: "0644"
loop: [provisioning, dashboards]
when: inventory_hostname in groups['platform']
- name: Deploy platform-side promtail config
ansible.builtin.copy:
src: "{{ role_path }}/../../../monitoring/promtail/promtail-config.platform.yml"
dest: /opt/rootnode/monitoring/promtail-config.yml
mode: "0644"
when: inventory_hostname in groups['platform']
notify: restart platform promtail
- name: Deploy lab-host promtail config
ansible.builtin.template:
src: "{{ role_path }}/../../../monitoring/promtail/promtail-config.lab-host.yml.j2"
dest: /opt/rootnode/lab-host/promtail-config.yml
mode: "0644"
when: inventory_hostname in groups['lab_host']
notify: restart lab-host promtail
@@ -0,0 +1,6 @@
---
- name: restart rootnode-platform
ansible.builtin.systemd:
name: rootnode-platform
state: restarted
daemon_reload: true
@@ -0,0 +1,85 @@
---
# rn-platform-01: Docker Engine, directory structure, rendered compose file + env files,
# Postgres tuning overlay, reverse proxy config, and the systemd unit that runs it all.
# Docker TLS material and the host firewall are separate roles (docker-tls, firewall) run
# before this one in site.yml — this role assumes both are already in place.
- name: Install Docker Engine
ansible.builtin.import_tasks: "{{ role_path }}/../common/tasks/docker-engine.yml"
- name: Create data/config directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: docker
mode: "0750"
loop:
- "{{ rootnode_data_root }}/postgres"
- "{{ rootnode_data_root }}/backups"
- /opt/rootnode/platform
- /opt/rootnode/platform/postgresql.conf.d
- /etc/rootnode/provisioner-config
- name: Deploy Postgres tuning overlay
ansible.builtin.copy:
src: "{{ role_path }}/../../../docker/postgres/postgresql.conf.d/00-rootnode-baseline.conf"
dest: /opt/rootnode/platform/postgresql.conf.d/00-rootnode-baseline.conf
mode: "0644"
notify: restart rootnode-platform
- name: Deploy provisioner-config overlay ({{ env }})
ansible.builtin.copy:
src: "{{ role_path }}/../../../../environments/{{ env }}/provisioner-config/"
dest: /etc/rootnode/provisioner-config/
mode: "0644"
notify: restart rootnode-platform
- name: Deploy per-service env files (from Ansible Vault-backed variables — see group_vars)
ansible.builtin.template:
src: "{{ item }}.env.j2"
dest: "/etc/rootnode/{{ item }}.env"
owner: root
group: root
mode: "0600"
loop:
- platform
- provisioner
- gateway
- postgres
notify: restart rootnode-platform
- name: Render production docker-compose.yml
ansible.builtin.template:
src: "{{ role_path }}/../../../docker/platform/docker-compose.production.yml"
dest: /opt/rootnode/platform/docker-compose.yml
owner: root
group: root
mode: "0640"
notify: restart rootnode-platform
- name: Render Caddyfile
ansible.builtin.template:
src: "{{ role_path }}/../../../docker/platform/Caddyfile.j2"
dest: /opt/rootnode/platform/Caddyfile
owner: root
group: root
mode: "0644"
notify: restart rootnode-platform
- name: Deploy systemd unit
ansible.builtin.template:
src: rootnode-platform.service.j2
dest: /etc/systemd/system/rootnode-platform.service
mode: "0644"
notify: restart rootnode-platform
# The `backup` role (applied after this one in site.yml) deploys the backup script and cron —
# kept separate since it also has to run correctly on a host that ISN'T freshly deployed.
- name: Enable and start rootnode-platform
ansible.builtin.systemd:
name: rootnode-platform
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,13 @@
# Rendered by Ansible — see platform.env.j2 for the pattern.
PLATFORM_API_URL=http://api:4300
PROVISIONER_URL=http://provisioner:4100
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
GATEWAY_PORT=4200
# Phase 4.1 — mTLS client identity for the Lab Host Router (data plane). Only used when a
# lab_host's routing_mode is 'router' (see platform/gateway/src/lab-endpoint.js); harmless if
# every configured lab_host is still 'direct'. Paths are inside the container — mounted from
# /etc/rootnode/lab-router-tls on the host by the `docker-tls` role, same pattern as the
# Provisioner's Docker TLS client cert.
LAB_ROUTER_TLS_CA=/tls/ca.pem
LAB_ROUTER_TLS_CERT=/tls/gateway-client-cert.pem
LAB_ROUTER_TLS_KEY=/tls/gateway-client-key.pem

Some files were not shown because too many files have changed in this diff Show More