6 Commits
Author SHA1 Message Date
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
38 changed files with 2512 additions and 367 deletions
+19 -7
View File
@@ -134,11 +134,18 @@ layers rather than changing the image itself:
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity → environment
→ routing token, proxies HTTP/WebSocket into that one student's container, never a raw
container IP/port (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`).
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
scenario attempts, student/admin sessions, instructor/admin control plane. No k3s, no
`--privileged` (`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
server-controlled session cookie (not a client-suppliable header/param), proxies
HTTP/WebSocket into that one student's container, never a raw container IP/port
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/` — a small addition alongside the existing,
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal
(cohorts, students, classroom "Prepare Labs," environment controls) lives in
`frontend/src/admin/` at the `/admin` route — both small additions alongside the existing,
unmodified Lab UI, not a rewrite of it.
**Never publish the raw privileged container** — front it with isolation (that's what the
@@ -151,9 +158,14 @@ Key properties of the platform (not this image):
- One container **per student**, its own Docker network, its own firewall chain — never a
shared cluster between learners. Verified with two simultaneous students: separate clusters,
separate progress, no cross-access.
- Progress/identity live outside the disposable container (a persistent identity instance
running this same image, currently SQLite-backed, moving to the Platform's PostgreSQL in a
later phase) — destroying or recreating a student's Lab never loses their academic record.
- 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 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.
- 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
+9 -7
View File
@@ -171,13 +171,15 @@ section is just the map.
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, even now — the Gateway (§9) sits in
front of it and is the only caller. Still exercised directly via its internal API
(shared-secret auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
- **Where identity lives:** unchanged from §5 — `students`/`progress` are still on the Lab
image's own SQLite. `environments.student_id` is deliberately opaque (no FK) until a later,
separate migration puts roster/progress on the same Postgres instance as the environment
registry.
- **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.
`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
+8 -7
View File
@@ -1,12 +1,13 @@
# RootNode Academy — Ephemeral Lab Architecture
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) IMPLEMENTED,
2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md` and
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for the concrete schema/API/test results, and
`platform/provisioner/README.md` for how to run the stack. This document remains the
architecture record for the full ephemeral-lab direction; instructor dashboard, production
auth/SSO, and production Proxmox deployment are still future work (Phase 2B — see the Phase 2A
doc's §9). Supersedes the namespace-isolation
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
(Platform API split, Postgres academic data, server sessions, admin portal) IMPLEMENTED,
2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.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, per-instructor accounts, and
production Proxmox deployment are still future work (Phase 3+ — see the Phase 2B doc's §13).
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.
+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).
+59
View File
@@ -0,0 +1,59 @@
/* 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; }
+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>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import AdminDashboard from './AdminDashboard'
import CohortsView from './CohortsView'
import StudentsView from './StudentsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
// separate rn_admin session cookie, separate login). A student session cannot reach any of
// these views or their underlying /api/admin/* routes — see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §10.
export default function AdminPortal() {
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
const [view, setView] = useState('dashboard')
useEffect(() => {
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
}, [])
if (authed === null) return null
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
return (
<div className={styles.wrap}>
<header className={styles.header}>
<div className={styles.brand}>⚙ RootNode Admin</div>
<nav className={styles.nav}>
{['dashboard', 'cohorts', 'students'].map((v) => (
<button key={v} className={`${styles.navLink} ${view === v ? styles.navLinkActive : ''}`} onClick={() => setView(v)}>
{v[0].toUpperCase() + v.slice(1)}
</button>
))}
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
</nav>
</header>
<div className={styles.content}>
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView />}
{view === 'students' && <StudentsView />}
</div>
</div>
)
}
function AdminLogin({ onLogin }) {
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 { await adminApi.login(password); onLogin() }
catch (e) { setError('Incorrect password.') }
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}>Password</label>
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function CohortsView() {
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <CohortDetail cohortId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<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, 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(newStudentName.trim(), 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>
<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>
<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>
}
+133
View File
@@ -0,0 +1,133 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import { StatusPill } from './CohortsView'
import styles from './Admin.module.css'
export default function StudentsView() {
const [students, setStudents] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.students().then(setStudents).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{students && (
<table>
<thead><tr><th>Name</th><th>ID</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><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 StudentDetail({ studentId, onBack }) {
const [progress, setProgress] = useState(null)
const [attempts, setAttempts] = useState(null)
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
adminApi.studentAttempts(studentId).then(setAttempts).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).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>}
<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>
</>
)
}
+56
View File
@@ -0,0 +1,56 @@
// Admin API client. Uses the rn_admin 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.
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 post(path, body) {
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
}
function patch(path, body) {
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
}
function del(path) {
return fetch(path, { method: 'DELETE' }).then(json)
}
function get(path) {
return fetch(path).then(json)
}
export const adminApi = {
login: (password) => post('/api/admin/login', { password }),
logout: () => post('/api/admin/logout'),
whoami: () => get('/api/admin/whoami'),
dashboard: () => get('/api/admin/dashboard'),
students: () => get('/api/admin/students'),
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id) => get(`/api/admin/students/${id}/attempts`),
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 }),
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`),
}
+8 -6
View File
@@ -2,22 +2,24 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import PlatformShell from './platform/PlatformShell.jsx'
import AdminPortal from './admin/AdminPortal.jsx'
import './index.css'
// Apply saved theme before first paint to avoid flash
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
document.documentElement.setAttribute('data-theme', savedTheme)
// This bundle is served both by the persistent identity/platform instance (root paths) and by
// 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/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student flow" for why: it lets the existing Lab UI
// (<App/>) stay completely unmodified while the new Platform Shell (entry/dashboard/launch)
// lives alongside it in the same bundle.
const isLabSession = window.location.pathname.startsWith('/lab/')
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Student/admin flow" for why: it lets the existing
// Lab UI (<App/>) stay completely unmodified while the Platform Shell and Admin Portal live
// alongside it in the same bundle.
const path = window.location.pathname
const view = path.startsWith('/lab/') ? 'lab' : path.startsWith('/admin') ? 'admin' : 'platform'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
{isLabSession ? <App /> : <PlatformShell />}
{view === 'lab' ? <App /> : view === 'admin' ? <AdminPortal /> : <PlatformShell />}
</React.StrictMode>
)
+1 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { api, storeStudent } from './api'
import { api } from './api'
import styles from './Platform.module.css'
// A. Entry screen — RootNode Academy branding, choose your name from roster, no password.
@@ -28,7 +28,6 @@ export default function EntryScreen({ onLogin }) {
setError(null)
try {
const student = await api.login(studentId)
storeStudent(student)
onLogin(student)
} catch (e) {
setError(e.message)
+20 -6
View File
@@ -1,14 +1,28 @@
import { useState } from 'react'
import { getStoredStudent } from './api'
import { useState, useEffect } from 'react'
import { api, getCachedDisplay } from './api'
import EntryScreen from './EntryScreen'
import StudentDashboard from './StudentDashboard'
// Top-level Platform Shell: entry screen if no student session, dashboard otherwise. This is
// everything OUTSIDE the /lab/<token>/ Lab experience (which stays the existing, unmodified
// <App/> — see main.jsx).
// Top-level Platform Shell: entry screen if no valid session, dashboard otherwise. Session
// validity is decided by the server (GET /api/session/whoami against the rn_session cookie),
// not by anything read from localStorage/sessionStorage — those only cache the display name
// for a snappier first paint, per api.js.
export default function PlatformShell() {
const [student, setStudent] = useState(getStoredStudent)
const [student, setStudent] = useState(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
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))
}, [])
if (checking) return null
if (!student) return <EntryScreen onLogin={setStudent} />
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
}
+6 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api, clearStudent } from './api'
import { api } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
@@ -44,9 +44,10 @@ export default function StudentDashboard({ student, onLogout }) {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) {
// sid is a defense-in-depth ownership check the Gateway verifies on this specific
// navigation — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Routing design."
window.location.href = `${result.launch_path}?sid=${encodeURIComponent(student.student_id)}`
// No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the
// rn_session cookie itself, which travels with this navigation automatically. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
window.location.href = result.launch_path
}
} catch (e) {
setError(e.message)
@@ -67,7 +68,7 @@ export default function StudentDashboard({ student, onLogout }) {
}
}
const handleLogout = () => { clearStudent(); onLogout() }
const handleLogout = () => { api.logout().then(onLogout) }
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
+32 -26
View File
@@ -1,26 +1,23 @@
// Platform Shell API client. Student identity is pilot-grade (roster pick-your-name, no
// password) — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation."
// student_id lives in localStorage only, never a cookie, never sent anywhere except as the
// X-Student-Id header on these specific requests.
// Platform Shell API client. Phase 2B: 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). This replaces Phase 2A's localStorage
// student_id + X-Student-Id header, which was explicitly pilot-only and client-forgeable.
// See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
//
// 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.
const STORAGE_KEY = 'rootnode_student'
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
export function getStoredStudent() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') } catch { return null }
export function getCachedDisplay() {
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
}
export function storeStudent(student) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(student))
function cacheDisplay(student) {
sessionStorage.setItem(DISPLAY_CACHE_KEY, JSON.stringify(student))
}
export function clearStudent() {
localStorage.removeItem(STORAGE_KEY)
}
function authHeaders() {
const student = getStoredStudent()
// Prevents accidental anonymous progress writes (Phase 2A requirement): every call that
// needs identity throws instead of silently going out unauthenticated.
if (!student?.student_id) throw new Error('No student session')
return { 'X-Student-Id': student.student_id }
function clearCachedDisplay() {
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
}
async function json(res) {
@@ -34,13 +31,22 @@ async function json(res) {
export const api = {
cohorts: () => fetch('/api/roster/cohorts').then(json),
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
login: (student_id) => fetch('/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
}).then(json),
login: async (student_id) => {
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 fetch('/api/logout', { method: 'POST' }).catch(() => {})
clearCachedDisplay()
},
whoami: () => fetch('/api/session/whoami').then(json),
curriculumPath: () => fetch('/api/curriculum/path', { headers: authHeaders() }).then(json),
curriculumPath: () => fetch('/api/curriculum/path').then(json),
environmentStatus: () => fetch('/api/environment/status', { headers: authHeaders() }).then(json),
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST', headers: authHeaders() }).then(json),
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST', headers: authHeaders() }).then(json),
environmentStatus: () => fetch('/api/environment/status').then(json),
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST' }).then(json),
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST' }).then(json),
}
+29 -19
View File
@@ -6,42 +6,52 @@ Start here, then go to the component you need:
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
scenario attempts, student/admin sessions, and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing, student identity glue). Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`.
routing derived from a server-controlled session — not a client-suppliable header/param).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3 (session ownership).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, a persistent
"identity" instance, Gateway) on one machine. This is the Phase 1/2A **local dev exception**
— production is the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1.
## Running the full stack locally
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
```
Wait for `identity` to boot (it runs a real k3s cluster, same as any Lab — check with
`curl http://localhost:7554/api/health`), then the whole platform is reachable at
`http://localhost:4200` (the Gateway) — that's the one origin a student's browser would use.
No k3s boot to wait for at this layer anymore (Platform API starts instantly) — the whole
platform is reachable at `http://localhost:4200` (the Gateway) right away: students at `/`,
instructors at `/admin`.
## Tests
- `provisioner/test/run.js` — fast, isolated Provisioner tests (network/container/firewall
mechanics, no Gateway/identity involved). Run against `provisioner/docker-compose.dev.yml`.
- `test/e2e-phase2a.js` — slow, full-stack tests against the compose file above: roster →
login → curriculum → launch → Gateway routing → cross-student rejection → progress relay →
reset. Real k3s boots, budget several minutes. Run whenever the Gateway, Provisioner API
surface, or the identity backend's progress/roster/curriculum routes change.
mechanics, no Gateway/Platform API involved). Run against `provisioner/docker-compose.dev.yml`.
- `test/e2e-phase2a.js` — student-flow regression through real session cookies: roster → login
→ curriculum → launch → Gateway routing → cross-student rejection → progress relay → reset.
- `test/e2e-phase2b.js` — the classroom acceptance workflow: instructor creates a cohort, adds
students, assigns curriculum, Prepare Labs (concurrent), students complete different
scenarios, instructor resets one, instructor destroys all cohort Labs after class — progress
survives every step. Needs `ADMIN_PASSWORD` set (see `platform/.env`).
Neither substitutes for the manual real-Lab-image checks (node-pty, terminal WebSocket,
`kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a
browser-equivalent client and aren't automated here.
Both e2e suites are slow (real k3s boots) and need the full stack up. Neither substitutes for
the manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation)
in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
aren't automated here.
## Known limitations
See `provisioner/README.md` (Phase 1) and `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9
(Phase 2A) for the full, honest lists — including the `identity` service's `network_mode:
bridge` requirement and its unused-k3s resource cost, both flagged as real, not swept under
the rug.
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
2A), and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 (Phase 2B) for the full, honest lists —
including in-memory sessions, the single shared admin credential, and exam mode's live
bookkeeping staying Lab-local.
+42
View File
@@ -0,0 +1,42 @@
# RootNode Platform API — lightweight, no k3s, no --privileged, no Lab terminal runtime.
# This is the fix for the Phase 2A "identity" instance problem: that was a full,
# --privileged Lab image booting a k3s cluster nobody used, just to serve roster/curriculum/
# progress APIs. This image never runs k3s and needs no elevated privileges at all.
#
# Built from the REPO ROOT as build context (see platform/docker-compose.dev.yml's
# `context: .. / dockerfile: platform/api/Dockerfile`), because it needs read-only access to
# scenarios/ and the frontend source — both live outside platform/api/'s own directory.
#
# Multi-stage: the root .dockerignore excludes frontend/dist/ from every build context (it's a
# build artifact, and the main Lab image always builds it fresh — see that Dockerfile's
# node-pty fix commit for why leaking host-built artifacts into an image is actively
# dangerous). Rather than special-case that, this image builds the frontend fresh too, the
# same way the Lab image does, then discards the Node/npm frontend toolchain from the final
# stage — the running image only ever needs the static output.
FROM node:20-slim AS frontend-build
WORKDIR /fe
COPY frontend/package.json frontend/vite.config.js ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY platform/api/package.json ./
RUN npm install --production
COPY platform/api/src/ ./src/
COPY platform/api/migrations/ ./migrations/
COPY platform/api/migrate-sqlite.js ./
# Read-only curriculum content — same source the Lab image ships, needed here purely for
# listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only).
COPY scenarios/ ./scenarios/
# Platform-shell + admin frontend, built in the stage above — served statically, no nginx
# needed for a lightweight Node API+static server.
COPY --from=frontend-build /fe/dist/ /frontend-dist/
ENV FRONTEND_DIST_DIR=/frontend-dist
EXPOSE 4300
CMD ["node", "src/server.js"]
+58
View File
@@ -0,0 +1,58 @@
# RootNode Academy — Platform API (Phase 2B)
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the design; this is the operational reference.
## Configuration reference
| Variable | Required | Meaning |
|---|---|---|
| `PLATFORM_DB_URL` | no (defaults to local dev Postgres) | Postgres connection string |
| `PROVISIONER_URL` | no | Provisioner base URL |
| `PROVISIONER_SHARED_SECRET` | **yes** | Internal Provisioner auth — process refuses to start without it |
| `ADMIN_PASSWORD` | **yes** | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext |
| `PLATFORM_API_PORT` | no | Listen port, default 4300 |
| `FRONTEND_DIST_DIR` | no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev |
| `SCENARIOS_DIR` / `BUNDLES_DIR` / `CURRICULUM_PATH_FILE` | no | Override the default `scenarios/` locations, mainly for local dev outside Docker |
## Local dev
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
```
Everything is reachable through the Gateway at `http://localhost:4200` — students at `/`,
instructors at `/admin`. Platform API itself isn't meant to be hit directly by a browser (same
boundary as the Provisioner relative to Platform API), though nothing stops you from curling
it directly at `:4300` for local debugging.
## SQLite migration
One-time cutover, run against the real data volume:
```bash
docker run --rm -v <identity-data-volume>:/data \
-e SQLITE_PATH=/data/progress.db -e PLATFORM_DB_URL=postgres://postgres:postgres@<host>:5433/rootnode_platform \
<platform-api-image> node migrate-sqlite.js
```
Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §2/§3 for the full guarantees and rollback
procedure.
## Tests
`platform/test/e2e-phase2a.js` (student-flow regression, cookie-based) and
`platform/test/e2e-phase2b.js` (the classroom acceptance workflow: cohort → students →
curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives).
Both need the full stack up and `ADMIN_PASSWORD` set — see `platform/README.md`.
## Known limitations
See `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 — in-memory sessions (a restart logs
everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential
rather than per-instructor accounts.
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// SQLite -> Postgres migration — the one-time cutover for Phase 2B. Run once against the real
// data volume before treating Postgres as authoritative. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Migration process / rollback" for the full
// procedure and how to roll back.
//
// Usage:
// SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js
//
// Guarantees:
// - Backs up the SQLite file before touching anything (never modifies or deletes the source).
// - Idempotent: safe to re-run. Every write uses ON CONFLICT — re-running re-syncs from the
// SQLite source, it does not duplicate rows or fail on a second run.
// - Never silently drops data: prints (and writes to a report file) exactly what was read,
// what was written, and anything that couldn't be migrated and why.
// - Preserves the old students.cohort column as a real cohort + enrollment, not just a
// dropped string.
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { getPool, runMigrations } = require('./src/db');
const SQLITE_PATH = process.env.SQLITE_PATH || '/data/progress.db';
async function main() {
const report = { students: 0, cohorts_created: 0, enrollments: 0, progress_rows: 0, exam_sessions: 0, skipped: [] };
if (!fs.existsSync(SQLITE_PATH)) {
console.error(`SQLite file not found at ${SQLITE_PATH} — nothing to migrate. Set SQLITE_PATH if it's elsewhere.`);
process.exit(1);
}
// 1. Backup — never touch the only copy of pre-migration data.
const backupPath = `${SQLITE_PATH}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
fs.copyFileSync(SQLITE_PATH, backupPath);
console.log(`Backed up ${SQLITE_PATH} -> ${backupPath}`);
const sqlite = new Database(SQLITE_PATH, { readonly: true });
await runMigrations();
const pool = getPool();
// 2. Students + cohorts + enrollments
const studentRows = safeAll(sqlite, `SELECT * FROM students`, report, 'students');
const cohortsSeen = new Set();
for (const s of studentRows) {
await pool.query(
`INSERT INTO students (student_id, display_name, created_at, last_active_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (student_id) DO UPDATE SET display_name = excluded.display_name, last_active_at = excluded.last_active_at`,
[s.student_id, s.display_name, s.created_at || new Date().toISOString(), s.last_active_at || null]
);
report.students++;
if (s.cohort && !cohortsSeen.has(s.cohort)) {
cohortsSeen.add(s.cohort);
const { rowCount } = await pool.query(
`INSERT INTO cohorts (cohort_id, name) VALUES ($1, $1) ON CONFLICT (cohort_id) DO NOTHING`,
[s.cohort]
);
if (rowCount) report.cohorts_created++;
}
if (s.cohort) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[s.cohort, s.student_id]
);
report.enrollments++;
}
}
// 3. Progress
const progressRows = safeAll(sqlite, `SELECT * FROM progress`, report, 'progress');
for (const p of progressRows) {
await pool.query(
`INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (student_id, scenario_id) DO UPDATE SET
status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated,
completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes,
time_spent_seconds = excluded.time_spent_seconds`,
[p.student_id, p.scenario_id, p.status, p.attempts || 0, p.last_validated, p.completed_at, p.started_at, p.notes, p.time_spent_seconds || 0]
);
report.progress_rows++;
}
// 4. Exam sessions (historical import only — see migrations/006_exam_sessions.sql note)
const sessionRows = safeAll(sqlite, `SELECT * FROM sessions`, report, 'sessions');
for (const s of sessionRows) {
await pool.query(
`INSERT INTO exam_sessions (session_id, student_id, bundle_id, started_at, submitted_at, status, exam_minutes, duration_secs, snapshot, scenario_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (session_id) DO UPDATE SET status = excluded.status, submitted_at = excluded.submitted_at`,
[s.id, s.student_id || null, s.bundle_id, s.started_at, s.submitted_at, s.status, s.exam_minutes, s.duration_secs,
s.snapshot ? JSON.parse(s.snapshot) : null, s.scenario_ids ? JSON.parse(s.scenario_ids) : null]
);
report.exam_sessions++;
}
sqlite.close();
const reportPath = path.join(path.dirname(SQLITE_PATH), `migration-report-${Date.now()}.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log('\nMigration report:');
console.log(JSON.stringify(report, null, 2));
console.log(`\nFull report written to ${reportPath}`);
console.log(`Backup preserved at ${backupPath} — not deleted, not modified.`);
await pool.end();
}
function safeAll(sqlite, sql, report, label) {
try {
return sqlite.prepare(sql).all();
} catch (e) {
report.skipped.push({ table: label, reason: e.message });
console.warn(`Could not read ${label}: ${e.message} (table may not exist in this SQLite file — recorded in report, not fatal)`);
return [];
}
}
main().catch((e) => { console.error('Migration failed:', e); process.exit(1); });
+16
View File
@@ -0,0 +1,16 @@
-- students — migrated off the Lab image's SQLite (Phase 1/2A). This is the FIRST table in
-- Postgres to become authoritative for platform academic/identity data — environments.student_id
-- (Provisioner schema) stays an opaque TEXT reference, no cross-database FK games; a real FK
-- from environments to this table is a natural follow-up once both are settled, not required
-- for Phase 2B.
CREATE TABLE IF NOT EXISTS students (
student_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'inactive'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_active_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_students_status ON students(status);
+28
View File
@@ -0,0 +1,28 @@
-- cohorts + cohort_enrollments — replaces the old students.cohort TEXT column with a real
-- many-to-many-capable model (a student can move between cohorts over time; the old model
-- couldn't represent that at all).
CREATE TABLE IF NOT EXISTS cohorts (
cohort_id TEXT PRIMARY KEY, -- slug, e.g. 'root-fall-2026'
name TEXT NOT NULL,
code TEXT UNIQUE, -- optional short join-style code, distinct from cohort_id
tier TEXT, -- e.g. 'root' — free text, not an enum yet (YAGNI)
start_date DATE,
end_date DATE,
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'archived'
seat_target INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cohorts_status ON cohorts(status);
CREATE TABLE IF NOT EXISTS cohort_enrollments (
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
student_id TEXT NOT NULL REFERENCES students(student_id),
enrollment_status TEXT NOT NULL DEFAULT 'enrolled', -- 'enrolled' | 'removed'
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (cohort_id, student_id)
);
CREATE INDEX IF NOT EXISTS idx_enrollments_student ON cohort_enrollments(student_id);
@@ -0,0 +1,16 @@
-- curriculum_assignments — which curriculum path a cohort or an individual student is on.
-- Only one curriculum exists today (scenarios/rootnode-path.json, "RootNode Learning Path") —
-- this table exists so assigning it is a real, visible instructor action (and so a second path
-- can be added later without a schema change), not because multiple paths exist yet.
CREATE TABLE IF NOT EXISTS curriculum_assignments (
assignment_id BIGSERIAL PRIMARY KEY,
cohort_id TEXT REFERENCES cohorts(cohort_id),
student_id TEXT REFERENCES students(student_id),
curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path',
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_curriculum_cohort ON curriculum_assignments(cohort_id);
CREATE INDEX IF NOT EXISTS idx_curriculum_student ON curriculum_assignments(student_id);
+18
View File
@@ -0,0 +1,18 @@
-- progress — same shape as the SQLite table it replaces (student_id, scenario_id composite
-- key), migrated data-for-data. Postgres is authoritative for this after migration runs.
CREATE TABLE IF NOT EXISTS progress (
student_id TEXT NOT NULL REFERENCES students(student_id),
scenario_id TEXT NOT NULL,
status TEXT,
attempts INTEGER DEFAULT 0,
last_validated TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
notes TEXT,
time_spent_seconds INTEGER DEFAULT 0,
PRIMARY KEY (student_id, scenario_id)
);
CREATE INDEX IF NOT EXISTS idx_progress_student ON progress(student_id);
CREATE INDEX IF NOT EXISTS idx_progress_status ON progress(status);
@@ -0,0 +1,29 @@
-- scenario_attempts — new capability, did not exist before Phase 2B. The old model only ever
-- kept aggregate progress (status + a bare attempts counter); this is a per-attempt log,
-- giving instructors real "validation outcomes where recorded" visibility instead of just a
-- final status.
--
-- hint_count is deliberately nullable and unpopulated: the existing Lab engine's hints are
-- static UI content only (frontend/src/components/ScenarioPanel.jsx renders them from the
-- scenario JSON) — nothing server-side ever records a hint being viewed. Do not invent a
-- number here. If hint-view tracking is added to the Lab engine later, this column is ready
-- for it without a schema change.
CREATE TABLE IF NOT EXISTS scenario_attempts (
attempt_id BIGSERIAL PRIMARY KEY,
student_id TEXT NOT NULL REFERENCES students(student_id),
scenario_id TEXT NOT NULL,
environment_id UUID REFERENCES environments(environment_id),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
result TEXT NOT NULL, -- 'passed' | 'failed'
validation_count INTEGER, -- number of validation checks run this attempt
hint_count INTEGER, -- NULL — not measurable today, see note above
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attempts_student ON scenario_attempts(student_id);
CREATE INDEX IF NOT EXISTS idx_attempts_scenario ON scenario_attempts(scenario_id);
CREATE INDEX IF NOT EXISTS idx_attempts_environment ON scenario_attempts(environment_id);
CREATE INDEX IF NOT EXISTS idx_attempts_created ON scenario_attempts(created_at);
@@ -0,0 +1,24 @@
-- exam_sessions — historical import of the old SQLite `sessions` table (exam mode records).
-- Scope note (see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations"): exam mode's
-- LIVE session bookkeeping (start/submit/shuffle/abandon) remains Lab-image-local for Phase
-- 2B — it's tightly coupled to a single Lab's live cluster state the same way scenario
-- validation is, and productionizing it would need the same relay pattern progress got in
-- Phase 2A, which is scoped out here (not in the Phase 2B acceptance test). This table exists
-- so historical exam records survive the SQLite migration and are visible to instructors, not
-- because new exam sessions get written here yet.
CREATE TABLE IF NOT EXISTS exam_sessions (
session_id TEXT PRIMARY KEY,
student_id TEXT REFERENCES students(student_id),
bundle_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
submitted_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
exam_minutes INTEGER NOT NULL DEFAULT 120,
duration_secs INTEGER,
snapshot JSONB,
scenario_ids JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_exam_sessions_student ON exam_sessions(student_id);
+16
View File
@@ -0,0 +1,16 @@
{
"name": "rootnode-platform-api",
"version": "0.1.0",
"description": "RootNode Academy Platform API — students, cohorts, curriculum, progress, sessions, instructor/admin control plane. No k3s, no --privileged, no Lab terminal runtime.",
"private": true,
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate-sqlite": "node migrate-sqlite.js"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5",
"better-sqlite3": "^9.4.3"
}
}
+29
View File
@@ -0,0 +1,29 @@
// Admin password verification. Phase 2B scope, explicitly not production SSO/OIDC (that's
// Phase 3+, documented in docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md).
//
// ADMIN_PASSWORD is read from the environment (the same secret-injection channel every other
// component here uses — PROVISIONER_SHARED_SECRET, etc. — not a new pattern). It is never
// stored in a database or logged. Comparison is done via scrypt + a timing-safe compare rather
// than a plain string comparison, so a login attempt never directly compares raw password
// bytes (defense in depth, cheap to do correctly with only Node's stdlib — no new dependency).
const crypto = require('crypto');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const SALT = 'rootnode-admin-fixed-salt-v1'; // fixed salt is fine here: single credential, not
// a multi-user password table needing per-row salts
if (!ADMIN_PASSWORD) {
console.error('FATAL: ADMIN_PASSWORD is not set. Refusing to start with no way to authenticate an instructor.');
process.exit(1);
}
const expectedHash = crypto.scryptSync(ADMIN_PASSWORD, SALT, 64);
function verifyAdminPassword(submitted) {
if (!submitted || typeof submitted !== 'string') return false;
const submittedHash = crypto.scryptSync(submitted, SALT, 64);
return crypto.timingSafeEqual(expectedHash, submittedHash);
}
module.exports = { verifyAdminPassword };
+300
View File
@@ -0,0 +1,300 @@
// Instructor/Admin API — /api/admin/*. Every route here requires an admin session
// (adminSession middleware), completely separate from student sessions (different cookie,
// different token namespace, see sessions.js). Student sessions can never call these routes;
// admin sessions can never be used for /api/environment/* student routes either, since those
// require a *student* session specifically.
//
// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset,
// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as
// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12).
module.exports = function registerAdminRoutes(app, deps) {
const { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps;
// ── Admin auth ───────────────────────────────────────────────────────────────────────
app.post('/api/admin/login', (req, res) => {
const { password } = req.body || {};
if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' });
const token = sessions.createAdminSession();
res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`);
res.json({ ok: true });
});
app.post('/api/admin/logout', (req, res) => {
const header = req.headers.cookie || '';
const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE];
sessions.destroyAdminSession(token);
res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
res.json({ ok: true });
});
app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true }));
const admin = express_router_guard(app, adminSession);
// ── Dashboard ────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/dashboard', async (req, res) => {
const pool = getPool();
const [cohorts, students, envs] = await Promise.all([
pool.query(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`),
pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`),
]);
const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n]));
const recentCompletions = await pool.query(
`SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at
FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id
WHERE sa.result = 'passed' ORDER BY sa.completed_at DESC LIMIT 10`
);
const recentFailures = await pool.query(
`SELECT student_id, lab_host_id, status, error_reason, started_at
FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10`
);
res.json({
active_cohorts: cohorts.rows[0].n,
total_students: students.rows[0].n,
labs: {
running: envCounts.running || 0,
provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
error: envCounts.error || 0,
idle_or_destroyed: envCounts.destroyed || 0,
},
recent_completions: recentCompletions.rows,
recent_failures: recentFailures.rows,
});
});
// ── Students ─────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/students', async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`);
res.json(rows);
});
admin.post('/api/admin/students', async (req, res) => {
const { display_name, cohort_id } = req.body || {};
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
const pool = getPool();
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
if (cohort_id) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[cohort_id, studentId]
);
}
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
});
admin.patch('/api/admin/students/:id', async (req, res) => {
const { display_name, status } = req.body || {};
const fields = [], values = [];
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); }
if (status && ['active', 'inactive'].includes(status)) { fields.push(`status = $${fields.length + 1}`); values.push(status); }
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
fields.push(`updated_at = now()`);
values.push(req.params.id);
const { rowCount } = await getPool().query(`UPDATE students SET ${fields.join(', ')} WHERE student_id = $${values.length}`, values);
if (!rowCount) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
admin.get('/api/admin/students/:id/progress', async (req, res) => {
const progress = await loadProgress(getPool(), req.params.id);
const path = curriculum.loadCurriculumPath();
const scenarios = curriculum.loadScenarios();
if (!path) return res.json({ progress });
const stages = path.stages.map((stage) => ({
id: stage.id, name: stage.name,
scenarios: stage.scenario_ids.map((sid) => ({
id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid,
status: progress[sid]?.status || 'not_started',
})),
}));
res.json({ stages });
});
admin.get('/api/admin/students/:id/attempts', async (req, res) => {
const { rows } = await getPool().query(
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
);
res.json(rows);
});
admin.get('/api/admin/students/:id/environment', async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.params.id);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
res.json(body);
});
admin.post('/api/admin/students/:id/environment/provision', async (req, res) => {
const { status, body } = await provisioner.provision(req.params.id);
res.status(status === 409 ? 200 : status).json(body);
});
admin.post('/api/admin/students/:id/cohort', async (req, res) => {
const { cohort_id } = req.body || {};
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
await getPool().query(
`INSERT INTO cohort_enrollments (cohort_id, student_id, enrollment_status) VALUES ($1, $2, 'enrolled')
ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`,
[cohort_id, req.params.id]
);
res.json({ ok: true });
});
admin.delete('/api/admin/students/:id/cohort/:cohortId', async (req, res) => {
await getPool().query(
`UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`,
[req.params.cohortId, req.params.id]
);
res.json({ ok: true });
});
// ── Cohorts ──────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts', async (req, res) => {
const { rows } = await getPool().query(`
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
GROUP BY c.cohort_id ORDER BY c.created_at DESC
`);
res.json(rows);
});
admin.post('/api/admin/cohorts', async (req, res) => {
const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {};
if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' });
try {
await getPool().query(
`INSERT INTO cohorts (cohort_id, name, tier, start_date, end_date, seat_target) VALUES ($1, $2, $3, $4, $5, $6)`,
[cohort_id, name, tier || null, start_date || null, end_date || null, seat_target || null]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' });
throw e;
}
res.status(201).json({ cohort_id });
});
admin.patch('/api/admin/cohorts/:id', async (req, res) => {
const { name, tier, start_date, end_date, status, seat_target } = req.body || {};
const map = { name, tier, start_date, end_date, status, seat_target };
const fields = [], values = [];
for (const [k, v] of Object.entries(map)) {
if (v !== undefined) { fields.push(`${k} = $${fields.length + 1}`); values.push(v); }
}
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
fields.push('updated_at = now()');
values.push(req.params.id);
const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values);
if (!rowCount) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
admin.get('/api/admin/cohorts/:id/students', async (req, res) => {
const { rows } = await getPool().query(
`SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_at
FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`,
[req.params.id]
);
res.json(rows);
});
admin.post('/api/admin/cohorts/:id/curriculum', async (req, res) => {
const { curriculum_path } = req.body || {};
await getPool().query(
`INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`,
[req.params.id, curriculum_path || 'rootnode-path']
);
res.json({ ok: true });
});
admin.get('/api/admin/cohorts/:id/progress', async (req, res) => {
const pool = getPool();
const { rows: students } = await pool.query(
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
[req.params.id]
);
const path = curriculum.loadCurriculumPath();
if (!path) return res.json({ stages: [] });
const stageTotals = path.stages.map((s) => ({ id: s.id, name: s.name, total: s.scenario_ids.length * students.length, completed: 0 }));
for (const { student_id } of students) {
const progress = await loadProgress(pool, student_id);
path.stages.forEach((stage, i) => {
stageTotals[i].completed += stage.scenario_ids.filter((sid) => progress[sid]?.status === 'completed').length;
});
}
res.json({
student_count: students.length,
stages: stageTotals.map((s) => ({ ...s, pct: s.total > 0 ? Math.round((s.completed / s.total) * 100) : 0 })),
});
});
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
admin.post('/api/admin/cohorts/:id/prepare-labs', async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
[req.params.id]
);
// Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a
// 20-student cohort prep take the better part of an hour. Promise.allSettled so one
// student's failure doesn't block reporting on the rest.
const results = await Promise.allSettled(students.map(async (s) => {
const existing = await provisioner.listForStudent(s.student_id);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
const { status, body } = await provisioner.provision(s.student_id);
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
}));
res.json({
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
});
});
admin.post('/api/admin/cohorts/:id/destroy-labs', async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
[req.params.id]
);
const results = await Promise.allSettled(students.map(async (s) => {
const { body } = await provisioner.listForStudent(s.student_id);
const active = body?.find((e) => e.status !== 'destroyed');
if (!active) return { student_id: s.student_id, status: 'none' };
await provisioner.destroy(active.environment_id);
return { student_id: s.student_id, status: 'destroyed' };
}));
// Academic progress is Postgres-side and untouched by any of this — destroying compute
// never touches the students/progress/scenario_attempts tables.
res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) });
});
// ── Instructor environment controls ─────────────────────────────────────────────────────
admin.post('/api/admin/environments/:id/reset', async (req, res) => {
const { status, body } = await provisioner.reset(req.params.id);
res.status(status).json(body);
});
admin.delete('/api/admin/environments/:id', async (req, res) => {
const { status, body } = await provisioner.destroy(req.params.id);
res.status(status).json(body);
});
admin.get('/api/admin/environments/:id/events', async (req, res) => {
const { status, body } = await provisioner.events(req.params.id);
res.status(status).json(body);
});
};
// Tiny helper so every admin.<verb>(...) call below doesn't need `adminSession` repeated
// as a second argument by hand — same effect, less visual noise.
function express_router_guard(app, adminSession) {
return {
get: (p, h) => app.get(p, adminSession, h),
post: (p, h) => app.post(p, adminSession, h),
patch: (p, h) => app.patch(p, adminSession, h),
delete: (p, h) => app.delete(p, adminSession, h),
};
}
+37
View File
@@ -0,0 +1,37 @@
// Scenario/bundle/curriculum-path loading — same source files and cache pattern as
// backend/server.js (scenarios/data, scenarios/bundles, scenarios/rootnode-path.json). The
// Platform API only ever READS this content for listing/progress-shaping purposes; it never
// executes setup/validate/teardown commands — that stays exclusively the Lab image's job.
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = process.env.SCENARIOS_DIR || path.join(__dirname, '../scenarios/data');
const BUNDLES_DIR = process.env.BUNDLES_DIR || path.join(__dirname, '../scenarios/bundles');
const CURRICULUM_PATH_FILE = process.env.CURRICULUM_PATH_FILE || path.join(__dirname, '../scenarios/rootnode-path.json');
function loadJsonDir(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
}
let scenariosCache = [];
let bundlesCache = [];
let curriculumPathCache = null;
function reloadCache() {
scenariosCache = loadJsonDir(SCENARIOS_DIR);
bundlesCache = loadJsonDir(BUNDLES_DIR);
curriculumPathCache = fs.existsSync(CURRICULUM_PATH_FILE)
? JSON.parse(fs.readFileSync(CURRICULUM_PATH_FILE, 'utf8'))
: null;
console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`);
}
reloadCache();
module.exports = {
reloadCache,
loadScenarios: () => scenariosCache,
loadBundles: () => bundlesCache,
loadCurriculumPath: () => curriculumPathCache,
};
+60
View File
@@ -0,0 +1,60 @@
// Postgres connection + migration runner — same pattern as platform/provisioner/src/db.js
// (check-before-alter idempotency, tracked in schema_migrations). Same database
// (rootnode_platform) as the Provisioner, different table set: this service owns
// students/cohorts/enrollments/curriculum_assignments/progress/scenario_attempts/
// exam_sessions; the Provisioner owns lab_hosts/environments/environment_events. Both connect
// to the same Postgres instance — scenario_attempts.environment_id is a real FK across that
// shared boundary, which Postgres allows regardless of which service's migrations created
// which table.
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
let _pool = null;
function getPool() {
if (_pool) return _pool;
_pool = new Pool({
connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform',
});
return _pool;
}
async function runMigrations() {
const pool = getPool();
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
const applied = new Set(
(await pool.query('SELECT version FROM schema_migrations')).rows.map((r) => r.version)
);
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
const ranNow = [];
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [file]);
await client.query('COMMIT');
ranNow.push(file);
} catch (e) {
await client.query('ROLLBACK');
throw new Error(`Migration ${file} failed: ${e.message}`);
} finally {
client.release();
}
}
return ranNow;
}
module.exports = { getPool, runMigrations };
+34
View File
@@ -0,0 +1,34 @@
// Postgres-backed progress store — same shape/semantics as the SQLite loadProgress/saveProgress
// it replaces in backend/server.js, now backed by the `progress` table (migrations/004).
async function loadProgress(pool, studentId) {
const { rows } = await pool.query('SELECT * FROM progress WHERE student_id = $1', [studentId]);
return Object.fromEntries(rows.map((r) => [r.scenario_id, {
status: r.status,
attempts: r.attempts,
last_validated: r.last_validated,
completed_at: r.completed_at,
started_at: r.started_at,
notes: r.notes,
time_spent_seconds: r.time_spent_seconds || 0,
}]));
}
async function saveOneProgress(pool, studentId, scenarioId, p) {
await pool.query(
`INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (student_id, scenario_id) DO UPDATE SET
status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated,
completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes,
time_spent_seconds = excluded.time_spent_seconds`,
[studentId, scenarioId, p.status || null, p.attempts || 0, p.last_validated || null,
p.completed_at || null, p.started_at || null, p.notes || null, p.time_spent_seconds || 0]
);
}
async function touchStudent(pool, studentId) {
await pool.query(`UPDATE students SET last_active_at = now() WHERE student_id = $1`, [studentId]).catch(() => {});
}
module.exports = { loadProgress, saveOneProgress, touchStudent };
+38
View File
@@ -0,0 +1,38 @@
// Thin client for the Provisioner's internal API — same role as
// platform/gateway/src/provisioner-client.js. Phase 2B consolidates ALL Provisioner access
// (both student launch/reset and admin prepare/destroy/reset) into this one service, per
// "environment orchestration calls" being a Platform API responsibility
// (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §1). The Gateway no longer calls the Provisioner
// directly — it proxies /api/environment/* to here instead.
const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4100';
const PROVISIONER_SHARED_SECRET = process.env.PROVISIONER_SHARED_SECRET;
if (!PROVISIONER_SHARED_SECRET) {
console.error('FATAL: PROVISIONER_SHARED_SECRET is not set. Refusing to start.');
process.exit(1);
}
async function call(method, path, body) {
const res = await fetch(`${PROVISIONER_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json', 'X-Provisioner-Key': PROVISIONER_SHARED_SECRET },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = null; }
return { status: res.status, body: json };
}
const provisioner = {
getEnvironmentByToken: (token) => call('GET', `/environments/by-token/${encodeURIComponent(token)}`),
getEnvironment: (id) => call('GET', `/environments/${encodeURIComponent(id)}`),
getToken: (id) => call('GET', `/environments/${encodeURIComponent(id)}/token`),
listForStudent: (studentId) => call('GET', `/environments?student_id=${encodeURIComponent(studentId)}`),
provision: (studentId) => call('POST', '/environments', { student_id: studentId }),
reset: (id) => call('POST', `/environments/${encodeURIComponent(id)}/reset`),
destroy: (id) => call('DELETE', `/environments/${encodeURIComponent(id)}`),
events: (id) => call('GET', `/environments/${encodeURIComponent(id)}/events`),
};
module.exports = { provisioner };
+243
View File
@@ -0,0 +1,243 @@
// RootNode Academy — Platform API (Phase 2B).
//
// Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students,
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, student/admin
// session identity, and all Provisioner (environment orchestration) calls — both the
// student-facing launch/reset path and the instructor/admin control plane. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md.
//
// Reached only through the Gateway (platform/gateway) — never directly by browser JS, the
// same trust boundary the Provisioner already has relative to this service.
const express = require('express');
const path = require('path');
const { getPool, runMigrations } = require('./db');
const sessions = require('./sessions');
const { verifyAdminPassword } = require('./admin-auth');
const { provisioner } = require('./provisioner-client');
const curriculum = require('./curriculum');
const { loadProgress, saveOneProgress, touchStudent } = require('./progress');
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
const STUDENT_COOKIE = 'rn_session';
const ADMIN_COOKIE = 'rn_admin';
const app = express();
app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST));
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
return Object.fromEntries(
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
);
}
// ── Student session middleware ──────────────────────────────────────────────────────────
// This is the actual fix for the Phase 2A pilot ?sid= mechanism: student_id is derived ONLY
// from an opaque, server-issued session cookie the browser cannot fabricate — never from a
// header or query parameter a client controls. See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
function studentSession(req, res, next) {
const token = parseCookies(req)[STUDENT_COOKIE];
const studentId = sessions.resolveStudentSession(token);
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
req.studentId = studentId;
next();
}
function adminSession(req, res, next) {
const token = parseCookies(req)[ADMIN_COOKIE];
if (!sessions.resolveAdminSession(token)) {
return res.status(401).json({ error: 'no_admin_session', message: 'Instructor login required.' });
}
next();
}
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: roster / login / session
// ═══════════════════════════════════════════════════════════════════════════════════════
app.get('/api/roster/cohorts', async (req, res) => {
const { rows } = await getPool().query(
`SELECT DISTINCT c.cohort_id, c.name FROM cohorts c
JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id AND e.enrollment_status = 'enrolled'
WHERE c.status = 'active' ORDER BY c.name`
);
res.json(rows.map((r) => r.cohort_id));
});
app.get('/api/roster', async (req, res) => {
const { cohort } = req.query;
const pool = getPool();
const { rows } = cohort
? await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'
ORDER BY s.display_name`, [cohort])
: await pool.query(`SELECT student_id, display_name, last_active_at FROM students WHERE status = 'active' ORDER BY display_name`);
res.json(rows);
});
app.post('/api/login', async (req, res) => {
const { student_id } = req.body || {};
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
const student = rows[0];
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
await touchStudent(getPool(), student.student_id);
const token = sessions.createStudentSession(student.student_id);
res.append('Set-Cookie', `${STUDENT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
res.json({ student_id: student.student_id, display_name: student.display_name });
});
app.post('/api/logout', (req, res) => {
const token = parseCookies(req)[STUDENT_COOKIE];
sessions.destroyStudentSession(token);
res.append('Set-Cookie', `${STUDENT_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
res.json({ ok: true });
});
// GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab
// ownership checks (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3). Also usable by the frontend.
app.get('/api/session/whoami', studentSession, (req, res) => {
res.json({ student_id: req.studentId });
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: curriculum / progress
// ═══════════════════════════════════════════════════════════════════════════════════════
app.get('/api/curriculum/path', studentSession, async (req, res) => {
const path = curriculum.loadCurriculumPath();
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
const scenarios = curriculum.loadScenarios();
const progress = await loadProgress(getPool(), req.studentId);
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 });
});
app.get('/api/progress', studentSession, async (req, res) => {
const scenarios = curriculum.loadScenarios();
const progress = await loadProgress(getPool(), req.studentId);
const total = scenarios.length;
const completed = Object.values(progress).filter((p) => p.status === 'completed').length;
res.json({ total, completed, details: progress });
});
// POST /api/progress/record — Phase 2A's progress relay, now Postgres-backed and also logging
// a scenario_attempts row (new in Phase 2B — real per-attempt history, not just aggregate
// status). Called by the Gateway after a real validate/answer succeeded against the student's
// own Lab — never re-runs validation itself. See relayScenarioOutcome in
// platform/gateway/src/server.js.
app.post('/api/progress/record', studentSession, async (req, res) => {
const { scenario_id, status, environment_id, validation_count } = 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 pool = getPool();
const progress = await loadProgress(pool, req.studentId);
const prev = progress[scenario_id] || { attempts: 0 };
const now = new Date().toISOString();
await saveOneProgress(pool, req.studentId, scenario_id, {
...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now,
completed_at: status === 'completed' ? now : prev.completed_at,
});
await pool.query(
`INSERT INTO scenario_attempts (student_id, scenario_id, environment_id, completed_at, result, validation_count)
VALUES ($1, $2, $3, now(), $4, $5)`,
[req.studentId, scenario_id, environment_id || null, status === 'completed' ? 'passed' : 'failed', validation_count ?? null]
);
await touchStudent(pool, req.studentId);
res.json({ ok: true });
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: environment lifecycle (moved here from the Gateway in Phase 2A — Phase 2B
// consolidates ALL Provisioner access into this one service)
// ═══════════════════════════════════════════════════════════════════════════════════════
async function publicEnvView(env) {
if (!env) return { status: 'none' };
const view = { status: env.status };
if (env.status === 'running') {
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
if (!token) {
const { status, body } = await provisioner.getToken(env.environment_id);
if (status === 200) token = body.routing_token;
}
if (token) view.launch_path = `/lab/${token}/`;
}
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
return view;
}
app.get('/api/environment/status', studentSession, async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.studentId);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
res.json(await publicEnvView(active));
});
app.post('/api/environment/launch', studentSession, async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return res.json(await publicEnvView(active));
const { status, body } = await provisioner.provision(req.studentId);
if (status === 409) {
const retry = await provisioner.listForStudent(req.studentId);
return res.json(await publicEnvView(retry.body?.[0]));
}
if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error });
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
});
app.post('/api/environment/reset', studentSession, async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const env = existing.body?.find((e) => e.status !== 'destroyed');
if (!env) return res.status(404).json({ error: 'no_environment' });
const { status, body } = await provisioner.reset(env.environment_id);
if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error });
res.json(await publicEnvView(body));
});
// Internal — the Gateway calls this to resolve a routing token to its owning environment
// (student_id, internal_ip, status) for Lab proxy ownership checks. Same trust boundary as
// the Provisioner's own by-token endpoint (internal callers only, never the browser).
app.get('/api/environment/by-token/:token', async (req, res) => {
const { status, body } = await provisioner.getEnvironmentByToken(req.params.token);
res.status(status).json(body);
});
require('./admin-routes')(app, { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress, saveOneProgress });
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
});
async function main() {
const applied = await runMigrations();
if (applied.length) console.log('Applied migrations:', applied.join(', '));
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
}
if (require.main === module) {
main().catch((e) => { console.error('FATAL:', e); process.exit(1); });
}
module.exports = { app };
+67
View File
@@ -0,0 +1,67 @@
// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied,
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
// In-memory, single-process: fine for a pilot admin/classroom tool (see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs
// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored).
//
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
// names AND different token namespaces.
const crypto = require('crypto');
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
const studentSessions = new Map(); // token -> { student_id, expiresAt }
const adminSessions = new Map(); // token -> { expiresAt }
function makeToken() {
return crypto.randomBytes(32).toString('base64url');
}
function createStudentSession(studentId) {
const token = makeToken();
studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS });
return token;
}
function resolveStudentSession(token) {
if (!token) return null;
const s = studentSessions.get(token);
if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; }
return s.student_id;
}
function destroyStudentSession(token) {
studentSessions.delete(token);
}
function createAdminSession() {
const token = makeToken();
adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS });
return token;
}
function resolveAdminSession(token) {
if (!token) return false;
const s = adminSessions.get(token);
if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; }
return true;
}
function destroyAdminSession(token) {
adminSessions.delete(token);
}
// Periodic sweep so long-idle sessions don't accumulate forever in memory.
setInterval(() => {
const now = Date.now();
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
}, 10 * 60 * 1000).unref();
module.exports = {
createStudentSession, resolveStudentSession, destroyStudentSession,
createAdminSession, resolveAdminSession, destroyAdminSession,
};
+37 -27
View File
@@ -1,19 +1,19 @@
# Phase 2A full-stack local dev/test — Postgres + egress-proxy + Provisioner + a persistent
# "identity" instance + the Gateway, all on one machine (the documented Phase 1/2A dev
# exception; production is the two-VM Proxmox split, see docs/LAB_ARCHITECTURE.md §1).
# Phase 2B full-stack local dev/test — Postgres + egress-proxy + Provisioner + the lightweight
# Platform API + the Gateway, all on one machine (the documented Phase 1/2A/2B dev exception;
# production is the two-VM Proxmox split, see docs/LAB_ARCHITECTURE.md §1).
#
# "identity" is a plain, non-ephemeral instance of the existing, unmodified rootnode-academy
# image — it's what holds the canonical roster/curriculum/progress SQLite and serves the
# platform-shell frontend. It is NOT managed by the Provisioner and never gets destroyed/reset;
# it's infrastructure, the same way Postgres or the egress proxy are. See
# docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md for why this role exists and its Phase 2B
# optimization opportunity (it currently boots a full unused k3s cluster just to serve as the
# identity backend — wasteful but zero-new-code, matching "don't redesign the Lab image").
# `api` (platform/api) replaces Phase 2A's "identity" service — that was a full, --privileged
# Lab image booting an unused k3s cluster just to serve roster/curriculum/progress APIs. This
# is a plain Node process: no k3s, no --privileged, no terminal runtime. It owns
# students/cohorts/enrollments/curriculum/progress/scenario_attempts in Postgres and is the
# only service that talks to the Provisioner (student launch/reset AND instructor/admin
# actions) — see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md.
#
# gateway and provisioner both run with network_mode: host — both need to reach ephemeral
# per-student Lab containers by their internal Docker-network IP, the same requirement that
# already applies to the Provisioner's own firewall management (see
# platform/provisioner/docker-compose.dev.yml).
# gateway, provisioner, and api's egress-adjacent needs aside, `api` does NOT need
# network_mode: host — it never touches Docker networking or firewall rules itself, it only
# calls the Provisioner's HTTP API and Postgres. gateway and provisioner keep network_mode:
# host because they need to reach ephemeral per-student Lab containers by internal IP /
# manage firewall rules on the host network namespace.
services:
postgres:
@@ -54,14 +54,27 @@ services:
postgres:
condition: service_healthy
identity:
image: ${LAB_IMAGE_REF:-rootnode-academy:test}
privileged: true
network_mode: bridge
ports:
- "7554:80"
volumes:
- identity-data:/data
api:
build:
context: ..
dockerfile: platform/api/Dockerfile
network_mode: host # needs to reach the Provisioner at localhost:4100 (host-networked);
# port 4300 is directly the host's port under host networking, no
# `ports:` mapping needed (and compose rejects one with this mode).
environment:
# localhost:5433, not the "postgres" compose hostname — network_mode: host means this
# container is on the host's network namespace, not the compose bridge network, so
# compose's internal DNS doesn't resolve here (same reason Provisioner uses localhost too).
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
PROVISIONER_URL: "http://localhost:4100"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set an instructor password, do not use a default}
PLATFORM_API_PORT: "4300"
depends_on:
postgres:
condition: service_healthy
provisioner:
condition: service_started
gateway:
build: ./gateway
@@ -70,10 +83,7 @@ services:
GATEWAY_PORT: "4200"
PROVISIONER_URL: "http://localhost:4100"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
IDENTITY_BACKEND_URL: "http://localhost:7554"
PLATFORM_API_URL: "http://localhost:4300"
depends_on:
- provisioner
- identity
volumes:
identity-data:
- api
+134 -170
View File
@@ -1,48 +1,51 @@
// RootNode Academy — Phase 2A Gateway.
// RootNode Academy — Gateway (Phase 2B revision).
//
// The only student-facing path to disposable Lab environments (docs/LAB_ARCHITECTURE.md §1/§6,
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md). Owns:
// - /api/environment/* — student-facing environment lifecycle glue, calls the Provisioner
// internally (shared secret never reaches the browser).
// - /lab/<token>/* — HTTP+WS proxy into a specific student's Lab container, resolved by
// opaque routing token, never a raw container IP/port.
// - everything else — proxied to the persistent identity/curriculum backend (roster,
// login, curriculum path, progress, and the platform-shell frontend), OR to the student's
// own Lab if a valid lab-session cookie is present (see "Routing design" in the doc above
// for why the cookie exists: the existing, unmodified Lab UI makes root-relative /api/*
// and /shell-ws calls that must resolve correctly once a browser has navigated into
// /lab/<token>/, without requiring any change to that UI's code).
// The only student-facing path to disposable Lab environments. Phase 2B changes from Phase 2A:
// - No longer calls the Provisioner directly. All environment orchestration (student launch/
// reset, admin prepare/destroy/reset) is consolidated in the Platform API
// (platform/api) — this Gateway proxies /api/environment/*, /api/admin/*, and everything
// else to it, the same way it proxied to the old "identity" instance in Phase 2A.
// - Lab ownership is no longer decided by a client-supplied ?sid= query param (Phase 2A
// pilot mechanism, trivially forgeable). It's derived from the browser's rn_session cookie,
// resolved server-side via the Platform API's session store
// (GET /api/session/whoami) — nothing client-controlled (URL, token, query param, header)
// can change who the Gateway thinks is asking. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
// - Ownership is verified on EVERY proxied Lab request (HTTP and WebSocket), not just the
// initial navigation — a short-TTL cache keeps this cheap.
const http = require('http');
const express = require('express');
const httpProxy = require('http-proxy');
const { provisioner } = require('./provisioner-client');
const { provisioner } = require('./provisioner-client'); // still used ONLY for the token->environment lookup a Lab request needs; all mutating calls (provision/reset/destroy) now live in the Platform API.
const envCache = require('./env-cache');
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
const IDENTITY_BACKEND_URL = process.env.IDENTITY_BACKEND_URL || 'http://localhost:7554';
const LAB_SESSION_COOKIE = 'rn_lab';
const COOKIE_MAX_AGE_SECONDS = 8 * 60 * 60; // 8h — generous for a work session, not indefinite
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const STUDENT_COOKIE = 'rn_session';
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
// which "does the student currently have a running env" cannot do — that's true on the
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
const SESSION_CACHE_TTL_MS = 5000;
const app = express();
// Deliberately NOT using express.json() globally: none of this Gateway's own routes read
// req.body (they only use headers/params/query), and body-parsing middleware consumes the
// request stream — which breaks http-proxy's re-streaming of proxied POST bodies (e.g.
// /api/roster) to the identity backend, causing them to hang forever waiting for a body that
// already got read. Found by testing: POST /api/roster through the Gateway hung indefinitely
// until this was removed.
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
// body-parsing middleware breaks http-proxy's re-streaming of proxied POST bodies.
const proxy = httpProxy.createProxyServer({ ws: true });
proxy.on('error', (err, req, res) => {
console.error('[proxy error]', err.message);
if (res && !res.headersSent && res.writeHead) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'lab_unreachable', message: 'The lab environment is not reachable right now.' }));
res.end(JSON.stringify({ error: 'unreachable' }));
} else if (res && res.destroy) {
res.destroy(); // WS socket case
res.destroy();
}
});
// ── Cookie helpers (no dependency — a handful of lines, not worth pulling in cookie-parser) ─
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
@@ -50,15 +53,25 @@ function parseCookies(req) {
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
);
}
function setLabSessionCookie(res, token) {
res.append('Set-Cookie', `${LAB_SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE_SECONDS}`);
// ── Session resolution — replaces Phase 2A's ?sid= entirely ────────────────────────────────
const sessionCache = new Map(); // cookie value -> { studentId, expiresAt }
async function resolveSessionStudentId(req) {
const token = parseCookies(req)[STUDENT_COOKIE];
if (!token) return null;
const cached = sessionCache.get(token);
if (cached && cached.expiresAt > Date.now()) return cached.studentId;
try {
const res = await fetch(`${PLATFORM_API_URL}/api/session/whoami`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
if (res.status !== 200) return null;
const { student_id } = await res.json();
sessionCache.set(token, { studentId: student_id, expiresAt: Date.now() + SESSION_CACHE_TTL_MS });
return student_id;
} catch {
return null;
}
}
// ── Environment resolution ───────────────────────────────────────────────────────────────
// Never trusts a token alone for anything beyond "which container to proxy to" — ownership
// checks (student_id match) happen separately, at the API layer, per docs/PHASE2A_*.md
// "Security requirements": token-alone, student-id-alone, and env-id-alone are all explicitly
// documented as insufficient for production auth; this is pilot-grade, stated as such.
async function resolveByToken(token) {
const cached = envCache.get(token);
if (cached) return cached;
@@ -72,109 +85,28 @@ function isReachable(env) {
return env && env.status === 'running' && env.internal_ip;
}
// ── /api/environment/* — student-facing glue, calls the Provisioner internally ─────────────
const studentIdPattern = /^[a-zA-Z0-9_.-]+$/;
function requireStudentId(req, res) {
const studentId = req.get('X-Student-Id');
if (!studentId || !studentIdPattern.test(studentId)) {
res.status(400).json({ error: 'missing_student_id', message: 'X-Student-Id header is required.' });
return null;
}
return studentId;
function sendLabError(res, status, error, message) {
res.status(status).type('html').send(`<!doctype html><meta charset="utf-8">
<title>RootNode Academy</title>
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
<h2>${message}</h2>
<p style="color:#666">(${error})</p>
</body>`);
}
// env.routing_token is redacted ('[redacted]') on every Provisioner read path except the
// initial POST /environments response and the dedicated GET /environments/:id/token (see
// provisioner-client.js and the Provisioner's own server.js comment on that route) — so
// building a launch_path for anything other than a just-created environment means fetching
// the real token separately. Still never sent anywhere except this one field, for this one
// purpose.
async function publicEnvView(env) {
if (!env) return { status: 'none' };
// Deliberately minimal otherwise: never send internal_ip, container_id, network info,
// lab_host_id, or environment_id to the browser. See
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security requirements."
const view = { status: env.status };
if (env.status === 'running') {
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
if (!token) {
const { status, body } = await provisioner.getToken(env.environment_id);
if (status === 200) token = body.routing_token;
}
if (token) view.launch_path = `/lab/${token}/`;
}
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
return view;
// Shared ownership check: valid session AND session's student owns this environment.
// Used identically by the explicit /lab/:token route, the cookie-fallback route, and the
// WebSocket upgrade handler — one rule, everywhere a Lab request could be routed.
async function checkOwnership(req, env) {
if (!env) return { ok: false, reason: 'invalid_token' };
const studentId = await resolveSessionStudentId(req);
if (!studentId) return { ok: false, reason: 'no_session' };
if (studentId !== env.student_id) return { ok: false, reason: 'forbidden' };
return { ok: true, studentId };
}
app.get('/api/environment/status', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const { status, body } = await provisioner.listForStudent(studentId);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
res.json(await publicEnvView(active));
});
app.post('/api/environment/launch', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const existing = await provisioner.listForStudent(studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) {
// Reuse — "check whether they already have a usable environment; if yes, reuse it."
return res.json(await publicEnvView(active));
}
const { status, body } = await provisioner.provision(studentId);
if (status === 409) {
// Race: someone else's concurrent request created one between our check and this call.
const retry = await provisioner.listForStudent(studentId);
return res.json(await publicEnvView(retry.body?.[0]));
}
if (status !== 201) {
return res.status(502).json({ error: 'provision_failed', message: body?.error || 'Failed to provision an environment.' });
}
// The un-redacted token IS available here directly (body.routing_token, from the fresh
// POST /environments response) — publicEnvView still works fine even without it since it
// falls back to the dedicated lookup, but passing it through avoids one extra round trip.
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
});
app.post('/api/environment/reset', async (req, res) => {
const studentId = requireStudentId(req, res);
if (!studentId) return;
const existing = await provisioner.listForStudent(studentId);
const env = existing.body?.find((e) => !['destroyed'].includes(e.status));
if (!env) return res.status(404).json({ error: 'no_environment', message: 'No environment to reset — launch one first.' });
if (env.student_id !== studentId) {
// Defense in depth — listForStudent is already scoped by student_id, so this should be
// unreachable, but ownership is verified explicitly rather than assumed from the query.
return res.status(403).json({ error: 'forbidden' });
}
// env.routing_token is redacted here (from listForStudent) — fetch the real one so the
// cache actually gets invalidated under the key requests are made with, not under the
// literal string '[redacted]'. Otherwise a request to the (unchanged, per reset semantics)
// token could serve a stale cached internal_ip for up to the cache TTL after reset.
const { body: tokenBody } = await provisioner.getToken(env.environment_id);
if (tokenBody?.routing_token) envCache.invalidate(tokenBody.routing_token);
const { status, body } = await provisioner.reset(env.environment_id);
if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error });
res.json(await publicEnvView(body));
});
// ── Progress relay — the one place the Gateway does more than blind proxying ───────────────
// Scenario validation must run against the student's own live cluster (inside their Lab
// container) — it can't be re-run anywhere else. But the canonical progress record lives on
// the persistent identity instance (docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §1/§9 note on why
// the ephemeral Lab's own SQLite can't be where progress lives). So these two routes are
// handled explicitly instead of falling through to the blind /lab/:token proxy below: forward
// the real request to the Lab, return its real response to the browser unchanged, and — only
// on success — separately relay the outcome to the identity backend's progress store. Found
// necessary by testing: without this, a scenario solved in the Lab UI never showed up in the
// dashboard's curriculum progress at all, and would have been lost outright on lab reset.
// ── Progress relay (unchanged mechanism from Phase 2A, now passes environment_id +
// validation_count through so Platform API can log a real scenario_attempts row) ──────────
async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcome }) {
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, {
@@ -183,21 +115,27 @@ async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcom
body: JSON.stringify(req.body || {}),
});
const body = await labRes.json().catch(() => ({}));
res.status(labRes.status).json(body); // real response to the browser, unchanged
res.status(labRes.status).json(body);
const passed = extractOutcome(body);
if (passed !== null) {
fetch(`${IDENTITY_BACKEND_URL}/api/progress/record`, {
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
const token = parseCookies(req)[STUDENT_COOKIE];
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Student-Id': env.student_id },
body: JSON.stringify({ scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress' }),
headers: { 'Content-Type': 'application/json', Cookie: `${STUDENT_COOKIE}=${token}` },
body: JSON.stringify({
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
environment_id: env.environment_id, validation_count: validationCount,
}),
}).catch((e) => console.error('[progress relay] failed:', e.message));
}
}
app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async (req, res) => {
const env = await resolveByToken(req.params.token);
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const own = await checkOwnership(req, env);
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null),
@@ -206,7 +144,8 @@ app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async
app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (req, res) => {
const env = await resolveByToken(req.params.token);
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const own = await checkOwnership(req, env);
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
@@ -220,11 +159,9 @@ app.use('/lab/:token', async (req, res, next) => {
if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.');
const sid = req.query.sid;
if (sid && env.student_id !== sid) {
// Explicit ownership check when we have something to check against (see "Routing design"
// in docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md for why sid is only available as a query
// param on the initial navigation, not on every subsequent proxied request).
const own = await checkOwnership(req, env);
if (!own.ok) {
if (own.reason === 'no_session') return sendLabError(res, 401, 'no_session', 'Please log in.');
return sendLabError(res, 403, 'forbidden', 'This lab does not belong to your session.');
}
if (env.status === 'provisioning' || env.status === 'requested' || env.status === 'resetting') {
@@ -234,53 +171,80 @@ app.use('/lab/:token', async (req, res, next) => {
return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running. Try recreating it.');
}
setLabSessionCookie(res, token);
// Marks this browser as "currently inside a lab page" — the only thing the unprefixed
// fallback route below uses to decide whether to even attempt Lab routing. Path-scoped
// isn't useful here (the whole point is covering root-relative /api/* and /shell-ws calls
// the Lab UI makes), so Path=/ with a short lifetime instead — long enough to cover a work
// session, short enough that leaving the lab page eventually stops privileging Lab-routing
// for this browser.
res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/';
proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
});
function sendLabError(res, status, error, message) {
res.status(status).type('html').send(`<!doctype html><meta charset="utf-8">
<title>RootNode Academy</title>
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
<h2>${message}</h2>
<p style="color:#666">(${error})</p>
</body>`);
// Resolves "the environment this browser's session currently owns and can reach," for the
// unprefixed fallback case (no /lab/:token in the URL — the existing Lab UI's own root-relative
// calls once a browser is already inside a lab session). Shared by both the HTTP fallback and
// the WS upgrade handler so there's exactly one place this logic lives. Gated on
// LAB_ACTIVE_COOKIE first — see its definition above for why "has a running env" alone isn't
// a safe signal (it's true on the dashboard too).
async function resolveOwnedRunningEnvironment(req) {
const cookies = parseCookies(req);
if (!cookies[LAB_ACTIVE_COOKIE]) return null;
const token = cookies[STUDENT_COOKIE];
if (!token) return null;
const studentId = await resolveSessionStudentId(req);
if (!studentId) return null;
try {
const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
const envInfo = await envRes.json().catch(() => null);
if (envInfo?.status !== 'running' || !envInfo.launch_path) return null;
const lookupToken = envInfo.launch_path.split('/')[2];
const env = await resolveByToken(lookupToken);
return isReachable(env) && env.student_id === studentId ? env : null;
} catch {
return null;
}
}
// ── Default fallback — cookie-routed Lab traffic, or the identity/platform backend ─────────
// Paths the unmodified Lab UI actually calls unprefixed (see backend/server.js's route list
// and frontend/src/App.jsx et al.). LAB_ACTIVE_COOKIE alone isn't a safe enough signal — it
// persists for the rest of the browser's session once a student has EVER visited their lab,
// which would otherwise keep misrouting dashboard calls (GET /api/session/whoami,
// /api/environment/*, /api/admin/*, /api/roster*, ...) to the Lab long after the student left
// it. Found by testing. Everything NOT matching this allowlist always goes to the Platform
// API, regardless of LAB_ACTIVE_COOKIE.
const LAB_OWNED_PATHS = [/^\/api\/scenarios\b/, /^\/api\/bundles\b/, /^\/api\/sessions\b/, /^\/api\/cache\b/, /^\/api\/health\b/, /^\/shell-ws$/, /^\/assets\//];
function looksLikeLabPath(pathname) {
return LAB_OWNED_PATHS.some((re) => re.test(pathname));
}
// ── Default fallback — unprefixed Lab traffic for a browser already "inside" a lab session,
// or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ────────
app.use(async (req, res) => {
const cookies = parseCookies(req);
const token = cookies[LAB_SESSION_COOKIE];
if (token) {
const env = await resolveByToken(token);
if (isReachable(env)) {
return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
if (looksLikeLabPath(req.path)) {
const env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
proxy.web(req, res, { target: IDENTITY_BACKEND_URL });
proxy.web(req, res, { target: PLATFORM_API_URL });
});
// ── WebSocket upgrade — same routing rules as HTTP, applied to the /shell-ws handshake ─────
// ── WebSocket upgrade — same ownership rule as HTTP, applied to the /shell-ws handshake ────
const server = http.createServer(app);
server.on('upgrade', async (req, socket, head) => {
const labMatch = req.url.match(/^\/lab\/([^/]+)(\/.*)?$/);
if (labMatch) {
const [, token, rest] = labMatch;
const env = await resolveByToken(token);
if (!isReachable(env)) return socket.destroy();
const own = await checkOwnership(req, env);
if (!own.ok || !isReachable(env)) return socket.destroy();
req.url = rest || '/';
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
const cookies = parseCookies(req);
const token = cookies[LAB_SESSION_COOKIE];
if (token) {
const env = await resolveByToken(token);
if (isReachable(env)) {
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
}
}
// Unprefixed WS (the existing Lab UI's /shell-ws, hit while "inside" a lab session).
const env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
socket.destroy();
});
+64 -85
View File
@@ -1,32 +1,38 @@
#!/usr/bin/env node
// Phase 2A end-to-end test — runs against a live platform/docker-compose.dev.yml stack
// (postgres + egress-proxy + provisioner + identity + gateway). Exercises the full student
// flow through the Gateway, exactly as a browser would, using two students concurrently to
// verify isolation. This is slow (each lab provision is a real k3s cold start, ~1-3 min) and
// deliberately not run on every commit — see platform/README.md for when to run it.
// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture.
// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as
// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact
// same flow through the real rn_session cookie instead, since that's how it actually works now.
//
// No framework, matching platform/provisioner/test/run.js's convention. Requires:
// cd platform && docker compose -f docker-compose.dev.yml up -d
// node test/e2e-phase2a.js
// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
// framework, matching project convention.
//
// This captures what was verified by hand during Phase 2A implementation (see
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8) as a repeatable regression script — it is not
// a substitute for the manual real-Lab-image verification (node-pty, terminal, kubectl,
// scenario validation) documented there, which needs a browser/WebSocket client and isn't
// automated here.
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js
const assert = require('assert');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const COHORT = `e2e-test-${Date.now()}`;
async function api(method, path, { studentId, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (studentId) headers['X-Student-Id'] = studentId;
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a
// browser does, so each "session" here explicitly carries its own Set-Cookie forward.
function makeSession() {
let cookie = '';
return {
async request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (cookie) headers.Cookie = cookie;
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
},
rawFetch(path) {
return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} });
},
};
}
let passed = 0, failed = 0;
@@ -36,110 +42,83 @@ async function test(name, fn) {
}
async function main() {
console.log('Phase 2A end-to-end tests (this is slow — real k3s boots)\n');
console.log('Phase 2A student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n');
const admin = makeSession();
await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD });
let alice, bob;
await test('roster: add two students', async () => {
const a = await api('POST', '/api/roster', { body: { display_name: 'E2E-Alice', cohort: COHORT } });
const b = await api('POST', '/api/roster', { body: { display_name: 'E2E-Bob', cohort: COHORT } });
assert.equal(a.status, 200); assert.equal(b.status, 200);
await test('roster: add two students via admin', async () => {
const a = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Alice-2A', cohort_id: null });
const b = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Bob-2A', cohort_id: null });
alice = a.body.student_id; bob = b.body.student_id;
assert.notEqual(alice, bob);
});
await test('roster: cohort listing includes the new cohort', async () => {
const { body } = await api('GET', `/api/roster?cohort=${COHORT}`);
assert.equal(body.length, 2);
const aliceSession = makeSession();
const bobSession = makeSession();
await test('login: student session cookie is issued and required for curriculum', async () => {
const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice });
assert.equal(status, 200);
const noSession = await fetch(`${GW}/api/curriculum/path`);
assert.equal(noSession.status, 401); // anonymous rejection
const { status: withSession } = await aliceSession.request('GET', '/api/curriculum/path');
assert.equal(withSession, 200);
});
await test('login: both students can log in', async () => {
const a = await api('POST', '/api/login', { body: { student_id: alice } });
const b = await api('POST', '/api/login', { body: { student_id: bob } });
assert.equal(a.status, 200); assert.equal(b.status, 200);
});
await test('curriculum: path loads with the real RootNode stages, in order', async () => {
const { body } = await api('GET', '/api/curriculum/path', { studentId: alice });
const ids = body.stages.map((s) => s.id);
assert.deepEqual(ids, ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']);
assert.equal(body.stages.find((s) => s.id === 'capstone').locked, true);
});
await test('environment: status is none before launch', async () => {
const { body } = await api('GET', '/api/environment/status', { studentId: alice });
assert.equal(body.status, 'none');
await test('curriculum: real RootNode stages, in order', async () => {
const { body } = await aliceSession.request('GET', '/api/curriculum/path');
assert.deepEqual(body.stages.map((s) => s.id), ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']);
});
let aliceLaunch, bobLaunch;
await test('launch: Alice gets a running environment with a launch_path (real k3s boot, be patient)', async () => {
const { status, body } = await api('POST', '/api/environment/launch', { studentId: alice });
await test('launch: Alice gets a running environment (real k3s boot, be patient)', async () => {
const { status, body } = await aliceSession.request('POST', '/api/environment/launch');
assert.equal(status, 200);
assert.equal(body.status, 'running');
assert.ok(body.launch_path?.startsWith('/lab/'));
aliceLaunch = body;
});
await test('launch: Bob gets a DIFFERENT running environment', async () => {
const { status, body } = await api('POST', '/api/environment/launch', { studentId: bob });
assert.equal(status, 200);
assert.equal(body.status, 'running');
await test('launch: Bob gets a different environment', async () => {
await bobSession.request('POST', '/api/login', { student_id: bob });
const { body } = await bobSession.request('POST', '/api/environment/launch');
assert.notEqual(body.launch_path, aliceLaunch.launch_path);
bobLaunch = body;
});
await test('launch: relaunching for Alice reuses the same environment (idempotent)', async () => {
const { body } = await api('POST', '/api/environment/launch', { studentId: alice });
assert.equal(body.launch_path, aliceLaunch.launch_path);
});
await test('gateway: Alice can reach her own lab', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
await test('gateway: Alice reaches her own lab via session (no ?sid= needed anymore)', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('gateway: an invalid token is rejected (404)', async () => {
const res = await fetch(`${GW}/lab/not-a-real-token-at-all/`);
assert.equal(res.status, 404);
});
await test('gateway: Alice\'s URL with Bob\'s sid is rejected (cross-student ownership check)', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${bob}`);
await test('security: Alice cannot reach Bob\'s lab even with her OWN valid session (impersonation)', async () => {
const res = await aliceSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 403);
});
await test('gateway: Alice\'s URL with her own sid succeeds', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${alice}`);
assert.equal(res.status, 200);
await test('security: no session at all is rejected, not just wrong session', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
assert.equal(res.status, 401);
});
await test('progress relay: solving a scenario in Alice\'s lab shows up in her curriculum', async () => {
const scenarioPath = `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`;
await api('POST', scenarioPath, { studentId: alice, body: { selected: 'b' } });
await new Promise((r) => setTimeout(r, 1500)); // relay is fire-and-forget on the Gateway side
const { body } = await api('GET', '/api/curriculum/path', { studentId: alice });
await aliceSession.request('POST', `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
await new Promise((r) => setTimeout(r, 1500));
const { body } = await aliceSession.request('GET', '/api/curriculum/path');
const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
await test('progress relay: Bob\'s curriculum is unaffected by Alice\'s answer', async () => {
const { body } = await api('GET', '/api/curriculum/path', { studentId: bob });
const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.notEqual(scenario.status, 'completed');
});
await test('reset: Alice\'s lab keeps the same launch_path (stable URL) and her progress survives', async () => {
const { status, body } = await api('POST', '/api/environment/reset', { studentId: alice });
assert.equal(status, 200);
await test('reset: stable launch_path, progress survives', async () => {
const { body } = await aliceSession.request('POST', '/api/environment/reset');
assert.equal(body.launch_path, aliceLaunch.launch_path);
const { body: curriculum } = await api('GET', '/api/curriculum/path', { studentId: alice });
const { body: curriculum } = await aliceSession.request('GET', '/api/curriculum/path');
const scenario = curriculum.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
console.log(`\n${passed} passed, ${failed} failed`);
console.log('\nNot covered here (needs a browser/WebSocket client — verified manually, see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8):');
console.log(' - real Lab image: node-pty load, terminal WS, kubectl execution');
console.log(' - idle reaper timing (needs a real threshold wait, or DB manipulation — see the doc)');
process.exit(failed ? 1 : 0);
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
// Phase 2B classroom workflow — the actual acceptance test from
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: instructor creates a cohort, adds three students,
// assigns curriculum, prepares Labs for the whole cohort, two students complete different
// scenarios, instructor resets one student's Lab, instructor destroys all cohort Labs after
// class — progress survives every step.
//
// Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots,
// concurrently). No framework, matching project convention.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// ADMIN_PASSWORD=<value from platform/.env> node test/e2e-phase2b.js
const assert = require('assert');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); }
const COHORT_ID = `e2e-cohort-${Date.now()}`;
function makeSession() {
let cookie = '';
return {
async request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (cookie) headers.Cookie = cookie;
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
},
};
}
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
}
async function main() {
console.log('Phase 2B classroom workflow acceptance test — slow, real k3s boots\n');
const admin = makeSession();
const other = makeSession();
await test('admin auth: wrong password rejected, correct password issues a session', async () => {
const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' });
assert.equal(bad.status, 401);
const good = await admin.request('POST', '/api/admin/login', { password: ADMIN_PASSWORD });
assert.equal(good.status, 200);
});
await test('security: a student session cannot call admin routes', async () => {
const res = await fetch(`${GW}/api/admin/dashboard`);
assert.equal(res.status, 401);
});
await test('admin: create cohort', async () => {
const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E Cohort', tier: 'root' });
assert.equal(status, 201);
});
let students = [];
await test('admin: add three students to the cohort', async () => {
for (const name of ['E2E-Alice', 'E2E-Bob', 'E2E-Carlos']) {
const { status, body } = await admin.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
assert.equal(status, 201);
students.push(body.student_id);
}
const { body: roster } = await admin.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
assert.equal(roster.length, 3);
});
await test('admin: assign RootNode curriculum to the cohort', async () => {
const { status } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/curriculum`, { curriculum_path: 'rootnode-path' });
assert.equal(status, 200);
});
let prepResults;
await test('admin: Prepare Labs provisions all three concurrently — 3/3 READY', async () => {
const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`);
assert.equal(status, 200);
prepResults = body.results;
assert.equal(prepResults.length, 3);
assert.ok(prepResults.every((r) => r.status === 'running'), `expected all running, got: ${JSON.stringify(prepResults)}`);
});
const [aliceId, bobId] = students;
const alice = makeSession();
const bob = makeSession();
await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => {
await alice.request('POST', '/api/login', { student_id: aliceId });
await bob.request('POST', '/api/login', { student_id: bobId });
const start = Date.now();
const { body: aliceEnv } = await alice.request('POST', '/api/environment/launch');
const { body: bobEnv } = await bob.request('POST', '/api/environment/launch');
const elapsedMs = Date.now() - start;
assert.equal(aliceEnv.status, 'running');
assert.equal(bobEnv.status, 'running');
assert.notEqual(aliceEnv.launch_path, bobEnv.launch_path);
// Reusing an already-prepared lab should be near-instant, not another full k3s boot.
assert.ok(elapsedMs < 15000, `expected instant reuse, took ${elapsedMs}ms`);
global.__aliceLaunch = aliceEnv; global.__bobLaunch = bobEnv;
});
await test('students: Alice and Bob complete DIFFERENT scenarios', async () => {
await alice.request('POST', `${global.__aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
await bob.request('POST', `${global.__bobLaunch.launch_path}api/scenarios/kubectl-essentials-mcq/answer`, { selected: 'd' });
await new Promise((r) => setTimeout(r, 1500));
});
await test('admin: dashboard reflects student completions', async () => {
const { body } = await admin.request('GET', '/api/admin/dashboard');
assert.equal(body.labs.running, 3);
assert.ok(body.recent_completions.some((c) => c.student_id === aliceId));
});
let aliceEnvId;
await test('admin: reset Alice\'s Lab — academic progress remains intact', async () => {
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
aliceEnvId = envs.find((e) => e.status !== 'destroyed').environment_id;
const { status } = await admin.request('POST', `/api/admin/environments/${aliceEnvId}/reset`);
assert.equal(status, 200);
const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`);
const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
await test('admin: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/destroy-labs`);
assert.equal(status, 200);
assert.ok(body.results.every((r) => r.status === 'destroyed'));
const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`);
const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => {
// Sanity check via the admin environment list — this doesn't re-run the Provisioner's own
// iptables tests (see platform/provisioner/test/run.js for those), just confirms nothing
// in the Phase 2B layer introduced a published port.
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });