# 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 `.backup-` 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-.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//` 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//` — 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//` 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).