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

17 KiB

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

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.


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.