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.
12 KiB
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_idstays the same opaque TEXT identifier used everywhere else (no breaking change toenvironments.student_id, which remains a plain reference with no FK, exactly as Phase 1 designed it).cohorts/cohort_enrollments— replaces the old singlestudents.cohortTEXT 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_countis 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 SQLitesessionstable. 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:
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.cohortcolumn 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 anHttpOnly,SameSite=Laxcookie (rn_session). The browser cannot read or fabricate this value.- Platform API derives
student_idonly from this cookie (studentSessionmiddleware) — 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 owningstudent_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 2Anetwork_mode: bridgerequirement 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).