diff --git a/README.md b/README.md index 3a8018a..16d4bc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ENGINEERING_SOP.md b/docs/ENGINEERING_SOP.md index f3b57e5..b311e49 100644 --- a/docs/ENGINEERING_SOP.md +++ b/docs/ENGINEERING_SOP.md @@ -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). diff --git a/docs/LAB_ARCHITECTURE.md b/docs/LAB_ARCHITECTURE.md index bb94d86..ab81891 100644 --- a/docs/LAB_ARCHITECTURE.md +++ b/docs/LAB_ARCHITECTURE.md @@ -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. diff --git a/docs/PHASE5B_SELF_PACED_ENROLLMENT.md b/docs/PHASE5B_SELF_PACED_ENROLLMENT.md new file mode 100644 index 0000000..61a1cd5 --- /dev/null +++ b/docs/PHASE5B_SELF_PACED_ENROLLMENT.md @@ -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. diff --git a/docs/PRODUCTION_VALIDATION_CHECKLIST.md b/docs/PRODUCTION_VALIDATION_CHECKLIST.md index bc6d394..8474002 100644 --- a/docs/PRODUCTION_VALIDATION_CHECKLIST.md +++ b/docs/PRODUCTION_VALIDATION_CHECKLIST.md @@ -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." diff --git a/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md b/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md index bbb1f98..94ba873 100644 --- a/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md +++ b/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md @@ -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. diff --git a/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md b/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md new file mode 100644 index 0000000..a48deb8 --- /dev/null +++ b/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md @@ -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. diff --git a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md new file mode 100644 index 0000000..1a0c87e --- /dev/null +++ b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md @@ -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: +``` + +- 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": "jamie@example.com", + "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": "test-student@example.com", "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). diff --git a/platform/README.md b/platform/README.md index 8a728d4..4048360 100644 --- a/platform/README.md +++ b/platform/README.md @@ -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,