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