8 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
32 changed files with 2473 additions and 62 deletions
+9 -1
View File
@@ -163,6 +163,14 @@ its core:
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
@@ -200,7 +208,7 @@ 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/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.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
+31
View File
@@ -357,3 +357,34 @@ Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
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).
+12
View File
@@ -507,3 +507,15 @@ any of it — the Provisioner, the `environments` state machine, the Lab Host Ro
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.
+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.
+15
View File
@@ -67,5 +67,20 @@ can prove in advance.
- [ ] `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."
+28 -21
View File
@@ -45,11 +45,12 @@ 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.**~~ **Closed in Phase 5A.** Students now have real
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
@@ -59,25 +60,31 @@ this flow specifically — see the Phase 4.1 report for the full run results.
/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. Student authentication (item 2, above) was
closed in Phase 5A; the rest remain open, unmodeled by design (YAGNI) until a real product
reason exists.
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.
+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.
+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).
+3
View File
@@ -9,6 +9,7 @@ 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,
@@ -21,6 +22,7 @@ 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' },
@@ -61,6 +63,7 @@ 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} />}
+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>
)
}
+24
View File
@@ -167,6 +167,7 @@ function StudentDetail({ studentId, canManage, onBack }) {
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)
@@ -180,6 +181,9 @@ function StudentDetail({ studentId, canManage, onBack }) {
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])
@@ -248,6 +252,26 @@ function StudentDetail({ studentId, canManage, 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) => (
+12
View File
@@ -116,4 +116,16 @@ export const adminApi = {
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`),
}
+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>
)
}
@@ -8,9 +8,11 @@ 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' },
@@ -45,6 +47,7 @@ export default function StudentDashboard({ student, onLogout }) {
</nav>
{tab === 'learning' && <MyLearningTab />}
{tab === 'programs' && <ProgramsTab />}
{tab === 'classroom' && <ClassroomTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
+5
View File
@@ -119,4 +119,9 @@ export const api = {
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`),
}
+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
+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); });
+8 -6
View File
@@ -7,17 +7,19 @@ 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, real student credentials + remote classrooms
(Phase 5A), 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:
(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),
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom).
`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,
+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');
+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);
});
};
+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,
});
}));
};
+39 -1
View File
@@ -17,6 +17,14 @@ 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).
@@ -39,6 +47,17 @@ async function collectGauges(pool, provisioner) {
}
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,
@@ -46,6 +65,9 @@ async function collectGauges(pool, provisioner) {
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,
};
}
@@ -53,6 +75,16 @@ 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 [
@@ -64,7 +96,13 @@ async function renderMetrics(pool, provisioner) {
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, renderMetrics };
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, renderMetrics };
+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 };
+33
View File
@@ -179,10 +179,43 @@ module.exports = function createRbac(pool) {
};
}
// 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);
}));
};
+31 -33
View File
@@ -245,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';
@@ -293,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);
@@ -322,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) => {
@@ -397,6 +384,17 @@ 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'));
+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.
+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); });