15 Commits
Author SHA1 Message Date
ced4568 1c08a30c7f docs(phase5b): self-paced enrollment documentation + website integration contract
- PHASE5B_SELF_PACED_ENROLLMENT.md: the full design doc.
- RUNBOOK_SELF_PACED_ENROLLMENT.md: operational runbook for /admin -> Programs.
- WEBSITE_ENROLLMENT_INTEGRATION.md: the complete contract for the separate website team —
  endpoint, auth, request/response schema, idempotency/retry rules, student matching, minor/
  no-email behavior, error responses, what not to send, local testing, production-secret
  handling. Written to be usable without reading the Platform API source.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the self-registration/curriculum-assignment/
  delivery-mode/enrollment-model gaps this doc previously flagged as Phase 5+ scope.
- ENGINEERING_SOP.md: new §15. LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md,
  README.md, platform/README.md: cross-references and a Phase 5B production-validation section.
2026-08-25 12:10:13 -05:00
ced4568 23f881884d test(phase5b): permanent regression suite + self-paced dress rehearsal
- platform/test/e2e-phase5b.js (32 checks): program seed, entitlement gating
  (pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/
  reaper-reconciliation/recreate persistence, server-derived idempotent completion via a full
  real capstone completion, the integration boundary (auth, idempotency, real concurrency,
  unknown/inactive program rejection, minor/no-email path, no card data), RBAC scoping, a
  regression test for the process-crash bug this suite found, Phase 5A smoke, and a real
  Platform API restart.
- infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js (15 checks): one continuous story —
  activation, a simulated website enrollment event, real Lab work, Lab destroy, a new session,
  Continue Learning, a fresh Lab, full real capstone completion, Completed transition, and a
  second uninvolved student proven unable to reach any of it.
2026-08-25 12:09:34 -05:00
ced4568 f89a769540 feat(observability): enrollment/completion metrics, no PII/high-cardinality labels
- metrics.js: rootnode_enrollments_total{status,delivery_mode}, rootnode_selfpaced_active_
  enrollments, rootnode_program_completions_total{program_id}, rootnode_enrollment_events_
  {created,duplicate,rejected}_total. Every label is a bounded enum or an admin-curated slug —
  never student_id/email/enrollment_id/external_reference.
2026-08-25 12:09:17 -05:00
ced4568 4bc202e5f3 feat(integration): provider-neutral website enrollment boundary
- integration-routes.js: POST /api/integrations/enrollments — separate from student/instructor
  session auth entirely (no cookie read), pre-shared-secret header auth matching the existing
  Gateway<->Provisioner trust-boundary pattern. Idempotent via a DB unique constraint on the
  caller's own event_id (not app-level check-then-insert) — proven safe under real concurrent
  duplicate requests. Student resolution is create-or-match by email OR login_identifier
  (minor/no-email support), reusing Phase 5A's exact credential-issuing path. A small,
  provider-neutral status vocabulary (paid/active/confirmed/pending/awaiting_payment) — never
  a raw payment-provider object model.
- docker-compose.dev.yml: INTEGRATION_SHARED_SECRET, optional in dev (fails closed if unset).
2026-08-25 12:09:02 -05:00
ced4568 1b6da0a966 feat(selfpaced): student-facing My Programs dashboard
- ProgramsTab.jsx: Active/Completed programs, real progress, deterministic Continue Learning
  (reuses the same launch/poll pattern MyLearningTab already established), entitlement-blocked
  states shown with a real reason, never a silent dead end.
- StudentDashboard.jsx: new My Programs tab. api.js: programs/program/launchProgram.
2026-08-25 12:08:44 -05:00
ced4568 17160da89e feat(admin): Programs area, enrollment management, student enrolled-programs view
- admin-programs-routes.js: program CRUD (admin/owner authoring, all roles can view — matches
  assessment_definitions' convention), scoped enrollment listing, manual enroll, activate/
  cancel (via rbac.js's requireEnrollmentAccess).
- ProgramsView.jsx: cohort-picker-free program list/create, per-program detail with
  enrollment table and a manual-enroll form.
- StudentsView.jsx: student detail gained an 'Enrolled programs' card.
- AdminPortal.jsx: new Programs nav entry.
2026-08-25 12:08:29 -05:00
ced4568 611e1ac4ba feat(enrollment): enrollment lifecycle, entitlement gating, self-paced student API
- enrollment.js: server-only status transitions (pending->active->completed, cancel),
  deriveEnrollmentAccess (pure, derived-at-read-time like classroom.js's derivePresence —
  'expired' is never stored, computed from access_ends_at), maybeCompleteEnrollment
  (idempotent, server-derived from the existing capstone achievement).
- environment-view.js: publicEnvView/launchEnvironmentForStudent extracted out of server.js's
  original /api/environment/launch route body so the new entitlement-gated self-paced launch
  reuses the identical disposable-Lab sequence instead of a second copy — behavior for the
  existing cohort/pilot route is completely unchanged.
- program-status.js: combines an enrollment with real curriculum/progress state into one view
  (access, percent-complete, current stage, deterministic next-activity/resume target) — shared
  by the student and admin surfaces so both show identical information.
- selfpaced-routes.js: GET /api/student/programs(/:id), POST .../launch — entitlement enforced
  server-side before ever touching the Provisioner, never just hidden in the UI.
- rbac.js: requireEnrollmentAccess — a plain instructor may manage an enrollment they created
  or that belongs to a student sharing one of their cohorts; a self-paced enrollment can have
  no cohort at all, so the existing cohort-only checks can't cover it.
- server.js: found and fixed a real bug — POST /api/progress/record's handler predated the
  wrap() convention, so an unexpected internal error (a malformed environment_id) crashed the
  entire process instead of just 500ing that request. Now wrapped + input-validated.
2026-08-25 12:08:12 -05:00
ced4568 895e16fea4 feat(programs): first-class program model — init_/compile_/root_ ladder, seeded root-kubernetes-foundations
- migrations/019: programs table (slug PK, matching assessment_definitions/cohorts
  convention), family (init/compile/root) and delivery_mode (self_paced/cohort/hybrid) as real
  CHECK-constrained enums, versioned like assessment_definitions so an edit to
  curriculum_path/delivery_mode never silently changes what an existing enrollment means. Seeds
  exactly one program wrapping the existing RootNode/Kubernetes curriculum and capstone — no
  new curriculum content invented.
- programs.js: list/get/create/update, with automatic version bump only when a
  snapshot-relevant field actually changes.
2026-08-25 12:07:34 -05:00
ced4568 896c230142 docs(phase5a): student identity and remote classroom documentation
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
- PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md: the full design doc.
- RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md, RUNBOOK_REMOTE_CLASSROOM.md: operational runbooks.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the 'no real student authentication' gap this
  doc previously flagged as the one item blocking self-paced delivery.
- ENGINEERING_SOP.md §5 rewritten (was describing a pre-Phase-2A state no longer true of any
  deployed version of this codebase) plus a new §14; roadmap item 3 updated.
- LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md:
  cross-references and a Phase 5A production-validation section.
2026-08-25 01:20:13 -05:00
ced4568 bfa16d9162 test(phase5a): permanent regression suite + dress rehearsal drill
- platform/test/e2e-phase5a.js (21 checks): the two-student RBAC matrix (student can't reach
  any /api/admin/* route, assigned/unassigned/viewer instructor access) and the full password
  security checklist (correct/wrong/unknown-identifier/deactivated/immediate-deactivation/
  rate-limit/rate-limit-survives-a-real-restart/change-password/session-revocation/
  forgot-password anti-enumeration/admin-issued reset/CSRF), plus a classroom lifecycle smoke
  pass. Replaces the throwaway smoke5a/smoke5b scripts used during development.
- infra/scripts/drills/test-phase5a-rehearsal.js (15 checks): 5 pilot students with real
  seeded pre-existing progress/attempt/achievement history, a live classroom, help requests,
  announcements, a REAL Platform API restart mid-rehearsal proving classroom/session/presence
  recovery, then converting two students mid-rehearsal and confirming zero data was orphaned.
- Both run against a live docker-compose.dev.yml stack over real HTTP, not mocks.
2026-08-25 01:20:02 -05:00
ced4568 7557fdc753 feat(admin): student credential management, CSV import UI, remote classroom control center
- StudentsView: credential status in the roster table, a create-student form
  (email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
  bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
  re-fetchable.
- ClassroomView: cohort picker -> schedule/list classes -> a live dashboard (participants with
  presence/Lab-status/current-work, help queue with acknowledge/resolve, current-activity
  setter, announcement poster, start/end/cancel, Prepare Labs). Polls the dashboard endpoint
  rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
  for createStudent's new object-argument signature.
2026-08-25 01:19:54 -05:00
ced4568 5c7e49c399 feat(student): live student experience — real login, classroom, profile
- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry
  and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by
  ?activate=/?reset= query params — no router library, matching this app's existing
  state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only
  reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
  guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
  exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
  change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
  tabs; api.js/Platform.module.css extended to match.
2026-08-25 01:19:44 -05:00
ced4568 ee8ccb2ab6 feat(classroom): remote classroom backend — lifecycle, presence, help queue, dashboard
- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one
  live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/
  leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored
  status to go stale), help-request create/acknowledge/resolve, announcements, current-activity
  (guidance only, never redirects a browser or marks work complete), append-only event log.
- admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's
  requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves
  through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere
  else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines
  real Provisioner Lab state and real academic records with classroom.js's own data — no
  second Lab-state or academic-state system.
- classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request,
  announcements), requireEnrolledClassroom middleware.
- prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new
  classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission
  control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
2026-08-25 01:19:33 -05:00
ced4568 d34ddae458 feat(student): admin student-account management + CSV import, session hardening
- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions,
  /sessions, /import (transactional CSV import — whole file validated, one Postgres
  transaction, never accepts a password/hash column from input). Fixed GET
  /api/admin/students to select an explicit safe column list instead of SELECT * — the new
  password_hash/credential_token_hash columns must never be exposed through this route.
- server.js: /api/login now also blocks on credential_token_hash (not just password_hash) —
  closes a real window where a student mid-conversion stayed reachable via the old
  bare-student_id path. studentSession middleware now re-verifies status='active' on every
  request, not just at session creation, matching instructorSession's existing behavior.
- sessions.js: revokeAllStudentSessions, listStudentSessions.
- rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the
  new classroom/help-request RBAC (next commit) reuses the exact same authorization rule
  instead of a parallel copy.
- student-csv.js + test: hand-rolled parser (no new dependency), unit tested.
- prometheus.yml.j2: activated the platform-api scrape job.
2026-08-25 01:19:19 -05:00
ced4568 2f09b1a498 feat(student-auth): real student credentials — email/login-ID + password, token-based activation and reset
- migrations/017: email, login_identifier, password_hash, last_login_at, credential_token_*
  on students. Fully additive, both nullable/independent, no destructive change.
- students-auth.js: shared credential/token lifecycle (issue/consume/find), reusing
  instructors' exact scrypt password.js verbatim.
- student-auth-routes.js: /api/student/login, /activate, /change-password,
  /request-password-reset (anti-enumeration, dev-only token echo), /reset-password, /profile.
- metrics.js: hand-rolled /metrics Prometheus exporter, login success/failure counters, no
  student/instructor ID ever used as a label value.
- migrations/018: classroom_sessions/participants/events/announcements/help_requests, with a
  partial unique index enforcing at most one live classroom per cohort at the DB level.
2026-08-25 01:18:59 -05:00
60 changed files with 5740 additions and 204 deletions
+23 -7
View File
@@ -150,12 +150,27 @@ its core:
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
Lab UI, not a rewrite of it.
- The student flow (real login (email or instructor-assigned login ID) or legacy pilot roster
entry → curriculum dashboard → Launch Lab → practice/assessments/capstone → achievements →
live classroom → profile) lives in `frontend/src/platform/`; the instructor/admin portal
(per-instructor login, cohorts, students with credential/CSV-import management, a remote
classroom control center, instructor accounts, audit log, environment controls, learning
analytics, assessment/capstone management) lives in `frontend/src/admin/` at the `/admin`
route — both additions alongside the existing Lab UI, not a rewrite of it.
- Students authenticate with real credentials as of Phase 5A (email+password or
login-identifier+password, first-login/reset via a single-use token an instructor relays) —
see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. Instructors run live, remote-capable
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
guidance-only current-activity) — RootNode owns identity/coordination, an external service
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
boundary — server-to-server only, no browser session involved — is what the future public
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -192,7 +207,8 @@ Key properties of the platform (not this image):
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`.
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
that runs all of this on real hardware lives in `infra/` — see
+87 -32
View File
@@ -90,35 +90,34 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
## 5. Student identity — what's built, what isn't
**Built (backend only, as of this doc):**
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
`created_at`, `last_active_at`.
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
a student (instructor-only action, currently unauthenticated at the API level — see
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
answering the same scenario don't affect each other's state, and pre-migration data was
preserved under `student_id = 'unassigned'`.
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
whoever's browser hits the API next.
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
full design):**
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
of and never changes across an account's lifecycle, including conversion.
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
{ student_id }`, no password, instructor-roster-only.
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
(`students-auth.js`) — instructors never see or set a student's actual password.
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
instructor login (Phase 4.1), now also applied to students.
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
already in place well before this — Phase 5A only changed how a student proves who they are,
not how their data or Labs are isolated.
**Not built yet — do not assume these exist:**
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
nothing calls it yet. This is the next slice.
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
Fine for a dev instance, not fine once this is reachable by actual students — needs an
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
item 3).
- **No concurrency / isolation.** Exam sessions are still global-single-active
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
cannot yet use this platform at the same time without stepping on each other.** That's
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
out — this SOP is not claiming that gate is cleared.
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
- No public self-registration — accounts are still instructor-created/converted/imported only.
- No email delivery infrastructure — password reset works via an instructor-issued token today;
self-service reset returns a real token only in non-production, by design.
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
reflects any deployed version of this codebase; that content has been superseded by the above.
## 6. Schema migration pattern
@@ -146,9 +145,11 @@ Order agreed 2026-08-17/18. Status as of this doc:
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
`frontend/src/platform/`.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
name" gap as of Phase 5A — see §5 above and
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
unchanged for anyone not yet converted.
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
owner/admin/instructor roles, audit log.
@@ -332,4 +333,58 @@ Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
- **Known limitation carried forward:** real remote/self-paced delivery needs actual student
authentication (today's `student_id`-only login relies on instructors controlling who gets a
valid id) — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` for the full accounting of what
already works vs. what's genuinely Phase 5 scope.
already works vs. what's genuinely Phase 5 scope. **Closed in Phase 5A, below.**
## 14. Student Identity & Remote Classroom (Phase 5A)
Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
`docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`.
- **What it is:** two deliverables built on the Phase 4.1 milestone. (A) Real student accounts —
email-or-login-identifier + password, reusing `instructors`' exact credential machinery, a
single first-login/reset token mechanism, deactivation enforced per-request — replacing pilot
"pick your name" identification without touching a single existing pilot student's progress.
(B) A remote classroom control center — scheduled/live/ended sessions, derived (not stored)
presence via heartbeat, a live instructor dashboard combining real Provisioner Lab state and
real academic records, a help-request queue, guidance-only announcements/current-activity, and
a generic external meeting-URL field. Explicitly **not** video/voice/chat/shell-takeover —
RootNode owns identity and coordination, an external service owns the call.
- **Tested, not just designed:** `platform/test/e2e-phase5a.js` (21 checks: the two-student RBAC
matrix and full password-security checklist, including a real Platform API restart proving
rate-limit state survives it) and `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks:
5 pilot students with seeded real history, a live classroom, help requests, **a real
mid-rehearsal API restart** proving classroom/session/presence recovery, then converting two
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
just hidden in the UI. Completion is entirely server-derived from the existing capstone
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
concurrent duplicate requests, not just sequential retries.
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
async-error-handling convention — an unexpected internal error there (a malformed
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
(wrapped + input-validated) and regression-tested.
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
(see that doc §14).
+21
View File
@@ -498,3 +498,24 @@ mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docke
breaks once they're genuinely on separate VMs); Phase 4.1
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
Router (`platform/lab-router/`).
## Phase 5A: reused, not modified
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
remote classroom control center on top of everything this document describes, without changing
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
the same state enum) rather than inventing a second one.
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
of the exact same disposable Lab this document describes — again without changing the
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
Lab container out-of-band and restarting the Provisioner to trigger its actual
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
is completely unaffected and a fresh Lab boots normally.
@@ -0,0 +1,195 @@
# Phase 5A — Student Identity & Remote Classroom
**Status: PHASE 5A COMPLETE. STUDENT IDENTITY COMPLETE. REMOTE CLASSROOM FOUNDATION COMPLETE.**
Phase 4.1 closed the last pre-production infrastructure gap without real hardware. Phase 5A's
job — still without real hardware — was the last real *product* gap flagged along the way: pilot
students identify themselves by picking a name from a list, and there was no way for an
instructor to run a live, remote-capable class through the platform itself. This phase replaces
pilot identification with real accounts and adds a remote classroom control center. It does
**not** add video, voice, chat, or any A/V — those stay Meet/Zoom/Teams's job; RootNode owns
identity, presence, curriculum guidance, Lab status, help requests, and instructor control.
## 1. Student identity model
`students` (migration `017_student_credentials.sql`) gained `email`, `login_identifier`,
`password_hash`, `last_login_at`, and a single credential-token triple
(`credential_token_hash`/`_purpose`/`_expires_at`) — all nullable, all additive. `email` and
`login_identifier` are independently unique (partial unique indexes, `WHERE ... IS NOT NULL`)
and both optional: an older/self-paced student gets an email, a younger instructor-managed
student gets an instructor-assigned login ID, neither is required, and nothing here blocks a
future `guardian_id` FK for a Parent Portal — deliberately not built this phase (§37).
A student with **neither** field set is unchanged from before Phase 5A: a plain pilot row,
identified with `POST /api/login { student_id }`. Setting either field (via account creation or
`/convert`) is what makes an account "real." `password_hash` reuses `platform/api/src/auth/
password.js` (scrypt, `salt:hash`) verbatim — the same function instructors use, no second
hashing scheme.
## 2. Credential lifecycle — one token mechanism, not two
Activation ("first login") and password reset are the same problem: a time-limited, single-use
secret that lets someone set a new password. `students-auth.js`'s `issueCredentialToken`/
`consumeCredentialToken` implement it once (SHA-256-hashed at rest, plaintext returned exactly
once in the API response, never logged), with `purpose` (`activation` | `reset`) distinguishing
the two callers. Activation tokens live 7 days, reset tokens 1 hour. `consumeCredentialToken` is
atomic: token verification, password hashing, `password_hash` write, and token-clearing happen
in one statement — there is no window where a token is "spent" but the password isn't set.
Instructors never see or choose a student's password (§5's "instructor creates account →
temporary credential" flow): `POST /api/admin/students`, `/convert`, and `/reset-password` all
return a token (activation or reset), which the instructor relays out of band (printed, read
aloud, handed over). The admin UI surfaces the resulting `?activate=<token>`/`?reset=<token>`
link once and never again — there is no "view credentials" screen anywhere.
## 3. Password reset without email delivery
No email-sending infrastructure exists yet, and this phase deliberately doesn't fake one.
`POST /api/student/request-password-reset` always returns an identical generic response
regardless of whether the identifier exists (anti-enumeration) and never echoes the token in
production. In non-production (`isProduction` false, i.e. `COOKIE_SECURE !== '1'`) it also
returns `dev_reset_token` so local/CI flows can complete the loop without a real inbox — a
documented dev/test path, not a production feature. The realistic production path today is
instructor-triggered: `POST /api/admin/students/:id/reset-password`. Real email delivery is a
named, not-yet-built follow-up, not something silently promised.
## 4. Session and login security — reused, not duplicated
Students and instructors share the exact same `sessions` table and mechanism
(`sessions.js`: opaque tokens, HttpOnly, SameSite, hashed at rest, revocable), differentiated
only by `session_type`/`student_id` vs `instructor_id`. `studentSession` middleware now
re-verifies `status = 'active'` on **every** request (previously only checked session
existence) — deactivating a student takes effect immediately, matching `instructorSession`'s
existing behavior, not just at next login (verified in `e2e-phase5a.js`).
Rate limiting reuses Phase 4.1's Postgres-backed `tooManyAttempts` (`security.js`) with a
`student:` key prefix — 8 attempts / 5 minutes, survives a Platform API restart because it's a
table, not an in-memory Map (verified with a real restart in both `e2e-phase5a.js` and the
rehearsal drill). CSRF reuses the existing double-submit cookie pattern (`rn_csrf_s`).
**A real bug this closed:** the legacy `POST /api/login` (bare `student_id`, no password) only
checked `password_hash` to decide whether an account had "moved on" from pilot mode. An account
mid-conversion — instructor has issued an activation token, student hasn't set a password yet —
has `credential_token_hash` set but `password_hash` still `NULL`, and was reachable through the
old insecure path for that whole window. Found via testing, fixed by checking
`student.password_hash || student.credential_token_hash` instead (`server.js`).
## 5. Pilot migration
No destructive migration. `017_student_credentials.sql` only adds nullable columns — every
existing pilot row, its progress, attempts, achievements, and cohort membership are completely
untouched at migration time. `POST /api/admin/students/:id/convert` assigns email/login_
identifier and issues an activation token without touching `student_id` (the FK every other
table hangs off of) or anything downstream of it. Verified directly, not just asserted: the
Phase 5A rehearsal drill (`infra/scripts/drills/test-phase5a-rehearsal.js`) seeds real pre-
existing progress/attempt/achievement rows for a pilot student, converts them, activates them,
logs in the new way, and confirms `/api/progress` shows the exact same completed scenario —
nothing orphaned, nothing re-created.
## 6. Remote classroom — a fourth session concept
Distinct from cohort membership, assessment sessions, and Lab environments: a scheduled,
bounded teaching event. `018_classroom_sessions.sql` adds `classroom_sessions` (lifecycle
`scheduled → live → ended`, or `cancelled`), `classroom_participants`, `classroom_events`
(append-only audit), `classroom_announcements`, and `help_requests`. A partial unique index
(`WHERE status = 'live'`) enforces at most one live classroom per cohort at the database level —
the app layer catches the resulting `23505` and returns a clean `ALREADY_LIVE` (409), not a
raw constraint error (verified in `e2e-phase5a.js`).
## 7. Presence — derived, not stored
`classroom.derivePresence(lastSeenAt, leftAt)` is a pure function computing `online` /
`recently_active` / `offline` from a timestamp at read time — there's no status column that can
go stale or need a background job to sweep. Heartbeats (`POST /api/classroom/:id/heartbeat`,
client fires every 20-30s while the Classroom tab is open and joined) are throttled server-side
(`WHERE last_seen_at IS NULL OR last_seen_at < now() - interval`), never depend on Lab terminal
activity, and stop the moment the tab is closed or the student leaves — no invasive tracking.
## 8. The classroom dashboard — real data, not a second state system
`GET /api/admin/classroom-sessions/:id` is the one place that reaches beyond `classroom.js`: it
joins participant/presence data with the Provisioner's actual `environments` state (same enum
every other Lab-status view uses — `none`/`requested`/`provisioning`/`running`/`resetting`/
`error`/`destroying`/`destroyed`) and a lightweight academic snapshot from the existing
`progress`/`scenario_attempts` tables (current stage, most recent scenario, validation-fail
count, hints used). Never inspects terminal command history. Nothing here is a second Lab-state
or academic-state system — it's the existing ones, read the same way every other admin view
already reads them.
## 9. Help requests — a queue, not a chat
`POST /api/classroom/:id/help-request { category, message }` → `open` → (instructor)
`acknowledge` → `resolve`. That's the entire surface — explicitly no chat, no video, no remote
shell takeover (§37). The instructor dashboard's help queue polls the same dashboard endpoint
everything else does.
## 10. Guidance, never control
`setCurrentActivity` (current scenario) and announcements are exactly that — a link the student
can choose to open, a message they can read. Nothing server-side redirects a student's browser
or marks work complete on their behalf. The external meeting URL (`classroom_sessions.
meeting_url`) is a generic field the student sees as a link — RootNode never proxies or embeds
Meet/Zoom/Teams/anything else, so it isn't coupled to a single vendor.
## 11. CSV roster import — whole-file, transactional
`student-csv.js` hand-parses (no CSV dependency — the file format here is small and simple
enough that pulling in a library would just be one more thing to audit) and validates the
**entire** file before any database write. `POST /api/admin/students/import` then does every
insert inside one Postgres transaction — one bad row rolls back the whole import rather than
leaving a half-imported roster, which is the more dangerous failure mode of the two. Never
accepts a `password`/`password_hash`/`credential_token_hash` column — activation tokens are
always generated server-side, never taken from input.
## 12. Observability
`GET /metrics` is a hand-rolled Prometheus text-exposition endpoint (no `prom-client`
dependency — a handful of counters/gauges don't need one) with real in-process counters for
login success/failure and on-demand gauges for live-classroom/help-request/Lab-state counts.
No student or instructor ID ever appears as a label value — every label is a bounded enum
(status, presence, result), per the explicit no-PII/no-high-cardinality-labels instruction. The
Prometheus scrape job for the Platform API (`infra/monitoring/prometheus/prometheus.yml.j2`),
previously commented out, is now active.
## 13. Frontend
**Student side** (`frontend/src/platform/`): `LoginScreen` (credentialed, now the default entry
point) with links to the legacy pilot entry and forgot-password; `SetPasswordScreen` (shared
activate/reset UI, driven by `?activate=`/`?reset=` query params — no router library, matching
this app's existing state-machine-per-shell convention); `ForgotPasswordScreen`; a `Classroom`
tab (live-class banner, join, guidance-only current-activity link, own Lab status/launch reusing
the My Learning tab's exact API, announcements, Need Help form); a `Profile` tab (identity,
cohort, progress, achievements, active-session count, change-password).
**Admin side** (`frontend/src/admin/`): `StudentsView` gained credential status in the roster
table, a create-student form (email/login-ID/cohort), convert/reset-password/revoke-sessions
controls per student, and a bulk CSV import form. A new `ClassroomView` is the control center:
cohort picker → schedule/list classes → a live dashboard (participants with presence/Lab-status/
current-work, help queue with acknowledge/resolve, current-activity setter, announcement
poster, start/end/cancel, Prepare Labs).
## 14. Testing — real, not asserted
- `platform/test/e2e-phase5a.js` (21 checks): activation single-use, password security (§32 —
correct/wrong/unknown-identifier/deactivated/immediate-deactivation/rate-limit/rate-limit-
survives-restart/change-password/session-revocation/forgot-password anti-enumeration/admin
reset/CSRF), and the two-student RBAC matrix (§31 — student can't reach `/api/admin/*`,
assigned/unassigned/viewer instructor access), plus a classroom lifecycle smoke pass.
- `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks): 5 pilot students, real seeded
progress/attempt/achievement history, a live classroom, help requests, announcements, **a real
Platform API restart mid-rehearsal** proving classroom/session/presence recovery, then
converting two students mid-rehearsal and confirming every bit of their prior history survived
under the new login.
- Both run against a live `docker-compose.dev.yml` stack over real HTTP, not mocks.
- The full pre-existing regression suite (Phase 1/2A/2B/3A/3B/4.1) was re-run unmodified given
how much shared code (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`) this phase
touched — see the Phase 5A completion report for results.
## 15. What was deliberately not built
Per §37: video/voice conferencing, student-to-student chat, remote instructor shell takeover,
screen recording or proctoring, public registration or course catalog, payments/Stripe, a Parent
Portal, school/SIS integrations, real email delivery infrastructure, certificates, an AI tutor,
a mobile app, a multi-Lab-host scheduler, and production deployment itself. Each is a plausible
"while we're in here" addition and each was left alone on purpose — the schema and API surface
built this phase don't block any of them later.
+195
View File
@@ -0,0 +1,195 @@
# Phase 5B — Self-Paced Learning & Enrollment Foundation
**Status: PHASE 5B COMPLETE. SELF-PACED LEARNING FOUNDATION COMPLETE. WEBSITE ENROLLMENT BOUNDARY READY.**
Phase 5A gave students real identity and instructors a remote classroom. Phase 5B's job was
the last piece needed for an authenticated student to be enrolled in a RootNode Academy program
and complete it entirely on their own — plus the clean, provider-neutral boundary a *separate*
public website/commerce system will call to create those enrollments later. This phase does
**not** build that website or any payment processing — see §12.
## 1. The RootNode program ladder
`init_` (ages ~5-10, robotics/block coding), `compile_` (ages ~11-14, ESP32/MicroPython/IoT),
`root_` (ages ~15-18+, Linux/containers/Kubernetes/DevOps). The existing KubeKosh-derived
curriculum belongs to `root_`. This phase does not write init_/compile_ curriculum content — it
makes the platform capable of holding those programs later without another schema change (the
`programs.family` enum already accepts `init`/`compile`/`root`).
## 2. Programs — a first-class object, not curriculum JSON
`programs` (migration `019_programs.sql`) is the commercial/academic unit a student enrolls in.
It is deliberately **not** the curriculum file itself — `programs.curriculum_path` just points at
one (today, always `rootnode-path`, the only curriculum that exists). `program_id` is the slug
primary key, matching `assessment_definitions`/`cohorts`' existing convention rather than a
separate id+slug pair. `family` (`init`/`compile`/`root`) and `delivery_mode`
(`self_paced`/`cohort`/`hybrid`) are both real CHECK-constrained enums, not free text — small,
stable vocabularies, matching how this codebase already treats `assessment_definitions.type` and
`hint_policy`. The phase seeds exactly one program: `root-kubernetes-foundations`, wrapping the
existing RootNode/Kubernetes curriculum and the existing `rootnode-capstone` as its completion
criterion — no new curriculum content invented.
`programs.version` bumps automatically whenever an edit changes a field an enrollment snapshots
(`curriculum_path`, `delivery_mode`) — same versioning principle `assessment_definitions`
already established, so editing a program later never silently changes what an already-enrolled
student is being held to.
## 3. Enrollments — real academic access, separate from cohorts
`enrollments` (migration `020_enrollments.sql`) represents a student's access to a program:
`pending → active → completed`, or `pending`/`active → cancelled`. All transitions are
server-side only (`enrollment.js`) — nothing accepts a client-supplied status. `expired` is
**derived**, not stored: `deriveEnrollmentAccess(enrollment, now)` is a pure function (matching
Phase 5A's `classroom.js` `derivePresence` pattern) that computes it from `access_ends_at` at
read time, so there's no background sweep and nothing to go stale.
A self-paced enrollment has **no cohort at all** — `cohort_enrollments` (Phase 1) is completely
untouched. A student can hold a cohort enrollment, a self-paced enrollment, and multiple
historical enrollments simultaneously without any of those concepts colliding; re-enrollment
after completion/cancellation always creates a new row rather than reviving a historical one (a
partial unique index enforces at most one `pending`/`active` enrollment per student+program at a
time — DB-level, not app-level "check then insert").
## 4. Curriculum assignment — the schema Phase 4.1 flagged, now wired
Phase 4.1's `curriculum_assignments` table (schema-ready since Phase 2B, never read by
anything) is **not** reused as-is — it models cohort/student curriculum assignment
independent of any commercial concept, which is a different problem than "this enrollment in
this program uses this curriculum." Instead, `enrollments.curriculum_path` is itself the
snapshot: set from `programs.curriculum_path` at enrollment time, so a later edit to the
program never changes what an existing enrollment means. `curriculum_assignments` remains
exactly as Phase 4.1 left it — still available for a future direct per-student assignment need
outside the program/enrollment model, not duplicated or removed.
The resolution chain is real: `enrollment.student_id → enrollment.program_id →
enrollment.curriculum_path → curriculum.loadCurriculumPath() → stages → scenarios`
(`program-status.js`'s `buildProgramStatus`). Only one curriculum path exists today, so this
always resolves to `rootnode-path` — the extension point for a second path is
`enrollments.curriculum_path` itself, already snapshotted per-enrollment.
## 5. Self-paced dashboard and resume — reused, not reinvented
`GET /api/student/programs` returns every enrollment a student holds (never a public catalog —
no public course browsing exists), each annotated with real access state, progress, and a
deterministic next activity. The heavy lifting is **entirely existing Phase 3B machinery**:
`curriculum-rules.js`'s `computeCurriculumState` already returns `current_stage` and each
stage's `next_scenario` — `program-status.js` just wraps that per-enrollment. "Continue
Learning" resume is not a guess based on the last page rendered; it's the same rule-driven
current-stage/next-scenario computation the My Learning tab has used since Phase 3B, now scoped
per program. Destroying a Lab never touches this — `progress`/`scenario_attempts` live in
Postgres, completely independent of any container's lifecycle (verified for real, not just
asserted — see §9).
## 6. Entitlement — enforced server-side, not by hiding a button
`POST /api/student/programs/:enrollmentId/launch` is the only self-paced entry point into a
Lab. It resolves the enrollment (404 if it isn't the caller's own), calls
`deriveEnrollmentAccess`, and 403s with a `reason` (`pending_activation` / `not_yet_started` /
`access_expired` / `cancelled`) if `can_launch` is false — **before** ever touching the
Provisioner. A pending, future-dated, expired, or cancelled enrollment cannot provision a Lab no
matter what the browser sends. The existing `POST /api/environment/launch` (cohort/pilot path)
is completely unchanged — this is a new, additive gate in front of the exact same disposable-Lab
sequence (`environment-view.js`, extracted from `server.js` so both routes share one
implementation), not a second kind of Lab.
## 7. Completion — server-derived and idempotent
`enrollment.maybeCompleteEnrollment` checks, for every `active` enrollment whose program has a
`completion_assessment_id`, whether a matching `capstone_completed` achievement already exists
(the achievement `recordAssessmentAchievement` already writes when a capstone session finalizes
— Phase 3B, unmodified). If so, it transitions the enrollment to `completed` — an `UPDATE ...
WHERE status = 'active'`, so calling it again once already completed is a guaranteed no-op, not
a duplicate transition or an error. This runs opportunistically on every program-status read
(`program-status.js`), which means completion is detected on the next read after the qualifying
event, not pushed instantly — acceptable latency for a status field, and it means there is no
"mark complete" button anywhere in the frontend to accidentally trust.
## 8. Pacing and availability
`starts_at`/`access_ends_at` are the entire pacing model this phase builds — no drip scheduling,
no per-lesson unlock dates. `starts_at` in the future: the enrollment is visible
(`effective_status: 'active'`) but `can_launch: false`, reason `not_yet_started`. `access_ends_at`
in the past: `effective_status: 'expired'`, prior progress/achievements remain fully visible,
`can_launch: false`. Both are enforced in `deriveEnrollmentAccess`, called from the same
server-side gate every launch goes through — never a frontend-only check.
## 9. Real Lab-persistence proof
`platform/test/e2e-phase5b.js` and `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js`
both drive this against a real stack: launch a Lab, complete a scenario, destroy the environment
via the admin API, relaunch, confirm identical progress and resume target; separately, kill the
Lab container directly (bypassing the Provisioner, exactly the idle reaper's failure mode from
the Provisioner's point of view) and restart the Provisioner to run its real
`reconcileOnStartup` reconciliation (the same technique `infra/scripts/drills/
test-lab-host-loss.js` established in Phase 4.1) before relaunching — progress is identical
both times.
## 10. Instructor/admin management
`/admin` → Programs: list/create/edit/activate-deactivate (admin/owner only, matching
`assessment_definitions`' authoring convention — every role can view), per-program enrollment
list and manual-enroll form, per-enrollment activate/cancel. The existing student detail view
gained an "Enrolled programs" card (status, delivery mode, progress, last activity).
**RBAC** (`rbac.js`'s new `requireEnrollmentAccess`): owner/admin have global management, as
everywhere else. A plain instructor may view/manage an enrollment if they created it
(`enrollments.created_by`) **or** the student shares one of their cohorts — never blanket
visibility into every self-paced student, since a self-paced enrollment can have no cohort
relationship at all. Enrollment *creation* stays admin/owner only, matching the existing
roster-creation precedent (`POST /api/admin/students` is the same restriction).
## 11. The website/commerce integration boundary
`POST /api/integrations/enrollments` — completely separate from student/instructor session
auth (no cookie is ever read), authenticated by a pre-shared secret header
(`X-RootNode-Integration-Key`), the exact same trust-boundary pattern this codebase already
uses for Gateway↔Provisioner (`X-Provisioner-Key`/`PROVISIONER_SHARED_SECRET`), not a new
scheme. Full contract for the separate website team: `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
Idempotent by construction: a unique index on `enrollments.external_event_id`, not an
app-level "check then insert" — proven under real concurrency (three simultaneous identical
requests produce exactly one enrollment, one `created: true`, two `created: false`) in both the
permanent suite and manually during development. Student matching is create-or-match by email
**or** login_identifier (§13's minor/no-email requirement) — reuses Phase 5A's exact
`students-auth.js` credential-issuing path, not a second account-creation mechanism, and this is
a trusted server-to-server caller (the website's own verified-payment backend), not a public
signup form.
## 12. What was deliberately not built (§22)
Stripe/payment processing, checkout UI, the public website itself, public self-registration, a
Parent Portal or full guardian accounts, email delivery infrastructure, certificates, complex
drip scheduling, and full init_/compile_ curriculum content. Each is a plausible "while we're in
here" addition; each was left alone on purpose. Nothing built this phase blocks any of them —
`enrollments.external_reference`/`metadata` and the create-or-match student resolution are the
deliberate extension points for a future guardian-purchases-for-minor flow, without inventing a
fake guardian record now.
## 13. A real bug found and fixed
`POST /api/progress/record` predated the `wrap()` async-error-handling convention (it's a
Phase 1/2A route, never updated). An unexpected internal error there — including Postgres
rejecting a malformed `environment_id` — became an unhandled promise rejection that **crashed
the entire Platform API process**, not just that one request. Found by this phase's own test
suite. Fixed: the handler is now wrapped like every route added since Phase 3B, plus explicit
UUID-shape validation so a bad `environment_id` is a clean 400 and never reaches Postgres as a
type error at all. Regression-tested in `e2e-phase5b.js`.
## 14. Testing
- `platform/test/e2e-phase5b.js` (32 checks): program seed, entitlement gating
(pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/reaper/
recreate persistence, server-derived idempotent completion, the full integration boundary
(auth, idempotency, concurrency, unknown/inactive program rejection, minor/no-email path, no
card data), RBAC scoping, Phase 5A regression smoke, and a real Platform API restart.
- `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks): one continuous
realistic story — activation, a simulated website enrollment event, real Lab work, Lab
destroy, a new session later, Continue Learning, a fresh Lab, full capstone completion,
program moves to Completed, and a second uninvolved student proven unable to reach any of it.
- Both against a live `docker-compose.dev.yml` stack, real HTTP, real k3s Lab boots.
- Migration safety proven explicitly: a fresh Postgres taken through Phase 1-5A's migrations
only, seeded with realistic pre-5B data (credentialed student, cohort membership, progress,
scenario attempts, achievements, classroom history), then 5B's migrations applied — every
prior record intact and correctly associated with the same `student_id`; a new enrollment
correctly links to that same pre-existing student; running the migration set a second time
applies nothing.
+28
View File
@@ -54,5 +54,33 @@ can prove in advance.
full" message, not a 500 — confirms the real host's admission control matches the local test
- [ ] `docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md`'s steps still make sense once capacity numbers are real
## Student identity & remote classroom (Phase 5A — see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md)
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms `/api/student/
request-password-reset` never returns `dev_reset_token` outside of dev/CI
- [ ] A real activation/reset link (`?activate=`/`?reset=`) round-trips against the production
domain, not just `localhost` — query-param parsing, no router library in play
- [ ] Student login rate limiting still 429s after a restart against the real production
Postgres, same as the local `e2e-phase5a.js` check
- [ ] `GET /metrics` is reachable by the real Prometheus instance (`infra/monitoring/
prometheus/prometheus.yml.j2`'s `rootnode-platform-api` job) and no student/instructor ID
appears in any label value
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
still make sense walked against the real admin portal
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
value) and is stored only in the Platform API's own server-side environment — never
committed, never in any browser-reachable config
- [ ] The website team's production integration key was delivered out of band (not plaintext
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
clean `401` against the real production endpoint, not a 500 or an information leak
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
external_reference ever appears in a real production scrape
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
actually integrated against (update it if reality drifted during that integration)
Only once every box above is checked against the real server does the deployment graduate from
"infrastructure package built" to "actually in production."
+35 -30
View File
@@ -45,41 +45,46 @@ this flow specifically — see the Phase 4.1 report for the full run results.
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
1. **No self-registration / public enrollment.** A student row is created only by an instructor
(`POST /api/admin/students`, admin/owner RBAC-gated). There is no public "sign up" surface.
This is almost certainly the right call to leave alone for now — it's explicitly a Phase 5+
product/business decision (course commerce, is it free, who's allowed to enroll), not an
infrastructure gap.
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
built the boundary a *separate* public website will call
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
row can now be created automatically by a trusted server-to-server enrollment event, but there
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
2. **No real student authentication.** A student session is created from a bare `student_id`
(`POST /api/login`, no password) — deliberately, since today every `student_id` is issued and
controlled by an instructor via the roster, not chosen or known by the public. A genuinely
remote/self-paced product where students access their account without an instructor in the
loop needs real credentials (email+password, or a magic link) — reusing the same
`password_hash`/`scrypt` machinery `instructors` already has (`src/auth/password.js`) would
be the natural extension, not a new auth system. **This is the one item on this list that
would actually block self-paced delivery today** — everything else already works or is a
product decision, not an infrastructure one.
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
`instructors`' exact `src/auth/password.js`), a first-login/reset token flow, and Postgres-
backed rate-limited/CSRF-protected sessions on the same `sessions` table instructors use — see
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. The old bare-`student_id` `POST
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
an account has real credentials or a pending activation token.
3. **`curriculum_assignments.student_id` is schema-ready but unwired.** The table has supported
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
(`CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT
NULL)`) — but nothing in the application ever reads or writes that column today; there's only
one curriculum path, so nothing has needed to differentiate yet. Not a gap that blocks
self-paced delivery now (every student already sees the one path with no gate), but the
moment a second curriculum path exists, this is where per-individual assignment lands — the
schema was designed for exactly this and doesn't need a migration to support it.
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
the real problem it was meant to eventually solve (which curriculum a given student sees) is
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
future direct per-student assignment need outside the program/enrollment model — it wasn't
removed, just not the mechanism Phase 5B ended up using. See
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
4. **No delivery-mode concept anywhere.** In-person, remote, and self-paced sessions aren't
distinguished in any data model today, and nothing currently needs them to be — capacity
planning, the reaper, and academic records all behave identically regardless of how a student
is attending. Worth being aware this is unmodeled, not worth building a taxonomy for before a
real product reason exists (YAGNI).
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
is modeled, not infrastructure.
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
self-paced student dashboard.
## What Phase 4.1 deliberately did NOT do here
No signup flow, no student password/email auth implementation, no per-student curriculum
assignment route, no payments, no public course catalog, no held/premium scenario content. All
explicit non-goals per this phase's authorization. This document exists so Phase 5 planning
starts from an accurate picture of the gap (one real item: student authentication) instead of a
guess.
explicit non-goals per this phase's authorization. Student authentication (Phase 5A) and the
self-paced enrollment/entitlement model (Phase 5B) have both since been built; a public
self-registration *page*, real payment processing, and a Parent/Guardian portal remain
deliberately out of scope — see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12.
+53
View File
@@ -0,0 +1,53 @@
# Runbook: Running a Remote Classroom
Covers scheduling, running, and recovering a live class through `/admin` → Classroom. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design. RootNode does
**not** provide video/voice — the meeting URL field just links out to Meet/Zoom/Teams/whatever
you already use for that.
**Before class: schedule it**
`/admin` → Classroom → pick the cohort → Schedule a class. Title is required; scheduled start,
description, and a meeting URL are optional. Only one class can be `live` per cohort at a time —
scheduling a second one is fine, starting it while another is live is rejected.
**Right before class: Prepare Labs**
Open the classroom → Prepare Labs. This provisions/warms every enrolled student's Lab ahead of
time (same underlying flow as the cohort-level Prepare Labs button) so nobody is waiting on a
cold start once class begins. If a Lab host is at capacity, that student shows as
`capacity_waiting` in the results rather than failing silently — check back or ease off other
concurrent load.
**Start class**
Open the classroom → Start class. Status moves to `live`; students now see a "LIVE NOW" banner
on their Classroom tab and can join.
**During class**
- **Participants table**: presence (`online`/`recently_active`/`offline`, derived from each
student's last heartbeat — not a hard real-time signal, allow ~30s of lag), Lab status, and a
one-line current-work snapshot (stage, scenario, validation failures, hints used).
- **Set current activity**: enter a scenario ID and Set. This only shows students a link — it
never redirects their browser or marks anything complete. Use it to say "everyone open this
next," not to force navigation.
- **Post an announcement**: short messages, shown newest-first on the student side.
- **Help queue**: students flag `lab_problem` / `dont_understand` / `validation_failing` /
`other` with an optional note. Acknowledge when you've seen it, Resolve when it's handled.
There is no chat here — if it needs back-and-forth, use your actual video call.
**End of class**
Open the classroom → End class. Status moves to `ended`; this does not touch Labs or student
progress — destroy Labs separately from the cohort view if you're done with them for the day.
**If you need to cancel a scheduled (not yet started) class:** Cancel, from the classroom
detail view. A `live` class can only be ended, not cancelled.
**If the Platform API restarts mid-class** (deploy, crash, host reboot): nothing to do. Sessions,
the live classroom row, and participant presence all live in Postgres, not in the API process's
memory — students' browsers keep their existing session and heartbeat right through it, and the
dashboard shows the same state a few seconds after the API comes back. This is exercised for
real in `infra/scripts/drills/test-phase5a-rehearsal.js`, which restarts the API mid-rehearsal
and asserts on exactly this.
+63
View File
@@ -0,0 +1,63 @@
# Runbook: Self-Paced Programs & Enrollment
Covers managing programs and enrollments from `/admin` → Programs. See
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for the underlying design and
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` for the separate website-driven path.
**Create a program** (Programs → Create program, admin/owner only):
Program ID is a slug (e.g. `root-linux-fundamentals`) and can't be changed later. Pick a
family (`init_`/`compile_`/`root_`) and delivery mode. New programs default to
`curriculum_path: rootnode-path` — the only curriculum content that exists today. A program for
`init_`/`compile_` can be created now as a placeholder and wired to real content later without a
schema change; it just won't have real lessons behind it yet.
**Activate/deactivate a program** (Programs → \[program\] → Activate/Deactivate):
An inactive program can't accept new enrollments (admin-manual or via the website integration
endpoint) but existing enrollments in it are untouched — students already enrolled keep their
access exactly as it was.
**Manually enroll a student** (Programs → \[program\] → Enroll a student):
Enter the student's ID (find it on the Students tab). Choose `pending` (the default — the
student sees the enrollment but can't start until you Activate it) or `active` (skip that step).
Optional start/end dates enforce access windows server-side — a future start date hides Lab
access until then; an end date cuts off new Lab launches after it while leaving prior progress
visible.
**Activate a pending enrollment** (Programs → \[program\] → Enrollments table → Activate):
Only needed if you enrolled as `pending`. This is the moment a student actually gains entry.
**Cancel an enrollment** (Programs → \[program\] → Enrollments table → Cancel, or Enrollments
list on a student's own page):
Immediately blocks new Lab launches. Does not touch the student's progress/achievements/attempt
history — those are permanent regardless of enrollment status. Cancelling is final for that
enrollment; if the student should have access again later, enroll them again (a new enrollment
row) rather than trying to "reactivate" a cancelled one.
**Check a student's enrollment history** (Students → \[student\] → "Enrolled programs" card):
Shows every program the student has ever been enrolled in — pending, active, completed,
cancelled, expired — with progress and last activity. A plain instructor sees this scoped to
enrollments they created or that belong to a student sharing one of their cohorts; owner/admin
see everything.
**A student says "Continue Learning" isn't working / shows the wrong thing:**
1. Check their enrollment status on the Programs or Student page. `pending` → activate it.
`expired`/`cancelled` → that's working as intended; enroll them again if they should continue.
2. If `active` and still stuck, check `/api/admin/students/:id/environment` for a stuck Lab —
same troubleshooting as any other Lab issue (`docs/RUNBOOK_STUCK_LAB_CLEANUP.md`). Destroying
the Lab never loses their program progress; a fresh launch resumes from the same place.
**A student's completion didn't register:**
Completion is detected the next time their program status is *read* (their dashboard, or an
admin viewing their enrollment) after they finish the capstone — not pushed instantly. Refresh
the page or reopen their student detail in `/admin`; if it still doesn't show `completed` after
that, check `GET /api/achievements` for that student for a `capstone_completed` record matching
the program's configured capstone — if that's missing, the capstone itself wasn't actually
finished, not an enrollment bug.
+44
View File
@@ -0,0 +1,44 @@
# Runbook: Student Account Lifecycle
Covers creating, converting, resetting, and revoking student accounts. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design.
**Create a new credentialed student** (`/admin` → Students → Add student):
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
link **once**: `https://<domain>/?activate=<token>`. Copy it immediately (print it, read it
aloud, hand it over) — it is never shown again and never stored in plaintext anywhere. The
student opens the link and sets their own password; the instructor never sees it.
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
Same as creation, but for a student who already exists with progress/attempts/achievements —
none of that is touched. Give an email or login ID, get a one-time activation link the same way.
**Bulk import via CSV** (Students → Bulk import):
Columns: `display_name, email, login_identifier, cohort_id` (only `display_name` is required per
row). The whole file is validated before anything is written — one bad row rejects the entire
file with a list of row errors, rather than importing some rows and skipping others. Never
include a password column; it will always be rejected if present. Activation links for every
created row are shown once after a successful import.
**Reset a student's password** (Students → \[student\] → Reset password):
Only for already-credentialed accounts. Issues a fresh reset link (`?reset=<token>`, valid 1
hour) and immediately revokes every existing session for that student — the instructor still
never sees or sets the actual password.
**Revoke a student's sessions** (Students → \[student\] → Revoke all sessions):
Immediately invalidates every active login for that student without touching their password.
Use this if a device is lost/shared or a session looks compromised.
**Deactivate a student** (Students → \[student\] → PATCH status: inactive, via the existing
students list — deactivation takes effect on their very next request, not just their next
login, because `studentSession` middleware re-checks `status='active'` every time.
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
time-limited (activation 7 days, reset 1 hour). Just issue a new one via Convert or Reset
password — there's no way to "resend" the same token, and that's intentional.
+197
View File
@@ -0,0 +1,197 @@
# Website → RootNode Academy Enrollment Integration
Audience: whoever builds `rootnodeacademy.com` (marketing, pricing, checkout) and needs to turn
a successful purchase/signup into a real RootNode Academy student enrollment. This document is
meant to be enough on its own — you should not need to read the Platform API's source to
integrate against it.
## Architecture
```
rootnodeacademy.com (marketing, pricing, checkout — built separately, not in this repo)
|
| your backend verifies payment/signup with your payment provider
v
your backend calls the RootNode Enrollment API (below) — server-to-server only
v
RootNode Academy Platform API
creates/matches a student account
creates or reuses (idempotently) an enrollment
v
the student logs in at the RootNode Academy platform and sees their program
```
**RootNode never talks to your payment provider, and your payment provider never talks to
RootNode.** Your backend is the only thing that calls this endpoint, after it has already
confirmed payment (or whatever your enrollment trigger is) on its own. This endpoint knows
nothing about Stripe, PayPal, or any other provider's object model — it only understands a
small, generic enrollment event described below.
## The endpoint
```
POST /api/integrations/enrollments
```
Reachable at the RootNode Academy platform's public origin (the same host your students'
browsers already use), via HTTPS in any real deployment. This is the **only** endpoint your
backend needs.
## Authentication
A pre-shared secret, sent as a header:
```
X-RootNode-Integration-Key: <the secret>
```
- This is **not** a student or admin login. It has nothing to do with cookies, sessions, or
browser auth of any kind.
- **The secret must live only in your backend's server-side configuration.** It must never be
embedded in any browser-served JavaScript, mobile app bundle, or client-side config — anyone
who obtains it can create enrollments.
- A request with a missing or wrong key gets `401 { "error": "unauthorized" }` and nothing else
happens — no student is looked up, nothing is created.
- RootNode will provide you with the production secret out of band (not over email/chat in
plaintext — ask the RootNode team for the current secure-sharing process). Rotate it by
asking RootNode to issue a new one; there is no self-service rotation yet.
## Request
```json
{
"event_id": "order_9f8e7d6c5b4a",
"source": "website",
"program_slug": "root-kubernetes-foundations",
"status": "paid",
"student": {
"email": "[email protected]",
"login_identifier": null,
"display_name": "Jamie Rivera"
}
}
```
| Field | Required | Notes |
|---|---|---|
| `event_id` | **yes** | A string, unique per real-world event, from *your* system (an order ID, a payment intent ID — anything stable). This is the idempotency key — see below. Not a RootNode-generated value. |
| `source` | no | Free text, e.g. `"website"`. Recorded for audit only, defaults to `"integration"` if omitted. |
| `program_slug` | **yes** | The RootNode program's id, e.g. `"root-kubernetes-foundations"`. Ask RootNode for the current list — there is no public program-catalog endpoint. |
| `status` | **yes** | One of: `"paid"`, `"active"`, `"confirmed"` (all mean "grant access now") or `"pending"`, `"awaiting_payment"` (means "record the enrollment but don't grant access yet"). This is a small, fixed, provider-neutral vocabulary — translate whatever your payment provider says into one of these five values before calling. Anything else is rejected. |
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
### What you must NOT send
- No card numbers, CVVs, bank details, or any other payment-card data. This endpoint has no
field for it and never will — RootNode never touches payment-card data at all.
- No raw provider webhook payloads (Stripe event objects, PayPal IPN bodies, etc.) — translate
to the shape above first. This endpoint is deliberately provider-neutral.
- No password. RootNode issues its own credential/activation flow (see "What the student
experiences" below) — you cannot set a student's password through this endpoint, and should
never try to invent one.
## Response
**Success — a new enrollment was created** (`201`):
```json
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": true }
```
**Success — this exact `event_id` was already processed** (`200`, idempotent replay — see below):
```json
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": false }
```
**Errors:**
| Status | Body `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong `X-RootNode-Integration-Key`. |
| `400` | `event_id is required` | — |
| `400` | `program_slug is required` | — |
| `400` | `status must be one of: paid, active, confirmed, pending, awaiting_payment` | — |
| `400` | `student.email or student.login_identifier is required` | — |
| `404` | `PROGRAM_NOT_FOUND` | `program_slug` doesn't exist. |
| `409` | `PROGRAM_INACTIVE` | The program exists but isn't currently open for enrollment. |
| `500` | `internal_error` | Something unexpected broke server-side. Safe to retry (see below). |
## Idempotency and retries
**`event_id` is the whole mechanism.** Send the exact same `event_id` again (your payment
provider retried a webhook, your own backend retried a timed-out request, anything) and you get
back the **same enrollment**, `created: false`, `200` instead of `201` — never a duplicate. This
is enforced at the database level (a unique constraint), not just "best effort," so it's safe
under concurrent/parallel retries too, not just sequential ones.
**Retry policy:** on a `5xx` or a network error/timeout, retry with the *same* `event_id`
(standard exponential backoff is fine — a few attempts over a couple of minutes is plenty). On a
`4xx`, do not blindly retry — fix the request (see the error table above) or, for `401`, check
your key.
**Never generate a new `event_id` for a retry of the same real-world event.** Doing so would
create a second enrollment. Reserve a fresh `event_id` only for a genuinely new enrollment event
(e.g. a renewal, a second purchase).
## Student matching rules
RootNode looks for an existing student by `email` (case-insensitive) or `login_identifier`
(exact match), in that priority. If found, that student is enrolled. If not found, a new student
account is created automatically — this is a trusted server-to-server call from your backend,
equivalent to an instructor adding a student through the admin portal, not a public signup form.
## Minor / no-email students
RootNode explicitly supports students without their own email address (younger students,
enrolled by a parent/guardian). Send `student.login_identifier` instead of (or in addition to)
`email` — any stable string you control, such as an internal account/order reference. You must
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
There is no guardian/parent account system yet — the enrollment is created directly against the
student identity you provide. If you need to support "a parent manages multiple children's
enrollments" as a first-class concept later, talk to the RootNode team before building around a
workaround; the schema has room for it but it isn't implemented.
## What the student experiences after you call this
If `status` mapped to "active": the student can log in immediately (once RootNode's own
activation flow — a one-time setup link — reaches them; RootNode does not send that email/link
for you today, see "Known limitation" below) and sees the program on their dashboard right away.
If `status` mapped to "pending": the enrollment exists but the student can't start until a
RootNode instructor activates it — useful if your flow needs a manual review step before access
is granted.
**Known limitation:** RootNode does not yet send the student's activation email itself. If the
student is brand new (no prior RootNode account), your integration is responsible for letting
them know they have access and how to set up their login, until RootNode's own email delivery
exists. Ask the RootNode team for the current interim process.
## Local/staging testing
Set `INTEGRATION_SHARED_SECRET` in your test environment's config (in local dev,
`platform/.env`; example dev value used by this repo's own test suite:
`dev-integration-secret-not-for-production` — never use this value anywhere real). Then:
```bash
curl -s -X POST http://localhost:4200/api/integrations/enrollments \
-H 'Content-Type: application/json' \
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
-d '{
"event_id": "test-order-001",
"source": "website",
"program_slug": "root-kubernetes-foundations",
"status": "paid",
"student": { "email": "[email protected]", "display_name": "Test Student" }
}'
```
All example data on this page is fake — do not reuse these values against a real deployment.
## Production secret handling
- The production `X-RootNode-Integration-Key` value must be requested from the RootNode team
and stored in your backend's secret manager/environment configuration — never in source
control, never in a browser-reachable config, never logged.
- RootNode's own logs and audit trail never record the secret's value, only that an
authenticated call happened (or a `401` if it didn't).
+6
View File
@@ -8,6 +8,8 @@ import AuditView from './AuditView'
import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -20,6 +22,8 @@ const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
@@ -59,6 +63,8 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
const DASHBOARD_POLL_MS = 5000
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
export default function ClassroomView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState('')
const [classrooms, setClassrooms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Classroom</h2>
<div className={styles.formRow}>
<div><label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
</div>
{cohortId && (
<div className={styles.card}>
<h3>Schedule a class</h3>
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h3>Classes</h3>
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
<tbody>
{classrooms.map((c) => (
<tr key={c.classroom_session_id}>
<td>{c.title}</td>
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateClassroomForm({ cohortId, onCreated }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [scheduledStart, setScheduledStart] = useState('')
const [meetingUrl, setMeetingUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createClassroom(cohortId, {
title: title.trim(), description: description.trim() || undefined,
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
})
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
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}>Title</label>
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
<div><label className={styles.label}>Scheduled start</label>
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
</div>
<label className={styles.label}>Description (optional)</label>
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
</form>
)
}
function ClassroomDashboard({ classroomId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [activityScenario, setActivityScenario] = useState('')
const reload = useCallback(() => {
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
}, [classroomId])
useEffect(() => {
reload()
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
return () => clearInterval(t)
}, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
const { classroom, students, announcements, help_requests: helpRequests } = data
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to classes</button>
<div className={styles.card} style={{ marginTop: '1rem' }}>
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.formRow}>
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { results } = await adminApi.prepareClassroomLabs(classroomId)
setNotice(`Labs prepared: ${results.length} student(s).`)
})}>Prepare Labs</button>
</div>
</div>
<div className={styles.card}>
<h3>Participants ({students.length})</h3>
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
<td className={styles.muted}>{s.hints_used}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
<table>
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
<tbody>
{helpRequests.map((h) => (
<tr key={h.help_request_id}>
<td>{h.display_name || h.student_id}</td>
<td className={styles.muted}>{h.category}</td>
<td className={styles.muted}>{h.message || '—'}</td>
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
<td>
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Current activity</h3>
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
<div className={styles.formRow}>
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
)}
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
</div>
</div>
</>
)
}
+1 -1
View File
@@ -101,7 +101,7 @@ function CohortDetail({ cohortId, canManage, onBack }) {
if (!newStudentName.trim()) return
setBusy(true); setError(null)
try {
await adminApi.createStudent(newStudentName.trim(), cohortId)
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
setNewStudentName('')
reload()
} catch (e) { setError(e.message) } finally { setBusy(false) }
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 5B §11 — Programs area. Authoring (create/edit/activate-deactivate) is admin/owner
// only, matching AssessmentsView's "viewed by all roles, only authoring restricted" pattern —
// canAuthor gates the create form and the activate/deactivate toggle only, not the list itself.
export default function ProgramsView({ role }) {
const [programs, setPrograms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.programs().then(setPrograms).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <ProgramDetail programId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create program</h2>
<CreateProgramForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Programs</h2>
{!programs ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Name</th><th>Family</th><th>Delivery</th><th>Enrollments</th><th>Status</th><th /></tr></thead>
<tbody>
{programs.map((p) => (
<tr key={p.program_id}>
<td>{p.name}</td>
<td><span className={styles.pill}>{p.family}</span></td>
<td className={styles.muted}>{p.delivery_mode.replace('_', ' ')}</td>
<td>{p.enrollment_count}</td>
<td><span className={`${styles.pill} ${p.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{p.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(p.program_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateProgramForm({ onCreated }) {
const [form, setForm] = useState({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createProgram(form)
setForm({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
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}>Program ID (slug)</label>
<input className={styles.input} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
<div><label className={styles.label}>Family</label>
<select className={styles.select} value={form.family} onChange={(e) => setForm({ ...form, family: e.target.value })}>
<option value="init">init_ (ages ~5-10)</option>
<option value="compile">compile_ (ages ~11-14)</option>
<option value="root">root_ (ages ~15-18+)</option>
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
<option value="self_paced">self-paced</option>
<option value="cohort">cohort</option>
<option value="hybrid">hybrid</option>
</select></div>
</div>
<label className={styles.label}>Description</label>
<input className={styles.input} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<p className={styles.muted} style={{ marginTop: '0.5rem' }}>
New programs default to curriculum_path 'rootnode-path' — the only curriculum content that exists today. A program for init_/compile_ can be created now and wired to real content later without a schema change.
</p>
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function ProgramDetail({ programId, canAuthor, onBack }) {
const [program, setProgram] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.program(programId).then(setProgram).catch((e) => setError(e.message))
adminApi.programEnrollments(programId).then(setEnrollments).catch(() => {})
}, [programId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to programs</button>
{!program ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{program.name} <span className={`${styles.pill} ${program.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{program.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{program.description || 'No description.'}</p>
<p>Family: <span className={styles.pill}>{program.family}</span> — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})</p>
<p className={styles.muted}>
Enrollments — pending: {program.enrollment_counts.pending || 0}, active: {program.enrollment_counts.active || 0},
{' '}completed: {program.enrollment_counts.completed || 0}, cancelled: {program.enrollment_counts.cancelled || 0}, expired: {program.enrollment_counts.expired || 0}
</p>
{canAuthor && (
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.updateProgram(programId, { status: program.status === 'active' ? 'inactive' : 'active' }))}>
{program.status === 'active' ? 'Deactivate' : 'Activate'} program
</button>
)}
</div>
{canAuthor && (
<div className={styles.card}>
<h3>Enroll a student</h3>
<EnrollForm programId={programId} onEnrolled={() => { setNotice('Enrolled.'); reload() }} />
</div>
)}
<div className={styles.card}>
<h3>Enrollments</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Status</th><th>Source</th><th>Enrolled</th><th /></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.student_display_name}</td>
<td><span className={`${styles.pill} ${e.status === 'active' ? styles.pillOk : e.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{e.status}</span></td>
<td className={styles.muted}>{e.source}</td>
<td className={styles.muted}>{new Date(e.enrolled_at).toLocaleDateString()}</td>
<td>
{e.status === 'pending' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.activateEnrollment(e.enrollment_id))}>Activate</button>}
{['pending', 'active'].includes(e.status) && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.cancelEnrollment(e.enrollment_id))}>Cancel</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function EnrollForm({ programId, onEnrolled }) {
const [studentId, setStudentId] = useState('')
const [status, setStatus] = useState('pending')
const [startsAt, setStartsAt] = useState('')
const [accessEndsAt, setAccessEndsAt] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createEnrollment({
student_id: studentId.trim(), program_id: programId, status,
starts_at: startsAt || undefined, access_ends_at: accessEndsAt || undefined,
})
setStudentId(''); setStartsAt(''); setAccessEndsAt('')
onEnrolled()
} 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}>Student ID</label>
<input className={styles.input} value={studentId} onChange={(e) => setStudentId(e.target.value)} placeholder="student-001" required /></div>
<div><label className={styles.label}>Initial status</label>
<select className={styles.select} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">pending</option>
<option value="active">active (skip activation step)</option>
</select></div>
<div><label className={styles.label}>Starts (optional)</label>
<input className={styles.input} type="date" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} /></div>
<div><label className={styles.label}>Access ends (optional)</label>
<input className={styles.input} type="date" value={accessEndsAt} onChange={(e) => setAccessEndsAt(e.target.value)} /></div>
</div>
<button className={styles.btn} disabled={busy || !studentId.trim()}>{busy ? 'Enrolling…' : 'Enroll'}</button>
</form>
)
}
+224 -21
View File
@@ -3,57 +3,187 @@ import { adminApi } from './api'
import { StatusPill } from './CohortsView'
import styles from './Admin.module.css'
export default function StudentsView() {
// Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that
// were already here. canManage gates create/convert/reset/revoke/import the same way
// CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary,
// this just avoids showing controls a plain instructor would get a 403 from.
export default function StudentsView({ role }) {
const [students, setStudents] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canManage = role !== 'instructor'
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() }} />
if (selected) return <StudentDetail studentId={selected} canManage={canManage} 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>
{canManage && (
<div className={styles.card}>
<h2>Add student</h2>
<CreateStudentForm onCreated={reload} />
</div>
)}
</div>
{canManage && (
<div className={styles.card}>
<h2>Bulk import (CSV)</h2>
<ImportForm onImported={reload} />
</div>
)}
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Login</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>
{s.has_credentials
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
</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 }) {
function CreateStudentForm({ onCreated }) {
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [loginIdentifier, setLoginIdentifier] = useState('')
const [cohortId, setCohortId] = useState('')
const [cohorts, setCohorts] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once
useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setCreated(null)
try {
const result = await adminApi.createStudent({
display_name: displayName.trim(), cohort_id: cohortId || undefined,
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
})
setCreated(result)
setDisplayName(''); setEmail(''); setLoginIdentifier('')
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
{created?.activation_token && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
</div>
)}
<div className={styles.formRow}>
<div><label className={styles.label}>Display name</label>
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
<div><label className={styles.label}>Email (older/self-paced)</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID (younger/managed)</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><label className={styles.label}>Cohort (optional)</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
<option value="">— none —</option>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
</form>
)
}
function ImportForm({ onImported }) {
const [csv, setCsv] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [rowErrors, setRowErrors] = useState(null)
const [created, setCreated] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setRowErrors(null); setCreated(null)
try {
const { created: rows } = await adminApi.importStudents(csv)
setCreated(rows); setCsv('')
onImported()
} catch (e) {
if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below
setError(e.message)
} finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
{error && <div className={styles.errorBox}>{error}</div>}
{rowErrors && (
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
)}
{created && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
{created.filter((c) => c.activation_token).map((c) => (
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
))}
</div>
)}
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
value={csv} onChange={(e) => setCsv(e.target.value)} required />
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
</form>
)
}
function StudentDetail({ studentId, canManage, onBack }) {
const [student, setStudent] = useState(null)
const [progress, setProgress] = useState(null)
const [attempts, setAttempts] = useState(null)
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
// recent page (labeled "Recent attempts," not a full history browser — see
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
// instructor only sees enrollments they created or that share a cohort with them.
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -72,6 +202,27 @@ function StudentDetail({ studentId, onBack }) {
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to students</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
{canManage && (
<div className={styles.card}>
<h3>Credentials</h3>
{student?.has_credentials ? (
<>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}</p>
<div className={styles.formRow}>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { reset_token } = await adminApi.resetStudentPassword(studentId)
setNotice(`Password reset. Link (shown once): ${window.location.origin}/?reset=${reset_token}`)
})}>Reset password</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</div>
)}
<div className={styles.card}>
<h3>Environment</h3>
@@ -101,6 +252,26 @@ function StudentDetail({ studentId, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Enrolled programs</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
<table>
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.program_name}</td>
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Curriculum progress</h3>
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
@@ -134,3 +305,35 @@ function StudentDetail({ studentId, onBack }) {
</>
)
}
function ConvertForm({ studentId, onDone }) {
const [email, setEmail] = useState('')
const [loginIdentifier, setLoginIdentifier] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const { activation_token } = await adminApi.convertStudent(studentId, {
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
})
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
</div>
</form>
)
}
+35 -2
View File
@@ -16,7 +16,9 @@ function getCookie(name) {
async function json(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || `Request failed (${res.status})`)
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
throw err
}
return res.json()
}
@@ -49,8 +51,14 @@ export const adminApi = {
dashboard: () => get('/api/admin/dashboard'),
students: () => get('/api/admin/students'),
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
createStudent: (fields) => post('/api/admin/students', fields),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
importStudents: (csv) => post('/api/admin/students/import', { csv }),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
@@ -95,4 +103,29 @@ export const adminApi = {
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
programs: () => get('/api/admin/programs'),
program: (id) => get(`/api/admin/programs/${id}`),
createProgram: (fields) => post('/api/admin/programs', fields),
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
}
+146
View File
@@ -0,0 +1,146 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
const POLL_MS = 5000
const HELP_CATEGORIES = [
{ value: 'lab_problem', label: 'Lab problem' },
{ value: 'dont_understand', label: "Don't understand the task" },
{ value: 'validation_failing', label: 'Validation failing' },
{ value: 'other', label: 'Other' },
]
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
// right now" state. Live -> title, current activity (guidance only — a link into the student's
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
export default function ClassroomTab() {
const [live, setLive] = useState(null)
const [detail, setDetail] = useState(null)
const [envStatus, setEnvStatus] = useState(null)
const [joined, setJoined] = useState(false)
const [helpCategory, setHelpCategory] = useState('lab_problem')
const [helpMessage, setHelpMessage] = useState('')
const [helpSent, setHelpSent] = useState(false)
const [error, setError] = useState(null)
const heartbeatRef = useRef(null)
const pollRef = useRef(null)
const loadLive = useCallback(() => {
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadLive() }, [loadLive])
const loadDetail = useCallback((id) => {
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
api.environmentStatus().then(setEnvStatus).catch(() => {})
}, [])
useEffect(() => {
if (!live) return
loadDetail(live.classroom_session_id)
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
return () => clearInterval(pollRef.current)
}, [live, loadDetail])
useEffect(() => {
if (!joined || !live) return
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
return () => clearInterval(heartbeatRef.current)
}, [joined, live])
const join = async () => {
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
catch (e) { setError(e.message) }
}
const sendHelp = async (e) => {
e.preventDefault()
try {
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
setHelpSent(true); setHelpMessage('')
} catch (e) { setError(e.message) }
}
const launchLab = async () => {
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
}
if (!live) {
return (
<div className={styles.card}>
<h2>Classroom</h2>
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
</div>
)
}
return (
<div>
<div className={styles.card}>
<p className={styles.livePill}>LIVE NOW</p>
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
{live.description && <p className={styles.muted}>{live.description}</p>}
{live.meeting_url && (
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
)}
{!joined ? (
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
) : (
<p className={styles.muted}>You're in this classroom.</p>
)}
</div>
{joined && detail && (
<>
{detail.classroom.current_scenario_id && (
<div className={styles.card}>
<h3>Current class activity</h3>
<p>{detail.classroom.current_scenario_id}</p>
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
</div>
)}
<div className={styles.card}>
<h3>Your Lab</h3>
{envStatus?.status === 'running'
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
</button>}
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
<ul>
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
</ul>
)}
</div>
<div className={styles.card}>
<h3>Need help?</h3>
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
<form onSubmit={sendHelp}>
<label className={styles.label}>What's going on?</label>
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<label className={styles.label}>Details (optional)</label>
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
<button className={styles.primaryBtn} type="submit">Need Help</button>
</form>
)}
</div>
</>
)}
{error && <div className={styles.errorBox}>{error}</div>}
</div>
)
}
@@ -0,0 +1,59 @@
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic
// message regardless of whether the identifier exists (anti-enumeration, matches the server's
// own response shape exactly). In dev/test, the server includes dev_reset_token in its
// response so this screen can offer a direct link — a real deployment omits it, and completing
// the flow needs an instructor to run the admin-side reset instead (see
// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md).
export default function ForgotPasswordScreen({ onCancel, onHaveToken }) {
const [identifier, setIdentifier] = useState('')
const [sent, setSent] = useState(null)
const [loading, setLoading] = useState(false)
const submit = async (e) => {
e.preventDefault()
setLoading(true)
try {
const result = await api.requestPasswordReset(identifier)
setSent(result)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Reset your password</p>
{!sent ? (
<form onSubmit={submit}>
<label className={styles.label}>Email or login</label>
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
</form>
) : (
<>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
{sent.dev_reset_token && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
Dev/test only (never shown in production):{' '}
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
</p>
)}
</>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §6 — the real, credentialed student login. This is now the default entry point
// (see PlatformShell.jsx); EntryScreen (pilot, no password) is still reachable for
// not-yet-converted accounts via the link at the bottom, not removed — see
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7.
export default function LoginScreen({ onLogin, onShowPilotEntry, onShowForgotPassword }) {
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleLogin = async (e) => {
e.preventDefault()
if (!identifier || !password) return
setLoading(true)
setError(null)
try {
const student = await api.studentLogin(identifier, password)
onLogin(student)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Sign in to continue your learning.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<form onSubmit={handleLogin}>
<label className={styles.label}>Email or login</label>
<input className={styles.input} type="text" autoComplete="username" value={identifier}
onChange={(e) => setIdentifier(e.target.value)} required />
<label className={styles.label}>Password</label>
<input className={styles.input} type="password" autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} required />
<button className={styles.primaryBtn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onShowForgotPassword}>Forgot password?</button>
</p>
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onShowPilotEntry}>Pick your name instead (pilot cohorts)</button>
</p>
</div>
</div>
)
}
+9 -1
View File
@@ -36,7 +36,7 @@
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
.select {
.select, .input {
width: 100%;
padding: 0.55rem 0.6rem;
background: var(--surface2);
@@ -45,6 +45,7 @@
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
box-sizing: border-box;
}
.primaryBtn {
@@ -179,5 +180,12 @@ table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pillErr { background: var(--red-dim); color: var(--red); }
.pillMuted { background: var(--surface3); color: var(--text-2); }
/* Phase 5A — classroom/profile screens */
.livePill {
display: inline-block; background: var(--red-dim); color: var(--red); font-family: var(--mono);
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; padding: 0.2rem 0.6rem; border-radius: 999px;
}
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
+38 -7
View File
@@ -1,17 +1,28 @@
import { useState, useEffect } from 'react'
import { api, getCachedDisplay } from './api'
import LoginScreen from './LoginScreen'
import EntryScreen from './EntryScreen'
import SetPasswordScreen from './SetPasswordScreen'
import ForgotPasswordScreen from './ForgotPasswordScreen'
import StudentDashboard from './StudentDashboard'
// 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.
// Top-level Platform Shell. Phase 5A: the credentialed LoginScreen is now the default entry
// point (was EntryScreen's pilot name-picker); an activation/reset link
// (?activate=<token> or ?reset=<token>) short-circuits straight to SetPasswordScreen before
// any session check, since neither flow requires an existing session. Session validity is
// still decided by the server (GET /api/session/whoami against the rn_session cookie), not by
// anything read from localStorage/sessionStorage.
export default function PlatformShell() {
const [student, setStudent] = useState(null)
const [checking, setChecking] = useState(true)
const params = new URLSearchParams(window.location.search)
const [view, setView] = useState(
params.get('activate') ? 'activate' : params.get('reset') ? 'reset' : 'login'
)
const [resetToken, setResetToken] = useState(params.get('reset') || null)
useEffect(() => {
if (view === 'activate' || view === 'reset') { setChecking(false); return }
const cached = getCachedDisplay()
if (cached) setStudent(cached) // optimistic paint while we confirm with the server
api.whoami()
@@ -20,9 +31,29 @@ export default function PlatformShell() {
})
.catch(() => setStudent(null))
.finally(() => setChecking(false))
}, [])
}, [view])
if (checking) return null
if (!student) return <EntryScreen onLogin={setStudent} />
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
if (view === 'activate') {
return <SetPasswordScreen token={params.get('activate')} purpose="activation"
onDone={(s) => { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }}
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
}
if (view === 'reset') {
return <SetPasswordScreen token={resetToken} purpose="reset"
onDone={() => { setView('login'); window.history.replaceState({}, '', '/') }}
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
}
if (view === 'forgot') {
return <ForgotPasswordScreen onCancel={() => setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} />
}
if (view === 'pilot') {
return <EntryScreen onLogin={(s) => { setStudent(s); setView('dashboard') }} />
}
if (!student) {
return <LoginScreen onLogin={(s) => { setStudent(s); setView('dashboard') }}
onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} />
}
return <StudentDashboard student={student} onLogout={() => { setStudent(null); setView('login') }} />
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §10 — minimal account/profile page. Deliberately narrow: identity, cohort, progress
// summary, achievements, active session count, and change-password. No social/profile features.
export default function ProfileTab() {
const [profile, setProfile] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { api.profile().then(setProfile).catch((e) => setError(e.message)) }, [])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!profile) return <p className={styles.muted}>Loading…</p>
return (
<div>
<div className={styles.card}>
<h2>Account</h2>
<p><strong>{profile.student.display_name}</strong></p>
<p className={styles.muted}>{profile.student.email || profile.student.login_identifier || 'No login identifier set'}</p>
<p className={styles.muted}>
{profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'}
</p>
<p className={styles.muted}>{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}</p>
</div>
<div className={styles.card}>
<h2>Progress</h2>
<p>{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed</p>
<div className={styles.progressBarTrack}>
<div className={styles.progressBarFill} style={{ width: `${profile.progress_summary.total ? Math.round(100 * profile.progress_summary.completed / profile.progress_summary.total) : 0}%` }} />
</div>
</div>
<div className={styles.card}>
<h2>Achievements</h2>
{!profile.achievements.length ? <p className={styles.muted}>None yet — keep going!</p> : (
<ul>
{profile.achievements.map((a, i) => (
<li key={i}>{a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}</li>
))}
</ul>
)}
</div>
<ChangePasswordCard hasCredentials={profile.student.has_credentials} />
</div>
)
}
function ChangePasswordCard({ hasCredentials }) {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
if (!hasCredentials) {
return (
<div className={styles.card}>
<h2>Change password</h2>
<p className={styles.muted}>This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.</p>
</div>
)
}
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try {
await api.changePassword(oldPassword, newPassword)
setNotice('Password updated.'); setOldPassword(''); setNewPassword('')
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
return (
<div className={styles.card}>
<h2>Change password</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<form onSubmit={submit}>
<label className={styles.label}>Current password</label>
<input className={styles.input} type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
<label className={styles.label}>New password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={10} />
<button className={styles.primaryBtn} type="submit" disabled={busy}>{busy ? 'Saving…' : 'Update password'}</button>
</form>
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
const BLOCKED_MESSAGES = {
pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
not_yet_started: 'This program opens on the date below. Check back then.',
access_expired: 'Access to this program has ended. Your progress is still here for reference.',
cancelled: 'This enrollment was cancelled.',
}
// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
// already carries real access/progress/next-activity per enrollment — see program-status.js),
// split into Active and Completed. Continue Learning delegates to the entitlement-gated
// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
export default function ProgramsTab() {
const [programs, setPrograms] = useState(null)
const [error, setError] = useState(null)
const load = useCallback(() => {
api.programs().then(setPrograms).catch((e) => setError(e.message))
}, [])
useEffect(load, [load])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!programs) return <p className={styles.muted}>Loading…</p>
const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
const completed = programs.filter((p) => p.effective_status === 'completed')
const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
if (!programs.length) {
return (
<div className={styles.card}>
<h2>My Programs</h2>
<p className={styles.muted}>You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.</p>
</div>
)
}
return (
<div>
{active.length > 0 && (
<div className={styles.card}>
<h2>My Programs</h2>
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
{completed.length > 0 && (
<div className={styles.card}>
<h2>Completed Programs</h2>
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
{cancelled.length > 0 && (
<div className={styles.card}>
<h2>Other</h2>
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
</div>
)
}
function ProgramCard({ program, onChange }) {
const [envStatus, setEnvStatus] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const pollRef = useRef(null)
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const poll = useCallback(() => {
api.environmentStatus().then((s) => {
setEnvStatus(s)
if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
clearInterval(pollRef.current); pollRef.current = null
}
}).catch(() => {})
}, [])
const handleContinue = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchProgram(program.enrollment_id)
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
pollRef.current = setInterval(poll, POLL_MS)
}
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const p = program.progress
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
return (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong>{program.program_name}</strong>
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
{program.effective_status.replace(/_/g, ' ')}
</span>
</div>
{p && (
<div style={{ margin: '0.6rem 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
</div>
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
</div>
)}
{program.effective_status === 'completed' ? (
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
) : program.blocked_reason ? (
<p className={styles.muted}>
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
</p>
) : (
<>
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
{error && <p className={styles.errorBox}>{error}</p>}
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
) : (
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
{busy ? 'Starting…' : 'Continue Learning'}
</button>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,76 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves:
// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same
// UI, different API call and success copy.
export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) {
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message))
}, [token, purpose])
const submit = async (e) => {
e.preventDefault()
if (password.length < 10) { setError('Password must be at least 10 characters.'); return }
if (password !== confirm) { setError('Passwords do not match.'); return }
setLoading(true)
setError(null)
try {
if (purpose === 'activation') {
const student = await api.activate(token, password)
onDone(student)
} else {
await api.resetPassword(token, password)
onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
{previewError && (
<div className={styles.errorBox}>
This link is invalid or has expired. Ask your instructor for a new one.
</div>
)}
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
{error && <div className={styles.errorBox}>{error}</div>}
{!previewError && (
<form onSubmit={submit}>
<label className={styles.label}>New password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
<label className={styles.label}>Confirm password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
<button className={styles.primaryBtn} type="submit" disabled={loading}>
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
</button>
</form>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}
@@ -6,13 +6,19 @@ import PracticeTab from './PracticeTab'
import AssessmentsTab from './AssessmentsTab'
import CapstoneTab from './CapstoneTab'
import AchievementsTab from './AchievementsTab'
import ClassroomTab from './ClassroomTab'
import ProfileTab from './ProfileTab'
import ProgramsTab from './ProgramsTab'
const TABS = [
{ key: 'learning', label: 'My Learning' },
{ key: 'programs', label: 'My Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'practice', label: 'Practice' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'capstone', label: 'Capstone' },
{ key: 'achievements', label: 'Achievements' },
{ key: 'profile', label: 'Profile' },
]
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
@@ -41,10 +47,13 @@ export default function StudentDashboard({ student, onLogout }) {
</nav>
{tab === 'learning' && <MyLearningTab />}
{tab === 'programs' && <ProgramsTab />}
{tab === 'classroom' && <ClassroomTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
{tab === 'capstone' && <CapstoneTab />}
{tab === 'achievements' && <AchievementsTab />}
{tab === 'profile' && <ProfileTab />}
</div>
)
}
+41
View File
@@ -83,4 +83,45 @@ export const api = {
capstone: () => fetch('/api/capstone').then(json),
achievements: () => fetch('/api/achievements').then(json),
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
// above: nothing authenticated exists yet to forge into.
studentLogin: async (identifier, password) => {
const { student } = await fetch('/api/student/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
}).then(json)
cacheDisplay(student)
return student
},
activate: async (token, password) => {
const { student } = await fetch('/api/student/activate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json)
cacheDisplay(student)
return student
},
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
}).then(json),
resetPassword: (token, password) => fetch('/api/student/reset-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json),
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
profile: () => fetch('/api/student/profile').then(json),
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
liveClassroom: () => fetch('/api/classroom/live').then(json),
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
programs: () => fetch('/api/student/programs').then(json),
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
}
@@ -83,11 +83,10 @@ scrape_configs:
- target_label: __address__
replacement: localhost:9115
# RootNode application metrics — see docs/OBSERVABILITY.md §"exposing /metrics" for the
# documented, NOT-YET-BUILT follow-up (Platform API/Gateway/Provisioner don't currently
# expose a Prometheus /metrics endpoint; this job is ready for when that lands, disabled
# until then rather than scraping a 404 forever).
# - job_name: rootnode-platform-api
# static_configs:
# - targets: ["api:4300"]
# metrics_path: /metrics
# RootNode application metrics — Phase 5A §34 (platform/api/src/metrics.js): classroom/help-
# request/Lab-state gauges and student login counters. No student/instructor id or other
# high-cardinality value is ever a label here — see that file's header comment.
- job_name: rootnode-platform-api
static_configs:
- targets: ["api:4300"]
metrics_path: /metrics
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Orchestrates the Phase 5A §30/§33 dress rehearsal: brings up the real dev stack, bootstraps
# an owner, runs test-phase5a-rehearsal.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P5A Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-phase5a-rehearsal.js
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Orchestrates the Phase 5B §18 self-paced dress rehearsal: brings up the real dev stack,
# bootstraps an owner, runs test-phase5b-selfpaced-rehearsal.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P5B Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
INTEGRATION_KEY="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
// Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous
// narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the
// real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real
// progress/attempt/achievement history, joins a live classroom, sends a help request, and the
// Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two
// of the five are then converted to real credentialed accounts and their entire prior history
// (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual
// migration-safety check §33 asks for, run against a live stack rather than asserted in the
// abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js).
//
// Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
const RUN_ID = Date.now();
const COHORT_ID = `p5a-rehearsal-${RUN_ID}`;
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
// Seeds one realistic progress/attempt/achievement record for a pilot student directly in
// Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full
// Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js.
function seedHistory(studentId) {
const sql = `
INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at)
VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days');
INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count)
VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4);
INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id)
VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations');
`;
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
}
async function main() {
console.log('Phase 5A student identity / remote classroom dress rehearsal\n');
const instructor = makeSession(GW);
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez'];
const students = {}; // name -> student_id
const sessions = {}; // name -> pilot login session
await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => {
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' });
for (const name of names) {
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`);
students[name] = body.student_id;
}
});
await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => {
for (const name of names) {
sessions[name] = makeSession(GW);
const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] });
assert.equal(status, 200);
}
});
await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => {
seedHistory(students['Reh-Ada']);
seedHistory(students['Reh-Bo']);
});
let classroomId;
await step('instructor: create and start a live classroom for the cohort', async () => {
const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' });
classroomId = body.classroom_session_id;
const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
assert.equal(status, 200);
});
await step('all 5 students join the classroom and heartbeat', async () => {
for (const name of names) {
const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`);
assert.equal(status, 200);
await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`);
}
});
await step('Reh-Cy sends a help request; instructor acknowledges it', async () => {
const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' });
assert.equal(status, 201);
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const req = dash.help_requests.find((h) => h.status === 'open');
assert.ok(req);
await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`);
});
await step('instructor posts an announcement and sets current activity', async () => {
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' });
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' });
});
// ── The restart: the whole point of §30's "prove recovery" requirement. ─────────────────
await step('Platform API restarts mid-rehearsal', () => {
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
});
await new Promise((r) => setTimeout(r, 4000));
await step('after restart: the classroom is still live and every session still works, with no re-login', async () => {
for (const name of names) {
const { status } = await sessions[name].request('GET', `/api/classroom/live`);
assert.equal(status, 200);
}
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
assert.equal(dash.classroom.status, 'live');
assert.equal(dash.students.length, 5);
assert.equal(dash.announcements.length, 1);
});
await step('after restart: heartbeats and help requests still work normally', async () => {
const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`);
assert.equal(status, 200);
});
// ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ───────────────
let adaActivation, boActivation;
await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => {
const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` });
const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` });
assert.equal(a.status, 200); assert.equal(b.status, 200);
adaActivation = a.body.activation_token; boActivation = b.body.activation_token;
});
await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => {
const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] });
assert.equal(adaRetry.status, 409);
const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] });
assert.equal(cyStillWorks.status, 200);
});
let adaNewSession, boNewSession;
await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => {
adaNewSession = makeSession(GW);
boNewSession = makeSession(GW);
const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' });
const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' });
assert.equal(a.status, 200); assert.equal(b.status, 200);
});
await step('every bit of prior history survived the conversion — nothing orphaned', async () => {
const adaProgress = await adaNewSession.request('GET', '/api/progress');
assert.equal(adaProgress.status, 200);
assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login');
assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed');
const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']);
assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created');
const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion');
});
await step('cleanup: end the classroom', async () => {
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
// Phase 5B §18 — the self-paced dress rehearsal. One continuous realistic story, not synthetic
// unit assertions: a student is created, activates, has no program, a simulated website
// enrollment event enrolls them, they work real curriculum in a real Lab, the Lab is destroyed,
// they come back later, Continue Learning resumes correctly, a fresh Lab provisions, and they
// complete enough of the program to prove the completion transition. A second, uninvolved
// student proves they cannot reach the first student's program or Lab. No framework, matching
// project convention (see test-fake-cohort-rehearsal.js / test-phase5a-rehearsal.js).
//
// Run via run-phase5b-selfpaced-rehearsal.sh, which brings up platform/docker-compose.dev.yml
// first.
const assert = require('assert');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
// Just enough of the real Foundation stage to prove stage-completion + capstone unlock without
// re-driving the entire curriculum a second time (e2e-phase5b.js already does that
// exhaustively) — this rehearsal seeds the REST of the curriculum's completion directly in
// Postgres (a real, documented technique — see test-phase5a-rehearsal.js's seedHistory) so the
// story can reach "program completed" without spending another 40+ real scenario round trips.
const FOUNDATION_FIRST = 'pod-basics-mcq';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function fireIntegrationEvent(payload) {
return fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
body: JSON.stringify(payload),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
}
async function main() {
console.log('Phase 5B self-paced dress rehearsal — slow, real k3s boots\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const { body: created } = await owner.request('POST', '/api/admin/students', {
display_name: 'Rehearsal Self-Paced Student', login_identifier: `p5b-selfpaced-${RUN_ID}`,
});
const session = makeSession(GW);
let enrollmentId, envId;
await step('student activates credentials and logs in', async () => {
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
await step('student initially has no program', async () => {
const { body } = await session.request('GET', '/api/student/programs');
assert.equal(body.length, 0);
});
await step('a simulated website enrollment event enrolls the student (server-to-server, not a browser session)', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5b-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { login_identifier: `p5b-selfpaced-${RUN_ID}`, display_name: 'Rehearsal Self-Paced Student' },
});
assert.equal(status, 201);
assert.equal(body.status, 'active');
enrollmentId = body.enrollment_id;
});
await step('the self-paced root program now appears on the student\'s dashboard, ready to go', async () => {
const { body } = await session.request('GET', '/api/student/programs');
assert.equal(body.length, 1);
assert.equal(body[0].can_launch, true);
assert.equal(body[0].next_activity.scenario_id, FOUNDATION_FIRST);
});
await step('student launches a REAL Lab and completes the first real scenario', async () => {
const { status, body } = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
const complete = await session.request('POST', '/api/progress/record', { scenario_id: FOUNDATION_FIRST, status: 'completed', environment_id: envId });
assert.equal(complete.status, 200);
});
await step('the Lab is destroyed (student closed the tab, session ended — the disposable-by-design lifecycle)', async () => {
await owner.request('DELETE', `/api/admin/environments/${envId}`);
});
await step('student logs out', async () => {
await session.request('POST', '/api/logout');
});
const returningSession = makeSession(GW);
await step('time passes — student logs back in on a new session', async () => {
const { status } = await returningSession.request('POST', '/api/student/login', { identifier: `p5b-selfpaced-${RUN_ID}`, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
await step('Continue Learning points to the correct next activity, unchanged by the Lab being gone', async () => {
const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`);
assert.notEqual(body.next_activity.scenario_id, FOUNDATION_FIRST); // moved past it
assert.ok(body.progress.percent_complete > 0);
});
await step('a new Lab provisions and prior progress is intact under it', async () => {
const { status, body } = await returningSession.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
});
await step('seed the rest of curriculum completion directly (realistic bulk backfill, not re-driving 40+ real requests — see file header)', async () => {
const client = `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c`;
const { execSync } = require('child_process');
const rest = ['kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics', 'multi-container-pod-basics',
'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics',
'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics',
'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq',
'deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq',
'services-mcq', 'expose-service-basics',
'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline',
'extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
const values = rest.map((sid) => `('${created.student_id}','${sid}','completed',1,now(),now())`).join(',');
execSync(`${client} "INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at) VALUES ${values} ON CONFLICT (student_id, scenario_id) DO UPDATE SET status='completed'"`, { cwd: `${__dirname}/../../../platform` });
});
await step('student completes the RootNode Capstone for real (the actual server-authoritative draw + validation)', async () => {
const start = await returningSession.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(start.status, 201);
for (const s of start.body.scenarios) {
const r = await returningSession.request('POST', '/api/progress/record', { scenario_id: s.scenario_id, status: 'completed', environment_id: envId });
assert.equal(r.status, 200);
}
});
await step('the program moves to Completed — server-derived, no client "mark complete" action', async () => {
const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.status, 'completed');
assert.ok(body.completed_at);
});
await step('the admin portal shows a durable completion record for this student', async () => {
const { body } = await owner.request('GET', `/api/admin/students/${created.student_id}/enrollments`);
const view = body.find((e) => e.enrollment_id === enrollmentId);
assert.equal(view.status, 'completed');
});
// ── A second, uninvolved student — proves entitlement is real, not just "logged in" ──────
const { body: otherCreate } = await owner.request('POST', '/api/admin/students', { display_name: 'Rehearsal Outsider', login_identifier: `p5b-outsider-${RUN_ID}` });
const outsider = makeSession(GW);
await outsider.request('POST', '/api/student/activate', { token: otherCreate.activation_token, password: 'OutsiderPass123!' });
await step('an uninvolved student cannot reach the first student\'s program or Lab path', async () => {
const read = await outsider.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(read.status, 404);
const launch = await outsider.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 404);
const list = await outsider.request('GET', '/api/student/programs');
assert.equal(list.body.length, 0);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+11 -7
View File
@@ -7,16 +7,20 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
- `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, detailed
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
- `api/` — the lightweight Platform API: students, cohorts and cohort membership, curriculum,
detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
capstone assessment engine, achievements, per-instructor accounts, persistent student/
instructor sessions, RBAC, audit logging, 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:
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
(Phase 5A), and self-paced programs/enrollment + the website integration boundary (Phase 5B),
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` (split + Postgres migration),
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
reference: `api/README.md`.
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom),
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary).
Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param — and,
as of Phase 3B, server-side gating of progressive hint reveal). Design:
@@ -0,0 +1,43 @@
-- Real student accounts — Phase 5A §2/§3/§7, docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md.
--
-- Additive only (check-before-alter, docs/ENGINEERING_SOP.md §6) — every existing pilot student
-- row stays a valid academic record with all columns NULL/default. A pilot student
-- (password_hash IS NULL) can still use the original bare student_id login
-- (POST /api/login, unchanged) until an instructor converts them; a converted/newly-created
-- student (password_hash IS NOT NULL) must use the new credentialed login
-- (POST /api/student/login) instead. See src/students-auth.js.
--
-- email vs login_identifier (Phase 5A §3): RootNode serves minors who may not have an email
-- address. Both are nullable, independently unique — an older/self-paced student gets `email`,
-- a younger instructor-managed student gets `login_identifier` (any safe non-email string the
-- instructor assigns, e.g. a classroom username). Exactly one of the two is required once an
-- account is credentialed (enforced in application code, not a CHECK, so the exact rule can
-- evolve without a migration). Neither column names or implies a parent/guardian relationship —
-- that's explicitly future work (docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md), not built here;
-- the schema deliberately doesn't need to change to add it later (a guardian would reference a
-- student_id, not the other way around).
ALTER TABLE students ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS login_identifier TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS password_hash TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- The activation/reset token is ONE mechanism serving two purposes (first-login activation and
-- forgot-password) — both are "a time-limited secure token that lets you set a new password,"
-- functionally identical, so one column set instead of two parallel ones. Only the hash is
-- ever stored (crypto.createHash('sha256'), same pattern as sessions.js's token_hash) — the
-- plaintext token exists only in the instructor's API response / student's activation link,
-- never persisted, never logged.
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_hash TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_purpose TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_expires_at TIMESTAMPTZ;
DO $$ BEGIN
ALTER TABLE students ADD CONSTRAINT students_credential_token_purpose_check
CHECK (credential_token_purpose IS NULL OR credential_token_purpose IN ('activation', 'reset'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_email ON students(email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_login_identifier ON students(login_identifier) WHERE login_identifier IS NOT NULL;
@@ -0,0 +1,99 @@
-- Live classroom sessions — Phase 5A §11/§12. A classroom session is a distinct concept from a
-- cohort (a standing roster), an assessment session (one student's exam attempt), a Lab
-- environment (disposable infrastructure), or a login session (one browser's auth) — it
-- represents ONE live teaching event, e.g. "Week 3 — Kubernetes Workloads, Aug 29 6-8pm."
--
-- Deliberately five tables, not one JSONB blob: classroom_sessions (the event itself),
-- classroom_participants (who's in it + presence), classroom_events (append-only audit trail),
-- classroom_announcements and help_requests get their own tables rather than being folded into
-- classroom_events, since both need efficient direct queries ("show me the open help queue",
-- "show me this classroom's announcements in order") that a generic event log would make
-- awkward. classroom_events stays the audit trail (docs/PHASE5A... §25) — it does NOT duplicate
-- announcement/help-request content, just records that the action happened.
CREATE TABLE IF NOT EXISTS classroom_sessions (
classroom_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')),
scheduled_start TIMESTAMPTZ,
scheduled_end TIMESTAMPTZ,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_by UUID REFERENCES instructors(instructor_id),
-- "Current activity" (§20) — a scenario id, not a stage id, matching how students actually
-- work (one scenario at a time). NULL means the instructor hasn't set one yet. This is
-- guidance only — never used to redirect a browser or auto-mark work complete (enforced in
-- application code: nothing reads this to gate access or grade anything).
current_scenario_id TEXT,
-- Optional external video-conferencing link (§23) — a plain URL, RootNode never validates
-- which provider it points at and never proxies/embeds it.
meeting_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_cohort ON classroom_sessions(cohort_id);
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_status ON classroom_sessions(status);
-- At most one LIVE classroom per cohort at a time — an instructor starting a second live
-- session for the same cohort while one is already live is almost certainly a mistake, not a
-- real use case (see classroom.js's startClassroom for the app-level check this backs).
CREATE UNIQUE INDEX IF NOT EXISTS idx_classroom_sessions_one_live_per_cohort
ON classroom_sessions(cohort_id) WHERE status = 'live';
CREATE TABLE IF NOT EXISTS classroom_participants (
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
student_id TEXT NOT NULL REFERENCES students(student_id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Presence (§15) — updated by a throttled heartbeat while the student has this classroom
-- open, never by Lab terminal activity. online/recently-active/offline is DERIVED from this
-- at read time (see admin-classroom-routes.js), not stored as a separate status column, so
-- there's nothing to keep in sync or go stale on its own.
last_seen_at TIMESTAMPTZ,
left_at TIMESTAMPTZ,
PRIMARY KEY (classroom_session_id, student_id)
);
CREATE INDEX IF NOT EXISTS idx_classroom_participants_student ON classroom_participants(student_id);
CREATE TABLE IF NOT EXISTS classroom_events (
event_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'student', 'system')),
actor_id TEXT,
event_type TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_events_session ON classroom_events(classroom_session_id, created_at);
CREATE TABLE IF NOT EXISTS classroom_announcements (
announcement_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
instructor_id UUID REFERENCES instructors(instructor_id),
message TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_announcements_session ON classroom_announcements(classroom_session_id, created_at);
CREATE TABLE IF NOT EXISTS help_requests (
help_request_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
student_id TEXT NOT NULL REFERENCES students(student_id),
category TEXT, -- free text ('lab_problem' | 'dont_understand' | 'validation_failing' | 'other' from the frontend, not a DB-enforced enum — see §18's example list)
message TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acknowledged_at TIMESTAMPTZ,
acknowledged_by UUID REFERENCES instructors(instructor_id),
resolved_at TIMESTAMPTZ,
instructor_note TEXT
);
CREATE INDEX IF NOT EXISTS idx_help_requests_session_status ON help_requests(classroom_session_id, status);
CREATE INDEX IF NOT EXISTS idx_help_requests_student ON help_requests(student_id);
+45
View File
@@ -0,0 +1,45 @@
-- Phase 5B §2 — programs: first-class learning products, distinct from curriculum JSON itself
-- (a program is the commercial/academic unit a student enrolls in; curriculum_path is still
-- just the content it points at, unchanged since Phase 1). program_id IS the slug — same
-- convention as assessment_definitions/cohorts (a single TEXT primary key, no separate numeric
-- id + slug pair).
--
-- family is the init_/compile_/root_ youth-engineering ladder (see
-- docs/PHASE5B_SELF_PACED_ENROLLMENT.md §1) — only 'root' has real curriculum content today;
-- init/compile rows can be added later without a schema change, they just won't have a working
-- curriculum_path yet. delivery_mode describes how the program is normally taken; an
-- individual enrollment can still snapshot a different mode (see 020_enrollments.sql).
--
-- version is a plain incrementing integer, matching assessment_definitions' existing
-- versioning convention — enrollments snapshot the version+curriculum_path they were created
-- against so a later edit to a program never silently changes what an already-enrolled
-- student is being held to.
CREATE TABLE IF NOT EXISTS programs (
program_id TEXT PRIMARY KEY, -- slug, e.g. 'root-kubernetes-foundations'
name TEXT NOT NULL,
description TEXT,
family TEXT NOT NULL CHECK (family IN ('init', 'compile', 'root')),
delivery_mode TEXT NOT NULL DEFAULT 'self_paced' CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid')),
curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path',
completion_assessment_id TEXT REFERENCES assessment_definitions(assessment_definition_id),
estimated_duration_hours INTEGER,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_by UUID REFERENCES instructors(instructor_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_programs_status ON programs(status);
CREATE INDEX IF NOT EXISTS idx_programs_family ON programs(family);
-- Seed: the one legitimate self-paced program this phase ships, wrapping the EXISTING
-- RootNode/Kubernetes curriculum and capstone — no new curriculum content invented. init_/
-- compile_ programs are intentionally NOT seeded (no real curriculum for them yet, per this
-- phase's explicit scope line).
INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours)
VALUES (
'root-kubernetes-foundations', 'ROOT — Kubernetes Foundations',
'Self-paced Linux, containers, and Kubernetes operations — the existing RootNode curriculum, from Foundations through the RootNode Capstone.',
'root', 'self_paced', 'rootnode-path', 'rootnode-capstone', 40
) ON CONFLICT (program_id) DO NOTHING;
@@ -0,0 +1,51 @@
-- Phase 5B §3 — enrollments: a student's academic access to a program. Deliberately separate
-- from cohort_enrollments (002_cohorts.sql) — a self-paced enrollment has no cohort at all, and
-- a student can hold both kinds independently (see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §4).
--
-- delivery_mode/program_version/curriculum_path are snapshotted from the program AT enrollment
-- time — same versioning principle as assessment_sessions (013_assessments.sql) snapshotting a
-- definition's fields at draw time, so editing a program later never silently changes what an
-- already-enrolled student is being held to.
--
-- status is server-transitioned only (enrollment.js) — pending -> active -> completed/expired,
-- or pending/active -> cancelled. 'expired' is set lazily/derived at read time from
-- access_ends_at (matching classroom.js's derivePresence pattern — no stored value to go
-- stale), so it is intentionally NOT a value the application ever writes directly except when
-- reconciling; see enrollment.js's deriveEnrollmentAccess.
CREATE TABLE IF NOT EXISTS enrollments (
enrollment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id TEXT NOT NULL REFERENCES students(student_id),
program_id TEXT NOT NULL REFERENCES programs(program_id),
delivery_mode TEXT NOT NULL, -- snapshot of programs.delivery_mode at enrollment time
program_version INTEGER NOT NULL, -- snapshot of programs.version at enrollment time
curriculum_path TEXT NOT NULL, -- snapshot of programs.curriculum_path at enrollment time
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'completed', 'cancelled', 'expired')),
source TEXT NOT NULL DEFAULT 'admin', -- free text provenance ('admin', or whatever the integration event's own "source" said, e.g. 'website') — NEVER used as a metrics label, see metrics.js
external_event_id TEXT, -- idempotency key for integration-created enrollments; null for admin-created ones
external_reference TEXT, -- optional business reference (order/payment id) — free text, not unique, audit-only
starts_at TIMESTAMPTZ, -- null = immediate access once active
access_ends_at TIMESTAMPTZ, -- null = no expiration
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
metadata JSONB,
created_by UUID REFERENCES instructors(instructor_id), -- null for integration-created enrollments
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_enrollments_student ON enrollments(student_id);
CREATE INDEX IF NOT EXISTS idx_enrollments_program ON enrollments(program_id);
CREATE INDEX IF NOT EXISTS idx_enrollments_status ON enrollments(status);
CREATE INDEX IF NOT EXISTS idx_enrollments_created_by ON enrollments(created_by);
-- Idempotency: the same external_event_id can never create two rows. A unique index (not just
-- app-level "SELECT first") is what actually makes concurrent duplicate submissions safe — see
-- enrollment.js's createFromIntegrationEvent, which relies on this via ON CONFLICT.
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_external_event ON enrollments(external_event_id) WHERE external_event_id IS NOT NULL;
-- At most one non-terminal (pending/active) enrollment per student+program at a time. A
-- completed/cancelled/expired enrollment doesn't block a new one — re-enrollment creates a new
-- row rather than reviving a historical one (§3: "do not corrupt historical records").
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_one_open_per_program ON enrollments(student_id, program_id) WHERE status IN ('pending', 'active');
+175
View File
@@ -0,0 +1,175 @@
// Instructor-facing classroom API — Phase 5A §14/§21. RBAC via rbac.js's
// requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolve
// through to the classroom's cohort, same owner/admin/instructor/viewer rules as everything
// else cohort-scoped — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §19 for role_in_cohort).
//
// The dashboard aggregation (GET /api/admin/classroom-sessions/:id) is the one place in this
// file that reaches beyond classroom.js — it pulls in Provisioner Lab state (§16) and Phase 3B
// academic records (§17) alongside classroom.js's own participant/presence/help-queue data,
// because that combined view is the entire point of a live classroom dashboard. Neither the
// Provisioner nor the academic-records shape is duplicated here — both are read exactly the
// way admin-routes.js/admin-learning-routes.js already read them.
const classroom = require('./classroom');
const { prepareLabsForCohort } = require('./prepare-labs');
module.exports = function registerAdminClassroomRoutes(app, deps) {
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireAnyInstructor, requireCohortAccess, requireCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess } = rbac;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
};
// Maps classroom.js's INVALID_STATE/NOT_FOUND/ALREADY_LIVE error codes to HTTP status —
// every lifecycle route below funnels through this instead of repeating the same try/catch.
function classroomErrorStatus(code) {
if (code === 'NOT_FOUND') return 404;
if (code === 'ALREADY_LIVE' || code === 'INVALID_STATE') return 409;
return 500;
}
admin.post('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
const { title, description, scheduled_start, scheduled_end, meeting_url } = req.body || {};
if (!title) return res.status(400).json({ error: 'title is required' });
const created = await classroom.createClassroom(getPool(), {
cohortId: req.params.id, title, description, scheduledStart: scheduled_start, scheduledEnd: scheduled_end,
meetingUrl: meeting_url, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_created', resourceType: 'classroom_session', resourceId: created.classroom_session_id, result: 'success', sourceIp: req.ip });
res.status(201).json(created);
});
admin.get('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
res.json(await classroom.listClassroomsForCohort(getPool(), req.params.id));
});
// The dashboard itself — §14's "ROOTNODE LIVE CLASSROOM" view. Built from real available
// data, not a fixed mock: participant presence (classroom.js), each participant's Lab status
// (Provisioner, reusing the SAME environment-state values everywhere else in the app uses —
// no second state system, §16), and a lightweight per-student learning snapshot (§17) —
// current stage, most recent scenario worked, its status/validation-failure count, hints
// used. Never inspects terminal command history.
admin.get('/api/admin/classroom-sessions/:id', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
const pool = getPool();
const classroomRow = await classroom.getClassroom(pool, req.params.id);
const [participants, announcements, helpRequests] = await Promise.all([
classroom.listParticipants(pool, req.params.id),
classroom.listAnnouncements(pool, req.params.id),
classroom.listHelpRequests(pool, req.params.id),
]);
const path = curriculum.loadCurriculumPath();
const students = await Promise.all(participants.map(async (p) => {
const [envRes, progress, lastAttempt] = await Promise.all([
provisioner.listForStudent(p.student_id),
loadProgress(pool, p.student_id),
pool.query(
`SELECT scenario_id, status, validation_fail_count, hint_count FROM scenario_attempts
WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 1`,
[p.student_id]
),
]);
const env = envRes.body?.find((e) => e.status !== 'destroyed');
const completed = Object.values(progress).filter((pr) => pr.status === 'completed').length;
const currentStage = path ? path.stages.find((s) => (s.scenario_ids || []).some((sid) => progress[sid]?.status !== 'completed'))?.id || null : null;
return {
...p,
lab_status: env ? env.status : 'none', // 'none'|'requested'|'provisioning'|'running'|'resetting'|'error'|'destroying'|'destroyed' — the real Provisioner enum, unchanged
current_stage: currentStage,
current_scenario: lastAttempt.rows[0]?.scenario_id || null,
attempt_status: lastAttempt.rows[0]?.status || null,
validation_failures: lastAttempt.rows[0]?.validation_fail_count || 0,
hints_used: lastAttempt.rows[0]?.hint_count || 0,
scenarios_completed: completed,
};
}));
res.json({ classroom: classroomRow, students, announcements, help_requests: helpRequests });
});
admin.post('/api/admin/classroom-sessions/:id/start', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.startClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_started', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/end', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.endClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_ended', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/cancel', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.cancelClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_cancelled', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
// §21 — the same underlying prepareLabsForCohort() the cohort-level route uses, just entered
// from the classroom dashboard and scoped to req.classroomCohortId (set by
// requireClassroomWriteAccess). Respects admission control exactly the same way (Phase 4.1
// §26) — a full host surfaces as lab_status: 'capacity_waiting' per student, not a crash.
admin.post('/api/admin/classroom-sessions/:id/prepare-labs', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const results = await prepareLabsForCohort(getPool(), provisioner, req.classroomCohortId);
await classroom.emitEvent(getPool(), req.params.id, 'instructor', req.instructor.instructor_id, 'labs_prepared', { student_count: results.length });
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_labs_prepared', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
res.json({ results });
});
admin.post('/api/admin/classroom-sessions/:id/activity', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const { scenario_id } = req.body || {};
try {
const updated = await classroom.setCurrentActivity(getPool(), req.params.id, scenario_id, req.instructor.instructor_id);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/announcements', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const { message } = req.body || {};
if (!message || !message.trim()) return res.status(400).json({ error: 'message is required' });
const created = await classroom.postAnnouncement(getPool(), req.params.id, req.instructor.instructor_id, message.trim());
res.status(201).json(created);
});
admin.get('/api/admin/classroom-sessions/:id/help-requests', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
res.json(await classroom.listHelpRequests(getPool(), req.params.id, req.query.status));
});
admin.post('/api/admin/help-requests/:id/acknowledge', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.acknowledgeHelpRequest(getPool(), req.params.id, req.instructor.instructor_id);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/help-requests/:id/resolve', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
const { note } = req.body || {};
try {
const updated = await classroom.resolveHelpRequest(getPool(), req.params.id, req.instructor.instructor_id, note);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
};
+167
View File
@@ -0,0 +1,167 @@
// Phase 5B §11 — instructor/admin program and enrollment management. Program authoring
// (create/edit) is admin/owner only, matching assessment_definitions' existing convention;
// every role can list/inspect (matches AssessmentsView's "viewed by all roles, only authoring
// restricted" pattern). Enrollment creation is admin/owner only (matches roster-creation
// precedent — POST /api/admin/students is the same restriction); a plain instructor can still
// view/activate/cancel an enrollment they created or that belongs to a student sharing one of
// their cohorts, via rbac.js's requireEnrollmentAccess — see that function's own comment for
// the exact documented rule.
const programsLib = require('./programs');
const enrollmentLib = require('./enrollment');
const { buildProgramStatus } = require('./program-status');
function enrollmentErrorStatus(code) {
if (code === 'NOT_FOUND') return 404;
if (code === 'PROGRAM_NOT_FOUND') return 404;
if (code === 'ALREADY_ENROLLED' || code === 'INVALID_STATE' || code === 'PROGRAM_INACTIVE') return 409;
return 500;
}
module.exports = function registerAdminProgramsRoutes(app, deps) {
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireAnyInstructor, requireAdminOrOwner, requireEnrollmentAccess } = rbac;
const statusDeps = { pool: getPool(), curriculum, loadProgress };
// Local wrap, same convention as admin-routes.js/admin-classroom-routes.js (NOT the stricter
// server.js-level `wrap`, which assumes fn always returns a promise — RBAC middleware like
// requireAnyInstructor is a plain sync `(req,res,next)` function, so it needs the
// Promise.resolve() guard here to be safely mixed into the same mw.map(wrap) list as the
// async route handlers).
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
};
// ── Programs ─────────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/programs', requireAnyInstructor, async (req, res) => {
res.json(await programsLib.listPrograms(getPool()));
});
admin.get('/api/admin/programs/:id', requireAnyInstructor, async (req, res) => {
const program = await programsLib.getProgram(getPool(), req.params.id);
if (!program) return res.status(404).json({ error: 'not found' });
const { rows: statusCounts } = await getPool().query(
`SELECT status, count(*)::int AS n FROM enrollments WHERE program_id = $1 GROUP BY status`, [req.params.id]
);
res.json({ ...program, enrollment_counts: Object.fromEntries(statusCounts.map((r) => [r.status, r.n])) });
});
admin.post('/api/admin/programs', requireAdminOrOwner, async (req, res) => {
const { program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours } = req.body || {};
if (!program_id || !name || !family) return res.status(400).json({ error: 'program_id, name, and family are required' });
if (!['init', 'compile', 'root'].includes(family)) return res.status(400).json({ error: 'family must be init, compile, or root' });
try {
const program = await programsLib.createProgram(getPool(), {
programId: program_id, name, description, family, deliveryMode: delivery_mode, curriculumPath: curriculum_path,
completionAssessmentId: completion_assessment_id, estimatedDurationHours: estimated_duration_hours, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_created', resourceType: 'program', resourceId: program.program_id, result: 'success', sourceIp: req.ip });
res.status(201).json(program);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'program_id already exists' });
throw e;
}
});
admin.patch('/api/admin/programs/:id', requireAdminOrOwner, async (req, res) => {
const { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status } = req.body || {};
const map = { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status };
const fields = {};
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v;
if (!Object.keys(fields).length) return res.status(400).json({ error: 'nothing to update' });
const program = await programsLib.updateProgram(getPool(), req.params.id, fields);
if (!program) return res.status(404).json({ error: 'not found' });
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_updated', resourceType: 'program', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: fields });
res.json(program);
});
// ── Enrollments ──────────────────────────────────────────────────────────────────────────
// Scoped list, same shape as GET /api/admin/students: owner/admin see every enrollment for
// the program; a plain instructor sees only the ones they created or that belong to a
// student sharing one of their cohorts (the same rule requireEnrollmentAccess enforces for
// single-resource routes, applied here as a query filter instead of a per-row check).
admin.get('/api/admin/programs/:id/enrollments', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT e.*, s.display_name AS student_display_name FROM enrollments e
JOIN students s ON s.student_id = e.student_id
LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled'
LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2
WHERE e.program_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL)
ORDER BY e.enrolled_at DESC`,
[req.params.id, req.instructor.instructor_id])
: await pool.query(
`SELECT e.*, s.display_name AS student_display_name FROM enrollments e
JOIN students s ON s.student_id = e.student_id WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`,
[req.params.id]);
res.json(rows);
});
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, metadata } = req.body || {};
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
try {
const created = await enrollmentLib.createManualEnrollment(getPool(), {
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
deliveryMode: delivery_mode, externalReference: external_reference, metadata, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
res.status(201).json(created);
} catch (e) {
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
});
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
const row = await enrollmentLib.getEnrollment(getPool(), req.params.id);
res.json(await buildProgramStatus(statusDeps, row));
});
admin.post('/api/admin/enrollments/:id/activate', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
try {
const updated = await enrollmentLib.activateEnrollment(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_activated', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/enrollments/:id/cancel', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
try {
const updated = await enrollmentLib.cancelEnrollment(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_cancelled', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
// §11 — extends the existing student detail view: enrolled programs, status, progress,
// current stage, last activity, completion, delivery mode. Scoped the same way the
// enrollments-list route above is (owner/admin see everything; a plain instructor sees only
// enrollments they created or that share a cohort with them) rather than requiring
// requireStudentCohortAccess, which would 403 for a self-paced-only student with no cohort at
// all even for the instructor who manually enrolled them.
admin.get('/api/admin/students/:id/enrollments', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT e.* FROM enrollments e
LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled'
LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2
WHERE e.student_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL)
ORDER BY e.enrolled_at DESC`,
[req.params.id, req.instructor.instructor_id])
: await pool.query(`SELECT * FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC`, [req.params.id]);
const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e)));
res.json(views);
});
};
+147 -23
View File
@@ -16,6 +16,9 @@
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
module.exports = function registerAdminRoutes(app, deps) {
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
@@ -95,33 +98,168 @@ module.exports = function registerAdminRoutes(app, deps) {
});
// ── Students ─────────────────────────────────────────────────────────────────────────
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
// credential_token_hash (migration 017), which must never reach an API response
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT s.* FROM students s
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
: await pool.query(
`SELECT student_id, display_name, status, email, login_identifier,
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
FROM students ORDER BY display_name`);
res.json(rows);
});
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
// self-paced student gets email, a younger instructor-managed one gets login_identifier
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
// an activation token immediately (§5's "instructor creates account -> temporary credential"
// flow) and returns it ONCE in this response — the instructor relays it to the student
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
const { display_name, cohort_id } = req.body || {};
const { display_name, cohort_id, email, login_identifier } = 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()]);
try {
await pool.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
if (cohort_id) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[cohort_id, studentId]
);
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
let activationToken = null;
if (email || login_identifier) {
const issued = await issueCredentialToken(pool, studentId, 'activation');
activationToken = issued.token;
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
});
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
// account: assigns email/login_identifier and issues an activation token, same mechanism as
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
// membership is touched — student_id, the FK every other table hangs off of, never changes.
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
const { email, login_identifier } = req.body || {};
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
try {
await pool.query(
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, activation_token: token });
});
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
// token and revokes every existing session, same "instructor never needs to know the
// student's password" principle as activation.
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, reset_token: token });
});
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
res.json(sessionsList);
});
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
// returned, rather than silently importing the valid rows and skipping the rest (the safer
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
// accepts a password/password_hash column — activation tokens are always generated server-side.
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
const { csv } = req.body || {};
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
const pool = getPool();
const client = await pool.connect();
const created = [];
try {
await client.query('BEGIN');
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
let nextN = countRows[0].n + 1;
for (const row of parsedRows) {
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
await client.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, row.display_name, row.email || null, row.login_identifier || null]
);
if (row.cohort_id) {
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
}
let activationToken = null;
if (row.email || row.login_identifier) {
// issueCredentialToken only ever calls .query() on what it's given — a transaction
// client works identically to a pool here, so the whole import (including every
// token) commits or rolls back together with everything else.
const issued = await issueCredentialToken(client, studentId, 'activation');
activationToken = issued.token;
}
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
throw e;
} finally {
client.release();
}
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
res.status(201).json({ created });
});
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
@@ -306,23 +444,9 @@ module.exports = function registerAdminRoutes(app, deps) {
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), 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]
);
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 };
}));
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
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 }),
});
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
res.json({ results });
});
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
+75
View File
@@ -0,0 +1,75 @@
// Student-facing classroom API — Phase 5A §22/§24. Every route here requires studentSession
// (session-derived student_id, never trusted from the request body/params — same rule as
// everywhere else a student acts) AND that the student is actually enrolled in the classroom's
// cohort (cohort_enrollments) — a student can never join/view/act on a classroom for a cohort
// they don't belong to, regardless of whether they know or can guess its id.
const classroom = require('./classroom');
module.exports = function registerClassroomRoutes(app, deps) {
const { studentSession, getPool, curriculum } = deps;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
// Resolves the classroom AND checks cohort enrollment in one place — every route below uses
// this instead of repeating the join. Sets req.classroom on success.
async function requireEnrolledClassroom(req, res, next) {
const classroomRow = await classroom.getClassroom(getPool(), req.params.id);
if (!classroomRow) return res.status(404).json({ error: 'not found' });
const { rowCount } = await getPool().query(
`SELECT 1 FROM cohort_enrollments WHERE cohort_id = $1 AND student_id = $2 AND enrollment_status = 'enrolled'`,
[classroomRow.cohort_id, req.studentId]
);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
req.classroom = classroomRow;
next();
}
// §22 — "LIVE NOW" banner on the student dashboard. Null if nothing is live for any cohort
// this student belongs to (deliberately not "their one cohort" — §27 self-paced compatible).
app.get('/api/classroom/live', studentSession, wrap(async (req, res) => {
const live = await classroom.getLiveClassroomForStudent(getPool(), req.studentId);
res.json({ classroom: live });
}));
app.get('/api/classroom/:id', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const [announcements, own] = await Promise.all([
classroom.listAnnouncements(getPool(), req.params.id),
getPool().query(`SELECT * FROM help_requests WHERE classroom_session_id = $1 AND student_id = $2 ORDER BY created_at DESC LIMIT 5`, [req.params.id, req.studentId]),
]);
res.json({ classroom: req.classroom, announcements, my_help_requests: own.rows });
}));
app.get('/api/classroom/:id/announcements', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
res.json(await classroom.listAnnouncements(getPool(), req.params.id));
}));
app.post('/api/classroom/:id/join', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
if (req.classroom.status !== 'live') return res.status(409).json({ error: 'not_live', message: 'This classroom is not currently live.' });
const participant = await classroom.joinClassroom(getPool(), req.params.id, req.studentId);
res.json(participant);
}));
app.post('/api/classroom/:id/leave', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.leaveClassroom(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §15 — throttled server-side (classroom.js's heartbeat only writes if the last recorded
// beat is old enough), so the frontend can just call this every 20-30s without either side
// needing to track timing beyond "did the interval fire."
app.post('/api/classroom/:id/heartbeat', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.heartbeat(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §18 — no chat, no video, no remote shell takeover: exactly one action (raise a flag with
// an optional category/message), visible only to instructors managing this cohort.
app.post('/api/classroom/:id/help-request', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const { category, message } = req.body || {};
const created = await classroom.createHelpRequest(getPool(), req.params.id, req.studentId, category, message);
res.status(201).json(created);
}));
};
+242
View File
@@ -0,0 +1,242 @@
// Classroom session lifecycle — Phase 5A §11-§20. A classroom session is a live teaching event,
// distinct from a cohort (standing roster), an assessment session (one exam attempt), a Lab
// environment (disposable infra), or a login session. This module owns the pure
// classroom_sessions/classroom_participants/classroom_events/classroom_announcements/
// help_requests CRUD and lifecycle rules; route files (admin-classroom-routes.js,
// classroom-routes.js) own RBAC/session/HTTP shape and the dashboard aggregation that pulls in
// Provisioner Lab state and Phase 3B academic records (this module doesn't know about either).
const PRESENCE_ONLINE_MS = 45 * 1000; // ~1.5x the 20-30s heartbeat interval the frontend uses
const PRESENCE_RECENTLY_ACTIVE_MS = 5 * 60 * 1000;
const HEARTBEAT_WRITE_THROTTLE_MS = 15 * 1000; // §15 "avoid excessive database writes" — coalesce heartbeats closer together than this into a no-op
async function emitEvent(pool, classroomSessionId, actorType, actorId, eventType, metadata) {
await pool.query(
`INSERT INTO classroom_events (classroom_session_id, actor_type, actor_id, event_type, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[classroomSessionId, actorType, actorId ? String(actorId) : null, eventType, metadata ? JSON.stringify(metadata) : null]
);
}
async function createClassroom(pool, { cohortId, title, description, scheduledStart, scheduledEnd, meetingUrl, createdBy }) {
const { rows } = await pool.query(
`INSERT INTO classroom_sessions (cohort_id, title, description, scheduled_start, scheduled_end, meeting_url, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[cohortId, title, description || null, scheduledStart || null, scheduledEnd || null, meetingUrl || null, createdBy]
);
const classroom = rows[0];
await emitEvent(pool, classroom.classroom_session_id, 'instructor', createdBy, 'created', { title });
return classroom;
}
async function getClassroom(pool, classroomSessionId) {
const { rows } = await pool.query(`SELECT * FROM classroom_sessions WHERE classroom_session_id = $1`, [classroomSessionId]);
return rows[0] || null;
}
async function listClassroomsForCohort(pool, cohortId) {
const { rows } = await pool.query(
`SELECT * FROM classroom_sessions WHERE cohort_id = $1 ORDER BY COALESCE(scheduled_start, created_at) DESC`,
[cohortId]
);
return rows;
}
// §13 lifecycle: scheduled -> live -> ended, or -> cancelled from scheduled/live. The DB's
// partial unique index (idx_classroom_sessions_one_live_per_cohort) is the actual enforcement
// for "at most one live classroom per cohort" — this pre-check just gives a clean error
// instead of a raw constraint-violation message.
async function startClassroom(pool, classroomSessionId, instructorId) {
const classroom = await getClassroom(pool, classroomSessionId);
if (!classroom) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
if (classroom.status !== 'scheduled') throw Object.assign(new Error(`cannot start a classroom in status '${classroom.status}'`), { code: 'INVALID_STATE' });
try {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'live', started_at = now(), updated_at = now() WHERE classroom_session_id = $1 RETURNING *`,
[classroomSessionId]
);
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'started', {});
return rows[0];
} catch (e) {
if (e.code === '23505') throw Object.assign(new Error('this cohort already has a live classroom'), { code: 'ALREADY_LIVE' });
throw e;
}
}
// Ending a classroom is purely a status change — never touches Labs, progress, attempts, or
// any academic data (§13/§26). Labs are only ever destroyed by an explicit, separate action
// (the existing cohort Destroy Labs / Prepare Labs machinery), never implicitly by this.
async function endClassroom(pool, classroomSessionId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'ended', ended_at = now(), updated_at = now()
WHERE classroom_session_id = $1 AND status = 'live' RETURNING *`,
[classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found or not live'), { code: 'INVALID_STATE' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'ended', {});
return rows[0];
}
async function cancelClassroom(pool, classroomSessionId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'cancelled', updated_at = now()
WHERE classroom_session_id = $1 AND status IN ('scheduled', 'live') RETURNING *`,
[classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found or already ended/cancelled'), { code: 'INVALID_STATE' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'cancelled', {});
return rows[0];
}
// §20 — guidance only. Never redirects a browser, never marks anything complete; students just
// see this value and choose to open that scenario themselves (see classroom-routes.js).
async function setCurrentActivity(pool, classroomSessionId, scenarioId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET current_scenario_id = $1, updated_at = now() WHERE classroom_session_id = $2 RETURNING *`,
[scenarioId || null, classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'activity_changed', { scenario_id: scenarioId });
return rows[0];
}
async function postAnnouncement(pool, classroomSessionId, instructorId, message) {
const { rows } = await pool.query(
`INSERT INTO classroom_announcements (classroom_session_id, instructor_id, message) VALUES ($1, $2, $3) RETURNING *`,
[classroomSessionId, instructorId, message]
);
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'announcement_posted', { announcement_id: rows[0].announcement_id });
return rows[0];
}
async function listAnnouncements(pool, classroomSessionId) {
const { rows } = await pool.query(
`SELECT announcement_id, message, created_at FROM classroom_announcements
WHERE classroom_session_id = $1 ORDER BY created_at DESC LIMIT 50`,
[classroomSessionId]
);
return rows;
}
// §11/§22 — join is idempotent: re-joining an already-joined classroom just clears left_at and
// bumps last_seen_at, it doesn't create a second row or duplicate joined_at (PRIMARY KEY is
// (classroom_session_id, student_id) — there can only ever be one participation row per
// student per classroom, matching how a student can leave and rejoin the same live session).
async function joinClassroom(pool, classroomSessionId, studentId) {
const { rows } = await pool.query(
`INSERT INTO classroom_participants (classroom_session_id, student_id, last_seen_at)
VALUES ($1, $2, now())
ON CONFLICT (classroom_session_id, student_id)
DO UPDATE SET last_seen_at = now(), left_at = NULL
RETURNING *`,
[classroomSessionId, studentId]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'joined', {});
return rows[0];
}
async function leaveClassroom(pool, classroomSessionId, studentId) {
await pool.query(
`UPDATE classroom_participants SET left_at = now() WHERE classroom_session_id = $1 AND student_id = $2`,
[classroomSessionId, studentId]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'left', {});
}
// §15 presence — throttled: a heartbeat within HEARTBEAT_WRITE_THROTTLE_MS of the last recorded
// one is a silent no-op (still returns success to the client, just skips the write) rather than
// hammering Postgres every 20s per online student. Deliberately NOT a full audit-logged event
// (§25 "do not turn presence heartbeats into audit spam") — no classroom_events row per beat.
async function heartbeat(pool, classroomSessionId, studentId) {
await pool.query(
`UPDATE classroom_participants SET last_seen_at = now()
WHERE classroom_session_id = $1 AND student_id = $2
AND (last_seen_at IS NULL OR last_seen_at < now() - ($3 || ' milliseconds')::interval)`,
[classroomSessionId, studentId, HEARTBEAT_WRITE_THROTTLE_MS]
);
}
function derivePresence(lastSeenAt, leftAt) {
if (leftAt) return 'offline';
if (!lastSeenAt) return 'offline';
const age = Date.now() - new Date(lastSeenAt).getTime();
if (age <= PRESENCE_ONLINE_MS) return 'online';
if (age <= PRESENCE_RECENTLY_ACTIVE_MS) return 'recently_active';
return 'offline';
}
async function listParticipants(pool, classroomSessionId) {
const { rows } = await pool.query(
`SELECT cp.student_id, s.display_name, cp.joined_at, cp.last_seen_at, cp.left_at
FROM classroom_participants cp JOIN students s ON s.student_id = cp.student_id
WHERE cp.classroom_session_id = $1 ORDER BY s.display_name`,
[classroomSessionId]
);
return rows.map((r) => ({ ...r, presence: derivePresence(r.last_seen_at, r.left_at) }));
}
// §27 — a student may be enrolled in more than one cohort eventually (self-paced compatible);
// this returns the FIRST live classroom across any cohort they're enrolled in, not "their one
// cohort's classroom" — matches "My Courses, not My Cohort" (§27) as the eventual shape.
async function getLiveClassroomForStudent(pool, studentId) {
const { rows } = await pool.query(
`SELECT cs.* FROM classroom_sessions cs
JOIN cohort_enrollments e ON e.cohort_id = cs.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND cs.status = 'live'
ORDER BY cs.started_at DESC LIMIT 1`,
[studentId]
);
return rows[0] || null;
}
// §18 help requests
async function createHelpRequest(pool, classroomSessionId, studentId, category, message) {
const { rows } = await pool.query(
`INSERT INTO help_requests (classroom_session_id, student_id, category, message) VALUES ($1, $2, $3, $4) RETURNING *`,
[classroomSessionId, studentId, category || null, message || null]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'help_requested', { help_request_id: rows[0].help_request_id, category });
return rows[0];
}
async function listHelpRequests(pool, classroomSessionId, statusFilter) {
const { rows } = await pool.query(
`SELECT hr.*, s.display_name FROM help_requests hr JOIN students s ON s.student_id = hr.student_id
WHERE hr.classroom_session_id = $1 AND ($2::text IS NULL OR hr.status = $2)
ORDER BY hr.created_at ASC`,
[classroomSessionId, statusFilter || null]
);
return rows;
}
async function acknowledgeHelpRequest(pool, helpRequestId, instructorId) {
const { rows } = await pool.query(
`UPDATE help_requests SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $1
WHERE help_request_id = $2 AND status = 'open' RETURNING *`,
[instructorId, helpRequestId]
);
if (!rows.length) throw Object.assign(new Error('help request not found or not open'), { code: 'INVALID_STATE' });
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_acknowledged', { help_request_id: helpRequestId });
return rows[0];
}
async function resolveHelpRequest(pool, helpRequestId, instructorId, note) {
const { rows } = await pool.query(
`UPDATE help_requests SET status = 'resolved', resolved_at = now(), instructor_note = COALESCE($1, instructor_note)
WHERE help_request_id = $2 AND status IN ('open', 'acknowledged') RETURNING *`,
[note || null, helpRequestId]
);
if (!rows.length) throw Object.assign(new Error('help request not found or already resolved'), { code: 'INVALID_STATE' });
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_resolved', { help_request_id: helpRequestId });
return rows[0];
}
module.exports = {
createClassroom, getClassroom, listClassroomsForCohort,
startClassroom, endClassroom, cancelClassroom, setCurrentActivity,
postAnnouncement, listAnnouncements,
joinClassroom, leaveClassroom, heartbeat, derivePresence, listParticipants,
getLiveClassroomForStudent,
createHelpRequest, listHelpRequests, acknowledgeHelpRequest, resolveHelpRequest,
emitEvent,
};
+227
View File
@@ -0,0 +1,227 @@
// Phase 5B §3/§10/§14 — enrollment lifecycle. A student's academic access to a program,
// separate from cohort membership (cohort_enrollments, untouched). All status transitions are
// server-side only — nothing here accepts a client-supplied status.
//
// Errors are thrown as `Error` with a `.code` the route layer maps to an HTTP status, the same
// convention classroom.js established in Phase 5A (NOT_FOUND/INVALID_STATE/ALREADY_LIVE).
function err(code, message) {
return Object.assign(new Error(message || code), { code });
}
// Pure and derived-at-read-time, matching classroom.js's derivePresence — 'expired' is never
// written to the database by a background sweep; it's computed here from access_ends_at
// whenever an enrollment is read, so there's no stale/out-of-sync value to worry about. The
// one status this function can "see" that the stored column doesn't is exactly that: whether a
// still-`active` row has actually run out its access window.
function deriveEnrollmentAccess(enrollment, now = new Date()) {
const { status, starts_at: startsAt, access_ends_at: accessEndsAt } = enrollment;
if (status === 'cancelled') return { effective_status: 'cancelled', can_launch: false, reason: 'cancelled' };
if (status === 'completed') return { effective_status: 'completed', can_launch: false, reason: 'completed' };
if (status === 'pending') return { effective_status: 'pending', can_launch: false, reason: 'pending_activation' };
// status === 'active' below.
if (accessEndsAt && new Date(accessEndsAt) <= now) {
return { effective_status: 'expired', can_launch: false, reason: 'access_expired' };
}
if (startsAt && new Date(startsAt) > now) {
return { effective_status: 'active', can_launch: false, reason: 'not_yet_started' };
}
return { effective_status: 'active', can_launch: true, reason: null };
}
async function getEnrollment(pool, enrollmentId) {
const { rows } = await pool.query(`SELECT * FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
return rows[0] || null;
}
async function listEnrollmentsForStudent(pool, studentId) {
const { rows } = await pool.query(
`SELECT e.*, p.name AS program_name, p.family AS program_family
FROM enrollments e JOIN programs p ON p.program_id = e.program_id
WHERE e.student_id = $1 ORDER BY e.enrolled_at DESC`,
[studentId]
);
return rows;
}
async function listEnrollmentsForProgram(pool, programId) {
const { rows } = await pool.query(
`SELECT e.*, s.display_name AS student_display_name FROM enrollments e
JOIN students s ON s.student_id = e.student_id
WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`,
[programId]
);
return rows;
}
// Admin manual enrollment (§11) — the program is snapshotted into the new row exactly the way
// createFromIntegrationEvent below does, so both creation paths produce identically-shaped
// enrollments. Throws PROGRAM_NOT_FOUND / PROGRAM_INACTIVE / ALREADY_ENROLLED (the DB's
// one-open-enrollment-per-program constraint, translated to a clean error instead of a raw
// 23505).
async function createManualEnrollment(pool, { studentId, programId, status, startsAt, accessEndsAt, deliveryMode, externalReference, metadata, createdBy }) {
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
const program = programRows[0];
if (!program) throw err('PROGRAM_NOT_FOUND', 'No such program.');
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
const initialStatus = status === 'active' ? 'active' : 'pending';
try {
const { rows } = await pool.query(
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_reference, starts_at, access_ends_at, activated_at, metadata, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'admin', $7, $8, $9, $10, $11, $12) RETURNING *`,
[studentId, programId, deliveryMode || program.delivery_mode, program.version, program.curriculum_path, initialStatus,
externalReference || null, startsAt || null, accessEndsAt || null, initialStatus === 'active' ? new Date() : null,
metadata ? JSON.stringify(metadata) : null, createdBy || null]
);
return rows[0];
} catch (e) {
if (e.code === '23505') throw err('ALREADY_ENROLLED', 'This student already has an open enrollment in this program.');
throw e;
}
}
// §12/§14 — the website/integration boundary's enrollment creation. Idempotent by construction:
// the DB-level unique index on external_event_id (not a SELECT-then-INSERT check) is what
// actually makes a concurrent duplicate submission safe, not just a sequential retry. Student
// resolution is create-or-match (§13): an existing student (matched by email OR
// login_identifier) is reused; no match creates a new one via the exact same credential/token
// machinery admin-created students already use (students-auth.js) — this endpoint is a
// trusted, authenticated, server-to-server caller, the same trust level an instructor's
// "add student" admin action already has, not a public signup form.
async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, programId, status }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent enrollment events.');
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
const program = programRows[0];
if (!program) throw err('PROGRAM_NOT_FOUND', 'Unknown program_slug.');
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
// Replay of an already-processed event — return the original result, create nothing.
if (eventId) {
const { rows: existing } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
if (existing[0]) return { enrollment: existing[0], created: false };
}
const student = await resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName });
const initialStatus = status === 'active' ? 'active' : 'pending';
try {
const { rows } = await pool.query(
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (external_event_id) WHERE external_event_id IS NOT NULL DO NOTHING
RETURNING *`,
[student.student_id, programId, program.delivery_mode, program.version, program.curriculum_path, initialStatus,
source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null]
);
if (rows[0]) return { enrollment: rows[0], created: true, student };
// Lost the ON CONFLICT race (concurrent identical replay) — the winner's row is now there.
const { rows: won } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
return { enrollment: won[0], created: false, student };
} catch (e) {
// The one-open-enrollment-per-program index fired instead — a genuinely new event for a
// student who already has an open enrollment in this program (e.g. a retried purchase with
// a different event id). Idempotent in spirit even though it's not a literal event replay:
// return the existing open enrollment rather than erroring or creating a second one.
if (e.code === '23505') {
const { rows: existingOpen } = await pool.query(
`SELECT * FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status IN ('pending','active')`,
[student.student_id, programId]
);
if (existingOpen[0]) return { enrollment: existingOpen[0], created: false, student };
}
throw e;
}
}
// Matches by email OR login_identifier; creates via students-auth's own credential-issuing path
// on no match (an activation token is generated exactly like an admin-created student, and
// handed back so a future email/notification step — not built in this phase — could relay it).
// A second concurrent call resolving the "same new student" case falls back to re-matching
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) {
const identifier = studentEmail || studentLoginIdentifier;
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
if (existing) return existing;
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
const displayName = studentDisplayName || studentEmail || studentLoginIdentifier;
try {
const { rows } = await pool.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
);
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
return rows[0];
} catch (e) {
if (e.code === '23505') {
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
if (reMatched) return reMatched;
}
throw e;
}
}
async function activateEnrollment(pool, enrollmentId) {
const { rows } = await pool.query(
`UPDATE enrollments SET status = 'active', activated_at = now(), updated_at = now()
WHERE enrollment_id = $1 AND status = 'pending' RETURNING *`,
[enrollmentId]
);
if (!rows[0]) {
const existing = await getEnrollment(pool, enrollmentId);
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending enrollment can be activated.' : 'No such enrollment.');
}
return rows[0];
}
async function cancelEnrollment(pool, enrollmentId) {
const { rows } = await pool.query(
`UPDATE enrollments SET status = 'cancelled', cancelled_at = now(), updated_at = now()
WHERE enrollment_id = $1 AND status IN ('pending', 'active') RETURNING *`,
[enrollmentId]
);
if (!rows[0]) {
const existing = await getEnrollment(pool, enrollmentId);
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending or active enrollment can be cancelled.' : 'No such enrollment.');
}
return rows[0];
}
// §10 — completion is entirely server-derived: called opportunistically (right after a
// capstone/exam finalizes, and again as a safety-net on every program-status read) rather than
// via a client "mark complete" action. Idempotent — the `WHERE status = 'active'` guard means
// calling this again after an enrollment is already completed is a no-op, not an error.
async function maybeCompleteEnrollment(pool, studentId, programId = null) {
const { rows: candidates } = await pool.query(
`SELECT e.enrollment_id, e.program_id, p.completion_assessment_id FROM enrollments e
JOIN programs p ON p.program_id = e.program_id
WHERE e.student_id = $1 AND e.status = 'active' AND p.completion_assessment_id IS NOT NULL
${programId ? 'AND e.program_id = $2' : ''}`,
programId ? [studentId, programId] : [studentId]
);
const completed = [];
for (const candidate of candidates) {
const { rowCount } = await pool.query(
`SELECT 1 FROM achievements
WHERE student_id = $1 AND achievement_type = 'capstone_completed' AND metadata->>'assessment_definition_id' = $2
LIMIT 1`,
[studentId, candidate.completion_assessment_id]
);
if (!rowCount) continue;
const { rows } = await pool.query(
`UPDATE enrollments SET status = 'completed', completed_at = now(), updated_at = now()
WHERE enrollment_id = $1 AND status = 'active' RETURNING *`,
[candidate.enrollment_id]
);
if (rows[0]) completed.push(rows[0]);
}
return completed;
}
module.exports = {
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
maybeCompleteEnrollment,
};
+39
View File
@@ -0,0 +1,39 @@
// Shared "launch/view a student's disposable Lab" logic. Phase 5B extracted this out of
// server.js's original POST /api/environment/launch route body so the new entitlement-gated
// self-paced launch route (selfpaced-routes.js) can reuse the EXACT same Provisioner call and
// response shape instead of a second copy — the underlying disposable-Lab mechanism (Phase
// 2A-4.1) is completely unchanged either way, only who's allowed to reach it differs.
async function publicEnvView(provisioner, 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;
}
async function launchEnvironmentForStudent(provisioner, studentId) {
const existing = await provisioner.listForStudent(studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { status: 200, body: await publicEnvView(provisioner, active) };
const { status, body } = await provisioner.provision(studentId);
if (status === 409) {
const retry = await provisioner.listForStudent(studentId);
return { status: 200, body: await publicEnvView(provisioner, retry.body?.[0]) };
}
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return { status: 503, body: { error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' } };
}
if (status !== 201) return { status: 502, body: { error: 'provision_failed', message: body?.error } };
return { status: 200, body: await publicEnvView(provisioner, { environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }) };
}
module.exports = { publicEnvView, launchEnvironmentForStudent };
+99
View File
@@ -0,0 +1,99 @@
// Phase 5B §12 — the website/commerce integration boundary. POST /api/integrations/enrollments
// is the ONLY thing an external system (the public website, a future payment provider) ever
// calls. It is completely separate from both student and instructor session auth — no
// rn_session/rn_instructor cookie is ever read here, and this route accepts none — so a
// browser cannot reach it just by having a logged-in student or admin tab open. See
// docs/WEBSITE_ENROLLMENT_INTEGRATION.md for the full contract the website team should build
// against.
//
// Auth follows the exact same pattern as platform/provisioner/src/server.js's
// X-Provisioner-Key (a pre-shared secret in an env var, checked via a custom header, fatal at
// startup if unset) — the same internal server-to-server trust boundary this codebase already
// established for Gateway<->Provisioner, not a new scheme. The header value must be held by
// the website's OWN backend only; it is never sent to or readable by browser JS.
const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
if (!INTEGRATION_SHARED_SECRET) {
console.error('WARNING: INTEGRATION_SHARED_SECRET is not set — POST /api/integrations/enrollments will refuse every request until it is. This is fine for a dev stack that never exercises the integration boundary, fatal for anything that needs to.');
}
// Small, deliberately provider-neutral vocabulary — NOT Stripe's (or anyone else's) object
// model. The website's backend translates whatever its payment provider says into one of these
// two values before calling this endpoint.
const STATUS_MAP = { paid: 'active', active: 'active', confirmed: 'active', pending: 'pending', awaiting_payment: 'pending' };
function enrollmentErrorStatus(code) {
if (code === 'PROGRAM_NOT_FOUND') return 404;
if (code === 'PROGRAM_INACTIVE') return 409;
if (code === 'EVENT_ID_REQUIRED' || code === 'STUDENT_IDENTIFIER_REQUIRED') return 400;
return 500;
}
module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
app.post('/api/integrations/enrollments', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
metrics.recordEnrollmentEvent('rejected');
return res.status(401).json({ error: 'unauthorized' });
}
const { event_id, source, student, program_slug, status } = req.body || {};
const email = student?.email ? String(student.email).trim() : null;
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
const mappedStatus = STATUS_MAP[status];
if (!event_id || typeof event_id !== 'string') {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'event_id is required' });
}
if (!program_slug) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'program_slug is required' });
}
if (!mappedStatus) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'status must be one of: ' + Object.keys(STATUS_MAP).join(', ') });
}
if (!email && !loginIdentifier) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'student.email or student.login_identifier is required' });
}
let result;
try {
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
studentDisplayName: student?.display_name, programId: program_slug, status: mappedStatus,
});
} catch (e) {
metrics.recordEnrollmentEvent('rejected');
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
metrics.recordEnrollmentEvent(result.created ? 'created' : 'duplicate');
// Audited regardless of created/duplicate — a replay is still a real, worth-recording
// event, just not a new enrollment. No payment/card data ever reaches this far (the
// payload never carries it — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md "what NOT to send").
await recordAudit(getPool(), {
actorType: 'integration', actorId: source || 'integration', action: result.created ? 'enrollment_integration_created' : 'enrollment_integration_duplicate',
resourceType: 'enrollment', resourceId: result.enrollment.enrollment_id, result: 'success', sourceIp: req.ip,
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
});
res.status(result.created ? 201 : 200).json({
enrollment_id: result.enrollment.enrollment_id,
student_id: result.enrollment.student_id,
program_id: result.enrollment.program_id,
status: result.enrollment.status,
created: result.created,
});
}));
};
+108
View File
@@ -0,0 +1,108 @@
// Prometheus text-exposition metrics — Phase 5A §34. Hand-rolled, not prom-client: the metric
// set here is small (a handful of counters/gauges), and the exposition format is trivial text
// (`# HELP`, `# TYPE`, `name value`) — not worth a new dependency for this (ponytail:
// stdlib/hand-rolled first). Deliberately NO student/instructor id (or any other
// high-cardinality value) ever appears as a label — every metric here is an aggregate count,
// matching the explicit "avoid high-cardinality student IDs... do not expose PII" requirement.
//
// Counters (login success/failure) are real in-process Prometheus counters — incremented at
// the exact point the event happens (student-auth-routes.js), not derived from a windowed SQL
// COUNT(*), which would behave like a gauge with a rolling window rather than a real counter.
// They reset on process restart, which is the normal, expected way Prometheus counters behave
// (rate()/increase() handle a counter reset transparently) — no persistence needed.
let studentLoginSuccessTotal = 0;
let studentLoginFailureTotal = 0;
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
function recordStudentLoginFailure() { studentLoginFailureTotal++; }
// Phase 5B §16 — the integration endpoint's result counter. `result` is a fixed 3-value enum
// ('created' | 'duplicate' | 'rejected'), never anything from the request body, so it can never
// become high-cardinality no matter what a caller sends.
const enrollmentEventTotals = { created: 0, duplicate: 0, rejected: 0 };
function recordEnrollmentEvent(result) {
if (result in enrollmentEventTotals) enrollmentEventTotals[result]++;
}
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
// keep in sync with reality).
async function collectGauges(pool, provisioner) {
const [{ rows: liveClassrooms }, { rows: helpOpen }, envRes] = await Promise.all([
pool.query(`SELECT classroom_session_id FROM classroom_sessions WHERE status = 'live'`),
pool.query(`SELECT count(*)::int AS n FROM help_requests WHERE status = 'open'`),
// Reuses the exact same environments-by-status query the admin dashboard already runs
// (admin-routes.js's /api/admin/dashboard) — one source of truth for Lab state counts.
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`).catch(() => ({ rows: [] })),
]);
let onlineParticipants = 0;
if (liveClassrooms.length) {
const classroom = require('./classroom');
for (const c of liveClassrooms) {
const participants = await classroom.listParticipants(pool, c.classroom_session_id);
onlineParticipants += participants.filter((p) => p.presence === 'online').length;
}
}
const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n]));
// Phase 5B — enrollments by status/delivery_mode (both fixed enums, safe labels) and
// completions by program (program_id is an admin-curated slug, small and bounded by design —
// the same trust level assessment_definition_id already has, unlike student_id/email/
// external_reference, which must NEVER appear as a label — see the file header).
const [{ rows: enrollmentBreakdown }, { rows: selfPacedActive }, { rows: completionsByProgram }] = await Promise.all([
pool.query(`SELECT status, delivery_mode, count(*)::int AS n FROM enrollments GROUP BY status, delivery_mode`),
pool.query(`SELECT count(*)::int AS n FROM enrollments WHERE status = 'active' AND delivery_mode = 'self_paced'`),
pool.query(`SELECT program_id, count(*)::int AS n FROM enrollments WHERE status = 'completed' GROUP BY program_id`),
]);
return {
classroom_sessions_live: liveClassrooms.length,
classroom_participants_online: onlineParticipants,
help_requests_open: helpOpen[0].n,
labs_running: envCounts.running || 0,
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
labs_error: envCounts.error || 0,
enrollment_breakdown: enrollmentBreakdown,
selfpaced_active_enrollments: selfPacedActive[0].n,
completions_by_program: completionsByProgram,
};
}
function line(name, help, type, value) {
return `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${name} ${value}\n`;
}
function labeledGauge(name, help, rows, labelCols, valueCol) {
const header = `# HELP ${name} ${help}\n# TYPE ${name} gauge\n`;
if (!rows.length) return header;
const body = rows.map((r) => {
const labels = labelCols.map((c) => `${c}="${String(r[c]).replace(/"/g, '\\"')}"`).join(',');
return `${name}{${labels}} ${r[valueCol]}`;
}).join('\n');
return header + body + '\n';
}
async function renderMetrics(pool, provisioner) {
const gauges = await collectGauges(pool, provisioner);
return [
line('rootnode_classroom_sessions_live', 'Number of classroom sessions currently live.', 'gauge', gauges.classroom_sessions_live),
line('rootnode_classroom_participants_online', 'Number of classroom participants currently online across all live sessions.', 'gauge', gauges.classroom_participants_online),
line('rootnode_help_requests_open', 'Number of open (not yet acknowledged/resolved) help requests.', 'gauge', gauges.help_requests_open),
line('rootnode_labs_running', 'Number of student Lab environments currently running.', 'gauge', gauges.labs_running),
line('rootnode_labs_provisioning', 'Number of student Lab environments currently being created/reset.', 'gauge', gauges.labs_provisioning),
line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error),
line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal),
line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal),
labeledGauge('rootnode_enrollments_total', 'Number of enrollments by lifecycle status and delivery mode.', gauges.enrollment_breakdown, ['status', 'delivery_mode'], 'n'),
line('rootnode_selfpaced_active_enrollments', 'Number of currently active self-paced enrollments.', 'gauge', gauges.selfpaced_active_enrollments),
labeledGauge('rootnode_program_completions_total', 'Number of completed enrollments by program.', gauges.completions_by_program, ['program_id'], 'n'),
line('rootnode_enrollment_events_created_total', 'Integration enrollment events that created a new enrollment.', 'counter', enrollmentEventTotals.created),
line('rootnode_enrollment_events_duplicate_total', 'Integration enrollment events that were idempotent replays of an already-processed event.', 'counter', enrollmentEventTotals.duplicate),
line('rootnode_enrollment_events_rejected_total', 'Integration enrollment events rejected (bad auth, unknown program, invalid payload).', 'counter', enrollmentEventTotals.rejected),
].join('\n');
}
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, renderMetrics };
+27
View File
@@ -0,0 +1,27 @@
// Shared "Prepare Labs for a cohort's enrolled students" logic — used by both the existing
// cohort-level admin route and the Phase 5A classroom-scoped one (a classroom's Prepare Labs is
// exactly this, just entered from the classroom dashboard instead of the cohort page — see
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §21). Extracted so there's one place this
// logic lives, not two copies that could drift.
async function prepareLabsForCohort(pool, provisioner, cohortId) {
const { rows: students } = await pool.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'`,
[cohortId]
);
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 === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return { student_id: s.student_id, display_name: s.display_name, status: 'capacity_waiting' };
}
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 };
}));
return 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 });
}
module.exports = { prepareLabsForCohort };
+63
View File
@@ -0,0 +1,63 @@
// Phase 5B — combines an enrollment row with real curriculum/progress/achievement state into
// one "program status" view: access/launch eligibility, stage/percent progress, and a
// deterministic next-activity (resume) target. Shared by the student self-paced routes and the
// admin program/enrollment routes so both surfaces show identical information computed one way,
// not two independently-maintained views that could drift.
const { deriveEnrollmentAccess, maybeCompleteEnrollment } = require('./enrollment');
const { computeCurriculumState } = require('./curriculum-rules');
async function buildProgramStatus(deps, enrollmentIn) {
const { pool, curriculum, loadProgress } = deps;
let enrollment = enrollmentIn;
// Opportunistic, idempotent completion check (§10) — safe to run on every read; the
// underlying UPDATE's `WHERE status = 'active'` guard makes a repeat call a no-op once an
// enrollment is already completed, so this never double-fires or races itself.
if (enrollment.status === 'active') {
const completed = await maybeCompleteEnrollment(pool, enrollment.student_id, enrollment.program_id);
if (completed.length) enrollment = { ...enrollment, ...completed[0] };
}
const access = deriveEnrollmentAccess(enrollment);
// Only one curriculum path exists today (curriculum.js) — enrollment.curriculum_path is the
// snapshot that would let a second path be resolved here later without touching this
// function's shape; see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §5.
const path = curriculum.loadCurriculumPath();
const progress = await loadProgress(pool, enrollment.student_id);
const capstoneStatus = enrollment.status === 'completed' ? 'completed' : undefined;
const state = path ? computeCurriculumState(path, progress, capstoneStatus) : null;
let nextActivity = null;
if (state) {
const currentStage = state.stages.find((s) => s.id === state.current_stage);
if (currentStage?.next_scenario) {
nextActivity = { type: 'scenario', scenario_id: currentStage.next_scenario, stage_id: currentStage.id };
} else if (state.capstone && state.capstone.status === 'unlocked') {
nextActivity = { type: 'capstone', assessment_definition_id: state.capstone.assessment_definition_id };
}
}
return {
enrollment_id: enrollment.enrollment_id,
program_id: enrollment.program_id,
program_name: enrollment.program_name || null,
program_family: enrollment.program_family || null,
status: enrollment.status,
effective_status: access.effective_status,
can_launch: access.can_launch,
blocked_reason: access.reason,
delivery_mode: enrollment.delivery_mode,
starts_at: enrollment.starts_at,
access_ends_at: enrollment.access_ends_at,
enrolled_at: enrollment.enrolled_at,
activated_at: enrollment.activated_at,
completed_at: enrollment.completed_at,
cancelled_at: enrollment.cancelled_at,
progress: state ? { percent_complete: state.percent_complete, current_stage: state.current_stage, stages: state.stages, capstone: state.capstone } : null,
next_activity: nextActivity,
};
}
module.exports = { buildProgramStatus };
+51
View File
@@ -0,0 +1,51 @@
// Phase 5B §2 — programs: first-class learning products (distinct from curriculum JSON, which
// stays just the content a program points at). CRUD here mirrors assessments.js's definition
// pattern (a slug primary key, a plain incrementing version, active/inactive status) rather
// than inventing a new convention.
async function listPrograms(pool, { activeOnly } = {}) {
const { rows } = await pool.query(
`SELECT p.*, count(e.enrollment_id)::int AS enrollment_count
FROM programs p LEFT JOIN enrollments e ON e.program_id = p.program_id
${activeOnly ? "WHERE p.status = 'active'" : ''}
GROUP BY p.program_id ORDER BY p.family, p.name`
);
return rows;
}
async function getProgram(pool, programId) {
const { rows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
return rows[0] || null;
}
async function createProgram(pool, { programId, name, description, family, deliveryMode, curriculumPath, completionAssessmentId, estimatedDurationHours, createdBy }) {
const { rows } = await pool.query(
`INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[programId, name, description || null, family, deliveryMode || 'self_paced', curriculumPath || 'rootnode-path', completionAssessmentId || null, estimatedDurationHours || null, createdBy || null]
);
return rows[0];
}
// Bumps version whenever a field an enrollment snapshots (curriculum_path, delivery_mode)
// actually changes — matches assessment_definitions' "edit later never changes what an
// already-drawn session is graded against" principle. Editing only cosmetic fields (name,
// description, estimated_duration_hours) does not bump the version.
async function updateProgram(pool, programId, fields) {
const current = await getProgram(pool, programId);
if (!current) return null;
const snapshotChanged = (fields.curriculum_path && fields.curriculum_path !== current.curriculum_path)
|| (fields.delivery_mode && fields.delivery_mode !== current.delivery_mode);
const map = { ...fields };
if (snapshotChanged) map.version = current.version + 1;
const cols = Object.keys(map);
if (!cols.length) return current;
const setClause = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
const { rows } = await pool.query(
`UPDATE programs SET ${setClause}, updated_at = now() WHERE program_id = $1 RETURNING *`,
[programId, ...cols.map((c) => map[c])]
);
return rows[0];
}
module.exports = { listPrograms, getProgram, createProgram, updateProgram };
+114 -31
View File
@@ -30,27 +30,34 @@ module.exports = function createRbac(pool) {
const requireAdminOrOwner = requireRole('owner', 'admin');
const requireAnyInstructor = requireRole('owner', 'admin', 'instructor');
// Core check, factored out so both the cohort_id-param routes below AND the
// classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead
// of two copies of the same query drifting apart over time.
async function checkCohortAccess(req, res, cohortId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
// same as any other authorization failure, no distinction made between "not your cohort" and
// "cohort doesn't exist" in the response.
function requireCohortAccess(cohortIdParam = 'id') {
return async (req, res, next) => {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return next();
const cohortId = req.params[cohortIdParam];
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
});
return res.status(403).json({ error: 'forbidden' });
}
next();
if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next();
};
}
@@ -84,24 +91,27 @@ module.exports = function createRbac(pool) {
// vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above,
// but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still
// bypass entirely, same as every other cohort-scoped check.
async function checkCohortWriteAccess(req, res, cohortId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rows } = await pool.query(
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
function requireCohortWriteAccess(cohortIdParam = 'id') {
return async (req, res, next) => {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return next();
const cohortId = req.params[cohortIdParam];
const { rows } = await pool.query(
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
[cohortId, instructor.instructor_id]
);
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
});
return res.status(403).json({ error: 'forbidden' });
}
next();
if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next();
};
}
@@ -130,9 +140,82 @@ module.exports = function createRbac(pool) {
};
}
// Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly,
// so this resolves the classroom's cohort first (404 if it doesn't exist — a route param
// isn't proof the resource exists) and then applies the exact same rule
// requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the
// request (req.classroomCohortId) so handlers that need it don't re-query.
function requireClassroomAccess(classroomIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
if (await checkCohortAccess(req, res, rows[0].cohort_id)) next();
};
}
function requireClassroomWriteAccess(classroomIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
};
}
// Same idea, one join further — help_requests routes are keyed by help_request_id, resolved
// through classroom_sessions to the cohort. Used by the acknowledge/resolve routes.
function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(
`SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr
JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id
WHERE hr.help_request_id = $1`,
[req.params[helpRequestIdParam]]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.classroomCohortId = rows[0].cohort_id;
req.classroomSessionId = rows[0].classroom_session_id;
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
};
}
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
// they already have cohort-based access to that student (the existing relationship, reused
// as-is) OR they personally created the enrollment (enrollments.created_by) — never blanket
// global visibility into every self-paced student. owner/admin bypass, same as everywhere
// else. Resolves the enrollment first (404 if it doesn't exist) and stashes the owning
// student_id on the request.
function requireEnrollmentAccess(enrollmentIdParam = 'id') {
return async (req, res, next) => {
const { rows } = await pool.query(`SELECT student_id, created_by FROM enrollments WHERE enrollment_id = $1`, [req.params[enrollmentIdParam]]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
req.enrollmentStudentId = rows[0].student_id;
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return next();
if (rows[0].created_by === instructor.instructor_id) return next();
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`,
[rows[0].student_id, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'enrollment', resourceId: req.params[enrollmentIdParam], result: 'failure', sourceIp: req.ip,
});
return res.status(403).json({ error: 'forbidden' });
}
next();
};
}
return {
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
requireCohortAccess, requireStudentCohortAccess,
requireCohortWriteAccess, requireStudentCohortWriteAccess,
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
requireEnrollmentAccess,
};
};
+44
View File
@@ -0,0 +1,44 @@
// Phase 5B §6/§7/§8/§9 — student-facing self-paced program API. Only ever shows a student
// programs THEY hold an enrollment for (never a public catalog — see §22, no public course
// catalog this phase) and enforces access/launch entitlement server-side, not just by hiding a
// button (§9: "Hiding a button in React is not access control").
const enrollment = require('./enrollment');
const { buildProgramStatus } = require('./program-status');
const { launchEnvironmentForStudent } = require('./environment-view');
module.exports = function registerSelfPacedRoutes(app, deps) {
const { studentSession, getPool, curriculum, loadProgress, provisioner, wrap } = deps;
const statusDeps = { pool: getPool(), curriculum, loadProgress };
// §6 — "My Programs": every enrollment this student holds, active and historical alike,
// annotated with real progress/access/next-activity. The frontend splits active vs completed
// client-side from the `status`/`effective_status` fields — no separate endpoint needed.
app.get('/api/student/programs', studentSession, wrap(async (req, res) => {
const rows = await enrollment.listEnrollmentsForStudent(getPool(), req.studentId);
const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e)));
res.json(views);
}));
app.get('/api/student/programs/:enrollmentId', studentSession, wrap(async (req, res) => {
const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId);
if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' });
res.json(await buildProgramStatus(statusDeps, row));
}));
// §8/§9 — the entitlement-gated launch. Delegates to the EXACT same disposable-Lab sequence
// /api/environment/launch uses (environment-view.js) — a self-paced Lab is not a different
// kind of Lab, just a gated entry point into the same one. The gate itself (deriveEnrollment
// Access) is what makes this route different from calling /api/environment/launch directly;
// that route is unchanged and still works exactly as before for cohort/pilot students.
app.post('/api/student/programs/:enrollmentId/launch', studentSession, wrap(async (req, res) => {
const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId);
if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' });
const access = enrollment.deriveEnrollmentAccess(row);
if (!access.can_launch) {
return res.status(403).json({ error: 'not_entitled', reason: access.reason });
}
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
res.status(status).json(body);
}));
};
+89 -34
View File
@@ -43,10 +43,20 @@ app.use(express.static(FRONTEND_DIST));
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
// controls. CSRF is checked here too, for every state-changing student request.
// Phase 5A: now re-checks the student is still 'active' on every request, matching
// instructorSession's existing pattern below — a deactivation takes effect on the very next
// request instead of waiting out the session's natural expiry. Previously this middleware only
// checked the session itself existed, which was fine when every student was pilot/trusted-roster
// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist.
async function studentSession(req, res, next) {
const token = parseCookies(req)[STUDENT_COOKIE];
const studentId = await sessions.resolveStudentSession(getPool(), token);
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]);
if (!rows.length || rows[0].status !== 'active') {
await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued
return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
}
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.studentId = studentId;
req.sessionToken = token;
@@ -88,6 +98,19 @@ app.get('/healthz', async (req, res) => {
}
});
// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry
// credentials here, matching every other exporter in infra/monitoring/) but restricted at the
// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already
// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public
// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js.
app.get('/metrics', async (req, res) => {
try {
res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner));
} catch (e) {
res.status(503).type('text/plain').send('# metrics collection failed\n');
}
});
// GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as
// minimal: an app version, the deployed Git SHA (or 'dev' locally — see
// infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same
@@ -140,11 +163,26 @@ app.get('/api/roster', async (req, res) => {
// can't be forged/switched by editing client state, and is checked on every Lab request. See
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
// institutional SSO) that upgrades identification without touching this session system.
// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real,
// credentialed account (password_hash set), this route refuses them and points at the real
// login, so a converted account can never be accessed by just knowing its bare student_id.
// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working
// exactly as before — no pilot data or access is lost.
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 { rows } = await getPool().query(
`SELECT student_id, display_name, status, password_hash, credential_token_hash 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' });
// Blocks on credential_token_hash too, not just password_hash — a student mid-conversion
// (instructor has issued an activation token but the student hasn't set a password yet) must
// not be reachable via the old insecure bare-id path either, or conversion doesn't actually
// close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js).
if (student.password_hash || student.credential_token_hash) {
return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' });
}
await touchStudent(getPool(), student.student_id);
const token = await sessions.createStudentSession(getPool(), student.student_id);
const csrf = makeCsrfToken();
@@ -207,11 +245,22 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
res.json({ total, completed, details: progress });
});
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
// Phase 5B — found by testing: this route's handler was never passed through wrap() (a
// leftover from Phase 1/2A, before that convention existed), so any unexpected internal error
// here — including Postgres rejecting a malformed environment_id, a 22P02 "invalid input
// syntax for type uuid" — became an unhandled promise rejection that CRASHED THE ENTIRE
// PLATFORM API PROCESS for every student, not just a 500 for this one request. Now wrapped
// like every route added since Phase 3B, plus explicit UUID-shape validation so a bad
// environment_id is a clean 400 rather than ever reaching Postgres as a type error at all.
const ENV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
app.post('/api/progress/record', wrap(studentSession), wrap(async (req, res) => {
const { scenario_id, status, environment_id } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
}
if (environment_id && !ENV_ID_RE.test(environment_id)) {
return res.status(400).json({ error: 'environment_id must be a valid UUID' });
}
const pool = getPool();
const passed = status === 'completed';
@@ -255,26 +304,17 @@ app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
}
res.json({ ok: true });
});
}));
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: environment lifecycle
// ═══════════════════════════════════════════════════════════════════════════════════════
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;
}
// Phase 5B — publicEnvView/the launch sequence now live in environment-view.js so
// selfpaced-routes.js's entitlement-gated launch route can call the identical logic instead of
// a second copy. Behavior here is unchanged from before the extraction.
const { publicEnvView: publicEnvViewFor, launchEnvironmentForStudent } = require('./environment-view');
const publicEnvView = (env) => publicEnvViewFor(provisioner, env);
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.studentId);
@@ -284,23 +324,8 @@ app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
});
app.post('/api/environment/launch', wrap(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]));
}
// Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic
// provision_failed 502 below. The student sees "temporarily full, try again shortly," never
// a raw 500/502 — this is expected operational backpressure, not an application error.
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' });
}
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 }));
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
res.status(status).json(body);
});
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
@@ -329,6 +354,15 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal
// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding
// a second env var, to decide whether /api/student/request-password-reset may echo the reset
// token back for local/test use (never in a real deployment).
require('./student-auth-routes')(app, {
pool: getPool(), sessions, studentSession, curriculum, loadProgress,
isProduction: process.env.COOKIE_SECURE === '1',
});
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
const rbac = createRbac(getPool());
@@ -340,6 +374,27 @@ require('./admin-learning-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half
// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor
// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/
// admin-learning-routes.js — a classroom session is a distinct concept from curriculum
// progress, even though its dashboard reads curriculum/progress data.
require('./classroom-routes')(app, { studentSession, getPool, curriculum });
require('./admin-classroom-routes')(app, {
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5B — programs/enrollment. selfpaced-routes.js is the student-facing half (My
// Programs, program detail, entitlement-gated launch); admin-programs-routes.js is the
// instructor/admin half (program authoring, manual enrollment, activate/cancel);
// integration-routes.js is the separate, non-session website/commerce boundary — see
// docs/PHASE5B_SELF_PACED_ENROLLMENT.md and docs/WEBSITE_ENROLLMENT_INTEGRATION.md.
require('./selfpaced-routes')(app, { studentSession, getPool, curriculum, loadProgress, provisioner, wrap });
require('./admin-programs-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+22
View File
@@ -71,6 +71,27 @@ async function resolveStudentSession(pool, token) {
}
const destroyStudentSession = revokeSession;
// Phase 5A §6/§10 — mirrors revokeAllInstructorSessions/listInstructorSessions exactly. Used
// by admin-triggered "revoke student sessions", and automatically after a student password
// change/reset (except the session that just made the change, if any).
async function revokeAllStudentSessions(pool, studentId, exceptToken) {
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
await pool.query(
`UPDATE sessions SET revoked_at = now()
WHERE student_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
[studentId, exceptHash]
);
}
async function listStudentSessions(pool, studentId) {
const { rows } = await pool.query(
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
FROM sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 20`,
[studentId]
);
return rows;
}
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
function createInstructorSession(pool, instructorId) {
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
@@ -131,6 +152,7 @@ function startSessionSweeper(pool) {
module.exports = {
createStudentSession, resolveStudentSession, destroyStudentSession,
revokeAllStudentSessions, listStudentSessions,
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
};
+178
View File
@@ -0,0 +1,178 @@
// POST /api/student/login, /activate, /change-password, /request-password-reset,
// /reset-password, GET /api/student/profile — Phase 5A §2/§5/§6/§9/§10. Logout reuses the
// existing POST /api/logout (session-mechanism-agnostic already, no change needed there).
//
// A pilot student (password_hash IS NULL) keeps using the original POST /api/login
// (bare student_id, unchanged) until an instructor converts them — see
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7. This file is exclusively the NEW
// credentialed path.
const {
verifyPassword, hashPassword, publicStudent, getStudentByIdentifier, getActiveStudent,
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
} = require('./students-auth');
const { recordAudit } = require('./audit');
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
const LOGIN_MAX_ATTEMPTS = 8;
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
const SESSION_MAX_AGE_SEC = 8 * 3600;
module.exports = function registerStudentAuthRoutes(app, { pool, sessions, studentSession, curriculum, loadProgress, isProduction }) {
app.post('/api/student/login', async (req, res) => {
const { identifier, password } = req.body || {};
const rateKey = `student:${req.ip}:${String(identifier || '').toLowerCase()}`;
if (await tooManyAttempts(pool, rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
}
if (!identifier || !password) return res.status(400).json({ error: 'identifier and password are required' });
const student = await getStudentByIdentifier(pool, identifier);
// Same generic response whether the identifier doesn't exist, has no credentials yet
// (pilot/unconverted), or the password is wrong — no account enumeration either way.
const passwordOk = student && student.password_hash && verifyPassword(password, student.password_hash);
if (!passwordOk) {
recordStudentLoginFailure();
await recordAudit(pool, {
actorType: 'student', actorId: student?.student_id, action: 'login', result: 'failure', sourceIp: req.ip,
});
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid login or password.' });
}
if (student.status !== 'active') {
recordStudentLoginFailure();
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' } });
return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' });
}
const token = await sessions.createStudentSession(pool, student.student_id);
const csrf = makeCsrfToken();
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
recordStudentLoginSuccess();
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'success', sourceIp: req.ip });
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: SESSION_MAX_AGE_SEC }));
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
res.json({ student: publicStudent({ ...student, last_login_at: new Date() }) });
});
// Consumes an activation token issued by GET/POST /api/admin/students (creation) or
// /api/admin/students/:id/convert — sets the student's first password and logs them straight
// in, same as any other login (§5's "temporary credential becomes invalid" is the token being
// single-use — consumeCredentialToken clears it atomically with setting the password).
app.post('/api/student/activate', async (req, res) => {
const { token, password } = req.body || {};
if (!token || !password || password.length < 10) {
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
}
const student = await consumeCredentialToken(pool, token, 'activation', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
const csrf = makeCsrfToken();
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'account_activated', result: 'success', sourceIp: req.ip });
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, sessionToken, { maxAgeSec: SESSION_MAX_AGE_SEC }));
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
res.json({ student: publicStudent(student) });
});
// Lets a not-yet-logged-in student preview whose activation/reset link this is before they
// submit a new password (e.g. "Activating account for Alice") — never reveals anything if
// the token is invalid/expired/wrong-purpose, same shape either way.
app.get('/api/student/credential-token/:purpose', async (req, res) => {
const { purpose } = req.params;
if (!['activation', 'reset'].includes(purpose)) return res.status(400).json({ error: 'invalid_purpose' });
const student = await findStudentByCredentialToken(pool, req.query.token, purpose);
if (!student) return res.status(404).json({ error: 'invalid_or_expired_token' });
res.json({ display_name: student.display_name });
});
app.post('/api/student/change-password', studentSession, async (req, res) => {
const { old_password, new_password } = req.body || {};
if (!old_password || !new_password || new_password.length < 10) {
return res.status(400).json({ error: 'invalid_request', message: 'new_password must be at least 10 characters' });
}
const student = await getActiveStudent(pool, req.studentId);
if (!student || !student.password_hash || !verifyPassword(old_password, student.password_hash)) {
await recordAudit(pool, { actorType: 'student', actorId: req.studentId, action: 'password_change', result: 'failure', sourceIp: req.ip });
return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' });
}
const newHash = hashPassword(new_password);
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
// (explicitly not required this phase), so this deliberately does NOT invent fake email
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
// Always returns the same generic response regardless of whether the identifier exists
// (anti-enumeration). In non-production, the real token is included so local/test flows can
// complete the loop without pretending email exists; in production it is never returned by
// this endpoint — completing a real self-service reset needs email delivery, a documented,
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
app.post('/api/student/request-password-reset', async (req, res) => {
const { identifier } = req.body || {};
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
if (!identifier) return res.json(generic);
const student = await getStudentByIdentifier(pool, identifier);
if (!student || student.status !== 'active') return res.json(generic);
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
res.json(generic);
});
app.post('/api/student/reset-password', async (req, res) => {
const { token, password } = req.body || {};
if (!token || !password || password.length < 10) {
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
}
const student = await consumeCredentialToken(pool, token, 'reset', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
// §10 — minimal profile page data: identity, cohort(s), progress summary, achievements,
// active session count. Reuses the same loadProgress/curriculum modules the existing
// /api/progress and /api/curriculum/path routes use — no parallel progress-reading logic.
app.get('/api/student/profile', studentSession, async (req, res) => {
const student = await getActiveStudent(pool, req.studentId);
if (!student) return res.status(401).json({ error: 'no_session' });
const [{ rows: cohorts }, progress, { rows: achievements }, sessionsList] = await Promise.all([
pool.query(
`SELECT c.cohort_id, c.name FROM cohort_enrollments e JOIN cohorts c ON c.cohort_id = e.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled'`,
[student.student_id]
),
loadProgress(pool, student.student_id),
pool.query(
`SELECT achievement_type, stage_id, achieved_at FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`,
[student.student_id]
),
sessions.listStudentSessions(pool, student.student_id),
]);
const scenarios = curriculum.loadScenarios();
const completed = Object.values(progress).filter((p) => p.status === 'completed').length;
res.json({
student: publicStudent(student),
cohorts,
progress_summary: { completed, total: scenarios.length },
achievements,
active_sessions: sessionsList.filter((s) => !s.revoked_at && new Date(s.expires_at) > new Date()).length,
});
});
};
module.exports.STUDENT_COOKIE = STUDENT_COOKIE;
module.exports.STUDENT_CSRF_COOKIE = STUDENT_CSRF_COOKIE;
+70
View File
@@ -0,0 +1,70 @@
// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the
// expected input is simple (no quoted fields containing commas expected in practice for
// names/identifiers), and pulling in a dependency for a three-column parser isn't justified
// (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level
// error at once — the caller decides whether to import (transactional, all-or-nothing).
const REQUIRED_HEADERS = ['display_name'];
const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id'];
const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment
function splitCsvLine(line) {
// No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't
// contain commas in practice) — a plain split covers the real input shape.
return line.split(',').map((cell) => cell.trim());
}
function parseStudentCsv(csv) {
const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
const errors = [];
if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] };
const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
for (const required of REQUIRED_HEADERS) {
if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` });
}
for (const forbidden of FORBIDDEN_HEADERS) {
if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` });
}
const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h));
if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` });
if (errors.length) return { rows: [], errors };
const seenEmails = new Set();
const seenIdentifiers = new Set();
const rows = [];
for (let i = 1; i < lines.length; i++) {
const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees
const cells = splitCsvLine(lines[i]);
if (cells.length !== headers.length) {
errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` });
continue;
}
const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]]));
if (!raw.display_name) {
errors.push({ row: rowNum, message: 'display_name is required' });
continue;
}
const email = raw.email ? raw.email.toLowerCase() : null;
const loginIdentifier = raw.login_identifier || null;
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push({ row: rowNum, message: `invalid email: ${raw.email}` });
continue;
}
if (email && seenEmails.has(email)) {
errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` });
continue;
}
if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) {
errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` });
continue;
}
if (email) seenEmails.add(email);
if (loginIdentifier) seenIdentifiers.add(loginIdentifier);
rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null });
}
if (errors.length) return { rows: [], errors };
return { rows, errors: [] };
}
module.exports = { parseStudentCsv };
+90
View File
@@ -0,0 +1,90 @@
// Student credential/account lifecycle — Phase 5A §2/§4/§5/§7. Parallel to instructors.js
// (same scrypt hashing from ./auth/password.js, same "row minus password_hash/token_hash"
// public-view pattern), not a duplicate auth system: student and instructor accounts share the
// exact same session store (sessions.js), CSRF pattern, and rate limiter — only the identity
// table and the specific login identifier (email/login_identifier vs. instructor email) differ.
const crypto = require('crypto');
const { hashPassword, verifyPassword } = require('./auth/password');
const CREDENTIAL_TOKEN_BYTES = 32;
const ACTIVATION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // a week — an instructor may hand this to a student who isn't logging in same-day
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // an hour — shorter, since a reset implies "I have access right now"
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// Never returns password_hash or credential_token_hash — the one and only place a student row
// is shaped for an API response. Every route below returns THIS, never the raw row.
function publicStudent(row) {
if (!row) return null;
const { password_hash, credential_token_hash, ...rest } = row;
return { ...rest, has_credentials: !!password_hash };
}
async function getActiveStudent(pool, studentId) {
const { rows } = await pool.query(`SELECT * FROM students WHERE student_id = $1 AND status = 'active'`, [studentId]);
return rows[0] || null;
}
// Case-insensitive on email (matches instructors.js's convention); login_identifier is matched
// exactly as stored — it's instructor-assigned, not self-chosen, so there's no "did they mean
// to capitalize it differently" ambiguity to normalize away.
async function getStudentByIdentifier(pool, identifier) {
const id = String(identifier || '').trim();
if (!id) return null;
const { rows } = await pool.query(
`SELECT * FROM students WHERE lower(email) = lower($1) OR login_identifier = $1`,
[id]
);
return rows[0] || null;
}
// Generates a credential token (activation or reset), returns the PLAINTEXT once — callers
// must hand it to the instructor/student and never persist or log it themselves. Only the hash
// goes to the database. Overwrites any previous outstanding token for this student (a new
// activation/reset link invalidates an older unused one — only the latest is ever valid).
async function issueCredentialToken(pool, studentId, purpose) {
const token = crypto.randomBytes(CREDENTIAL_TOKEN_BYTES).toString('base64url');
const ttlMs = purpose === 'reset' ? RESET_TOKEN_TTL_MS : ACTIVATION_TOKEN_TTL_MS;
const expiresAt = new Date(Date.now() + ttlMs);
await pool.query(
`UPDATE students SET credential_token_hash = $1, credential_token_purpose = $2, credential_token_expires_at = $3
WHERE student_id = $4`,
[hashToken(token), purpose, expiresAt, studentId]
);
return { token, expiresAt };
}
// Looks up the student a token belongs to, WITHOUT consuming it — callers that need to show
// "who is this token for" before the student submits a new password (e.g. to greet them by
// name) use this; consumeCredentialToken is the one that actually spends it.
async function findStudentByCredentialToken(pool, token, purpose) {
if (!token) return null;
const { rows } = await pool.query(
`SELECT * FROM students WHERE credential_token_hash = $1 AND credential_token_purpose = $2
AND credential_token_expires_at > now()`,
[hashToken(token), purpose]
);
return rows[0] || null;
}
// Sets a new password and clears the token in one statement — a token can only ever be used
// once, whether it succeeds or the caller retries with a stale value afterward.
async function consumeCredentialToken(pool, token, purpose, newPassword) {
const passwordHash = hashPassword(newPassword);
const { rows } = await pool.query(
`UPDATE students SET password_hash = $1, credential_token_hash = NULL, credential_token_purpose = NULL,
credential_token_expires_at = NULL, updated_at = now()
WHERE credential_token_hash = $2 AND credential_token_purpose = $3 AND credential_token_expires_at > now()
RETURNING *`,
[passwordHash, hashToken(token), purpose]
);
return rows[0] || null;
}
module.exports = {
hashPassword, verifyPassword, publicStudent, getActiveStudent, getStudentByIdentifier,
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
};
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// Fast, isolated unit test for student-csv.js — no Postgres, no HTTP. No framework, matching
// project convention (see platform/lab-router/test/run.js).
const assert = require('assert');
const { parseStudentCsv } = require('../src/student-csv');
function main() {
{
const r = parseStudentCsv('display_name,email,login_identifier,cohort_id\nAlice,[email protected],,root-fall\nBob,,bob01,root-fall');
assert.equal(r.errors.length, 0);
assert.equal(r.rows.length, 2);
assert.equal(r.rows[0].email, '[email protected]');
assert.equal(r.rows[1].login_identifier, 'bob01');
console.log('ok — valid file parses');
}
{
const r = parseStudentCsv('email\[email protected]');
assert.ok(r.errors.length > 0);
console.log('ok — missing display_name column rejected');
}
{
const r = parseStudentCsv('display_name,password\nAlice,hunter2');
assert.ok(r.errors.some((e) => e.message.includes('password')));
console.log('ok — password column never accepted from a file');
}
{
const r = parseStudentCsv('display_name,email\nAlice,[email protected],extra');
assert.ok(r.errors.length > 0);
assert.equal(r.rows.length, 0);
console.log('ok — a malformed row rejects the whole file (transactional, no partial import)');
}
{
const r = parseStudentCsv('display_name,email\nAlice,[email protected]\nBob,[email protected]');
assert.ok(r.errors.some((e) => e.message.includes('duplicate email')));
console.log('ok — duplicate email within one file rejected');
}
{
const r = parseStudentCsv('display_name,email\nAlice,not-an-email');
assert.ok(r.errors.some((e) => e.message.includes('invalid email')));
console.log('ok — invalid email format rejected');
}
console.log('\nAll student-csv unit tests passed.');
}
main();
+5
View File
@@ -68,6 +68,11 @@ services:
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}
# Phase 5B — the website/commerce integration boundary's shared secret (see
# docs/WEBSITE_ENROLLMENT_INTEGRATION.md). Optional in dev: unset means
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
# for anyone not exercising that boundary locally.
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env node
// Phase 5A — student identity & remote classroom. Permanent regression test replacing the
// throwaway smoke5a/smoke5b scripts used during development. Covers §31 (two-student + RBAC
// matrix) and §32 (password security), plus a real classroom lifecycle smoke pass. Same
// conventions as e2e-phase3a.js: assert-based, no framework, runs against a live
// docker-compose.dev.yml stack, shells out for the restart-persistence test.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase5a.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
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 5A student identity / remote classroom acceptance test\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.ok(owner.hasCookie('rn_instructor'), 'owner login must succeed before anything else can run');
// ── Fixtures: two cohorts, an assigned instructor, an unassigned instructor, a viewer,
// two students (Alice/Bob) in cohort A. ────────────────────────────────────────────────
const cohortA = `p5a-cohort-a-${RUN_ID}`;
const cohortB = `p5a-cohort-b-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortA, name: 'P5A Cohort A' });
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortB, name: 'P5A Cohort B' });
const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `p5a-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned Instructor', role: 'instructor', password: 'AssignedPass123!',
});
const { body: unassignedInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `p5a-unassigned-${RUN_ID}@rootnode.test`, display_name: 'Unassigned Instructor', role: 'instructor', password: 'UnassignedPass123!',
});
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: assignedInstr.instructor_id, role_in_cohort: 'teacher' });
const { body: viewerInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `p5a-viewer-${RUN_ID}@rootnode.test`, display_name: 'Viewer Instructor', role: 'instructor', password: 'ViewerPass123!',
});
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: viewerInstr.instructor_id, role_in_cohort: 'viewer' });
const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', {
display_name: 'Alice', cohort_id: cohortA, login_identifier: `p5a-alice-${RUN_ID}`,
});
const { body: bobCreate } = await owner.request('POST', '/api/admin/students', {
display_name: 'Bob', cohort_id: cohortA, login_identifier: `p5a-bob-${RUN_ID}`,
});
const alicePassword = 'AlicePassword123!';
const bobPassword = 'BobPassword123!';
const alice = makeSession(GW);
const bob = makeSession(GW);
await test('activation: token sets password and logs the student in', async () => {
const { status, body } = await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: alicePassword });
assert.equal(status, 200);
assert.equal(body.student.display_name, 'Alice');
assert.ok(alice.hasCookie('rn_session'));
});
await test('activation: the same token cannot be used twice (single-use)', async () => {
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'SomeOtherPass123!' });
assert.equal(status, 400);
});
await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: bobPassword });
const { body: classroomCreate } = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'P5A Live Class' });
const classroomId = classroomCreate.classroom_session_id;
// ── §32 Password security ───────────────────────────────────────────────────────────────
console.log('\nPassword security (§32)');
await test('correct identifier + password logs in', async () => {
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
assert.equal(status, 200);
});
await test('wrong password is rejected generically', async () => {
const s = makeSession(GW);
const { status, body } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
assert.equal(status, 401);
assert.equal(body.error, 'invalid_credentials');
});
await test('unknown identifier fails with the SAME response as a wrong password (no enumeration)', async () => {
const s1 = makeSession(GW), s2 = makeSession(GW);
const unknown = await s1.request('POST', '/api/student/login', { identifier: `nobody-${RUN_ID}`, password: 'whatever12345' });
const wrongPw = await s2.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
assert.equal(unknown.status, wrongPw.status);
assert.deepEqual(unknown.body, wrongPw.body);
});
await test('deactivated account is blocked at login', async () => {
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
assert.equal(status, 403);
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' }); // restore for later tests
});
await test('deactivation takes effect immediately on an EXISTING session, not just at next login', async () => {
const s = makeSession(GW);
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
const before = await s.request('GET', '/api/student/profile');
assert.equal(before.status, 200);
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
const after = await s.request('GET', '/api/student/profile');
assert.notEqual(after.status, 200); // studentSession middleware re-checks status='active' every request
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' });
});
await test('rate limiting: repeated failures against the same identifier eventually 429s', async () => {
const key = `p5a-ratelimit-${RUN_ID}`;
let sawTooMany = false;
for (let i = 0; i < 12 && !sawTooMany; i++) {
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
if (status === 429) sawTooMany = true;
}
assert.ok(sawTooMany, 'expected a 429 within 12 attempts (LOGIN_MAX_ATTEMPTS=8)');
});
if (!SKIP_RESTART_TEST) {
await test('rate limit state survives a Platform API restart (Postgres-backed, not in-memory)', async () => {
const key = `p5a-ratelimit-restart-${RUN_ID}`;
for (let i = 0; i < 9; i++) {
await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
}
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 4000));
const { status } = await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
assert.equal(status, 429, 'the attempt count must have survived the restart');
});
} else {
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
}
await test('change-password: wrong current password is rejected', async () => {
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: 'not-the-real-password', new_password: 'NewAlicePass123!' });
assert.equal(status, 401);
});
await test('change-password: correct current password succeeds and revokes OTHER sessions', async () => {
const otherSession = makeSession(GW);
await otherSession.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: alicePassword, new_password: 'NewAlicePass123!' });
assert.equal(status, 200);
const { status: otherStatus } = await otherSession.request('GET', '/api/student/profile');
assert.notEqual(otherStatus, 200); // the OTHER session (not the one that made the change) is revoked
const { status: selfStatus } = await alice.request('GET', '/api/student/profile');
assert.equal(selfStatus, 200); // the session that made the change is untouched
});
await test('forgot-password: identical generic response for a real vs unknown identifier (anti-enumeration)', async () => {
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5a-alice-${RUN_ID}` });
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}` });
assert.equal(real.body.message, fake.body.message);
});
await test('admin-issued reset token: sets a new password and revokes ALL sessions', async () => {
const { body } = await owner.request('POST', `/api/admin/students/${aliceCreate.student_id}/reset-password`);
assert.ok(body.reset_token);
const preCheck = await alice.request('GET', '/api/student/profile');
assert.notEqual(preCheck.status, 200); // reset-password revokes existing sessions immediately
const fresh = makeSession(GW);
const { status } = await fresh.request('POST', '/api/student/reset-password', { token: body.reset_token, password: 'ResetAlicePass123!' });
assert.equal(status, 200);
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'ResetAlicePass123!' });
assert.equal(login.status, 200);
});
await test('CSRF: a mutating request without the header is rejected even with a valid session cookie', async () => {
const s = makeSession(GW);
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
const res = await fetch(`${GW}/api/student/change-password`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${s.getCookie('rn_session')}` },
body: JSON.stringify({ old_password: bobPassword, new_password: 'ShouldNotWork123!' }),
});
assert.notEqual(res.status, 200);
});
// ── §31 Two-student + RBAC matrix ───────────────────────────────────────────────────────
console.log('\nTwo-student and RBAC matrix (§31)');
const bobSession = makeSession(GW);
await bobSession.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
await test('a student session cannot reach ANY /api/admin/* route', async () => {
const { status } = await bobSession.request('GET', '/api/admin/students');
assert.ok([401, 403].includes(status));
});
await test('a student profile request only ever returns that student\'s own identity', async () => {
const { body } = await bobSession.request('GET', '/api/student/profile');
assert.equal(body.student.display_name, 'Bob');
});
await test('assigned instructor CAN view and manage cohort A (students, classroom)', async () => {
const assigned = makeSession(GW);
await assigned.request('POST', '/api/auth/instructor/login', { email: `p5a-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
const view = await assigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
assert.equal(view.status, 200);
const write = await assigned.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Assigned-created class' });
assert.equal(write.status, 201);
});
await test('instructor NOT assigned to cohort A is DENIED (403) on its resources', async () => {
const unassigned = makeSession(GW);
await unassigned.request('POST', '/api/auth/instructor/login', { email: `p5a-unassigned-${RUN_ID}@rootnode.test`, password: 'UnassignedPass123!' });
const view = await unassigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
assert.equal(view.status, 403);
const dashboard = await unassigned.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
assert.equal(dashboard.status, 403);
});
await test('viewer role_in_cohort CAN view but is DENIED mutation on cohort A', async () => {
const viewer = makeSession(GW);
await viewer.request('POST', '/api/auth/instructor/login', { email: `p5a-viewer-${RUN_ID}@rootnode.test`, password: 'ViewerPass123!' });
const view = await viewer.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
assert.equal(view.status, 200);
const write = await viewer.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'should be denied' });
assert.equal(write.status, 403);
});
// ── Classroom lifecycle smoke pass ──────────────────────────────────────────────────────
console.log('\nClassroom lifecycle');
await test('classroom: start -> join -> heartbeat -> dashboard reflects presence -> end', async () => {
const { status: startStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
assert.equal(startStatus, 200);
await bobSession.request('POST', `/api/classroom/${classroomId}/join`);
await bobSession.request('POST', `/api/classroom/${classroomId}/heartbeat`);
const { body: dash } = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const bobRow = dash.students.find((s) => s.student_id === bobCreate.student_id);
assert.ok(bobRow, 'Bob must appear as a participant after joining');
assert.equal(bobRow.presence, 'online');
const { status: endStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
assert.equal(endStatus, 200);
});
await test('classroom: only one LIVE classroom per cohort at a time', async () => {
const c2 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Second class' });
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/start`);
const c3 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Third class' });
const { status } = await owner.request('POST', `/api/admin/classroom-sessions/${c3.body.classroom_session_id}/start`);
assert.equal(status, 409);
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/end`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env node
// Phase 5B — self-paced learning & enrollment foundation acceptance test. Covers the program/
// enrollment model, entitlement gating (pending/future/expired/cancelled), curriculum
// assignment, resume/Continue Learning, disposable-Lab persistence across destroy/reaper/
// recreate, server-derived idempotent completion, the website/commerce integration boundary
// (auth, idempotency, concurrency, minor/no-email path), RBAC scoping, and a real restart. Same
// conventions as e2e-phase5a.js/e2e-phase3b.js: assert-based, no framework, live
// docker-compose.dev.yml stack, real k3s boots for the Lab-persistence section.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_KEY=... node test/e2e-phase5b.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
// Same curriculum snapshot e2e-phase3b.js drives full completion against — see that file's
// header for why this is a real (not synthetic) content list.
const FOUNDATION = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics',
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
const NETWORKING = ['services-mcq', 'expose-service-basics'];
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING];
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 complete(session, scenarioId, envId) {
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed', environment_id: envId });
assert.equal(status, 200, `completing ${scenarioId} should 200`);
}
// The routing token in launch_path is NOT the environment_id scenario_attempts/progress
// records expect (a UUID) — resolve the real one via the admin view, same technique
// e2e-phase3b.js uses.
async function resolveEnvId(owner, studentId) {
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/environment`);
return body.find((e) => e.status === 'running')?.environment_id;
}
function fireIntegrationEvent(payload) {
return fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
body: JSON.stringify(payload),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
}
async function main() {
console.log('Phase 5B self-paced learning / enrollment foundation acceptance test\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
// ── Regression: a malformed environment_id must never crash the whole Platform API ──────
// Found by this test suite — POST /api/progress/record's handler predated the wrap()
// convention and an unexpected internal error (Postgres rejecting a non-UUID environment_id)
// became an unhandled promise rejection that killed the entire process. Fixed in server.js
// (wrap() + explicit UUID-shape validation); this proves it stays fixed.
await test('malformed environment_id is a clean 400, not a process crash', async () => {
const s = makeSession(GW);
const { body: probe } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-CrashProbe-${RUN_ID}` });
await s.request('POST', '/api/login', { student_id: probe.student_id });
const bad = await s.request('POST', '/api/progress/record', { scenario_id: 'pod-basics-mcq', status: 'completed', environment_id: 'not-a-uuid' });
assert.equal(bad.status, 400);
const health = await fetch(`${API}/healthz`);
assert.equal(health.status, 200, 'the API process must still be alive');
});
// ── §1: program seed ─────────────────────────────────────────────────────────────────────
await test('program seed exists (root-kubernetes-foundations)', async () => {
const { status, body } = await owner.request('GET', '/api/admin/programs');
assert.equal(status, 200);
const program = body.find((p) => p.program_id === PROGRAM);
assert.ok(program);
assert.equal(program.family, 'root');
assert.equal(program.curriculum_path, 'rootnode-path');
});
const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-Alice-${RUN_ID}`, login_identifier: `e2e5b-alice-${RUN_ID}` });
const { body: bobCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-Bob-${RUN_ID}`, login_identifier: `e2e5b-bob-${RUN_ID}` });
const alice = makeSession(GW);
const bob = makeSession(GW);
await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'AlicePassword123!' });
await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: 'BobPassword123!' });
// ── §2: no entitlement ───────────────────────────────────────────────────────────────────
await test('authenticated student with no enrollment sees an empty programs list', async () => {
const { status, body } = await alice.request('GET', '/api/student/programs');
assert.equal(status, 200);
assert.equal(body.length, 0);
});
// ── §3-4: admin enrolls, activation, entitlement gating ─────────────────────────────────
let enrollmentId;
await test('admin manually enrolls student', async () => {
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: aliceCreate.student_id, program_id: PROGRAM });
assert.equal(status, 201);
assert.equal(body.status, 'pending');
enrollmentId = body.enrollment_id;
});
await test('pending enrollment is visible but launch is blocked server-side, not just hidden', async () => {
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.can_launch, false);
assert.equal(body.blocked_reason, 'pending_activation');
const { status } = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 403);
});
await test('activated enrollment appears active on the student dashboard/API', async () => {
const activated = await owner.request('POST', `/api/admin/enrollments/${enrollmentId}/activate`);
assert.equal(activated.status, 200);
const { body } = await alice.request('GET', '/api/student/programs');
const view = body.find((p) => p.enrollment_id === enrollmentId);
assert.equal(view.can_launch, true);
assert.ok(view.progress); // §8: curriculum assignment resolved — real progress state, not a stub
});
// ── §5-7: future / expired / cancelled ──────────────────────────────────────────────────
await test('future-dated enrollment is visible but blocked before starts_at', async () => {
const created = await owner.request('POST', '/api/admin/enrollments', {
student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active', starts_at: '2099-01-01',
});
assert.equal(created.status, 201);
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
assert.equal(body.effective_status, 'active');
assert.equal(body.can_launch, false);
assert.equal(body.blocked_reason, 'not_yet_started');
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
assert.equal(launch.status, 403);
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`); // cleanup for later RBAC tests
});
await test('expired enrollment (access_ends_at passed) is visible historically but blocked from new Lab work', async () => {
const created = await owner.request('POST', '/api/admin/enrollments', {
student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active', access_ends_at: '2020-01-01',
});
assert.equal(created.status, 201);
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
assert.equal(body.effective_status, 'expired');
assert.equal(body.can_launch, false);
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
assert.equal(launch.status, 403);
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`);
});
await test('cancelled enrollment is blocked from launch', async () => {
const created = await owner.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active' });
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`);
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
assert.equal(body.effective_status, 'cancelled');
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
assert.equal(launch.status, 403);
});
// ── §16-17: cross-student isolation ─────────────────────────────────────────────────────
await test('student B cannot read student A\'s enrollment or launch A\'s program entitlement', async () => {
const read = await bob.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(read.status, 404);
const launch = await bob.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 404);
});
// ── §9/§10/§11/§12: resume, real Lab launch, destroy/reaper/recreate persistence ────────
let aliceEnvId;
await test('entitled launch provisions the real disposable Lab (same mechanism as /api/environment/launch)', async () => {
const { status, body } = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id);
assert.ok(aliceEnvId);
});
await test('resume/Continue Learning points at the first real activity', async () => {
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.next_activity.type, 'scenario');
assert.equal(body.next_activity.scenario_id, FOUNDATION[0]);
});
await test('completing a scenario advances resume state deterministically', async () => {
await complete(alice, FOUNDATION[0], aliceEnvId);
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.next_activity.scenario_id, FOUNDATION[1]);
assert.ok(body.progress.percent_complete > 0);
});
await test('academic progress survives a Lab destroy + a fresh Lab recreation', async () => {
const before = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
await owner.request('DELETE', `/api/admin/environments/${aliceEnvId}`);
const relaunch = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(relaunch.body.status, 'running');
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id); // a NEW environment_id — same student, fresh container
assert.ok(aliceEnvId);
const after = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.deepEqual(after.body.progress.percent_complete, before.body.progress.percent_complete);
assert.equal(after.body.next_activity.scenario_id, before.body.next_activity.scenario_id);
});
await test('academic progress survives the reaper\'s exact consequence (destroy out-of-band + Provisioner reconciliation, same as test-lab-host-loss.js)', async () => {
// Mirrors infra/scripts/drills/test-lab-host-loss.js's technique exactly: kill the
// container directly (bypassing the Provisioner entirely, exactly what the real idle
// reaper's failure mode looks like from the Provisioner's point of view), then restart the
// Provisioner to run its real reconcileOnStartup pass — a plain `docker rm` alone leaves
// the DB row still saying 'running' and launch would just hand back the now-dead
// environment instead of a fresh one, which wouldn't prove anything.
const before = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
const containers = execSync(`docker ps --filter "name=rootnode-lab-" --format "{{.Names}}"`).toString().trim().split('\n').filter(Boolean);
for (const c of containers) execSync(`docker rm -f ${c}`, { stdio: 'pipe' });
execSync('docker compose -f docker-compose.dev.yml restart provisioner', { cwd: `${__dirname}/..`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 5000));
const relaunch = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(relaunch.body.status, 'running');
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id);
const after = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.deepEqual(after.body.progress.percent_complete, before.body.progress.percent_complete);
});
// ── §13-15: completion is server-derived, idempotent, and correctly categorized ─────────
await test('completing every remaining stage scenario + the capstone transitions the enrollment to completed (server-derived, not a client button)', async () => {
for (const sid of ALL_NON_CAPSTONE.filter((s) => s !== FOUNDATION[0])) await complete(alice, sid, aliceEnvId);
const { status: startStatus, body: capstone } = await alice.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(startStatus, 201);
for (const s of capstone.scenarios) await complete(alice, s.scenario_id, aliceEnvId);
// No client ever calls anything like "mark enrollment complete" — reading the program
// status is what triggers the (idempotent) server-side check, via program-status.js's
// maybeCompleteEnrollment.
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.status, 'completed');
assert.ok(body.completed_at);
});
await test('completion is idempotent — reading again does not change completed_at or error', async () => {
const first = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
const second = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(first.body.completed_at, second.body.completed_at);
assert.equal(second.status, 200);
});
await test('completed program is categorized correctly in the programs list', async () => {
const { body } = await alice.request('GET', '/api/student/programs');
const view = body.find((p) => p.enrollment_id === enrollmentId);
assert.equal(view.effective_status, 'completed');
});
// ── §18-23: website/commerce integration boundary ───────────────────────────────────────
await test('integration endpoint rejects a request carrying an ordinary browser/student session instead of the shared key', async () => {
const res = await fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${alice.getCookie('rn_session')}` },
body: JSON.stringify({ event_id: 'evt-session-auth', program_slug: PROGRAM, status: 'paid', student: { email: '[email protected]' } }),
});
assert.equal(res.status, 401);
});
let integrationEnrollmentId, integrationStudentId;
await test('a valid integration event creates the correct enrollment', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `evt-${RUN_ID}-1`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `newkid-${RUN_ID}@example.com`, display_name: 'New Kid' },
});
assert.equal(status, 201);
assert.equal(body.status, 'active');
assert.equal(body.program_id, PROGRAM);
integrationEnrollmentId = body.enrollment_id;
integrationStudentId = body.student_id;
});
await test('the exact same external event_id replayed creates no duplicate enrollment', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `evt-${RUN_ID}-1`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `newkid-${RUN_ID}@example.com` },
});
assert.equal(status, 200);
assert.equal(body.created, false);
assert.equal(body.enrollment_id, integrationEnrollmentId);
});
await test('concurrent duplicate integration events create exactly one logical enrollment', async () => {
const eventId = `evt-${RUN_ID}-race`;
const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `racer-${RUN_ID}@example.com` } });
const [a, b, c] = await Promise.all([fire(), fire(), fire()]);
const ids = new Set([a.body.enrollment_id, b.body.enrollment_id, c.body.enrollment_id]);
assert.equal(ids.size, 1);
assert.equal([a, b, c].filter((r) => r.body.created).length, 1);
});
await test('unknown program_slug rejected cleanly (404), not a raw FK error', async () => {
const { status } = await fireIntegrationEvent({ event_id: `evt-${RUN_ID}-badprog`, program_slug: 'not-a-real-program', status: 'paid', student: { email: `x-${RUN_ID}@example.com` } });
assert.equal(status, 404);
});
await test('inactive program rejected cleanly (409)', async () => {
await owner.request('POST', '/api/admin/programs', { program_id: `e2e-5b-inactive-${RUN_ID}`, name: 'Inactive Test Program', family: 'root' });
await owner.request('PATCH', `/api/admin/programs/e2e-5b-inactive-${RUN_ID}`, { status: 'inactive' });
const { status } = await fireIntegrationEvent({ event_id: `evt-${RUN_ID}-inactive`, program_slug: `e2e-5b-inactive-${RUN_ID}`, status: 'paid', student: { email: `x2-${RUN_ID}@example.com` } });
assert.equal(status, 409);
});
await test('the integration payload/response never carries payment-card data (no such field exists to leak)', async () => {
const { body } = await owner.request('GET', `/api/admin/enrollments/${integrationEnrollmentId}`);
const serialized = JSON.stringify(body).toLowerCase();
assert.ok(!serialized.includes('card') && !serialized.includes('cvv') && !serialized.includes('pan'));
});
await test('minor/no-email enrollment works via login_identifier alone (§13)', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `evt-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { login_identifier: `guardian-ref-${RUN_ID}`, display_name: 'Guardian-Enrolled Kid' },
});
assert.equal(status, 201);
assert.ok(body.student_id);
});
// ── §25-26: RBAC ─────────────────────────────────────────────────────────────────────────
const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned', role: 'instructor', password: 'AssignedPass123!',
});
const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `e2e5b-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!',
});
await test('admin RBAC: creating a program/enrollment is admin/owner only', async () => {
const assigned = makeSession(GW);
await assigned.request('POST', '/api/auth/instructor/login', { email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
const { status } = await assigned.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM });
assert.equal(status, 403);
});
await test('instructor scope: an instructor sharing a cohort with the student can manage the enrollment; an unrelated instructor cannot', async () => {
const assigned = makeSession(GW);
await assigned.request('POST', '/api/auth/instructor/login', { email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
const unrelated = makeSession(GW);
await unrelated.request('POST', '/api/auth/instructor/login', { email: `e2e5b-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' });
// Neither plain instructor can create (admin/owner only, proven above) — so exercise scope
// on an owner-created enrollment via cohort-sharing instead: assign `assigned` to a cohort
// Bob is in, leave `unrelated` with no relationship at all.
const cohortId = `e2e5b-scope-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'E2E 5B scope' });
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: assignedInstr.instructor_id });
await owner.request('POST', `/api/admin/students/${bobCreate.student_id}/cohort`, { cohort_id: cohortId });
const enr = await owner.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM, external_reference: 'scope-test' });
const assignedRead = await assigned.request('GET', `/api/admin/enrollments/${enr.body.enrollment_id}`);
assert.equal(assignedRead.status, 200);
const unrelatedRead = await unrelated.request('GET', `/api/admin/enrollments/${enr.body.enrollment_id}`);
assert.equal(unrelatedRead.status, 403);
});
// ── §30: no secrets in new APIs ──────────────────────────────────────────────────────────
await test('no password/credential-token hashes are ever returned by the new program/enrollment APIs', async () => {
const list = await owner.request('GET', '/api/admin/programs');
const detail = await owner.request('GET', `/api/admin/enrollments/${enrollmentId}`);
const serialized = JSON.stringify(list.body) + JSON.stringify(detail.body);
assert.ok(!/password_hash|credential_token_hash/i.test(serialized));
});
// ── §28-29: Phase 5A still works ─────────────────────────────────────────────────────────
await test('Phase 5A student authentication still works unmodified', async () => {
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/student/login', { identifier: `e2e5b-alice-${RUN_ID}`, password: 'AlicePassword123!' });
assert.equal(status, 200);
});
await test('Phase 5A remote classroom still works unmodified', async () => {
const cohortId = `e2e5b-classroom-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'E2E 5B classroom check' });
const created = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Still works' });
const started = await owner.request('POST', `/api/admin/classroom-sessions/${created.body.classroom_session_id}/start`);
assert.equal(started.status, 200);
await owner.request('POST', `/api/admin/classroom-sessions/${created.body.classroom_session_id}/end`);
});
// ── §27: restart persistence ─────────────────────────────────────────────────────────────
if (!SKIP_RESTART_TEST) {
await test('enrollment and resume state survive a real Platform API restart', async () => {
const before = await alice.request('GET', '/api/student/programs');
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 4000));
const after = await alice.request('GET', '/api/student/programs');
assert.equal(after.status, 200);
assert.deepEqual(after.body.map((p) => p.enrollment_id).sort(), before.body.map((p) => p.enrollment_id).sort());
const completedView = after.body.find((p) => p.enrollment_id === enrollmentId);
assert.equal(completedView.status, 'completed');
});
} else {
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });