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

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

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

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

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

- Renders GET /api/curriculum/path as-is: the existing RootNode path
  (Foundation -> Workloads -> Networking -> Configuration -> Troubleshooting
  -> Real Ops -> Capstone), no new structure invented. Capstone stays the
  locked placeholder from the earlier slice.
- Launch Lab -> POST /api/environment/launch, polls status every 2.5s while
  provisioning/resetting, transitions through none -> provisioning -> running
  -> error with a distinct UI state for each (EnvironmentPanel). On running,
  navigates to launch_path with the student's own id as ?sid= — a
  defense-in-depth ownership check the Gateway verifies on that specific
  navigation (see the gateway commit).
- One "Recreate Lab" control, calling POST /api/environment/reset — no raw
  destroy-without-recreate exposed to students, matching "don't expose raw
  destroy/admin controls beyond what is safe for a student." A visible note
  states progress is saved separately and survives.

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

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

- EntryScreen.jsx — cohort dropdown -> name dropdown (existing
  GET /api/roster/cohorts, GET /api/roster?cohort=) -> POST /api/login. No
  password. Visibly labeled pilot identity, not production auth, per the
  governing requirement that this be stated explicitly, not just true in the
  architecture docs.
- api.js — student_id lives in localStorage only, sent as X-Student-Id on
  every authenticated request. Every authenticated call throws if no student
  is stored rather than silently going out anonymously — this is what
  "prevent accidental anonymous progress writes" means in practice, not just
  a comment.
- PlatformShell.jsx — entry screen if no stored session, dashboard otherwise.

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

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

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

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

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

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

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

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

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

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

1. node-gyp generates an incorrect build/config.gypi for this NodeSource-packaged
   Node 20 build — it reports node_shared: true (expects a shared libnode.so),
   but the actual installed Node is statically linked (no libnode.so.<abi>
   exists anywhere on the system; process.config.variables.node_shared on the
   real running Node is false). node-gyp trusted the wrong config and linked
   node-pty's native addon against -lnode, producing a binary that fails at
   require() time with "libnode.so.127: cannot open shared object file" —
   confirmed by loading the compiled .node file directly, bypassing node-pty's
   own path-search/error-masking logic (which collapses this into a generic
   "Cannot find module .../pty.node" across every path it checks).

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

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

No terminal architecture change, no fake shell, no changes to
backend/server.js's PTY handling. RootNode branding and upstream KubeKosh
attribution untouched. The single-container dev workflow
(`docker build -t rootnode-academy:test .`) is the same command as before —
it now just works.

Verified against the real image end-to-end, not a stand-in: build -> container
starts -> k3s reaches Ready -> backend starts, no crash -> nginx starts ->
browser UI 200s -> terminal WebSocket opens showing the RootNode banner ->
`kubectl get nodes` run through the terminal returns real cluster state ->
deploy-nginx scenario solved via the terminal -> all 5 validation checks pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-18 11:15:26 -05:00
22 changed files with 1529 additions and 37 deletions
+17
View File
@@ -0,0 +1,17 @@
# Root cause of the node-pty "Cannot find module .../pty.node" failure (Phase 2A): with no
# .dockerignore, a host-side `npm install` (e.g. for local testing) leaves backend/node_modules
# on disk, built against the host's own Node/libnode — not the container's. The Dockerfile's
# later `COPY backend/ ./backend/` (done after the npm-install RUN layer, for build caching)
# then silently overwrites the correctly-built-in-container node_modules with that host copy,
# which fails to load at runtime with an ABI/architecture mismatch. Native modules (node-pty,
# better-sqlite3) MUST always be built inside the image, never leaked in from the host.
#
# IMPORTANT: bare `node_modules/` only matches a node_modules directory at the build context
# ROOT in Docker's .dockerignore matching rules — it does NOT recursively match
# backend/node_modules or frontend/node_modules. This bit us once already (fixed here with
# `**/`): a first pass at this file used the bare pattern and the leak still happened.
**/node_modules/
frontend/dist/
.git/
*.db
*.db-journal
+22 -1
View File
@@ -22,6 +22,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# node-gyp fix (Phase 2A root cause, see the note on the backend npm-install line below):
# set as a persistent image ENV, not a one-off RUN prefix, so it applies no matter when or how
# many times a native module gets (re)built in this image — inline `VAR=x npm install` only
# scopes the var to that one RUN command, which isn't a strong enough guarantee.
ENV npm_config_node_shared=false
# ── k3s (bundles kubectl, containerd, everything) ────────────────────────────
# We download the binary and the airgap images so the cluster starts offline.
RUN set -eux && \
@@ -41,8 +47,23 @@ RUN set -eux && \
WORKDIR /app
# Install backend dependencies
#
# npm_config_node_shared=false (set above, image-level) works around a real node-gyp bug
# affecting native modules (node-pty, better-sqlite3): node-gyp generates build/config.gypi
# from a Node headers/config reference it downloads for the target version, and for this
# NodeSource-packaged Node build that reference incorrectly reports node_shared=true (a shared
# libnode.so build) when the actual installed Node is statically linked (no libnode.so.<abi>
# exists at all). Left uncorrected, node-gyp links native addons against -lnode, producing a
# binary that fails at require() time with "libnode.so.<abi>: cannot open shared object file"
# — this is the prior node-pty runtime failure ("Cannot find module .../pty.node"),
# root-caused during Phase 2A: node-pty's own error handling collapses the real dlopen error
# into a generic "module not found" message across all the paths it checks, which is what made
# this look like a missing-file problem rather than a link-time misconfiguration. The
# `node -e require(...)` check below makes this a hard build failure if it ever regresses,
# instead of a silent runtime failure discovered later.
COPY backend/package.json ./backend/
RUN cd backend && npm install --production
RUN cd backend && npm install --production \
&& node -e "require('./node_modules/node-pty'); console.log('node-pty loads OK')"
# Build frontend
COPY frontend/package.json frontend/vite.config.js ./frontend/
+26 -16
View File
@@ -128,29 +128,39 @@ Everything runs inside one image managed by `scripts/entrypoint.sh`.
## Multi-Student Deployment (RootNode Academy addition)
The upstream project assumes one learner, one container — this image is still exactly that,
unmodified. RootNode Academy multiplies it into a real platform via a separate provisioning
layer rather than changing the image itself: see `docs/LAB_ARCHITECTURE.md` for the
architecture and `platform/provisioner/` for the Phase 1 implementation (Postgres-backed
environment registry, per-student Docker networking, firewall-enforced egress, idle reaper —
see `platform/provisioner/README.md` for how to run it).
unmodified except for a node-pty build fix (see `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1).
RootNode Academy multiplies it into a real platform via separate provisioning and gateway
layers rather than changing the image itself:
**Never publish the raw privileged container** — front it with isolation. The one-container
`docker run --privileged -p 7554:80 ...` command above stays supported for local development
and scenario authoring; it is not how this gets deployed to real students.
- `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`).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/` — a small addition alongside the existing,
unmodified Lab UI, not a rewrite of it.
Key properties of the Phase 1 platform (not this image):
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
supported for local development and scenario authoring; it is not how this gets deployed to
real students.
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.
- Progress/identity live outside the disposable container (currently this image's own SQLite,
moving to the Platform's PostgreSQL in a later phase) — destroying a student's environment
never loses their academic record.
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.
- 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.
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
one and keep going without losing progress.
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md` and
`docs/PHASE1_PLATFORM_FOUNDATION.md`.
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`.
---
+26
View File
@@ -886,6 +886,32 @@ app.post('/api/scenarios/:id/time', (req, res) => {
res.json({ ok: true });
});
// POST /api/progress/record — Phase 2A addition. Records a scenario outcome WITHOUT
// re-running validation. Exists because scenario validation itself must run against the
// student's own live cluster (inside their ephemeral Lab container), but the canonical
// progress record needs to live here, on the persistent identity instance — the Gateway
// calls this after a real validate/answer already succeeded against the student's Lab, to
// relay the outcome. See docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Progress relay."
app.post('/api/progress/record', (req, res) => {
const { scenario_id, status } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
}
const studentId = getStudentId(req);
const progress = loadProgress(studentId);
const prev = progress[scenario_id] || { attempts: 0 };
progress[scenario_id] = {
...prev,
status,
attempts: (prev.attempts || 0) + 1,
last_validated: new Date().toISOString(),
completed_at: status === 'completed' ? new Date().toISOString() : prev.completed_at,
};
saveProgress(studentId, progress);
touchStudent(studentId);
res.json({ ok: true });
});
// GET /api/progress — full progress summary
app.get('/api/progress', (req, res) => {
const scenarios = loadScenarios();
+14 -13
View File
@@ -142,13 +142,14 @@ Order agreed 2026-08-17/18. Status as of this doc:
3. Student accounts / persistent progress — **backend done** (§5), frontend not started,
no instructor auth yet.
4. Instructor dashboard — not started.
5. Per-student isolation — **Phase 1 (platform foundation) done**, see §8. Ephemeral
per-student Lab environments with Postgres-backed lifecycle, per-environment Docker
networking, and firewall-enforced egress restriction are implemented and tested on a single
dev machine. **Still not cleared for minors** — no Gateway/frontend wiring yet (environments
are only reachable via the internal Provisioner API), no instructor auth on top of it, and
the production two-VM Proxmox split this depends on doesn't exist yet. See
`docs/PHASE1_PLATFORM_FOUNDATION.md` final report for exactly what's verified vs. not.
5. Per-student isolation — **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow)
done**, see §8/§9. Ephemeral per-student Lab environments, Gateway-mediated routing, and a
working entry→dashboard→launch→lab→progress flow are implemented and end-to-end tested on a
single dev machine, including two simultaneous students with verified cross-student
isolation. **Still not cleared for minors** — pilot-grade identity only (no
password/SSO), no instructor auth layer, and the production two-VM Proxmox split this
depends on doesn't exist yet. See `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for exactly
what's verified vs. not.
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
7. Capstone/exam engine — not started (placeholder stage only, see §4).
8. Progressive hint levels — not started.
@@ -156,9 +157,9 @@ Order agreed 2026-08-17/18. Status as of this doc:
10. CI scenario testing — not started.
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
Next slice: either the deferred frontend login (name picker → `X-Student-Id` header, curriculum
view), or the Phase 2 platform work (Gateway, frontend wiring to the Provisioner, instructor
auth) — see the Phase 1 final report for the concrete Phase 2 list. Not decided here.
Next slice: Phase 2B — instructor dashboard, production auth/SSO, and the identity/progress
Postgres migration are the biggest remaining items. See
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 for the full Phase 2B list.
## 8. Platform Provisioner (Phase 1)
@@ -170,9 +171,9 @@ 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 (yet):** reachable by a student. There's no Gateway and no frontend wiring
— it's exercised directly via its internal API (shared-secret auth) or
`platform/provisioner/test/run.js`. Nothing here is a live feature.
- **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
+7 -6
View File
@@ -1,11 +1,12 @@
# RootNode Academy — Ephemeral Lab Architecture
Status: **Phase 1 (platform foundation) IMPLEMENTED 2026-08-18** — see
`docs/PHASE1_PLATFORM_FOUNDATION.md` for the concrete schema/API/test results, and
`platform/provisioner/README.md` for how to run it. This document remains the architecture
record for the full ephemeral-lab direction; later phases (Gateway, frontend wiring, instructor
dashboard, production Proxmox deployment — see the Phase 1 report's "Future Phase 2 items")
build on it but aren't built yet. Supersedes the namespace-isolation
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
(`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.
+292
View File
@@ -0,0 +1,292 @@
# RootNode Academy — Phase 2A: Gateway & Student Flow
Status: **IMPLEMENTED and end-to-end tested, 2026-08-18.** Builds on
`docs/LAB_ARCHITECTURE.md` and `docs/PHASE1_PLATFORM_FOUNDATION.md`
(Phase 1 milestone `a15897c78f7012c59ea3629a74dd8b1425f6f7f2`). This doc covers the three
Phase 2A workstreams: fixing the real Lab image, the Gateway, and the student frontend flow.
---
## 1. node-pty root cause and fix
**Symptom:** the real Lab image's backend crashed at startup with
`Error: Failed to load native module: pty.node, checked: build/Release, build/Debug,
prebuilds/linux-x64: Cannot find module './prebuilds/linux-x64//pty.node'`.
**What it looked like vs. what it was:** node-pty's own error handling collapses whatever
actually went wrong at each candidate path into one generic "module not found" message using
only the *last* path it tried — this made a link-time failure look like a missing-file problem.
**Root cause, confirmed empirically (not guessed):**
1. **node-gyp generates an incorrect `config.gypi`.** For this NodeSource-packaged Node 20
build, `node-gyp`'s downloaded headers/config reference reports `node_shared: true` (a
shared `libnode.so` build), but the actual installed Node is statically linked — there is no
`libnode.so.<abi>` anywhere on the system (confirmed: `find / -iname 'libnode*'` → nothing;
`process.config.variables.node_shared` on the real running Node → `false`). Node-gyp
trusted the wrong config and linked node-pty's native addon against `-lnode`, producing a
binary that fails at `require()` time with `libnode.so.127: cannot open shared object file`
— confirmed by loading the compiled `.node` file directly, bypassing node-pty's own
path-search/error-masking logic.
2. **A second, independent bug compounded it:** the repo had no `.dockerignore`. A host-side
`npm install` (run earlier in this project for unrelated backend testing) left a
`backend/node_modules/` on disk, built against the *host's* environment. The Dockerfile's
`COPY backend/ ./backend/` (placed after the npm-install `RUN` layer, for build-cache
efficiency) then silently overwrote the correctly-built-in-container `node_modules` with
that host copy — including after a first `.dockerignore` fix that used the pattern
`node_modules/`, which (a real gotcha) only matches a `node_modules` directory at the build
**context root** in Docker's matching rules, not `backend/node_modules` at any depth.
**Fix (`Dockerfile`, `.dockerignore`):**
```dockerfile
ENV npm_config_node_shared=false # image-level, not a one-off RUN prefix — applies no
# matter when/how often a native module gets built
...
RUN cd backend && npm install --production \
&& node -e "require('./node_modules/node-pty'); console.log('node-pty loads OK')"
```
The `node -e require(...)` line makes this a **hard build failure** if it ever regresses again,
instead of a silent runtime failure discovered later — not a workaround for the terminal, a
real build-time verification of a real native dependency.
```
# .dockerignore
**/node_modules/ # NOT `node_modules/` — that only matches the context root
frontend/dist/
.git/
*.db
*.db-journal
```
**Nothing else changed.** No terminal architecture change, no fake shell, no rewrite of
`backend/server.js`'s PTY handling. RootNode branding and upstream KubeKosh attribution
untouched. The single-container dev workflow (`docker build -t rootnode-academy:test .`) is
the same command as before — it now just works.
**Verified against the real image, not a stand-in** (see §8 for full results): image builds →
container starts → k3s reaches `Ready` → backend starts (`API server running on :4000`, no
crash) → nginx starts → browser UI returns 200 → terminal WebSocket opens and shows the
RootNode banner → `kubectl get nodes` run through the terminal returns real cluster state →
`deploy-nginx` scenario solved via the terminal and validated via
`POST /api/scenarios/deploy-nginx/validate` → all 5 checks pass.
---
## 2. Gateway architecture
New component: `platform/gateway/`. The only student-facing path to disposable Lab
environments — browsers never talk to a Lab container, the Provisioner, or Postgres directly.
```
Browser
│ (one stable origin — the Gateway)
▼
Gateway
├── /api/environment/* → calls the Provisioner internally (shared secret never
│ leaves the Gateway process)
├── /lab/<token>/* → HTTP + WS proxy into that specific student's Lab
│ container, resolved by opaque token via the Provisioner
└── everything else → proxied to the persistent "identity" backend (roster,
login, curriculum, progress, and the platform-shell
frontend) — OR to the student's own Lab, if a valid
lab-session cookie is present (see §5 for why)
```
**Why a persistent "identity" instance exists, and what it is:** the roster/curriculum/
progress SQLite data (built in the pre-Phase-2A slice) needs to be reachable *before* a student
has a Lab — they pick their name and see their curriculum before ever clicking Launch Lab. That
data can't live inside an ephemeral, disposable-by-design Lab container. Phase 2A runs it as
one plain, non-ephemeral instance of the existing, unmodified Lab image (`identity` in
`platform/docker-compose.dev.yml`) — same image, same code, just never destroyed/reset by the
Provisioner, the same way Postgres or the egress proxy are infrastructure rather than
per-student resources. This is a deliberate zero-new-code choice (see §9 known limitations for
its cost).
**Provisioner addition:** `GET /environments/by-token/:token` (small, internal-only, same
shared-secret auth as every other Provisioner route) — resolves a routing token to its
environment for the Gateway. This is the only change to `platform/provisioner/`.
**What the Gateway never does:** expose a Lab container's port publicly, expose the Docker
API, expose the Provisioner's shared secret to a browser, or trust a client-supplied
environment ID for anything without checking it against the Provisioner's own record.
---
## 3. Student flow
**A. Entry screen** (`frontend/src/platform/EntryScreen.jsx`) — cohort dropdown → name
dropdown (existing `GET /api/roster/cohorts` / `GET /api/roster?cohort=`) → `POST /api/login`.
No password. A visible note states this is pilot identity, not production auth.
**B. Session** — `student_id` stored in `localStorage` only (`frontend/src/platform/api.js`),
sent as `X-Student-Id` on every subsequent platform request. Every authenticated call throws
if no student is stored, rather than silently going out anonymously — this is what "prevent
accidental anonymous progress writes" means in practice.
**C. Dashboard / "Up Next"** (`StudentDashboard.jsx`) — renders `GET /api/curriculum/path`
as-is: the existing RootNode path (Foundation → Workloads → Networking → Configuration →
Troubleshooting → Real Ops → Capstone), no new structure invented. Capstone stays the locked
placeholder from the earlier slice.
**D. Launch Lab** — `POST /api/environment/launch` (Gateway): reuses a non-terminal
environment if one exists, otherwise provisions one via the Provisioner. UI states map directly
to environment status: none → provisioning/resetting (polled every 2.5s while in flight) →
running → error, each rendered distinctly (`EnvironmentPanel` in `StudentDashboard.jsx`). On
`running`, the browser navigates to `launch_path` (`/lab/<token>/`), with the student_id
attached as a `?sid=` query param for the Gateway's ownership check (see §5).
**E. Lab session** — the existing, **completely unmodified** Lab UI (`App.jsx` and everything
under `frontend/src/components/`). It has no student-identity awareness and needs none —
identity and progress stay entirely in the Platform layer, per the explicit requirement not to
inject student identity into the privileged Lab container.
**F. Reset/Recreate** — one button, "Recreate Lab," calling `POST /api/environment/reset`
(destroy + provision against the same environment row, per the Phase 1 lifecycle — same
`environment_id`, fresh container). No raw destroy-without-recreate control is exposed to
students; that keeps the surface to exactly what's safe, per the explicit requirement.
---
## 4. API routes added
All on the Gateway (`platform/gateway/src/server.js`), all internal glue — the Provisioner's
own API remains unreachable from the browser:
| Method & path | Auth | Purpose |
|---|---|---|
| `GET /api/environment/status` | `X-Student-Id` | Current environment state for this student, or `{status:'none'}` |
| `POST /api/environment/launch` | `X-Student-Id` | Reuse or provision; blocks until running/error (matches the Provisioner's own synchronous provisioning) |
| `POST /api/environment/reset` | `X-Student-Id` | Destroy + recreate, same environment identity |
| `GET/POST/* /lab/:token/*` | opaque token (+ optional `?sid=`) | Proxy into that student's Lab |
Plus one Provisioner addition: `GET /environments/by-token/:token` (internal-only).
Responses to the browser never include `internal_ip`, `container_id`, `network_id`,
`lab_host_id`, `environment_id`, or the raw routing token beyond its one legitimate use as a
navigation path segment (`launch_path`) — see §6.
---
## 5. Routing design — HTTP/WebSocket, and why there's a cookie
**The problem this solves:** the existing Lab UI makes root-relative calls
(`fetch('/api/scenarios')`, `new WebSocket(`${proto}//${location.host}/shell-ws`)`) — it has no
idea it might be served under a `/lab/<token>/` prefix, and it shouldn't have to (preserving
the existing architecture unmodified was an explicit requirement). Naively prefix-stripping
only the *first* request would leave every subsequent asset/API/WS call unrouted.
**Solution:** the Gateway sets an `HttpOnly`, `SameSite=Lax`, `Path=/` cookie (`rn_lab`,
8h max-age) on the first `/lab/<token>/` response. Every later request from that browser —
regardless of path — carries it automatically. Routing priority:
1. `/lab/<token>/*` (explicit) → resolve token, strip prefix, proxy, (re)set the cookie.
2. `/api/environment/*` → always the Gateway's own routes, **never** affected by the cookie —
this is what keeps "student is inside their lab" from accidentally hijacking the dashboard's
own environment-status calls.
3. Anything else, unprefixed → if the `rn_lab` cookie is present and resolves to a `running`
environment, proxy there (unprefixed) — this is what makes the *existing, unmodified* Lab
UI's own `/api/scenarios`, `/shell-ws`, static assets, etc. resolve correctly once a browser
has navigated into a lab session, with zero changes to that UI's code. Otherwise, proxy to
the identity backend (this is what serves `/`, the platform-shell bundle, `/api/roster`,
`/api/curriculum/path`, etc.).
WebSocket upgrades follow the identical priority (`server.on('upgrade', ...)` in
`gateway/src/server.js`), so the terminal's `/shell-ws` connection is routed the same way as
any other request.
**Stable URL, even across recreate:** the student-facing URL is always `/lab/<routing_token>/`
— the token lives on the `environments` row and does not change across `reset` (same row, new
container/network/IP). A bookmarked or previously-loaded lab URL keeps working after a
recreate without the student needing a new link.
---
## 6. Security — environment ownership, and what Phase 2A does *not* claim
- **Token as bearer capability for the proxy path.** A `/lab/<token>/` request is authorized by
possession of the token itself (CSPRNG, 32 bytes, never guessable) — the same trust model as
a signed download link. This is a deliberate, stated design choice, not an oversight.
- **`?sid=` as an additional check where it's available.** A full top-level browser navigation
cannot carry a custom header, so the Dashboard's "Launch/Continue Lab" link appends
`?sid=<student_id>` to the URL. If present, the Gateway compares it against the environment's
actual owning `student_id` (from the Provisioner) and rejects (403) on mismatch. This is
strictly additive security on top of token-alone — its absence (e.g., a bookmarked link
without the param) falls back to token-only trust, not a hole opened by omitting it.
- **`/api/environment/*` ownership is checked against the Provisioner's own record**, not
assumed from the header — `listForStudent`/`reset` always re-verify `env.student_id`.
- **Explicitly not production auth.** Per the governing requirement: routing-token-alone,
student-supplied-environment-ID-alone, and `X-Student-Id`-alone are each individually
insufficient and documented as such — Phase 2A's actual security property is the
*combination*, appropriate for a supervised pilot, not a claim of production-grade identity.
Production SSO/OIDC is explicitly future work (§9).
- **Nothing sensitive reaches the browser**: no internal Docker IPs, no Docker/Provisioner
endpoints, no shared secret, no Postgres credentials — verified by inspecting every Gateway
response shape in `server.js`'s `publicEnvView()`.
---
## 7. Reset/recreate and reaper recovery semantics
- **Recreate Lab** (student-initiated) = the Provisioner's existing `reset`: destroy + recreate
against the same `environments` row. Same `environment_id`/routing token, same
`/lab/<token>/` URL, fresh container/network/IP. Academic progress is untouched because it
never lived in the Lab container to begin with (Phase 1 property, unchanged).
- **Reaper-caused expiry** (idle timeout, no student action): the environment transitions to
`destroyed`. `GET /api/environment/status` correctly reports `none`/`destroyed` afterward —
the Dashboard's "Launch Lab" button provisions a **new** environment (new `environment_id`,
new token, new URL) rather than trying to resurrect the old row, matching the Provisioner's
actual lifecycle model. Progress survives because, again, it was never in the Lab.
- **Unexpected Lab death** (crash, not reaped): the Gateway's `isReachable()` check
(`status==='running' && internal_ip` present) fails closed — a dead container never gets
silently proxied to. The student sees the `error` state with a "Try Again" action.
---
## 8. Testing — results
*(Filled in after the full end-to-end run against `platform/docker-compose.dev.yml`; see the
Phase 2A final report for the authoritative results table. Summary below.)*
- Lab image: node-pty load, k3s Ready, terminal, kubectl, scenario validation — **all pass**,
§1 above.
- Gateway HTTP/WS proxying, two-student isolation, cross-student rejection, stable URL across
recreate, reaper recovery, existing single-container dev mode, scenario files untouched,
frontend/backend builds, Provisioner test suite — see final report for the full table and
exact commands run.
---
## 9. Known limitations / Phase 2B
**BUILT NOW:**
- Real Lab image boots reliably (node-pty fixed at the actual root cause).
- Gateway: HTTP + WS proxying, environment-aware routing, ownership checks, no public Lab
ports, no exposed Docker/Provisioner internals.
- Student entry (roster pick-name) → dashboard (curriculum + environment status) → Launch Lab
→ Lab session → Reset/Recreate, all wired through the Gateway as the single origin.
- Reaper-recovery and reset semantics correctly reflected in the student-facing states.
**FUTURE (explicit Phase 2A non-goals, unchanged):**
- Instructor dashboard, production SSO/OIDC, public signup, billing, parent portal, email
notifications, multi-host scheduling, full SQLite→Postgres migration, production
observability, capstone engine, production Proxmox deployment, certificates, self-paced
public enrollment, the six held scenarios, namespace-only isolation.
**Known limitations found during this phase, not previously documented:**
- The `identity` instance boots a full, unused k3s cluster just to serve as the roster/
curriculum/progress backend — it's the existing image running exactly as designed, but it's
real wasted resource. A Phase 2B optimization is a slimmed identity-only variant of the image
(skip k3s entirely) — not done here, to keep this phase at zero changes to the Lab image's
own runtime behavior beyond the node-pty fix.
- `identity` requires `network_mode: bridge` explicitly in `docker-compose.dev.yml` — under
Compose's own auto-created bridge network, k3s failed with "no default routes found in
/proc/net/route" at startup. Root cause not fully chased down (a Compose-network-vs-classic-
bridge routing-table timing/detection difference); the explicit `network_mode: bridge`
workaround is confirmed reliable and is what's shipped. Worth a deeper look before this
matters for a real deployment (bridge mode isn't compatible with a per-student isolated
network model anyway — the production identity service placement needs its own design pass).
- The Gateway's environment-lookup cache (5s TTL) is in-memory and per-process — fine for one
Gateway instance (Phase 2A's actual topology), would need a shared cache (or none) if the
Gateway is ever horizontally scaled.
- `?sid=` ownership verification only covers the initial `/lab/<token>/` navigation, not every
subsequent proxied request (by design, per §6) — acceptable for the stated pilot threat
model, revisit if this graduates past pilot use.
+10 -1
View File
@@ -1,14 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import PlatformShell from './platform/PlatformShell.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
// 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/')
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
{isLabSession ? <App /> : <PlatformShell />}
</React.StrictMode>
)
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect } from 'react'
import { api, storeStudent } from './api'
import styles from './Platform.module.css'
// A. Entry screen — RootNode Academy branding, choose your name from roster, no password.
// Explicitly pilot/dev identity, not production auth — see
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation."
export default function EntryScreen({ onLogin }) {
const [cohorts, setCohorts] = useState([])
const [cohort, setCohort] = useState('')
const [roster, setRoster] = useState([])
const [studentId, setStudentId] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
api.cohorts().then(setCohorts).catch((e) => setError(e.message))
}, [])
useEffect(() => {
if (!cohort) { setRoster([]); return }
api.roster(cohort).then(setRoster).catch((e) => setError(e.message))
}, [cohort])
const handleLogin = async () => {
if (!studentId) return
setLoading(true)
setError(null)
try {
const student = await api.login(studentId)
storeStudent(student)
onLogin(student)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Real-world Kubernetes labs. Pick your name to continue.</p>
<p className={styles.pilotNote}>Pilot identity — no password. Not production auth.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohort} onChange={(e) => { setCohort(e.target.value); setStudentId('') }}>
<option value="">Select a cohort…</option>
{cohorts.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
{cohort && (
<>
<label className={styles.label}>Your name</label>
<select className={styles.select} value={studentId} onChange={(e) => setStudentId(e.target.value)}>
<option value="">Select your name…</option>
{roster.map((s) => <option key={s.student_id} value={s.student_id}>{s.display_name}</option>)}
</select>
</>
)}
<button className={styles.primaryBtn} disabled={!studentId || loading} onClick={handleLogin}>
{loading ? 'Signing in…' : 'Continue'}
</button>
</div>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
/* Platform Shell styling — reuses the existing design tokens from index.css (--bg, --surface,
--green, --text, etc.) so the entry/dashboard screens feel like the same product as the Lab
UI, not a bolted-on separate app. */
.entryWrap {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
font-family: var(--sans);
color: var(--text);
}
.entryCard {
width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
}
.brand {
font-family: var(--mono);
font-size: 1.3rem;
font-weight: 600;
color: var(--green);
display: flex;
align-items: center;
gap: 0.5rem;
}
.brandIcon { font-size: 1.4rem; }
.tagline { color: var(--text-2); margin: 0.5rem 0 0.25rem; font-size: 0.9rem; }
.pilotNote { color: var(--text-3); font-size: 0.75rem; margin-bottom: 1.25rem; }
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
.select {
width: 100%;
padding: 0.55rem 0.6rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
}
.primaryBtn {
margin-top: 1.25rem;
width: 100%;
padding: 0.65rem 1rem;
background: var(--green);
color: #05130a;
border: none;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
text-decoration: none;
display: inline-block;
}
.primaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.secondaryBtn {
padding: 0.65rem 1rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
}
.secondaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.linkBtn {
background: none;
border: none;
color: var(--text-2);
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
}
.errorBox {
background: var(--red-dim);
color: var(--red);
border: 1px solid var(--red);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* ── Dashboard ─────────────────────────────────────────────────────────── */
.dashWrap {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
padding: 0 0 3rem;
}
.dashHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 2rem;
border-bottom: 1px solid var(--border);
}
.headerRight { display: flex; align-items: center; gap: 1rem; }
.studentName { font-weight: 600; }
.card {
max-width: 760px;
margin: 1.5rem auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.card h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text); }
.muted { color: var(--text-2); font-size: 0.9rem; }
.stageRow { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.stagePill {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.7rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-2);
}
.stagePillActive { border-color: var(--green); color: var(--text); }
.stagePillLocked { opacity: 0.5; }
.stageCount { color: var(--text-3); font-family: var(--mono); font-size: 0.75rem; }
.nextUp { font-size: 0.9rem; color: var(--text); }
.statusLine { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; margin-bottom: 0.9rem; }
.btnRow { display: flex; gap: 0.75rem; }
.hint { color: var(--text-3); font-size: 0.78rem; margin-top: 0.75rem; }
.dotOk, .dotErr { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dotOk { background: var(--green); }
.dotErr { background: var(--red); }
.spinner {
width: 12px; height: 12px;
border: 2px solid var(--border2);
border-top-color: var(--green);
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
+14
View File
@@ -0,0 +1,14 @@
import { useState } from 'react'
import { getStoredStudent } 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).
export default function PlatformShell() {
const [student, setStudent] = useState(getStoredStudent)
if (!student) return <EntryScreen onLogin={setStudent} />
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
}
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api, clearStudent } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
// C/D/F. Student home ("Up Next") + Launch Lab / Reset flow.
// Uses the existing GET /api/curriculum/path — the RootNode path (Foundation -> Workloads ->
// Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone) is not reinvented
// here, just rendered.
export default function StudentDashboard({ student, onLogout }) {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null) // { status, launch_path?, error_reason? }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumPath().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
// Poll while an environment is in-flight (provisioning/resetting) so the UI transitions to
// ready/error on its own — matches requirement D.4: provisioning -> starting -> ready -> error.
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) {
// 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)}`
}
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleReset = async () => {
setBusy(true); setError(null)
try {
const result = await api.resetEnvironment()
setEnvStatus(result)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleLogout = () => { clearStudent(); onLogout() }
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
return (
<div className={styles.dashWrap}>
<header className={styles.dashHeader}>
<div className={styles.brand}><span className={styles.brandIcon}>⎈</span> RootNode Academy</div>
<div className={styles.headerRight}>
<span className={styles.studentName}>{student.display_name}</span>
<button className={styles.linkBtn} onClick={handleLogout}>Sign out</button>
</div>
</header>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Up Next</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.next_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.stats.completed}/{s.stats.total}</span>
</div>
))}
</div>
{nextStage && (
<p className={styles.nextUp}>
Next: <strong>{nextStage.name}</strong>
{nextScenario && <> — {nextScenario.title}</>}
</p>
)}
{!nextStage && <p className={styles.nextUp}>All unlocked stages complete — nice work.</p>}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
</div>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}
+46
View File
@@ -0,0 +1,46 @@
// 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.
const STORAGE_KEY = 'rootnode_student'
export function getStoredStudent() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') } catch { return null }
}
export function storeStudent(student) {
localStorage.setItem(STORAGE_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 }
}
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()
}
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),
curriculumPath: () => fetch('/api/curriculum/path', { headers: authHeaders() }).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),
}
+47
View File
@@ -0,0 +1,47 @@
# RootNode Academy — Platform
The full multi-student platform, layered on top of the unmodified Lab image at the repo root.
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`.
- `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`.
- `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.
## Running the full stack locally
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .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.
## 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.
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.
+79
View File
@@ -0,0 +1,79 @@
# 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).
#
# "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").
#
# 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).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rootnode_platform
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
egress-proxy:
build: ./egress-proxy
network_mode: host
provisioner:
build: ./provisioner
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PROVISIONER_PORT: "4100"
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
EGRESS_PROXY_PORT: "3128"
DNS_RESOLVER: "127.0.0.11"
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
depends_on:
postgres:
condition: service_healthy
identity:
image: ${LAB_IMAGE_REF:-rootnode-academy:test}
privileged: true
network_mode: bridge
ports:
- "7554:80"
volumes:
- identity-data:/data
gateway:
build: ./gateway
network_mode: host
environment:
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"
depends_on:
- provisioner
- identity
volumes:
identity-data:
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY src/ ./src/
EXPOSE 4200
CMD ["node", "src/server.js"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "rootnode-gateway",
"version": "0.1.0",
"description": "RootNode Academy Phase 2A Gateway — the only student-facing path to disposable Lab environments.",
"private": true,
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.19.2",
"http-proxy": "^1.18.1"
}
}
+23
View File
@@ -0,0 +1,23 @@
// Short-TTL cache for token -> environment lookups, so a page full of static assets doesn't
// hit the Provisioner once per asset. Deliberately short (a few seconds) — correctness
// (routing to a destroyed/reset environment) matters more than shaving a few Provisioner
// round-trips, and a short TTL means a destroy/reset is reflected almost immediately.
const TTL_MS = 5000;
const cache = new Map(); // token -> { env, expiresAt }
function get(token) {
const hit = cache.get(token);
if (!hit || hit.expiresAt < Date.now()) return null;
return hit.env;
}
function set(token, env) {
cache.set(token, { env, expiresAt: Date.now() + TTL_MS });
}
function invalidate(token) {
cache.delete(token);
}
module.exports = { get, set, invalidate };
@@ -0,0 +1,36 @@
// Thin client for the Provisioner's internal API. This is the ONLY place the Provisioner
// shared secret is used — it's read from an env var at startup and never forwarded to, or
// readable by, anything the browser sends or receives. See docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md
// "Security requirements" for why this boundary matters.
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 — the Gateway cannot call the Provisioner without it, and must not fall back to calling it unauthenticated.');
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)}`),
};
module.exports = { provisioner };
+287
View File
@@ -0,0 +1,287 @@
// RootNode Academy — Phase 2A Gateway.
//
// 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).
const http = require('http');
const express = require('express');
const httpProxy = require('http-proxy');
const { provisioner } = require('./provisioner-client');
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 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.
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.' }));
} else if (res && res.destroy) {
res.destroy(); // WS socket case
}
});
// ── 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 {};
return Object.fromEntries(
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}`);
}
// ── 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;
const { status, body } = await provisioner.getEnvironmentByToken(token);
if (status !== 200) return null;
envCache.set(token, body);
return body;
}
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;
}
// 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;
}
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.
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}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body || {}),
});
const body = await labRes.json().catch(() => ({}));
res.status(labRes.status).json(body); // real response to the browser, unchanged
const passed = extractOutcome(body);
if (passed !== null) {
fetch(`${IDENTITY_BACKEND_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' }),
}).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.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null),
});
});
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.');
await relayScenarioOutcome({
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
});
});
// ── /lab/:token/* — HTTP proxy into the student's own Lab, prefix-stripped ─────────────────
app.use('/lab/:token', async (req, res, next) => {
const { token } = req.params;
const env = await resolveByToken(token);
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).
return sendLabError(res, 403, 'forbidden', 'This lab does not belong to your session.');
}
if (env.status === 'provisioning' || env.status === 'requested' || env.status === 'resetting') {
return sendLabError(res, 202, 'provisioning', 'Your lab is still starting. Try again in a moment.');
}
if (!isReachable(env)) {
return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running. Try recreating it.');
}
setLabSessionCookie(res, token);
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>`);
}
// ── Default fallback — cookie-routed Lab traffic, or the identity/platform backend ─────────
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}` });
}
}
proxy.web(req, res, { target: IDENTITY_BACKEND_URL });
});
// ── WebSocket upgrade — same routing rules 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();
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}` });
}
}
socket.destroy();
});
server.listen(PORT, () => console.log(`Gateway listening on :${PORT}`));
+23
View File
@@ -125,6 +125,29 @@ app.get('/environments', asyncRoute(async (req, res) => {
res.json(envs.map(redactRow));
}));
// GET /environments/by-token/:token — Phase 2A addition, used by the Gateway to resolve a
// student's opaque routing token to the environment it addresses (internal_ip, status,
// owning student_id) before proxying. Internal-only, same shared-secret auth as every other
// route here — the Gateway is the only caller, browsers never reach this directly.
app.get('/environments/by-token/:token', asyncRoute(async (req, res) => {
const { rows } = await getPool().query('SELECT * FROM environments WHERE routing_token = $1', [req.params.token]);
if (!rows.length) return res.status(404).json({ error: 'not found', request_id: req.requestId });
res.json(redactRow(rows[0]));
}));
// GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts
// routing_token (see redactRow, and the "never log secrets in plaintext" requirement) — this
// is the one narrow exception, for the Gateway's own legitimate need to reconstruct a
// student's /lab/<token>/ launch URL for an environment that already exists (not just one it
// just created, where the token comes back un-redacted from POST /environments directly).
// Still shared-secret gated like every other route — nothing about this is student-reachable.
app.get('/environments/:id/token', asyncRoute(async (req, res) => {
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
const env = await lifecycle.getEnvironment(getPool(), req.params.id);
if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId });
res.json({ environment_id: env.environment_id, routing_token: env.routing_token });
}));
// GET /environments/:id
app.get('/environments/:id', asyncRoute(async (req, res) => {
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
+146
View File
@@ -0,0 +1,146 @@
#!/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.
//
// 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
//
// 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.
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 };
}
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 2A end-to-end tests (this is slow — real k3s boots)\n');
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);
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);
});
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');
});
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 });
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');
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}`);
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}`);
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('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 });
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);
assert.equal(body.launch_path, aliceLaunch.launch_path);
const { body: curriculum } = await api('GET', '/api/curriculum/path', { studentId: alice });
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);
}
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });