Files
rootnode-academy/docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md
T
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

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_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:

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).

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).