- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry
and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by
?activate=/?reset= query params — no router library, matching this app's existing
state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only
reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
tabs; api.js/Platform.module.css extended to match.
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).
Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
Non-HttpOnly rn_csrf_s/rn_csrf_i cookies, set on login, echoed back as X-CSRF-Token on every
state-changing request — defense in depth on top of (not instead of) SameSite=Lax. In-memory
sliding-window rate limiting on instructor login (8 attempts / 5 min per ip:email, no new
dependency).
Real bug found while wiring this up: the Gateway's own progress-relay call (triggered when a
student solves a scenario) is a server-to-server POST to /api/progress/record relaying the
browser's session — it needs to forward the CSRF token too, and needs to forward BOTH the
rn_csrf_s cookie and a matching X-CSRF-Token header (forwarding only the header still 403'd,
since the double-submit check needs both on the request Platform API actually receives).
Also tightens the Gateway's session-resolution cache from 5000ms to 2000ms, so a
revoked/expired session stops working at the Gateway within a tighter, tested bound.
Frontend (admin + student API clients) now reads the relevant rn_csrf_* cookie via
document.cookie and attaches it on every POST/PATCH/DELETE.
Replaces the Phase 2A pilot ?sid= query param with an HttpOnly,
SameSite=Lax session cookie. Gateway now derives student identity from
the session (via Platform API's /api/session/whoami) instead of trusting
a client-suppliable id, and checks ownership on every proxied HTTP and
WebSocket Lab request, not just the initial navigation.
Fixes a routing bug found during testing: the fallback route was using
'does this session own a running environment' to decide whether an
unprefixed /api/* call belonged to the Lab UI or the dashboard — true in
both places. Replaced with an explicit LAB_OWNED_PATHS allowlist of the
exact unprefixed paths the existing Lab UI actually calls.
e2e-phase2a.js rewritten to use a cookie jar instead of headers/query
params, with explicit impersonation-rejection tests added. 10/10 passing.
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]>
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]>