Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd38d06055 | ||
|
|
8ffc923a84 | ||
|
|
db927b8081 | ||
|
|
8c09ef8f3e | ||
|
|
28c925069b | ||
|
|
e2298e95de | ||
|
|
fe702754cd | ||
|
|
f152b9a489 | ||
|
|
e4cbcf6c40 | ||
|
|
4c0a950afb | ||
|
|
7baf7074e5 | ||
|
|
777efd168d | ||
|
|
b38c46aaec | ||
|
|
be92be81cd | ||
|
|
f25535027c | ||
|
|
3c7f0ca59c | ||
|
|
9d382c95c5 | ||
|
|
5183a6af09 | ||
|
|
1c08a30c7f | ||
|
|
23f881884d | ||
|
|
f89a769540 | ||
|
|
4bc202e5f3 | ||
|
|
1b6da0a966 | ||
|
|
17160da89e | ||
|
|
611e1ac4ba | ||
|
|
895e16fea4 | ||
|
|
896c230142 | ||
|
|
bfa16d9162 | ||
|
|
7557fdc753 | ||
|
|
5c7e49c399 | ||
|
|
ee8ccb2ab6 | ||
|
|
d34ddae458 | ||
|
|
2f09b1a498 |
@@ -150,12 +150,32 @@ its core:
|
||||
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
|
||||
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
|
||||
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
|
||||
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
|
||||
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
|
||||
Lab UI, not a rewrite of it.
|
||||
- The student flow (real login (email or instructor-assigned login ID) or legacy pilot roster
|
||||
entry → curriculum dashboard → Launch Lab → practice/assessments/capstone → achievements →
|
||||
live classroom → profile) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students with credential/CSV-import management, a remote
|
||||
classroom control center, instructor accounts, audit log, environment controls, learning
|
||||
analytics, assessment/capstone management) lives in `frontend/src/admin/` at the `/admin`
|
||||
route — both additions alongside the existing Lab UI, not a rewrite of it.
|
||||
- Students authenticate with real credentials as of Phase 5A (email+password or
|
||||
login-identifier+password, first-login/reset via a single-use token an instructor relays) —
|
||||
see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. Instructors run live, remote-capable
|
||||
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
|
||||
guidance-only current-activity) — RootNode owns identity/coordination, an external service
|
||||
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
|
||||
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
|
||||
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
|
||||
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
|
||||
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
|
||||
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
|
||||
boundary — server-to-server only, no browser session involved — is what the future public
|
||||
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
- As of Phase 5C, RootNode actually tells students what's happening — real activation,
|
||||
enrollment-confirmation, and password-reset email, delivered asynchronously through a
|
||||
provider-neutral notification worker (Google Workspace via the Gmail API in production, a
|
||||
deterministic dev provider everywhere else — no test/dev run ever emails a real person). See
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`.
|
||||
|
||||
**Never publish the raw privileged container** — front it with isolation (that's what the
|
||||
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
|
||||
@@ -192,7 +212,9 @@ Key properties of the platform (not this image):
|
||||
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`,
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
|
||||
that runs all of this on real hardware lives in `infra/` — see
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Why Health Checks Exist & Probe Types
|
||||
|
||||
Kubernetes cannot see inside your application. It doesn't know if your process is deadlocked,
|
||||
stuck in an infinite loop, or quietly failing every request while the process itself stays
|
||||
alive. Without a way to ask "are you actually working?", the only signal Kubernetes has is
|
||||
whether the container's process is still running — and a hung process still counts as running.
|
||||
|
||||
**A health probe is how your application tells Kubernetes the truth.** It's a small check —
|
||||
an HTTP request, a TCP connection attempt, or a command run inside the container — that
|
||||
Kubernetes runs on a schedule and uses to make real decisions: restart this container, or stop
|
||||
sending it traffic.
|
||||
|
||||
## The three probe types
|
||||
|
||||
Kubernetes supports three distinct probes, and they answer three different questions. Mixing
|
||||
them up is the single most common probe mistake in production.
|
||||
|
||||
**Liveness probe — "Is this container still alive, or should it be restarted?"**
|
||||
If a liveness probe fails repeatedly, the kubelet kills the container and it gets restarted per
|
||||
its restart policy. Use this for detecting a genuinely stuck process — a deadlock, a crashed
|
||||
event loop. Don't use liveness to mean "not ready for traffic yet" — that's what readiness is
|
||||
for, and confusing the two causes the exact incident you're about to fix.
|
||||
|
||||
**Readiness probe — "Is this container ready to receive traffic right now?"**
|
||||
If a readiness probe fails, the Pod is removed from the Service's list of endpoints —
|
||||
Kubernetes stops routing traffic to it, but does **not** restart the container. This is the
|
||||
right tool for "still starting up," "warming a cache," or "temporarily overloaded, back off for
|
||||
a bit." A Pod can be Running and healthy but still correctly Not Ready.
|
||||
|
||||
**Startup probe — "Has this slow-starting container finished starting up yet?"**
|
||||
Some applications take a long time to become ready (loading a large dataset, running
|
||||
migrations). A startup probe disables liveness/readiness checks until it succeeds once, so a
|
||||
slow-but-healthy startup doesn't get killed by an impatient liveness probe. Once it succeeds,
|
||||
liveness and readiness take over as normal.
|
||||
|
||||
## How a probe is actually defined
|
||||
|
||||
Every probe has a **mechanism** (how the check runs) and a **schedule** (how often, how long to
|
||||
wait, how many failures before acting):
|
||||
|
||||
- `httpGet` — a GET request to a path and **port** on the container. A 2xx or 3xx response
|
||||
counts as success.
|
||||
- `tcpSocket` — a TCP connection attempt to a **port**. A successful connection counts as
|
||||
success — this doesn't check application-level health, just "is something listening."
|
||||
- `exec` — runs a command inside the container. Exit code `0` counts as success.
|
||||
|
||||
Timing fields (`initialDelaySeconds`, `periodSeconds`, `timeoutSeconds`, `failureThreshold`,
|
||||
`successThreshold`) control how patient Kubernetes is before it acts — but no amount of correct
|
||||
timing fixes a probe that's pointed at the wrong port or path in the first place.
|
||||
|
||||
## Common production mistakes
|
||||
|
||||
1. **Wrong port.** The probe's `httpGet.port` (or `tcpSocket.port`) doesn't match the port the
|
||||
application actually listens on. The container is completely healthy — Kubernetes is just
|
||||
asking the wrong door to open. This is the exact failure you'll diagnose in this module's lab.
|
||||
2. **Liveness probe too aggressive on a slow-starting app**, with no startup probe — the app
|
||||
gets killed for "not being ready yet," restarts, and never gets far enough to pass.
|
||||
3. **Readiness probe that always passes** (e.g. checking a static file instead of real
|
||||
dependencies) — traffic gets routed to a Pod that can't actually serve it.
|
||||
4. **No probes configured at all** — Kubernetes has no way to know the container is stuck, so a
|
||||
deadlocked process just sits there serving nothing, forever, looking "Running" the whole time.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 12-15 minutes for a live read-through with pauses for questions;
|
||||
10 minutes for a self-paced read.
|
||||
|
||||
**Demo plan:** Before moving to the guided exercise, it's worth live-editing one Deployment's
|
||||
`readinessProbe.httpGet.port` in front of the class to an obviously wrong value and running
|
||||
`kubectl get pods` / `kubectl describe pod` together, so students see the Ready column and the
|
||||
Events section change in real time before they do it themselves.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume a failing liveness probe and a failing readiness probe look the same in
|
||||
`kubectl get pods` — they don't (liveness failures show restart counts climbing; readiness
|
||||
failures show `0/1` Ready with no restarts). Worth calling out explicitly.
|
||||
- Students sometimes think `tcpSocket` checks are "less safe" than `httpGet` — reframe as
|
||||
"checks a different, shallower thing," not "worse."
|
||||
|
||||
**Discussion question:** "Why might a team deliberately choose a readiness probe that fails
|
||||
under high load, even though it takes healthy capacity out of rotation?" (Answer: shedding load
|
||||
from an overloaded Pod is often better than serving every request slowly or with errors.)
|
||||
|
||||
**Optional extension:** Ask students to sketch what would happen if liveness and readiness
|
||||
probes were swapped (readiness probe restarts on failure, liveness probe just removes from
|
||||
Service) — this clarifies the distinction by making the wrong version visibly worse.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Demonstration: Probes in Action
|
||||
|
||||
Watch what a probe failure actually looks like from the outside, before you're the one
|
||||
diagnosing it.
|
||||
|
||||
Picture a Deployment called `web` running `nginx`, which listens on port 80. Someone adds a
|
||||
readiness probe but mistypes the port as `8080`:
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
**What you'd see immediately after this rolls out:**
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-7d8f9c6b5d-abcde 0/1 Running 0 45s
|
||||
```
|
||||
|
||||
`STATUS` says `Running` — the container started fine, nginx is up. But `READY` says `0/1` — the
|
||||
readiness probe is failing, so this Pod has never been added to its Service's endpoints. No
|
||||
traffic reaches it. `RESTARTS` stays at `0` because this is a readiness failure, not a liveness
|
||||
failure — nothing is being killed, it's just being excluded.
|
||||
|
||||
**The trail that explains why**, from `kubectl describe pod`:
|
||||
|
||||
```
|
||||
Events:
|
||||
Type Reason Age From Message
|
||||
---- ------ ---- ---- -------
|
||||
Normal Scheduled 50s default-scheduler Successfully assigned default/web-... to node-1
|
||||
Normal Pulled 49s kubelet Container image "nginx:1.25" already present on machine
|
||||
Normal Created 49s kubelet Created container web
|
||||
Normal Started 49s kubelet Started container web
|
||||
Warning Unhealthy 15s (x6 over 40s) kubelet Readiness probe failed: Get "http://10.244.0.7:8080/": dial tcp 10.244.0.7:8080: connect: connection refused
|
||||
```
|
||||
|
||||
That last line is the whole story: kubelet tried to connect to port `8080` and nothing was
|
||||
listening there — because nginx is listening on `80`. The container was never broken. The probe
|
||||
was asking the wrong question.
|
||||
|
||||
**The general diagnostic shape** for any probe issue: `kubectl get pods` tells you *that*
|
||||
something is wrong (Ready count, restart count). `kubectl describe pod` and its Events section
|
||||
tell you *what* is wrong, usually in plain English, including the exact port/path that was
|
||||
tried and the exact error. Reach for Events before logs — logs show you what the application
|
||||
did; Events show you what Kubernetes tried to do and how it failed.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Live-run this exact sequence against a throwaway Deployment (or the classroom
|
||||
projector) rather than only showing static output — `kubectl get pods`, then
|
||||
`kubectl describe pod <name>`, scrolling straight to Events. Ask the class to predict the
|
||||
`READY` column and `RESTARTS` count *before* you show it.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; ~5 minutes as a self-paced read.
|
||||
|
||||
**Common misconception to preempt:** students often reach for `kubectl logs` first when
|
||||
something isn't Ready. For a probe misconfiguration, logs are frequently silent or unhelpful —
|
||||
the application is healthy and has nothing to say. This is a good moment to reinforce
|
||||
"Events explain what Kubernetes tried; logs explain what the app did," and that reading Events
|
||||
first is usually faster for this class of failure.
|
||||
|
||||
**Optional extension:** show the same failure signature for a `tcpSocket` probe (a bare
|
||||
"connection refused" with no HTTP verb in the Events message) so students can tell the two
|
||||
mechanisms apart from Events alone.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Knowledge Check: Health Probes
|
||||
|
||||
Answer each question yourself before reading the answer that follows it. This isn't graded —
|
||||
it's a checkpoint to catch a misunderstanding before you're troubleshooting a live Pod in the
|
||||
next activity.
|
||||
|
||||
---
|
||||
|
||||
**1. A Pod shows `RESTARTS: 12` and `READY: 1/1`. What kind of probe failure, if any, is most
|
||||
consistent with this?**
|
||||
|
||||
*Answer:* A liveness probe that was failing and has now recovered (or is intermittently
|
||||
failing) — the climbing restart count is the signature of liveness failures, since a failed
|
||||
liveness check causes the kubelet to kill and restart the container. `READY: 1/1` means it's
|
||||
currently passing its readiness check right now.
|
||||
|
||||
---
|
||||
|
||||
**2. A Pod shows `RESTARTS: 0` and `READY: 0/1`, and has looked this way for several minutes.
|
||||
What kind of probe failure is most consistent with this?**
|
||||
|
||||
*Answer:* A readiness probe that is failing. The container is running fine (no restarts), but
|
||||
Kubernetes has decided it isn't ready to receive traffic and has excluded it from the Service's
|
||||
endpoints. This is exactly the symptom you'll investigate in this module's lab.
|
||||
|
||||
---
|
||||
|
||||
**3. Why does Kubernetes offer a separate startup probe instead of just making
|
||||
`initialDelaySeconds` on the liveness probe longer?**
|
||||
|
||||
*Answer:* `initialDelaySeconds` is a fixed, one-size-fits-all wait before checks even begin —
|
||||
set it too short and a normally-slow startup gets killed; set it too long and every fast
|
||||
startup pays that same delay before liveness/readiness protection is even active. A startup
|
||||
probe instead keeps checking until the application actually reports ready (bounded by its own
|
||||
failure threshold), so liveness/readiness only take over once startup is confirmed done —
|
||||
correct for both a consistently slow app and one whose startup time varies.
|
||||
|
||||
---
|
||||
|
||||
**4. True or false: a readiness probe failure will cause Kubernetes to restart the container.**
|
||||
|
||||
*Answer:* False. A readiness probe failure only removes the Pod from Service endpoints — it
|
||||
does not restart anything. Only a liveness (or startup, before it succeeds once) probe failure
|
||||
causes a restart.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** In a live classroom, ask these out loud and take verbal answers before
|
||||
revealing each one, rather than having students read this silently — the goal is to surface a
|
||||
wrong mental model while it's still cheap to correct.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
|
||||
**Likely misconception this catches:** question 2 specifically catches students who default to
|
||||
"something is broken, kubectl logs will show an error" — for a pure readiness-probe
|
||||
misconfiguration, logs are usually clean, which is disorienting the first time you hit it.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Guided Exercise: Configure a Readiness Probe
|
||||
|
||||
This is a paper-and-thinking exercise, not a Lab task — you won't run these commands against a
|
||||
real cluster here (that comes next). The goal is to practice reading and writing a probe
|
||||
definition correctly before you're diagnosing someone else's broken one.
|
||||
|
||||
## The scenario
|
||||
|
||||
You're deploying a small HTTP API that listens on port `3000` and exposes a health endpoint at
|
||||
`/healthz`. It takes a couple of seconds to finish connecting to its database on startup — if a
|
||||
request arrives before that finishes, it correctly returns a `503` at `/healthz` until it's
|
||||
ready.
|
||||
|
||||
## Your task
|
||||
|
||||
Work out, on paper (or in a scratch file), what you'd write for this container's readiness
|
||||
probe. Think through:
|
||||
|
||||
1. Which mechanism fits — `httpGet`, `tcpSocket`, or `exec`? (Hint: you have a real health
|
||||
endpoint that returns a meaningful status code — use it rather than a shallower check.)
|
||||
2. What `port` and `path` should the probe target?
|
||||
3. Roughly what `initialDelaySeconds` makes sense, given the database connection takes a couple
|
||||
of seconds?
|
||||
4. Should this be a `readinessProbe`, a `livenessProbe`, or both? Justify your answer using the
|
||||
distinction from the lesson — what actually goes wrong if you get this specific choice wrong
|
||||
here?
|
||||
|
||||
## A worked shape to check yourself against
|
||||
|
||||
Your answer should end up structurally similar to this (exact field names matter more than the
|
||||
precise numbers you chose):
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 3000
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
If you reached for `livenessProbe` instead of `readinessProbe` for this specific scenario,
|
||||
re-read the lesson's distinction before continuing — "returns 503 while warming up" is
|
||||
precisely the readiness case, not the liveness case. A liveness probe here would restart a
|
||||
perfectly healthy, still-starting container over and over, which never lets it finish starting.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Have students write their probe block on paper or a shared doc before showing
|
||||
the worked shape — resist showing the answer early, since the value here is in students
|
||||
committing to an answer and comparing, not passively reading a correct one.
|
||||
|
||||
**Approximate timing:** 10-15 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Common mistake to watch for:** choosing `livenessProbe` because "the app isn't healthy yet"
|
||||
— this is the single most common real-world probe mistake and exactly what question 4 is
|
||||
designed to surface before the next activity's real diagnostic task.
|
||||
|
||||
**Optional extension:** ask what would need to change in this same YAML if the health endpoint
|
||||
took up to 60 seconds to become available on cold start (answer: a `startupProbe` with a
|
||||
generous `failureThreshold` × `periodSeconds`, so liveness/readiness don't engage too early).
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Launch Lab: Stuck Not-Ready: The Probe Port Mismatch
|
||||
|
||||
Time to apply everything from this module against a real, running incident — not a diagram, an
|
||||
actual Deployment in your own disposable Lab environment.
|
||||
|
||||
## What you're walking into
|
||||
|
||||
A deployment called `api-gateway` has been rolled out. The pods start, the container runs — but
|
||||
the deployment never reaches Ready, and traffic isn't being routed to it. Somewhere on-call, an
|
||||
alert is firing.
|
||||
|
||||
You now have everything you learned in this module to work with:
|
||||
|
||||
- **The lesson** told you what "stuck at 0/2 Ready with 0 restarts" actually means (a readiness
|
||||
problem, not a crash).
|
||||
- **The demonstration** showed you exactly where to look for the explanation — `kubectl get
|
||||
pods`, then `kubectl describe pod` and its Events section — and what a port-mismatch failure
|
||||
looks like in that output.
|
||||
- **The guided exercise** had you reason through what a correct probe definition looks like,
|
||||
which is exactly what you'll need to compare the broken one against.
|
||||
|
||||
## Your task in this Lab
|
||||
|
||||
Diagnose why the pods are failing their readiness check, correct the probe configuration, and
|
||||
restore the deployment to fully Ready. Start with pod status and events, the same way you just
|
||||
practiced — read before you patch.
|
||||
|
||||
This Lab has its own in-context hints if you get stuck, revealed one at a time — use the
|
||||
concepts from this module first; reach for a hint only if you're genuinely stuck, not as the
|
||||
first move.
|
||||
|
||||
When the Lab confirms your fix, come back to this module to close it out with a short
|
||||
reflection on what actually caused this.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Classroom flow:** if teaching this live, this is the natural point to have students launch
|
||||
their own Lab environment and work independently or in pairs, while you circulate. The
|
||||
Real-Time Classroom dashboard's help-request and progress views (see the instructor Curriculum
|
||||
view) show who's stuck without needing to walk around asking.
|
||||
|
||||
**Approximate timing:** 15-20 minutes for most students; some will resolve it in under 5 once
|
||||
the module's concepts have landed.
|
||||
|
||||
**Common mistake to watch for:** students jumping straight to `kubectl logs` here, which will
|
||||
be unhelpful (nginx has nothing to say about a probe pointed at the wrong port) — this is a good
|
||||
moment to reinforce the demonstration's point about reading Events first for this failure class.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Root-Cause Reflection & Real Ops Takeaway
|
||||
|
||||
You just fixed a real incident pattern, not a puzzle. Before moving on, take a few minutes to
|
||||
think through what actually happened and why it matters beyond this one Lab.
|
||||
|
||||
## What actually went wrong
|
||||
|
||||
The container was never broken. `nginx` started fine and was serving requests correctly the
|
||||
entire time. The only thing wrong was that the readiness probe was configured to check a port
|
||||
nginx wasn't listening on — so Kubernetes, working exactly as designed, correctly reported the
|
||||
Pod as Not Ready based on the information it had. The system didn't fail; it was given wrong
|
||||
information and acted rationally on it.
|
||||
|
||||
This is worth sitting with: most "Kubernetes is broken" incidents are actually "Kubernetes is
|
||||
doing exactly what its configuration tells it to do, and the configuration is wrong."
|
||||
|
||||
## Reflection questions
|
||||
|
||||
Think through your own answers to these — there's no single correct wording, but you should be
|
||||
able to explain each one out loud:
|
||||
|
||||
1. If you had jumped straight to `kubectl logs` instead of `kubectl describe pod`, how long do
|
||||
you think it would have taken you to find this? What would the logs have actually shown you?
|
||||
2. This Lab's port mismatch was introduced deliberately. What real-world change could introduce
|
||||
the exact same failure by accident? (Consider: someone changes the container's listening
|
||||
port in a new image version, but forgets the probe definition was hand-written separately.)
|
||||
3. Why does a rolling update stall — not fail outright, just hang — when new Pods can't pass
|
||||
their readiness probe? What is Kubernetes protecting you from by refusing to proceed?
|
||||
|
||||
## Real Ops takeaway: prevention, not just fixes
|
||||
|
||||
In a real production system, you'd want to prevent this class of incident from recurring, not
|
||||
just fix it once:
|
||||
|
||||
- **Keep the probe port derived from the same source as the container's actual port**, where
|
||||
your tooling allows it (a shared value in a Helm chart or Kustomize overlay), instead of two
|
||||
independently hand-typed numbers that can drift apart silently.
|
||||
- **Treat a stalled rollout as a first-class alert**, not just "the deploy is taking a while" —
|
||||
a Deployment sitting at partial readiness for an unusual length of time is exactly this
|
||||
failure mode, and catching it via rollout status is faster than waiting for a user-facing
|
||||
outage report.
|
||||
- **Practice reading Events before logs** for anything in the "Running but not Ready" state —
|
||||
you just did this under low stakes; the habit is what pays off during a real incident at 2am.
|
||||
|
||||
This module is complete. Return to the course to continue with the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** use the three reflection questions above as
|
||||
discussion prompts rather than a silent worksheet — question 2 in particular tends to produce
|
||||
good real-world examples from students who've touched CI/CD pipelines before.
|
||||
|
||||
**Approximate timing:** 10 minutes for a live discussion; 5-8 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to write the one-sentence postmortem summary they'd post
|
||||
in a team channel for this incident (real on-call teams write these) — a good answer names the
|
||||
root cause (probe port mismatch) and the fix (corrected port), not just "it's fixed now."
|
||||
|
||||
**Why this activity is a reflection, not another knowledge check:** the goal here is
|
||||
consolidation and transfer to real-world judgment, not testing recall — resist turning this
|
||||
into another right/wrong quiz.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Course Authoring Standard
|
||||
|
||||
Phase 6A §11 — the standard every future RootNode lesson/module must follow, so a future Claude
|
||||
Code session or a human instructor can author content consistently. The module at
|
||||
`content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/` (backing
|
||||
`root-10-m01-health-probes-and-troubleshooting`) is the reference implementation — when in
|
||||
doubt, read it before writing new content.
|
||||
|
||||
## 1. Target learner
|
||||
|
||||
State explicitly, in the module's Postgres `description` (not buried in prose): who this is for
|
||||
and what they're assumed to already know. root_ targets ages ~15-18+ through adult learners
|
||||
(see `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` §1) — write at a level that respects that range;
|
||||
don't write down to it.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
Declare real prerequisite course/module ids in the `prerequisites` column, not prose alone (see
|
||||
`docs/COURSE_CONTENT_MODEL.md` §5 for how locking derives from this). If a module assumes a
|
||||
concept the platform doesn't yet teach anywhere, say so explicitly in the module description
|
||||
rather than silently assuming it.
|
||||
|
||||
## 3. Measurable objectives
|
||||
|
||||
3-5 objectives per module, stored in the `objectives` column, each a sentence that starts with a
|
||||
verb describing an observable capability ("Diagnose...", "Configure...", "Explain..."), not a
|
||||
topic list ("Probes"). A learner (or an instructor) should be able to look at an objective and
|
||||
know whether it was met.
|
||||
|
||||
## 4. Estimated time
|
||||
|
||||
Set `estimated_duration_minutes` per activity and let the module/course total derive from the
|
||||
sum — don't hand-guess a module-level number that doesn't match its activities. Time estimates
|
||||
are honest guesses, not commitments; revise them once real completion-time data exists (the
|
||||
mechanically-mapped modules' per-scenario 15/20/30-minute estimates in
|
||||
`022_course_content.sql` are explicitly flagged as a rough heuristic for exactly this reason).
|
||||
|
||||
## 5. Concept explanation (the `lesson` activity)
|
||||
|
||||
Explain the underlying concept BEFORE any lab is presented — this is the module's core
|
||||
pedagogical premise (see `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`'s "core product principle").
|
||||
Cover: why the concept/mechanism exists (the problem it solves), the mechanism itself, and
|
||||
common real-world mistakes. Use concrete, runnable examples (`kubectl` output, YAML snippets)
|
||||
over abstract description wherever possible.
|
||||
|
||||
## 6. Demonstration
|
||||
|
||||
Show the concept in action with a worked example BEFORE asking the learner to do it themselves.
|
||||
A demonstration activity should include what the learner would actually SEE (real-looking
|
||||
command output), not just a description of what would happen.
|
||||
|
||||
## 7. Guided practice (the `guided_exercise` activity)
|
||||
|
||||
Lower-stakes practice with a visible worked answer to check against (paper/scratch-file
|
||||
exercises are fine — see the gold module's readiness-probe exercise). The goal is committing to
|
||||
an answer and comparing, not passively reading a correct one first.
|
||||
|
||||
## 8. Independent practice → lab mapping
|
||||
|
||||
A `lab` activity references an EXISTING `scenario_id` — never invent duplicate lab content when
|
||||
an existing scenario already teaches the concept (check `docs/ROOT_CURRICULUM_INVENTORY.md`
|
||||
first). Write a short `content_path` intro (see the gold module's `05-lab-intro.md`) that
|
||||
connects what was just taught to the incident the student is about to solve, without handing out
|
||||
the scenario's own hints/solution — the Lab's own progressive hints stay the only place the
|
||||
actual answer path lives.
|
||||
|
||||
## 9. Knowledge check
|
||||
|
||||
A handful of self-check questions with answers shown inline (not a graded exam — that's what the
|
||||
assessment engine is for). Use plain Markdown (`*Answer:*` after each question), not raw HTML
|
||||
`<details>` — `react-markdown` renders this repo's course content and does not have `rehype-raw`
|
||||
enabled, so raw HTML tags render as literal text, not a collapsible element.
|
||||
|
||||
## 10. Reflection
|
||||
|
||||
A reflection activity consolidates and connects to real-world judgment — it is not a second
|
||||
knowledge check. Ask "why does this matter beyond this one Lab," not "what did you just do."
|
||||
Include a "Real Ops takeaway": a concrete prevention/production practice a learner could apply
|
||||
on a real team.
|
||||
|
||||
## 11. Instructor notes
|
||||
|
||||
Every activity type CAN have an `## Instructor Notes` section (see
|
||||
`docs/COURSE_CONTENT_MODEL.md` §3); a `lesson`/`demonstration`/`guided_exercise`/`reflection`
|
||||
activity SHOULD have one. At minimum include: approximate timing, one likely misconception to
|
||||
watch for, and (where relevant) a discussion question or optional extension activity. Keep it
|
||||
actionable — a bullet list an instructor can glance at mid-class, not a second essay.
|
||||
|
||||
## 12. Accessibility/readability
|
||||
|
||||
Plain Markdown only (headings, lists, code fences, bold/italic, tables) — no content that
|
||||
depends on color alone to convey meaning, no ASCII art carrying information a screen reader
|
||||
would lose. Write short paragraphs; prefer lists over dense prose for anything enumerable
|
||||
(steps, mistakes, questions).
|
||||
|
||||
## 13. Terminology rules
|
||||
|
||||
Match the terminology the scenario JSON and existing Platform UI already use exactly (e.g.
|
||||
"readiness probe," not "health check probe" — check `scenarios/data/<id>.json`'s own
|
||||
`description`/`hints` for the canonical phrasing before introducing a lesson's own term for the
|
||||
same thing). Define a term the first time it's used in a module; don't assume a prerequisite
|
||||
module's terminology carried over without a one-line reminder.
|
||||
|
||||
## 14. Versioning
|
||||
|
||||
Bump the `version` integer on courses/course_modules/module_activities by hand on any semantic
|
||||
content edit — same convention `assessment_definitions`/`programs` already use. A version bump
|
||||
is informational only in Phase 6A (nothing snapshots against it the way an assessment session
|
||||
snapshots its definition version) — it exists so a future phase can detect "this changed since a
|
||||
student last saw it" without re-deriving that from `updated_at` alone.
|
||||
|
||||
## 15. Review/testing requirements
|
||||
|
||||
Before marking a module `status = 'ready'`:
|
||||
|
||||
- Every activity's `content_path` file must exist and load (verify via
|
||||
`platform/api/src/content.js`'s `loadContent`, or just hit the admin curriculum API and check
|
||||
`body` is non-empty).
|
||||
- A `lab` activity's `scenario_id` must be a real, existing scenario id.
|
||||
- An `assessment`/`capstone` activity's `assessment_definition_id` must be a real, active
|
||||
`assessment_definitions` row.
|
||||
- Walk the module once as a self-paced student end to end (lesson → ... → lab → reflection),
|
||||
and once through the instructor Curriculum view, before shipping — this is what
|
||||
`e2e-phase6a.js` and `infra/scripts/drills/test-phase6a-course-rehearsal.js` automate for the
|
||||
gold module; extend one of them (or add a new phase-scoped test) for future modules rather
|
||||
than skipping this step.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Course Content Model
|
||||
|
||||
Phase 6A §3 — how RootNode represents actual teaching content (lessons, demonstrations,
|
||||
knowledge checks, guided exercises, reflections) around the existing scenario/assessment
|
||||
systems, without duplicating either. See `docs/ROOT_PROGRAM_ARCHITECTURE.md` for what this
|
||||
model is populated with, and `docs/COURSE_AUTHORING_STANDARD.md` for how to author new content
|
||||
against it.
|
||||
|
||||
## 1. Shape
|
||||
|
||||
```
|
||||
program (existing, 019_programs.sql — unchanged)
|
||||
→ course (new: courses)
|
||||
→ module (new: course_modules)
|
||||
→ activity (new: module_activities)
|
||||
```
|
||||
|
||||
`platform/api/migrations/022_course_content.sql` is the schema. Every table follows the same
|
||||
conventions already established by `programs`/`assessment_definitions`: a TEXT slug primary key,
|
||||
a plain incrementing `version` integer bumped by hand on a semantic edit, `status`/`visibility`
|
||||
CHECK constraints, `created_at`/`updated_at`.
|
||||
|
||||
## 2. Activity types and what they reference
|
||||
|
||||
| Type | References | Completion behavior |
|
||||
|---|---|---|
|
||||
| `lesson`, `demonstration`, `knowledge_check`, `guided_exercise`, `reflection` | a Markdown file (`content_path`) | `manual_marker` |
|
||||
| `lab` | an EXISTING `scenario_id` (scenarios/data/\*.json) | `auto_scenario` |
|
||||
| `assessment`, `capstone` | an EXISTING `assessment_definition_id` (`assessment_definitions`) | `auto_assessment` |
|
||||
|
||||
A `lab` activity never duplicates scenario content — it stores a bare `scenario_id` string, the
|
||||
same id the existing curriculum/attempt/progress system already uses. An `assessment`/`capstone`
|
||||
activity never duplicates an assessment definition — it stores the existing
|
||||
`assessment_definition_id` and a foreign key enforces it actually exists. Neither activity type
|
||||
is prevented from also having its own `content_path` (e.g. the gold module's lab activity has a
|
||||
short Markdown intro at `05-lab-intro.md`) — that's framing prose around the reference, not a
|
||||
copy of the referenced thing.
|
||||
|
||||
## 3. Where the prose lives
|
||||
|
||||
Long-form lesson content is git-tracked Markdown under `content/root/<course>/<module>/`, not
|
||||
Postgres. Reasoning, weighed explicitly per the authorization's instruction:
|
||||
|
||||
- **Reviewable like code.** A lesson edit is a normal diff/PR, not an opaque row update.
|
||||
- **No CMS needed.** The authorization explicitly ruled one out ("Do not build a complex CMS").
|
||||
A Postgres TEXT column holding Markdown would technically work but loses git history/diffing
|
||||
for the one thing (prose) that benefits from it most.
|
||||
- **Structural metadata still belongs in Postgres** (ordering, type, references, status,
|
||||
objectives, prerequisites) because it's queried, joined, and mutated (locking/completion) —
|
||||
exactly the kind of relational state Postgres is for, and exactly what the authorization asked
|
||||
for ("Postgres preferred for metadata/state").
|
||||
|
||||
### The Instructor Notes convention
|
||||
|
||||
One Markdown file per activity, not two. Everything before a `## Instructor Notes` heading is
|
||||
shared (self-paced-visible) content; that heading and everything after it is instructor-only.
|
||||
`platform/api/src/content.js` does the split (`splitContent()`) — a plain string search on that
|
||||
exact heading, no frontmatter parser, no template engine. The API layer (`course-progress.js`'s
|
||||
`shapeActivity()`) only ever attaches `instructor_notes` to the response when the caller is
|
||||
resolved (server-side, from session identity) to be an instructor — never from a request
|
||||
parameter. A self-paced student and an instructor read the identical file; they just see
|
||||
different slices of it.
|
||||
|
||||
## 4. Completion rules — one source of truth per behavior
|
||||
|
||||
`platform/api/src/course-progress.js`'s `isActivityComplete()` is the only place completion is
|
||||
decided, and it never invents new state for anything that's independently verifiable:
|
||||
|
||||
- **`auto_scenario`** — reads `progress[scenario_id].status === 'completed'`, the exact same
|
||||
`progress` table `curriculum-rules.js` already reads for stage completion (`platform/api/src/
|
||||
progress.js`'s `loadProgress`). A lab activity's completion is a real fact about whether the
|
||||
student actually solved the scenario, not a flag a course layer could get out of sync with.
|
||||
- **`auto_assessment`** — reads `achievements` (`achievement_type IN ('assessment_completed',
|
||||
'capstone_completed')` with `metadata->>'assessment_definition_id'` matching), the exact
|
||||
mechanism `achievements.js`'s `recordAssessmentAchievement` already writes on a real pass. No
|
||||
new achievement type, no new table.
|
||||
- **`manual_marker`** — the ONE new piece of state this phase adds: `activity_completions`
|
||||
(student_id, activity_id). Used ONLY for activity types with nothing else to derive completion
|
||||
from (a lesson has no "pass/fail," a reflection has no validator). `POST /api/student/
|
||||
activities/:id/complete` refuses (409 `not_manually_completable`) for any activity whose
|
||||
`completion_behavior` isn't `manual_marker` — a student can never fake a lab or assessment
|
||||
completion through this route.
|
||||
|
||||
Course and module completion are pure aggregates over their activities' completion (`total`/
|
||||
`completed`/`pct`/`complete` — see `totals()`), never separately stored.
|
||||
|
||||
## 5. Prerequisite/locking rule
|
||||
|
||||
A course or module lists `prerequisites` (an array of other course/module ids). It's locked if
|
||||
ANY prerequisite is not satisfied, where **satisfied** means: the prerequisite has zero total
|
||||
activities (a PLANNED entry — vacuously satisfied, since there's nothing to have completed) OR
|
||||
the prerequisite is fully complete. This is deliberate: without the "zero activities is vacuously
|
||||
satisfied" clause, an honestly-PLANNED course with no content yet would permanently lock every
|
||||
real course that lists it as a prerequisite, which would perversely punish honesty about what
|
||||
isn't built yet. See `course-progress.js`'s `prerequisitesSatisfied()`.
|
||||
|
||||
Locking is computed in two passes over each level (compute every entry's totals/prerequisite-
|
||||
check, THEN strip working state) rather than one — mutating and reading totals in the same pass
|
||||
made the result depend on Map iteration order, a real bug caught by `e2e-phase6a.js` during
|
||||
development (see that file's "found by testing" comment in `course-progress.js`).
|
||||
|
||||
## 6. Draft vs. published — separate from READY/PARTIAL/PLANNED
|
||||
|
||||
`status` (`ready`/`partial`/`planned`) describes authoring completeness and is shown to
|
||||
everyone, including students, honestly ("PLANNED — coming soon" is useful information, not a
|
||||
leak). `visibility` (`published`/`draft`) is a hard gate: a `draft` course/module/activity is
|
||||
excluded from every query a student-facing route runs (`buildCourseTree`'s `visClause`), full
|
||||
stop, regardless of its `status`. Use `draft` for genuinely unfinished authoring work you don't
|
||||
want a student to see at all yet; use `status = planned` for an honest "not built yet" that's
|
||||
fine to be visible.
|
||||
|
||||
## 7. Why this doesn't touch Phase 1-5 systems
|
||||
|
||||
No column was added to `scenarios/*.json`, `assessment_definitions`, `achievements`,
|
||||
`progress`, or `scenario_attempts`. No existing API route's behavior changed (the `/api/
|
||||
progress/record`, `/api/assessments/*`, `/api/capstone` routes are byte-for-byte unchanged).
|
||||
The course layer is purely additive: new tables, new routes, new frontend views, reading
|
||||
existing tables through their existing shapes.
|
||||
+160
-32
@@ -88,37 +88,42 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
|
||||
`POST /api/cache/reload` or on server restart.
|
||||
|
||||
As of Phase 6A, `root_` also has a course/module/activity layer ON TOP of this (lessons,
|
||||
demonstrations, guided exercises around the same scenario ids) — see §17 and
|
||||
`docs/COURSE_CONTENT_MODEL.md`. `rootnode-path.json` itself is unmodified and still the thing
|
||||
`curriculum-rules.js` computes stage lock/completion from; the course layer is a parallel,
|
||||
additive structure that references the same scenario ids, not a replacement.
|
||||
|
||||
## 5. Student identity — what's built, what isn't
|
||||
|
||||
**Built (backend only, as of this doc):**
|
||||
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
|
||||
`created_at`, `last_active_at`.
|
||||
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
|
||||
a student (instructor-only action, currently unauthenticated at the API level — see
|
||||
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
|
||||
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
|
||||
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
|
||||
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
|
||||
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
|
||||
answering the same scenario don't affect each other's state, and pre-migration data was
|
||||
preserved under `student_id = 'unassigned'`.
|
||||
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
|
||||
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
|
||||
whoever's browser hits the API next.
|
||||
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
|
||||
full design):**
|
||||
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
|
||||
of and never changes across an account's lifecycle, including conversion.
|
||||
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
|
||||
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
|
||||
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
|
||||
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
|
||||
{ student_id }`, no password, instructor-roster-only.
|
||||
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
|
||||
(`students-auth.js`) — instructors never see or set a student's actual password.
|
||||
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
|
||||
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
|
||||
instructor login (Phase 4.1), now also applied to students.
|
||||
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
|
||||
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
|
||||
already in place well before this — Phase 5A only changed how a student proves who they are,
|
||||
not how their data or Labs are isolated.
|
||||
|
||||
**Not built yet — do not assume these exist:**
|
||||
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
|
||||
nothing calls it yet. This is the next slice.
|
||||
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
|
||||
Fine for a dev instance, not fine once this is reachable by actual students — needs an
|
||||
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
|
||||
item 3).
|
||||
- **No concurrency / isolation.** Exam sessions are still global-single-active
|
||||
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
|
||||
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
|
||||
cannot yet use this platform at the same time without stepping on each other.** That's
|
||||
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
|
||||
out — this SOP is not claiming that gate is cleared.
|
||||
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
|
||||
- No public self-registration — accounts are still instructor-created/converted/imported only.
|
||||
- No email delivery infrastructure — password reset works via an instructor-issued token today;
|
||||
self-service reset returns a real token only in non-production, by design.
|
||||
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
|
||||
|
||||
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
|
||||
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
|
||||
reflects any deployed version of this codebase; that content has been superseded by the above.
|
||||
|
||||
## 6. Schema migration pattern
|
||||
|
||||
@@ -146,9 +151,11 @@ Order agreed 2026-08-17/18. Status as of this doc:
|
||||
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
|
||||
`frontend/src/platform/`.
|
||||
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
|
||||
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
|
||||
identification, not strong student auth — documented, not silently glossed over
|
||||
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
|
||||
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
|
||||
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
|
||||
name" gap as of Phase 5A — see §5 above and
|
||||
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
|
||||
unchanged for anyone not yet converted.
|
||||
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
|
||||
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
|
||||
owner/admin/instructor roles, audit log.
|
||||
@@ -332,4 +339,125 @@ Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
|
||||
- **Known limitation carried forward:** real remote/self-paced delivery needs actual student
|
||||
authentication (today's `student_id`-only login relies on instructors controlling who gets a
|
||||
valid id) — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` for the full accounting of what
|
||||
already works vs. what's genuinely Phase 5 scope.
|
||||
already works vs. what's genuinely Phase 5 scope. **Closed in Phase 5A, below.**
|
||||
|
||||
## 14. Student Identity & Remote Classroom (Phase 5A)
|
||||
|
||||
Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
|
||||
`docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`.
|
||||
|
||||
- **What it is:** two deliverables built on the Phase 4.1 milestone. (A) Real student accounts —
|
||||
email-or-login-identifier + password, reusing `instructors`' exact credential machinery, a
|
||||
single first-login/reset token mechanism, deactivation enforced per-request — replacing pilot
|
||||
"pick your name" identification without touching a single existing pilot student's progress.
|
||||
(B) A remote classroom control center — scheduled/live/ended sessions, derived (not stored)
|
||||
presence via heartbeat, a live instructor dashboard combining real Provisioner Lab state and
|
||||
real academic records, a help-request queue, guidance-only announcements/current-activity, and
|
||||
a generic external meeting-URL field. Explicitly **not** video/voice/chat/shell-takeover —
|
||||
RootNode owns identity and coordination, an external service owns the call.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5a.js` (21 checks: the two-student RBAC
|
||||
matrix and full password-security checklist, including a real Platform API restart proving
|
||||
rate-limit state survives it) and `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks:
|
||||
5 pilot students with seeded real history, a live classroom, help requests, **a real
|
||||
mid-rehearsal API restart** proving classroom/session/presence recovery, then converting two
|
||||
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
|
||||
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
|
||||
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
|
||||
|
||||
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
|
||||
|
||||
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
|
||||
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
|
||||
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
|
||||
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
|
||||
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
|
||||
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
|
||||
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
|
||||
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
|
||||
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
|
||||
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
|
||||
just hidden in the UI. Completion is entirely server-derived from the existing capstone
|
||||
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
|
||||
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
|
||||
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
|
||||
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
|
||||
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
|
||||
concurrent duplicate requests, not just sequential retries.
|
||||
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
|
||||
async-error-handling convention — an unexpected internal error there (a malformed
|
||||
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
|
||||
(wrapped + input-validated) and regression-tested.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
|
||||
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
|
||||
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
|
||||
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
|
||||
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
|
||||
(see that doc §14).
|
||||
|
||||
## 16. Enrollment Operations & Communications (Phase 5C)
|
||||
|
||||
Full design: `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`, plus
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` and `docs/RUNBOOK_STUDENT_ACTIVATION.md`.
|
||||
|
||||
- **What it is:** a durable, provider-neutral notification pipeline (`notification_events`, a
|
||||
`FOR UPDATE SKIP LOCKED` worker polling on a plain `setInterval` — same pattern as the
|
||||
existing session/login-attempt sweepers, no new infrastructure) that finally sends real email
|
||||
for activation, enrollment confirmation, password reset, and password-changed — closing the
|
||||
gap Phase 5A explicitly left open. Google Workspace adapter: Gmail API via a service account
|
||||
with domain-wide delegation (no human password, no SMTP basic auth — see that doc §2 for why),
|
||||
behind the same provider interface a deterministic dev provider implements, so no test or dev
|
||||
run ever reaches a real inbox. Reuses Phase 5A's activation/reset token mechanism entirely — no
|
||||
competing invitation-token system.
|
||||
- **A real bug found and fixed:** `request-password-reset` would mint a reset token for a
|
||||
never-activated student, which doesn't make sense (there's no password yet to reset) and would
|
||||
have sent a confusing email. Fixed to fall through to the same anti-enumeration response as a
|
||||
nonexistent account.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5c.js` (28 checks: activation vs.
|
||||
confirmation routing, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted
|
||||
retry classification, resend/regenerate token invalidation, production token-secrecy gating
|
||||
via a real container recreate with `COOKIE_SECURE=1`, minor/contact-email resolution, RBAC
|
||||
scoping, metrics/audit hygiene, a real restart) and
|
||||
`infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks: one continuous story
|
||||
from website enrollment through a real rendered/extracted activation email, a real Lab launch
|
||||
and scenario completion, a second enrollment's confirmation email, and a full password-reset
|
||||
round trip) — both against a live stack, using only the dev provider (no real network calls).
|
||||
Live Google Workspace delivery has since been validated for real, end to end, via
|
||||
`academy@rootnodeacademy.com` — see `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §14.
|
||||
|
||||
## 17. ROOT_ Course Architecture + Content Delivery Foundation (Phase 6A)
|
||||
|
||||
Full design: `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`, plus `docs/ROOT_CURRICULUM_INVENTORY.md`,
|
||||
`docs/ROOT_PROGRAM_ARCHITECTURE.md`, `docs/COURSE_CONTENT_MODEL.md`, and
|
||||
`docs/COURSE_AUTHORING_STANDARD.md`.
|
||||
|
||||
- **What it is:** a course → module → activity content model (`022_course_content.sql`) layered
|
||||
over the existing scenario/assessment/achievement systems for `root_` only, so a lesson can be
|
||||
taught BEFORE a lab is presented instead of the platform only ever showing a bare scenario
|
||||
list. A `lab` activity references an existing `scenario_id`; an `assessment`/`capstone`
|
||||
activity references an existing `assessment_definition_id` — neither duplicates content.
|
||||
Long-form lesson prose lives in git-tracked Markdown (`content/root/`), split into
|
||||
shared/instructor-only sections by a `## Instructor Notes` heading convention
|
||||
(`content.js`). One `buildCourseTree()` (`course-progress.js`) serves both the self-paced
|
||||
student route and the instructor curriculum view — no forked curriculum for the two delivery
|
||||
modes.
|
||||
- **Completion, never a second source of truth:** a lab's completion reads the existing
|
||||
`progress` table; an assessment/capstone activity's completion reads the existing
|
||||
`achievements` table; only lesson-only activities (nothing else to derive completion from) get
|
||||
the one genuinely new table, `activity_completions`.
|
||||
- **A real bug found and fixed during testing:** computing a module/course's prerequisite lock
|
||||
and then stripping its working totals in the SAME map iteration made the result depend on Map
|
||||
iteration order — a dependent entry could see its prerequisite's totals already deleted. Fixed
|
||||
by splitting into two explicit passes (`course-progress.js`).
|
||||
- **One gold-standard module fully authored:** ROOT 10's Kubernetes Health Probes &
|
||||
Troubleshooting (lesson → demonstration → knowledge check → guided exercise → real Lab
|
||||
`rootnode-probe-port-mismatch` → reflection), the template `docs/COURSE_AUTHORING_STANDARD.md`
|
||||
documents for all future modules. Every other course is honestly `partial` (real labs, no
|
||||
lessons yet) or `planned` (no content exists) — nothing was fabricated to look finished.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase6a.js` (21 checks: hierarchy/ordering,
|
||||
all three completion behaviors, prerequisite locking, resume, cross-student isolation, RBAC
|
||||
for self-paced no-cohort students, draft-content hiding, version stability) and
|
||||
`infra/scripts/drills/test-phase6a-course-rehearsal.js` (12 checks, real k3s: a student reads
|
||||
the gold module's real lesson content, launches a real Lab, the Lab's own `setup_commands`
|
||||
stand up the broken deployment, a real `kubectl patch` fixes it, and the module completes and
|
||||
unlocks the next one) — both against a live stack.
|
||||
|
||||
@@ -498,3 +498,37 @@ mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docke
|
||||
breaks once they're genuinely on separate VMs); Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
|
||||
Router (`platform/lab-router/`).
|
||||
|
||||
## Phase 5A: reused, not modified
|
||||
|
||||
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
|
||||
remote classroom control center on top of everything this document describes, without changing
|
||||
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
|
||||
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
|
||||
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
|
||||
the same state enum) rather than inventing a second one.
|
||||
|
||||
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
|
||||
|
||||
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
|
||||
of the exact same disposable Lab this document describes — again without changing the
|
||||
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
|
||||
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
|
||||
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
|
||||
Lab container out-of-band and restarting the Provisioner to trigger its actual
|
||||
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
|
||||
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
|
||||
is completely unaffected and a fresh Lab boots normally.
|
||||
|
||||
## Phase 6A: reused, not modified — a teaching layer in front, still one Lab
|
||||
|
||||
Phase 6A (`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`) added a course/module/activity content model
|
||||
in front of the exact same disposable Lab this document describes — no change to the
|
||||
Provisioner, `environments`, the reaper, the Lab Host Router, or the Lab image itself
|
||||
(`Dockerfile`/`entrypoint.sh`/`backend/server.js` untouched). A course's `lab` activity is a
|
||||
`scenario_id` reference; "Launch Lab" from the course view calls the identical
|
||||
`/api/student/programs/:id/launch` sequence Phase 5B already established — not a third launch
|
||||
path. Verified for real, not assumed: `infra/scripts/drills/test-phase6a-course-rehearsal.js`
|
||||
launches a real Lab, calls the Lab's own `POST /api/scenarios/:id/setup` through the Gateway's
|
||||
existing `/lab/:token/*` proxy exactly as the browser UI does, and runs a real `kubectl patch`
|
||||
against the live k3s cluster inside that container before recording completion.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Phase 5A — Student Identity & Remote Classroom
|
||||
|
||||
**Status: PHASE 5A COMPLETE. STUDENT IDENTITY COMPLETE. REMOTE CLASSROOM FOUNDATION COMPLETE.**
|
||||
|
||||
Phase 4.1 closed the last pre-production infrastructure gap without real hardware. Phase 5A's
|
||||
job — still without real hardware — was the last real *product* gap flagged along the way: pilot
|
||||
students identify themselves by picking a name from a list, and there was no way for an
|
||||
instructor to run a live, remote-capable class through the platform itself. This phase replaces
|
||||
pilot identification with real accounts and adds a remote classroom control center. It does
|
||||
**not** add video, voice, chat, or any A/V — those stay Meet/Zoom/Teams's job; RootNode owns
|
||||
identity, presence, curriculum guidance, Lab status, help requests, and instructor control.
|
||||
|
||||
## 1. Student identity model
|
||||
|
||||
`students` (migration `017_student_credentials.sql`) gained `email`, `login_identifier`,
|
||||
`password_hash`, `last_login_at`, and a single credential-token triple
|
||||
(`credential_token_hash`/`_purpose`/`_expires_at`) — all nullable, all additive. `email` and
|
||||
`login_identifier` are independently unique (partial unique indexes, `WHERE ... IS NOT NULL`)
|
||||
and both optional: an older/self-paced student gets an email, a younger instructor-managed
|
||||
student gets an instructor-assigned login ID, neither is required, and nothing here blocks a
|
||||
future `guardian_id` FK for a Parent Portal — deliberately not built this phase (§37).
|
||||
|
||||
A student with **neither** field set is unchanged from before Phase 5A: a plain pilot row,
|
||||
identified with `POST /api/login { student_id }`. Setting either field (via account creation or
|
||||
`/convert`) is what makes an account "real." `password_hash` reuses `platform/api/src/auth/
|
||||
password.js` (scrypt, `salt:hash`) verbatim — the same function instructors use, no second
|
||||
hashing scheme.
|
||||
|
||||
## 2. Credential lifecycle — one token mechanism, not two
|
||||
|
||||
Activation ("first login") and password reset are the same problem: a time-limited, single-use
|
||||
secret that lets someone set a new password. `students-auth.js`'s `issueCredentialToken`/
|
||||
`consumeCredentialToken` implement it once (SHA-256-hashed at rest, plaintext returned exactly
|
||||
once in the API response, never logged), with `purpose` (`activation` | `reset`) distinguishing
|
||||
the two callers. Activation tokens live 7 days, reset tokens 1 hour. `consumeCredentialToken` is
|
||||
atomic: token verification, password hashing, `password_hash` write, and token-clearing happen
|
||||
in one statement — there is no window where a token is "spent" but the password isn't set.
|
||||
|
||||
Instructors never see or choose a student's password (§5's "instructor creates account →
|
||||
temporary credential" flow): `POST /api/admin/students`, `/convert`, and `/reset-password` all
|
||||
return a token (activation or reset), which the instructor relays out of band (printed, read
|
||||
aloud, handed over). The admin UI surfaces the resulting `?activate=<token>`/`?reset=<token>`
|
||||
link once and never again — there is no "view credentials" screen anywhere.
|
||||
|
||||
## 3. Password reset without email delivery
|
||||
|
||||
No email-sending infrastructure exists yet, and this phase deliberately doesn't fake one.
|
||||
`POST /api/student/request-password-reset` always returns an identical generic response
|
||||
regardless of whether the identifier exists (anti-enumeration) and never echoes the token in
|
||||
production. In non-production (`isProduction` false, i.e. `COOKIE_SECURE !== '1'`) it also
|
||||
returns `dev_reset_token` so local/CI flows can complete the loop without a real inbox — a
|
||||
documented dev/test path, not a production feature. The realistic production path today is
|
||||
instructor-triggered: `POST /api/admin/students/:id/reset-password`. Real email delivery is a
|
||||
named, not-yet-built follow-up, not something silently promised.
|
||||
|
||||
## 4. Session and login security — reused, not duplicated
|
||||
|
||||
Students and instructors share the exact same `sessions` table and mechanism
|
||||
(`sessions.js`: opaque tokens, HttpOnly, SameSite, hashed at rest, revocable), differentiated
|
||||
only by `session_type`/`student_id` vs `instructor_id`. `studentSession` middleware now
|
||||
re-verifies `status = 'active'` on **every** request (previously only checked session
|
||||
existence) — deactivating a student takes effect immediately, matching `instructorSession`'s
|
||||
existing behavior, not just at next login (verified in `e2e-phase5a.js`).
|
||||
|
||||
Rate limiting reuses Phase 4.1's Postgres-backed `tooManyAttempts` (`security.js`) with a
|
||||
`student:` key prefix — 8 attempts / 5 minutes, survives a Platform API restart because it's a
|
||||
table, not an in-memory Map (verified with a real restart in both `e2e-phase5a.js` and the
|
||||
rehearsal drill). CSRF reuses the existing double-submit cookie pattern (`rn_csrf_s`).
|
||||
|
||||
**A real bug this closed:** the legacy `POST /api/login` (bare `student_id`, no password) only
|
||||
checked `password_hash` to decide whether an account had "moved on" from pilot mode. An account
|
||||
mid-conversion — instructor has issued an activation token, student hasn't set a password yet —
|
||||
has `credential_token_hash` set but `password_hash` still `NULL`, and was reachable through the
|
||||
old insecure path for that whole window. Found via testing, fixed by checking
|
||||
`student.password_hash || student.credential_token_hash` instead (`server.js`).
|
||||
|
||||
## 5. Pilot migration
|
||||
|
||||
No destructive migration. `017_student_credentials.sql` only adds nullable columns — every
|
||||
existing pilot row, its progress, attempts, achievements, and cohort membership are completely
|
||||
untouched at migration time. `POST /api/admin/students/:id/convert` assigns email/login_
|
||||
identifier and issues an activation token without touching `student_id` (the FK every other
|
||||
table hangs off of) or anything downstream of it. Verified directly, not just asserted: the
|
||||
Phase 5A rehearsal drill (`infra/scripts/drills/test-phase5a-rehearsal.js`) seeds real pre-
|
||||
existing progress/attempt/achievement rows for a pilot student, converts them, activates them,
|
||||
logs in the new way, and confirms `/api/progress` shows the exact same completed scenario —
|
||||
nothing orphaned, nothing re-created.
|
||||
|
||||
## 6. Remote classroom — a fourth session concept
|
||||
|
||||
Distinct from cohort membership, assessment sessions, and Lab environments: a scheduled,
|
||||
bounded teaching event. `018_classroom_sessions.sql` adds `classroom_sessions` (lifecycle
|
||||
`scheduled → live → ended`, or `cancelled`), `classroom_participants`, `classroom_events`
|
||||
(append-only audit), `classroom_announcements`, and `help_requests`. A partial unique index
|
||||
(`WHERE status = 'live'`) enforces at most one live classroom per cohort at the database level —
|
||||
the app layer catches the resulting `23505` and returns a clean `ALREADY_LIVE` (409), not a
|
||||
raw constraint error (verified in `e2e-phase5a.js`).
|
||||
|
||||
## 7. Presence — derived, not stored
|
||||
|
||||
`classroom.derivePresence(lastSeenAt, leftAt)` is a pure function computing `online` /
|
||||
`recently_active` / `offline` from a timestamp at read time — there's no status column that can
|
||||
go stale or need a background job to sweep. Heartbeats (`POST /api/classroom/:id/heartbeat`,
|
||||
client fires every 20-30s while the Classroom tab is open and joined) are throttled server-side
|
||||
(`WHERE last_seen_at IS NULL OR last_seen_at < now() - interval`), never depend on Lab terminal
|
||||
activity, and stop the moment the tab is closed or the student leaves — no invasive tracking.
|
||||
|
||||
## 8. The classroom dashboard — real data, not a second state system
|
||||
|
||||
`GET /api/admin/classroom-sessions/:id` is the one place that reaches beyond `classroom.js`: it
|
||||
joins participant/presence data with the Provisioner's actual `environments` state (same enum
|
||||
every other Lab-status view uses — `none`/`requested`/`provisioning`/`running`/`resetting`/
|
||||
`error`/`destroying`/`destroyed`) and a lightweight academic snapshot from the existing
|
||||
`progress`/`scenario_attempts` tables (current stage, most recent scenario, validation-fail
|
||||
count, hints used). Never inspects terminal command history. Nothing here is a second Lab-state
|
||||
or academic-state system — it's the existing ones, read the same way every other admin view
|
||||
already reads them.
|
||||
|
||||
## 9. Help requests — a queue, not a chat
|
||||
|
||||
`POST /api/classroom/:id/help-request { category, message }` → `open` → (instructor)
|
||||
`acknowledge` → `resolve`. That's the entire surface — explicitly no chat, no video, no remote
|
||||
shell takeover (§37). The instructor dashboard's help queue polls the same dashboard endpoint
|
||||
everything else does.
|
||||
|
||||
## 10. Guidance, never control
|
||||
|
||||
`setCurrentActivity` (current scenario) and announcements are exactly that — a link the student
|
||||
can choose to open, a message they can read. Nothing server-side redirects a student's browser
|
||||
or marks work complete on their behalf. The external meeting URL (`classroom_sessions.
|
||||
meeting_url`) is a generic field the student sees as a link — RootNode never proxies or embeds
|
||||
Meet/Zoom/Teams/anything else, so it isn't coupled to a single vendor.
|
||||
|
||||
## 11. CSV roster import — whole-file, transactional
|
||||
|
||||
`student-csv.js` hand-parses (no CSV dependency — the file format here is small and simple
|
||||
enough that pulling in a library would just be one more thing to audit) and validates the
|
||||
**entire** file before any database write. `POST /api/admin/students/import` then does every
|
||||
insert inside one Postgres transaction — one bad row rolls back the whole import rather than
|
||||
leaving a half-imported roster, which is the more dangerous failure mode of the two. Never
|
||||
accepts a `password`/`password_hash`/`credential_token_hash` column — activation tokens are
|
||||
always generated server-side, never taken from input.
|
||||
|
||||
## 12. Observability
|
||||
|
||||
`GET /metrics` is a hand-rolled Prometheus text-exposition endpoint (no `prom-client`
|
||||
dependency — a handful of counters/gauges don't need one) with real in-process counters for
|
||||
login success/failure and on-demand gauges for live-classroom/help-request/Lab-state counts.
|
||||
No student or instructor ID ever appears as a label value — every label is a bounded enum
|
||||
(status, presence, result), per the explicit no-PII/no-high-cardinality-labels instruction. The
|
||||
Prometheus scrape job for the Platform API (`infra/monitoring/prometheus/prometheus.yml.j2`),
|
||||
previously commented out, is now active.
|
||||
|
||||
## 13. Frontend
|
||||
|
||||
**Student side** (`frontend/src/platform/`): `LoginScreen` (credentialed, now the default entry
|
||||
point) with links to the legacy pilot entry and forgot-password; `SetPasswordScreen` (shared
|
||||
activate/reset UI, driven by `?activate=`/`?reset=` query params — no router library, matching
|
||||
this app's existing state-machine-per-shell convention); `ForgotPasswordScreen`; a `Classroom`
|
||||
tab (live-class banner, join, guidance-only current-activity link, own Lab status/launch reusing
|
||||
the My Learning tab's exact API, announcements, Need Help form); a `Profile` tab (identity,
|
||||
cohort, progress, achievements, active-session count, change-password).
|
||||
|
||||
**Admin side** (`frontend/src/admin/`): `StudentsView` gained credential status in the roster
|
||||
table, a create-student form (email/login-ID/cohort), convert/reset-password/revoke-sessions
|
||||
controls per student, and a bulk CSV import form. A new `ClassroomView` is the control center:
|
||||
cohort picker → schedule/list classes → a live dashboard (participants with presence/Lab-status/
|
||||
current-work, help queue with acknowledge/resolve, current-activity setter, announcement
|
||||
poster, start/end/cancel, Prepare Labs).
|
||||
|
||||
## 14. Testing — real, not asserted
|
||||
|
||||
- `platform/test/e2e-phase5a.js` (21 checks): activation single-use, password security (§32 —
|
||||
correct/wrong/unknown-identifier/deactivated/immediate-deactivation/rate-limit/rate-limit-
|
||||
survives-restart/change-password/session-revocation/forgot-password anti-enumeration/admin
|
||||
reset/CSRF), and the two-student RBAC matrix (§31 — student can't reach `/api/admin/*`,
|
||||
assigned/unassigned/viewer instructor access), plus a classroom lifecycle smoke pass.
|
||||
- `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks): 5 pilot students, real seeded
|
||||
progress/attempt/achievement history, a live classroom, help requests, announcements, **a real
|
||||
Platform API restart mid-rehearsal** proving classroom/session/presence recovery, then
|
||||
converting two students mid-rehearsal and confirming every bit of their prior history survived
|
||||
under the new login.
|
||||
- Both run against a live `docker-compose.dev.yml` stack over real HTTP, not mocks.
|
||||
- The full pre-existing regression suite (Phase 1/2A/2B/3A/3B/4.1) was re-run unmodified given
|
||||
how much shared code (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`) this phase
|
||||
touched — see the Phase 5A completion report for results.
|
||||
|
||||
## 15. What was deliberately not built
|
||||
|
||||
Per §37: video/voice conferencing, student-to-student chat, remote instructor shell takeover,
|
||||
screen recording or proctoring, public registration or course catalog, payments/Stripe, a Parent
|
||||
Portal, school/SIS integrations, real email delivery infrastructure, certificates, an AI tutor,
|
||||
a mobile app, a multi-Lab-host scheduler, and production deployment itself. Each is a plausible
|
||||
"while we're in here" addition and each was left alone on purpose — the schema and API surface
|
||||
built this phase don't block any of them later.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Phase 5C — Enrollment Operations & Communications
|
||||
|
||||
**Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.**
|
||||
**LIVE GOOGLE WORKSPACE DELIVERY VALIDATED** — real activation and password-reset emails
|
||||
delivered end to end via `[email protected]` (a Workspace alias), DKIM/SPF passing,
|
||||
inbox delivery confirmed. See §14.
|
||||
|
||||
Phase 5B built the enrollment/entitlement model and the website integration boundary. What was
|
||||
still missing for a real student: nobody ever told them. This phase closes that gap — real
|
||||
email delivery for activation, enrollment confirmation, and password reset — plus finally
|
||||
completing the production password-reset delivery Phase 5A explicitly left unfinished.
|
||||
|
||||
## 1. Architecture: provider-neutral by design
|
||||
|
||||
```
|
||||
Enrollment/Auth code (integration-routes.js, admin-routes.js, student-auth-routes.js)
|
||||
↓ (mint token if needed, then) notifications.enqueue()
|
||||
notification_events row, committed in the SAME request — no network I/O yet
|
||||
↓
|
||||
notification-worker.js (a setInterval poller, same pattern as sessions.js's sweeper)
|
||||
↓
|
||||
email/provider.js — EMAIL_PROVIDER env var picks the implementation
|
||||
↓
|
||||
email/dev-provider.js (default) OR email/gws-provider.js
|
||||
(never a real network call) (Gmail API, service account)
|
||||
```
|
||||
|
||||
No enrollment/auth code anywhere imports a concrete provider. Swapping Google Workspace for a
|
||||
different provider later means writing one new file under `platform/api/src/email/` and
|
||||
changing `EMAIL_PROVIDER` — nothing else changes.
|
||||
|
||||
## 2. Google Workspace delivery mechanism: Gmail API + service account domain-wide delegation
|
||||
|
||||
**Selected mechanism:** the Gmail API's `users.messages.send`, authenticated as a Google Cloud
|
||||
service account using Workspace domain-wide delegation to impersonate one dedicated sending
|
||||
mailbox.
|
||||
|
||||
**Why, not SMTP or a human login:**
|
||||
- **No human password, ever.** Basic SMTP auth (or an "app password") means storing credentials
|
||||
that log in as an actual person's Workspace account — anyone with that value could read that
|
||||
mailbox's mail, change its settings, or use it to send as that person. A service account has
|
||||
no login of its own and no inbox; domain-wide delegation grants it exactly one narrow OAuth
|
||||
scope (`gmail.send`) against one specific mailbox, nothing more.
|
||||
- **Least privilege.** `gmail.send` cannot read mail, cannot manage the mailbox, cannot act as
|
||||
any address other than the one configured. Compare to SMTP relay, which typically wants either
|
||||
a static allow-listed sending IP (fragile for infrastructure that isn't pinned yet) or a
|
||||
Workspace login.
|
||||
- **This is Google's own documented pattern** for "an application needs to send mail as a
|
||||
specific Workspace address" — see `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the exact
|
||||
setup steps and links.
|
||||
|
||||
**No `googleapis`/`google-auth-library` dependency.** The JWT-bearer OAuth flow (RS256-sign a
|
||||
short JWT, exchange it for an access token, POST one REST call) is ~120 lines using only Node's
|
||||
built-in `crypto` and `fetch` — consistent with this codebase's established "hand-roll before
|
||||
adding a dependency" convention (CSRF tokens, Prometheus exposition, and CSV parsing all do the
|
||||
same). See `platform/api/src/email/gws-provider.js`.
|
||||
|
||||
## 3. RootNode email identity
|
||||
|
||||
`EMAIL_FROM_NAME` / `EMAIL_FROM_ADDRESS` / `EMAIL_REPLY_TO` — all environment-configured, no
|
||||
default mailbox address baked into the code (`EMAIL_FROM_NAME` defaults to the display string
|
||||
"RootNode Academy"; `EMAIL_FROM_ADDRESS` has no default at all — sending fails closed with a
|
||||
clean `gws_not_configured` error until it's set, rather than guessing a real mailbox that
|
||||
doesn't exist in this repository's configuration). `EMAIL_FROM_ADDRESS` doubles as the Gmail API
|
||||
adapter's delegated-user (`sub`) claim — it must be a real mailbox in the Workspace domain,
|
||||
authorized for delegation (see the runbook).
|
||||
|
||||
DNS/domain requirements before any of this can send real mail in production — SPF, DKIM, DMARC,
|
||||
and Workspace's own sender-authorization step — are **entirely external to this repository**
|
||||
and are documented, not performed, in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (marked
|
||||
`REQUIRES GOOGLE WORKSPACE ADMIN ACTION`).
|
||||
|
||||
## 4. Notification model
|
||||
|
||||
`notification_events` (migration `021_notification_events.sql`): `student_id` (required — every
|
||||
notification this phase sends is about a specific student), `enrollment_id` (nullable —
|
||||
password-reset/changed notifications have none), `notification_type`
|
||||
(`activation`/`enrollment_confirmation`/`password_reset`/`password_changed`), `recipient_email`
|
||||
(a snapshot, not a live join — see §10), `status`
|
||||
(`queued`→`sending`→`sent`/`failed`, or `cancelled`, unused this phase but reserved),
|
||||
`attempt_count`/`next_attempt_at` for bounded exponential backoff, `last_error_code` (a
|
||||
classified string, never a raw stack trace), and `metadata` JSONB.
|
||||
|
||||
**Privacy note on `recipient_email`:** duplicates PII already stored elsewhere (`students.email`
|
||||
or an enrollment's `contact_email`) purely so the worker and any later delivery audit don't need
|
||||
to reconstruct "what address would this have gone to" after the student record potentially
|
||||
changes. Same sensitivity class as data already in this database — never a new exposure, and
|
||||
never returned unmasked through any admin API (`notifications.js`'s `maskEmail`/`toAdminView`
|
||||
are the only shapes any route returns).
|
||||
|
||||
## 5. The transient-plaintext-token design (read this before touching token handling)
|
||||
|
||||
Phase 5A's invariant — only a HASH of an activation/reset token is ever persisted long-term
|
||||
(`students.credential_token_hash`) — is unchanged and non-negotiable. But an activation/reset
|
||||
*email* needs the real token to build a working link, and the whole point of this phase is that
|
||||
the actual send happens later, asynchronously, in the worker — not in the request that
|
||||
originally minted the token.
|
||||
|
||||
The resolution: the triggering request mints the token exactly as Phase 5A already does
|
||||
(synchronously, in-request), and hands the plaintext to `notifications.enqueue()`, which stores
|
||||
it in that one row's `metadata.token` **only until the row reaches a terminal state**
|
||||
(`markSent`/`markFailedOrRetry` both scrub it — see their comments in `notifications.js`). This
|
||||
is a deliberate, narrow, documented exception for the row that exists specifically to deliver
|
||||
the plaintext once — not a second credential store, and never returned by any admin API
|
||||
regardless of whether it's still present (`toAdminView` never includes `metadata` at all).
|
||||
|
||||
Activation notifications from a **new enrollment** are the one case where no token is minted
|
||||
ahead of time and handed in — `notifyForEnrollment()` mints it itself, at enqueue time, because
|
||||
there's no other synchronous caller already holding one. Everywhere else (admin create/convert/
|
||||
reset-password, self-service forgot-password, admin resend), the existing Phase 5A minting call
|
||||
site is reused unchanged and its plaintext is simply also passed to `enqueue()`.
|
||||
|
||||
## 6. Activation flow
|
||||
|
||||
Reuses Phase 5A's activation token mechanism entirely — no competing invitation-token system was
|
||||
built. `issueCredentialToken` already unconditionally invalidates any previous outstanding token
|
||||
on mint (Phase 5A), which means **"resend" and "regenerate" are mechanically the same
|
||||
operation**: there is never a moment with two simultaneously valid activation links, so a second
|
||||
button that did something different would have nothing different to do. `POST /api/admin/
|
||||
students/:id/resend-activation` is the one action that covers both.
|
||||
|
||||
```
|
||||
Enrollment event/admin enrollment → student matched/created
|
||||
→ credentials already active? → enrollment_confirmation (§7)
|
||||
→ else → issueCredentialToken('activation') → notifications.enqueue()
|
||||
→ worker delivers via the configured provider
|
||||
→ student opens the link → existing Set Password flow (Phase 5A, unchanged)
|
||||
→ token consumed atomically with setting the password
|
||||
→ account active, student can log in
|
||||
```
|
||||
|
||||
## 7. Enrollment confirmation flow
|
||||
|
||||
Fires whenever a **new enrollment** is created (integration event or admin manual enroll) for a
|
||||
student who **already has credentials** (`students.password_hash IS NOT NULL`). No token, no
|
||||
link beyond "go sign in" — `notifyForEnrollment()` (shared by both creation paths) is the single
|
||||
decision point, so a website-triggered and an admin-triggered enrollment behave identically.
|
||||
|
||||
## 8. Password reset flow — now actually delivered
|
||||
|
||||
Phase 5A's `POST /api/student/request-password-reset` already minted a token and, only outside
|
||||
production, echoed it back for local/test use. Phase 5C adds exactly one thing: the same
|
||||
minted token is now also handed to `notifications.enqueue()`, so a real email goes out. Every
|
||||
other behavior is unchanged: the response is the same generic anti-enumeration message
|
||||
regardless of whether the account exists, is inactive, or was never activated (this phase
|
||||
additionally excludes never-activated accounts, a real fix — see §13); production never returns
|
||||
the token, dev/test still can. `POST /api/student/reset-password` (consuming the token) now also
|
||||
enqueues a `password_changed` security-notice email once the new password is set.
|
||||
|
||||
## 9. Password-changed / security notice
|
||||
|
||||
Fires after both `POST /api/student/change-password` (self-service) and `POST /api/student/
|
||||
reset-password` (token consumption) succeed — no token, no link, purely "this happened, if it
|
||||
wasn't you here's what to do."
|
||||
|
||||
## 10. Minor/no-email communication design
|
||||
|
||||
Phase 5A/5B's minor-safe model (a student may have `login_identifier` with no `email` at all) is
|
||||
preserved exactly. `enrollments.metadata.contact_email` (an existing JSONB column, not a new
|
||||
migration) holds an OPTIONAL "who to notify about THIS enrollment" address — a guardian's email
|
||||
collected at checkout, for example. **It is never written to `students.email`** — that field
|
||||
stays reserved for the student's own identity/login credential; a guardian is not a student.
|
||||
`enrollment.js`'s `enrollmentContactEmail()` is the one accessor with that one meaning; recipient
|
||||
resolution (`notifications.js`'s `resolveRecipient`) tries the student's own email first, falls
|
||||
back to the enrollment's contact email only for enrollment-scoped notification types, and
|
||||
correctly has no fallback at all for password-reset/changed (which require the student to
|
||||
already be able to log in themselves). This is deliberately transitional — not a guardian
|
||||
account, not a linked identity, no relationship table — and does not block a future real
|
||||
Guardian Portal from being built on top of it later.
|
||||
|
||||
## 11. Website → enrollment → communication
|
||||
|
||||
`POST /api/integrations/enrollments` (Phase 5B, unchanged) now also decides and enqueues the
|
||||
right notification, but **only when its own enrollment-creation call reports `created: true`**
|
||||
(a genuinely new enrollment, not an idempotent replay). This is the entire duplicate-webhook
|
||||
safety net for notifications: since Phase 5B's DB-level idempotency already guarantees at most
|
||||
one caller ever sees `created: true` for a given event/race, at most one notification is ever
|
||||
queued, with zero new dedup mechanism needed.
|
||||
|
||||
## 12. Retry/failure handling
|
||||
|
||||
`markFailedOrRetry` classifies every provider error as `temporary` or `permanent` (both
|
||||
providers' `send()` throw an `Error` with `.errorClass`/`.errorCode` set). Permanent failures
|
||||
(bad recipient, missing/invalid Google config) go straight to `failed` on the first attempt —
|
||||
retrying can't fix them. Temporary failures (network/provider hiccups, rate limiting) requeue
|
||||
with bounded exponential backoff (30s base, doubling, capped at 1 hour) until `MAX_ATTEMPTS` (5)
|
||||
is exhausted, then `failed` too. Never an infinite loop; every attempt is persisted
|
||||
(`attempt_count`, `last_error_code`).
|
||||
|
||||
## 13. Real bugs found and fixed
|
||||
|
||||
- `request-password-reset` would previously mint a `reset` token for a student who had **never
|
||||
been activated** (`password_hash IS NULL`) — a "reset" for an account with no password to
|
||||
reset doesn't make semantic sense, and would have sent a confusing "reset your password" email
|
||||
to someone who needs activation, not reset. Fixed: that branch now also checks
|
||||
`student.password_hash` and falls through to the same generic anti-enumeration response,
|
||||
exactly as if the account didn't exist.
|
||||
- **HTML injection in email templates.** `displayName` (which can come straight from a website
|
||||
integration event's `student.display_name` — genuinely external, caller-controlled text, not
|
||||
admin-typed) was interpolated directly into the HTML email body with no escaping. `templates.js`
|
||||
now escapes every user-controlled value before it reaches HTML (`escapeHtml`); the plain-text
|
||||
bodies are correctly left unescaped, since there's no markup there to break. Both fixes are
|
||||
regression-tested in `e2e-phase5c.js`.
|
||||
|
||||
## 14. Testing
|
||||
|
||||
- `platform/test/e2e-phase5c.js` (28 checks): activation vs enrollment-confirmation decision,
|
||||
duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry classification,
|
||||
resend/regenerate token invalidation, activation/reset single-use and expiration, production
|
||||
token-secrecy gating (a real container recreate with `COOKIE_SECURE=1`), minor/contact-email
|
||||
resolution, RBAC scoping, metrics/audit hygiene, and a real Platform API restart.
|
||||
- `infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks): one continuous
|
||||
story — website enrollment → activation email rendered and its URL extracted exactly like a
|
||||
real inbox click → password set → real Lab launched → real scenario completed → a second
|
||||
enrollment gets confirmation not activation → full password-reset round trip with session
|
||||
revocation → the minor/contact-email path.
|
||||
- Both against a live `docker-compose.dev.yml` stack, using the deterministic dev email provider
|
||||
exclusively — **no test in this phase ever makes a real network call to Google or anyone
|
||||
else.**
|
||||
- Migration safety proven explicitly against a fresh Postgres taken through every prior phase's
|
||||
migrations, seeded with realistic Phase 5B data (credentialed student, minor/no-email student,
|
||||
cohort, enrollment with a contact-email, progress, attempts, achievements, classroom history),
|
||||
then this phase's migration applied — every prior record intact; a new notification correctly
|
||||
links to the pre-existing student/enrollment; re-running the migration set is a no-op.
|
||||
|
||||
**Live Google Workspace delivery has been validated for real**, both in isolation
|
||||
(`infra/scripts/gws-email-test.js`, one message, DKIM/SPF passing, delivered) and through the
|
||||
full genuine flow: a real student created, enrolled, receiving a real activation email, setting
|
||||
their password from the real link, logging in, seeing their enrolled program, requesting and
|
||||
receiving a real password-reset email, and completing the reset — every step against the live
|
||||
Platform API, `EMAIL_PROVIDER=gws`, nothing simulated.
|
||||
|
||||
**One real Google Workspace limitation found and handled:** newly-authorized domain-wide
|
||||
delegation does not take effect instantly across all of Google's auth infrastructure — the
|
||||
identical token request succeeded, then failed with `unauthorized_client`, then succeeded again,
|
||||
over the span of about a minute, before settling. This is Google-side propagation delay, not a
|
||||
configuration error, and it self-resolved. `gws-provider.js` now classifies `unauthorized_client`
|
||||
as a *temporary* failure (retried with the existing bounded backoff) rather than permanent —
|
||||
this is exactly the failure mode that backoff exists for, and it means a real deployment doesn't
|
||||
need a human to notice and manually retry during that propagation window. See
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting section.
|
||||
|
||||
**A second real-world detail found during setup, not a bug:** the sending address
|
||||
(`[email protected]`) is a Workspace *alias* of an existing account rather than its
|
||||
own paid seat — a deliberate cost-saving choice. Domain-wide delegation authorizes a real
|
||||
account, not an arbitrary alias string, so `GOOGLE_DELEGATED_USER` (the alias's real owner) was
|
||||
added as a setting distinct from `EMAIL_FROM_ADDRESS` (the visible sending address) — see §2 and
|
||||
the runbook §5.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 6A — ROOT_ Course Architecture + Content Delivery Foundation
|
||||
|
||||
**Status: PHASE 6A COMPLETE. COURSE/MODULE/ACTIVITY CONTENT MODEL LIVE. GOLD-STANDARD MODULE
|
||||
READY, VALIDATED AGAINST A REAL LAB.**
|
||||
|
||||
Phases 1-5 built the RootNode Academy platform: identity, cohorts, curriculum, the assessment
|
||||
engine, remote classrooms, self-paced enrollment, and enrollment communications. All of it sat
|
||||
on top of `scenarios/rootnode-path.json` — a flat list of scenario ids per stage, with no notion
|
||||
of a lesson, a demonstration, or "learn this before you're asked to solve it." Phase 6A builds
|
||||
that layer, for the `root_` track only, without touching any of the systems underneath it.
|
||||
|
||||
## 1. The canonical youth ladder (context, not built here)
|
||||
|
||||
RootNode Academy's product spans three tracks by age/stage:
|
||||
|
||||
- **`init_`** (~5-10) — robotics / computational thinking / block coding. Not built.
|
||||
- **`compile_`** (~11-14) — electronics / microcontrollers / ESP32 / programming. Not built.
|
||||
- **`root_`** (~15-18+, college, adult) — Linux → systems → containers → Kubernetes → DevOps/
|
||||
platform engineering. **This phase.**
|
||||
|
||||
`programs.family` (Phase 5B) already models this three-way split; only `root` has real content.
|
||||
Phase 6A does not invent `init_`/`compile_` curriculum — see §15 of the authorization and
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md` §7 for the evidence that no such content exists yet.
|
||||
|
||||
## 2. Core product principle
|
||||
|
||||
KubeKosh/the RootNode Lab is the LABORATORY, not the whole course. A RootNode course can contain
|
||||
lessons, demonstrations, examples, knowledge checks, guided exercises, labs (existing
|
||||
scenarios), assessments, reflections, and capstone work — a student learns a concept BEFORE
|
||||
being asked to solve the corresponding scenario. See `docs/COURSE_AUTHORING_STANDARD.md`.
|
||||
|
||||
## 3. Google Workspace status (recorded per this phase's instruction, not new work)
|
||||
|
||||
The external blocker at the end of Phase 5C — no real Google Workspace mailbox to test against —
|
||||
was resolved by the owner during this session before Phase 6A began: `[email protected]`
|
||||
now exists and live delivery was validated end to end (isolated send test + a full real
|
||||
activation/reset lifecycle). This is already recorded in `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`
|
||||
(status header, §14) and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (§11's propagation-delay note),
|
||||
committed at `4c0a950` and tagged `v0.5.3-enrollment-comms` — this phase did not need to redo
|
||||
that work, only confirm it was recorded, which it was.
|
||||
|
||||
## 4. Architecture summary
|
||||
|
||||
```
|
||||
program (existing) ── courses ── course_modules ── module_activities
|
||||
├─ lab → scenario_id (existing)
|
||||
└─ assessment/capstone → assessment_definition_id (existing)
|
||||
```
|
||||
|
||||
Full design: `docs/COURSE_CONTENT_MODEL.md`. Full course/module map:
|
||||
`docs/ROOT_PROGRAM_ARCHITECTURE.md`. Full existing-content inventory this was built from:
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md`.
|
||||
|
||||
Delivery modes: the same content model serves both instructor-led (Phase 5A classrooms) and
|
||||
self-paced (Phase 5B enrollment) students — one `buildCourseTree()` function
|
||||
(`platform/api/src/course-progress.js`), one `content.js` Markdown loader, consumed by both
|
||||
`course-routes.js` (student) and `admin-course-routes.js` (instructor), the latter additionally
|
||||
exposing instructor notes via the same file's `## Instructor Notes` convention. No forked
|
||||
curriculum.
|
||||
|
||||
## 5. What was built
|
||||
|
||||
**Migration** — `platform/api/migrations/022_course_content.sql`: `courses`, `course_modules`,
|
||||
`module_activities`, `activity_completions`, plus seed data for 13 courses, 8 modules, and 51
|
||||
activities (44 mechanically mapped from existing `rootnode-path.json` stages, 6 gold-module
|
||||
activities, 1 capstone activity) — see that file's own extensive header comments.
|
||||
|
||||
**Content** — `content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/`:
|
||||
6 Markdown files (lesson, demonstration, knowledge check, guided exercise, lab intro,
|
||||
reflection), each following the shared/instructor-notes split convention.
|
||||
|
||||
**Backend** — `content.js` (Markdown loader), `course-progress.js` (completion derivation +
|
||||
prerequisite locking, shared by both surfaces), `course-routes.js` (student API),
|
||||
`admin-course-routes.js` (instructor API), wired into `server.js`. `platform/api/Dockerfile`
|
||||
now also copies `content/`.
|
||||
|
||||
**Frontend** — `CourseView.jsx` (student: My Programs → ROOT_ → Course → Module → Activity →
|
||||
Launch Lab → back → continue), wired into `ProgramsTab.jsx`'s existing "Continue Learning" flow
|
||||
via a new "View Courses" button (the original direct-to-Lab button is unchanged).
|
||||
`CurriculumView.jsx` (instructor: program structure, per-module teaching detail, instructor
|
||||
notes, per-student progress lookup), wired into `AdminPortal.jsx` as a new "Curriculum" tab.
|
||||
|
||||
**Tests** — `platform/test/e2e-phase6a.js` (21 assertions: hierarchy, ordering, all three
|
||||
completion behaviors, locking, resume, cross-student isolation, RBAC, draft-hiding, version
|
||||
stability) and `infra/scripts/drills/test-phase6a-course-rehearsal.js` +
|
||||
`run-phase6a-course-rehearsal.sh` (a real-k3s dress rehearsal of the gold module end to end,
|
||||
including the Lab's own `setup_commands` and a real `kubectl patch` against the live cluster).
|
||||
|
||||
## 6. The gold-standard module
|
||||
|
||||
`root-10-m01-health-probes-and-troubleshooting`, under ROOT 10 (Kubernetes Troubleshooting &
|
||||
Observability): lesson (probe concepts) → demonstration (reading `kubectl describe pod` Events
|
||||
for a probe failure) → knowledge check → guided exercise (write a readiness probe on paper) →
|
||||
Launch Lab (`rootnode-probe-port-mismatch`, RootNode-authored) → reflection (root cause + Real
|
||||
Ops prevention takeaway). Instructor notes on 5 of its 6 activities (demo plan, misconceptions,
|
||||
timing, discussion questions, extensions). This is the template `docs/COURSE_AUTHORING_STANDARD.md`
|
||||
documents for all future module authoring.
|
||||
|
||||
## 7. Honest content states
|
||||
|
||||
No course is `ready`. 5 courses (01-04, 11) are `planned` with zero modules — genuinely no
|
||||
content exists for them. 7 courses (05-10, 12, capstone) are `partial` — real, working labs
|
||||
mapped in, one fully `ready` module (the gold standard), everything else awaiting lesson
|
||||
authoring in Phase 6B. Nothing was fabricated to make the UI look more complete than it is.
|
||||
|
||||
## 8. Test results
|
||||
|
||||
- `e2e-phase6a.js`: **21 passed, 0 failed.**
|
||||
- `test-phase6a-course-rehearsal.js` (real k3s Lab): **12 passed, 0 failed**, including a real
|
||||
`kubectl patch` against a live cluster inside the student's actual disposable Lab container.
|
||||
- Full regression (Phase 2A/2B/3A/3B/5A/5B/5C) — see the final report for the run-by-run result;
|
||||
no Phase 6A change touched any file those suites exercise other than additive route
|
||||
registration in `server.js`.
|
||||
|
||||
## 9. What Phase 6A deliberately did not build
|
||||
|
||||
Per the authorization's explicit scope line: `init_`/`compile_` curriculum, a Parent Portal,
|
||||
Stripe/payments, public registration, an AI tutor, chat/video/voice, proctoring, a mobile app,
|
||||
certificates, a multi-host scheduler, production deployment changes, a CMS, or dozens of
|
||||
placeholder lessons. Lesson authoring for courses 06-10/12/capstone beyond the gold standard is
|
||||
explicitly Phase 6B+ scope.
|
||||
|
||||
## 10. Known limitations / recommended Phase 6B scope
|
||||
|
||||
- ROOT 08/09's real, existing scenario content (storage/scheduling, and a 22-scenario CKS
|
||||
security bank) is inventoried but not yet wired into modules — the natural next content slice.
|
||||
- Course-level lesson authoring for ROOT 05-07/10 (m02)/12/capstone: labs exist, teaching
|
||||
content doesn't yet — apply `docs/COURSE_AUTHORING_STANDARD.md` per module.
|
||||
- `POST /api/assessments/:definitionId/start` does not itself re-check the capstone's
|
||||
`unlock_rule` server-side (a pre-existing Phase 3B gap, not introduced by this phase — the
|
||||
student-facing `/api/capstone` view does compute and display lock state correctly, and the
|
||||
UI never exposes a "start" action while locked, but the raw route would technically accept a
|
||||
premature start if called directly). Not fixed here — it's outside the course/content-model
|
||||
scope this phase was authorized for; worth a small, isolated fix in a future phase.
|
||||
- Course-to-course prerequisite locking is currently sequential/manually declared, not derived
|
||||
from the assessment engine's own `unlock_rule` JSON — the two happen to agree today by
|
||||
construction (see `docs/ROOT_PROGRAM_ARCHITECTURE.md`'s "Real Ops and Capstone placement");
|
||||
a future phase could unify them if course content grows complex enough to warrant it.
|
||||
@@ -54,5 +54,53 @@ can prove in advance.
|
||||
full" message, not a 500 — confirms the real host's admission control matches the local test
|
||||
- [ ] `docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md`'s steps still make sense once capacity numbers are real
|
||||
|
||||
## Student identity & remote classroom (Phase 5A — see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md)
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms `/api/student/
|
||||
request-password-reset` never returns `dev_reset_token` outside of dev/CI
|
||||
- [ ] A real activation/reset link (`?activate=`/`?reset=`) round-trips against the production
|
||||
domain, not just `localhost` — query-param parsing, no router library in play
|
||||
- [ ] Student login rate limiting still 429s after a restart against the real production
|
||||
Postgres, same as the local `e2e-phase5a.js` check
|
||||
- [ ] `GET /metrics` is reachable by the real Prometheus instance (`infra/monitoring/
|
||||
prometheus/prometheus.yml.j2`'s `rootnode-platform-api` job) and no student/instructor ID
|
||||
appears in any label value
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
|
||||
still make sense walked against the real admin portal
|
||||
|
||||
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
|
||||
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
|
||||
value) and is stored only in the Platform API's own server-side environment — never
|
||||
committed, never in any browser-reachable config
|
||||
- [ ] The website team's production integration key was delivered out of band (not plaintext
|
||||
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
|
||||
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
|
||||
clean `401` against the real production endpoint, not a 500 or an information leak
|
||||
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
|
||||
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
|
||||
external_reference ever appears in a real production scrape
|
||||
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
|
||||
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
|
||||
actually integrated against (update it if reality drifted during that integration)
|
||||
|
||||
## Enrollment communications / Google Workspace email (Phase 5C — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md)
|
||||
- [ ] Every step in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` marked REQUIRES GOOGLE WORKSPACE
|
||||
ADMIN ACTION has actually been completed (sending mailbox, service account, domain-wide
|
||||
delegation scoped to `gmail.send` only, SPF/DKIM/DMARC)
|
||||
- [ ] `EMAIL_PROVIDER=gws` set in the real production environment — confirm it is NOT left at
|
||||
the dev-provider default, which would silently mean no real student ever receives email
|
||||
- [ ] `GOOGLE_SERVICE_ACCOUNT_JSON`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`,
|
||||
and `PUBLIC_APP_ORIGIN` are all set from the real production secret store, not left at any
|
||||
dev/example value
|
||||
- [ ] `infra/scripts/gws-email-test.js` (opt-in, `RUN_GWS_EMAIL_TEST=true`) run once against a
|
||||
real `TEST_EMAIL_RECIPIENT` and confirmed delivered — the one and only "does this actually
|
||||
work" proof this checklist can't fake
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms the dev-preview
|
||||
notification route (`GET /api/admin/notifications/:id/dev-preview`) is unreachable and no
|
||||
activation/reset token is ever returned by any API response
|
||||
- [ ] `GET /metrics`'s notification gauges/counters contain no student_id/email/token/
|
||||
notification_id/external_event_id in any label value on a real production scrape
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACTIVATION.md`'s steps still make sense walked against the real
|
||||
admin portal, including a real (not dev-provider) activation email actually arriving
|
||||
|
||||
Only once every box above is checked against the real server does the deployment graduate from
|
||||
"infrastructure package built" to "actually in production."
|
||||
|
||||
@@ -45,41 +45,60 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
|
||||
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
|
||||
|
||||
1. **No self-registration / public enrollment.** A student row is created only by an instructor
|
||||
(`POST /api/admin/students`, admin/owner RBAC-gated). There is no public "sign up" surface.
|
||||
This is almost certainly the right call to leave alone for now — it's explicitly a Phase 5+
|
||||
product/business decision (course commerce, is it free, who's allowed to enroll), not an
|
||||
infrastructure gap.
|
||||
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
|
||||
built the boundary a *separate* public website will call
|
||||
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
|
||||
row can now be created automatically by a trusted server-to-server enrollment event, but there
|
||||
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
|
||||
|
||||
2. **No real student authentication.** A student session is created from a bare `student_id`
|
||||
(`POST /api/login`, no password) — deliberately, since today every `student_id` is issued and
|
||||
controlled by an instructor via the roster, not chosen or known by the public. A genuinely
|
||||
remote/self-paced product where students access their account without an instructor in the
|
||||
loop needs real credentials (email+password, or a magic link) — reusing the same
|
||||
`password_hash`/`scrypt` machinery `instructors` already has (`src/auth/password.js`) would
|
||||
be the natural extension, not a new auth system. **This is the one item on this list that
|
||||
would actually block self-paced delivery today** — everything else already works or is a
|
||||
product decision, not an infrastructure one.
|
||||
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
|
||||
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
|
||||
`instructors`' exact `src/auth/password.js`), a first-login/reset token flow, and Postgres-
|
||||
backed rate-limited/CSRF-protected sessions on the same `sessions` table instructors use — see
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. The old bare-`student_id` `POST
|
||||
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
|
||||
an account has real credentials or a pending activation token.
|
||||
|
||||
3. **`curriculum_assignments.student_id` is schema-ready but unwired.** The table has supported
|
||||
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
|
||||
(`CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT
|
||||
NULL)`) — but nothing in the application ever reads or writes that column today; there's only
|
||||
one curriculum path, so nothing has needed to differentiate yet. Not a gap that blocks
|
||||
self-paced delivery now (every student already sees the one path with no gate), but the
|
||||
moment a second curriculum path exists, this is where per-individual assignment lands — the
|
||||
schema was designed for exactly this and doesn't need a migration to support it.
|
||||
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
|
||||
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
|
||||
the real problem it was meant to eventually solve (which curriculum a given student sees) is
|
||||
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
|
||||
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
|
||||
future direct per-student assignment need outside the program/enrollment model — it wasn't
|
||||
removed, just not the mechanism Phase 5B ended up using. See
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
|
||||
|
||||
4. **No delivery-mode concept anywhere.** In-person, remote, and self-paced sessions aren't
|
||||
distinguished in any data model today, and nothing currently needs them to be — capacity
|
||||
planning, the reaper, and academic records all behave identically regardless of how a student
|
||||
is attending. Worth being aware this is unmodeled, not worth building a taxonomy for before a
|
||||
real product reason exists (YAGNI).
|
||||
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
|
||||
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
|
||||
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
|
||||
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
|
||||
is modeled, not infrastructure.
|
||||
|
||||
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
|
||||
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
|
||||
self-paced student dashboard.
|
||||
|
||||
6. ~~**No enrollment communication.**~~ **Closed in Phase 5C.** An enrolled student now actually
|
||||
gets told — real activation/enrollment-confirmation/password-reset email, delivered
|
||||
asynchronously through a provider-neutral notification worker (Google Workspace via the
|
||||
Gmail API in production, a deterministic dev provider everywhere else). See
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
7. ~~**No notion of a lesson, only a flat scenario list per curriculum stage.**~~ **Closed in
|
||||
Phase 6A**, for `root_` only. A self-paced enrollment now resolves to a real course/module/
|
||||
activity tree (lessons, demonstrations, knowledge checks, guided exercises, labs,
|
||||
reflections, assessments/capstone) instead of just a bare scenario id list — the same tree an
|
||||
instructor-led classroom student sees, consumed through the identical `buildCourseTree()`.
|
||||
See `docs/COURSE_CONTENT_MODEL.md` and `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT do here
|
||||
|
||||
No signup flow, no student password/email auth implementation, no per-student curriculum
|
||||
assignment route, no payments, no public course catalog, no held/premium scenario content. All
|
||||
explicit non-goals per this phase's authorization. This document exists so Phase 5 planning
|
||||
starts from an accurate picture of the gap (one real item: student authentication) instead of a
|
||||
guess.
|
||||
explicit non-goals per this phase's authorization. Student authentication (Phase 5A), the
|
||||
self-paced enrollment/entitlement model (Phase 5B), and enrollment/account-lifecycle
|
||||
communication (Phase 5C) have all 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 and `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# ROOT_ Curriculum Inventory
|
||||
|
||||
Phase 6A §1 — a factual inventory of what already exists in this repository, gathered by
|
||||
inspecting `scenarios/data/`, `scenarios/bundles/`, `scenarios/rootnode-path.json`,
|
||||
`assessment_definitions` (013_assessments.sql), `programs` (019_programs.sql), `achievements`,
|
||||
and the curriculum/progress APIs — before any Phase 6A design work happened. Nothing here was
|
||||
modified to produce this document.
|
||||
|
||||
## 1. The current program model
|
||||
|
||||
One program exists: `root-kubernetes-foundations` (`programs` table, family `root`,
|
||||
`delivery_mode = self_paced`, `curriculum_path = rootnode-path`,
|
||||
`completion_assessment_id = rootnode-capstone`). `init`/`compile` families are supported by the
|
||||
schema's CHECK constraint but have zero seeded programs — no init_/compile_ curriculum exists
|
||||
anywhere in this repository.
|
||||
|
||||
## 2. `scenarios/rootnode-path.json` — the existing stage structure
|
||||
|
||||
The one curriculum path that exists, "RootNode Learning Path," version 1, with 7 stages:
|
||||
|
||||
| Stage | Scenario count | Completion rule |
|
||||
|---|---|---|
|
||||
| `foundation` | 20 | all_required |
|
||||
| `workloads` | 8 | all_required |
|
||||
| `networking` | 2 | all_required |
|
||||
| `configuration` | 5 | all_required |
|
||||
| `troubleshooting` | 5 | all_required |
|
||||
| `real-ops` (bundle `rootnode-real-ops`) | 4 | all_required |
|
||||
| `capstone` (type `capstone`) | 0 (references `rootnode-capstone`) | unlocks when all 6 content stages above are complete |
|
||||
|
||||
44 of the repository's 98 total scenarios are wired into this path. The remaining 54 exist as
|
||||
real, working scenario content but are not referenced by `rootnode-path.json` at all — see §4.
|
||||
|
||||
## 3. `scenarios/bundles/` — exam-prep bundle groupings
|
||||
|
||||
Five bundles exist (`1-k8s-basics`, `2-k8s-appdev`, `3-k8s-admin`, `4-k8s-security`,
|
||||
`rootnode-real-ops`), each a flat list of scenario ids used for CKS-style exam-prep grouping in
|
||||
the existing Practice area — overlapping by design (a scenario can appear in more than one
|
||||
bundle). These are unrelated to `rootnode-path.json`'s stages and untouched by Phase 6A.
|
||||
|
||||
## 4. Full scenario inventory
|
||||
|
||||
For every scenario in `scenarios/data/` (98 total): id, title, category, difficulty, task vs.
|
||||
MCQ, which bundle(s) it's in, which `rootnode-path.json` stage (if any) it's wired into, whether
|
||||
it appears in the `rootnode-capstone` or `rootnode-quick-assessment` `assessment_definitions`
|
||||
pool_spec, and whether it's RootNode-authored or generic upstream content.
|
||||
|
||||
Only 3 scenarios are RootNode-specific (`rootnode-runaway-monitor`,
|
||||
`rootnode-probe-port-mismatch`, `rootnode-enforce-limits`) — every other scenario, including all
|
||||
44 wired into `rootnode-path.json`, is generic upstream Kubernetes/CKS content. This matters for
|
||||
Phase 6A's course design: the "gold-standard module" spec explicitly asked for a RootNode-
|
||||
authored incident as the lab, which narrows the real choice to those 3 (plus `broken-deployment`,
|
||||
generic but already framed as an incident in the `real-ops` stage).
|
||||
|
||||
<!-- Generated from scenarios/data/*.json, scenarios/bundles/*.json, scenarios/rootnode-path.json,
|
||||
and platform/api/migrations/013_assessments.sql's seeded pool_specs — not hand-typed. -->
|
||||
|
||||
| Scenario ID | Title | Category | Difficulty | Type | Bundles | Path Stage | In Assessment | Origin |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| annotate-resource-basics | Annotating Kubernetes Resources | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| broken-deployment | Debug a Failing Deployment | Troubleshooting | Medium | task | 3-k8s-admin, rootnode-real-ops | real-ops | capstone, quick-assessment | generic upstream |
|
||||
| cks-apparmor-profile | AppArmor Profiles | Workload Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-audit-policy | Write a Kubernetes Audit Policy | Cluster Setup | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-automount-token | Disable ServiceAccount Token | Workload Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-drop-capabilities | Drop Linux Capabilities | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-egress-namespace | Restrict Egress to Namespace | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-image-pull-secret | Private Registry Image Pull Secret | Supply Chain Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-immutable-secret | Immutable Secrets | Cluster Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-api-server | API Server Authentication | Cluster Setup | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-audit-policy | Kubernetes Audit Logging Levels | Cluster Setup | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-image-footprint | Minimizing Image Footprint | Supply Chain Security | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-kubelet-auth | Securing the Kubelet | Cluster Setup | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-psp-replacement | Pod Security Evolution | Cluster Security | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-runtime-security | Runtime Security Tools | System Hardening | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-sandboxing | Container Runtime Sandboxing | System Hardening | Hard | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-network-policy | Default Deny Network Policy | Network Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-network-policy-metadata | Block Cloud Metadata | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-non-root-enforce | Enforce Non-Root Container | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-pod-security-context | Pod Security Context | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-psa-namespace | Pod Security Admission | Cluster Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-rbac-clusterrole | Cluster-Level RBAC | Cluster Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-rbac-least-privilege | RBAC Least Privilege | Cluster Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-readonly-filesystem | Read-Only Root Filesystem | Workload Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-runtime-class | RuntimeClass for Sandboxed Workloads | System Hardening | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-seccomp-profile | Seccomp Profiles | Workload Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-tls-ingress | Secure Ingress with TLS | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cluster-upgrade-mcq | Cluster Upgrade Order | Cluster Administration | Hard | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| configmap-volume | Mount ConfigMap as Volume | Configuration | Medium | task | 1-k8s-basics, 2-k8s-appdev | configuration | — | generic upstream |
|
||||
| container-logging-mcq | Container Logging and Monitoring | Troubleshooting | Easy | mcq | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| crashloop-fix | Fix a CrashLoopBackOff Pod | Troubleshooting | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| cronjob-task | Create a CronJob | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| daemonset-mcq | Understanding DaemonSets | Workloads | Easy | mcq | 1-k8s-basics | workloads | quick-assessment | generic upstream |
|
||||
| delete-by-label-basics | Bulk Deletion by Label | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| deploy-nginx | Deploy and Expose Nginx | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| deployment-rollback | Rolling Back a Deployment | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| deployment-strategy-mcq | Deployment Update Strategies | Workloads | Medium | mcq | 1-k8s-basics | workloads | — | generic upstream |
|
||||
| dns-resolution-mcq | Kubernetes DNS Resolution | Services & Networking | Easy | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| dry-run-manifest-basics | Generating YAML Manifests | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| edit-deployment-basics | Updating Deployments | Workloads | Easy | task | 1-k8s-basics | workloads | capstone | generic upstream |
|
||||
| emptydir-pod | Shared emptyDir Volume Between Containers | Storage | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| endpoint-fix-task | Fix a Broken Service Selector | Troubleshooting | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| env-vars-configmap | Environment Variables from ConfigMap | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| etcd-backup-mcq | etcd Backup and Restore | Cluster Administration | Hard | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| exec-command-basics | Execute Commands in Pods | Troubleshooting | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| expose-service-basics | Exposing an Application | Services & Networking | Easy | task | 1-k8s-basics | networking | quick-assessment | generic upstream |
|
||||
| extract-logs-basics | Extract Container Logs | Troubleshooting | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| field-selector-basics | Filtering Resources with Field Selectors | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| ingress-task | Create an Ingress Resource | Services & Networking | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| init-container-task-basics | Pod with Init Container | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| init-containers-mcq | Init Containers Behaviour | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| job-task | Create a One-Shot Job | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| jobs-cronjobs-mcq | Jobs vs CronJobs | Workloads | Easy | mcq | 1-k8s-basics | workloads | — | generic upstream |
|
||||
| jsonpath-basics | JSONPath Data Extraction | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-cp-basics | Copy Files to Containers | Troubleshooting | Medium | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| kubectl-diff-basics | Previewing Changes with kubectl diff | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-essentials-mcq | Essential kubectl Commands | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| kubectl-explain-basics | Exploring the API with kubectl explain | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-output-format-mcq | kubectl Output Formats | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-patch-basics | Patching Resources with kubectl patch | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| labels-selectors-mcq | Labels and Selectors | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| limitrange-task | LimitRange for Default Container Limits | Workloads & Scheduling | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| liveness-probe-task | Configure a Liveness Probe | Workloads | Medium | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| multi-container-pod-basics | Create a Multi-Container Pod | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| namespaces-basics | Working with Namespaces | Core Concepts | Easy | task | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| network-policy | Isolate Traffic with NetworkPolicy | Networking | Hard | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| networkpolicy-egress | Egress NetworkPolicy | Security | Hard | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| node-cordon-basics | Cordoning and Uncordoning a Node | Cluster Management | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| node-label-selector | Node Labels and nodeSelector | Cluster Administration | Easy | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| node-taint-toleration | Taints and Tolerations | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| nodeport-task | Expose a Deployment via NodePort | Services & Networking | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-affinity-mcq | Pod Affinity vs Anti-Affinity | Workloads & Scheduling | Medium | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-basics-mcq | Pod Lifecycle States | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| pod-disruption-budget-task | Configure a PodDisruptionBudget | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-env-vars-inline | Pod with Inline Environment Variables | Configuration | Easy | task | 1-k8s-basics | configuration | — | generic upstream |
|
||||
| pod-labeling-basics | Labelling Resources | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| pod-node-assignment-mcq | Assigning Pods to Nodes | Scheduling | Medium | mcq | 1-k8s-basics | troubleshooting | — | generic upstream |
|
||||
| pod-security-context | Pod Security Context | Security | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| priority-class-task | Pod Priority and PriorityClass | Workloads & Scheduling | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pv-pvc-mount | Persistent Storage with PV and PVC | Storage | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pvc-dynamic-task | Dynamic PVC Provisioning | Storage | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| rbac-clusterrole | ClusterRole and ClusterRoleBinding | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| rbac-role | RBAC: Create Role and RoleBinding | Security | Hard | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| readiness-probe-task | Configure a Readiness Probe | Workloads & Scheduling | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| resource-limits-task | Resource Requests and Limits | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| resource-quota-ns | ResourceQuota for a Namespace | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| restart-policy-mcq | Pod Restart Policies | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| rolling-update-task | Perform a Rolling Update | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| rootnode-enforce-limits | Stop the Next Outage: Enforce Resource Defaults | Observability & Ops | Hard | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| rootnode-probe-port-mismatch | Stuck Not-Ready: The Probe Port Mismatch | Observability & Ops | Medium | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| rootnode-runaway-monitor | Recover the Monitoring Stack | Observability & Ops | Medium | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| scale-deployment | Scaling a Deployment | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| secrets-basics | Creating and Using Secrets | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| serviceaccount-pod | ServiceAccount for Pod API Access | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| services-mcq | Kubernetes Service Types | Networking | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | networking | quick-assessment | generic upstream |
|
||||
| sort-by-basics | Sorting kubectl Output | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| static-pods-mcq | Static Pods in Kubernetes | Core Concepts | Medium | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| storageclass-mcq | StorageClass Reclaim Policies | Storage | Easy | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
|
||||
## 5. Unused content bank, grouped for future ROOT_ modules
|
||||
|
||||
The 54 scenarios with no Path Stage above, grouped by category — a real, working content bank
|
||||
ROOT 07/08/09/10 can draw on in Phase 6B, not content that needs to be invented:
|
||||
|
||||
- **Workload Security (7):** cks-apparmor-profile, cks-automount-token, cks-drop-capabilities, cks-non-root-enforce, cks-pod-security-context, cks-readonly-filesystem, cks-seccomp-profile
|
||||
- **Cluster Setup (4):** cks-audit-policy, cks-mcq-api-server, cks-mcq-audit-policy, cks-mcq-kubelet-auth
|
||||
- **Network Security (4):** cks-egress-namespace, cks-network-policy-metadata, cks-network-policy, cks-tls-ingress
|
||||
- **Supply Chain Security (2):** cks-image-pull-secret, cks-mcq-image-footprint
|
||||
- **Cluster Security (5):** cks-immutable-secret, cks-mcq-psp-replacement, cks-psa-namespace, cks-rbac-clusterrole, cks-rbac-least-privilege
|
||||
- **System Hardening (3):** cks-mcq-runtime-security, cks-mcq-sandboxing, cks-runtime-class
|
||||
- **Cluster Administration (8):** cluster-upgrade-mcq, etcd-backup-mcq, node-label-selector, node-taint-toleration, pod-disruption-budget-task, rbac-clusterrole, resource-quota-ns, serviceaccount-pod
|
||||
- **Troubleshooting (3):** container-logging-mcq, crashloop-fix, endpoint-fix-task
|
||||
- **Workloads & Scheduling (7):** cronjob-task, deployment-rollback, job-task, limitrange-task, pod-affinity-mcq, priority-class-task, readiness-probe-task
|
||||
- **Services & Networking (3):** dns-resolution-mcq, ingress-task, nodeport-task
|
||||
- **Storage (4):** emptydir-pod, pv-pvc-mount, pvc-dynamic-task, storageclass-mcq
|
||||
- **Networking (1):** network-policy
|
||||
- **Security (3):** networkpolicy-egress, pod-security-context, rbac-role
|
||||
|
||||
## 6. Other existing systems inspected
|
||||
|
||||
- **Assessment definitions** (`assessment_definitions`): `rootnode-capstone` (90 min, hint_policy
|
||||
`flagged`, required = the 4 real-ops scenarios, random draw across workloads/config/
|
||||
troubleshooting) and `rootnode-quick-assessment` (60 min exam, fully random draw). Both real,
|
||||
both working, neither touched by Phase 6A — the new capstone course activity references
|
||||
`rootnode-capstone` by id only.
|
||||
- **Achievements** (`achievements`): 4 types already implemented — `stage_completed`,
|
||||
`curriculum_completed`, `assessment_completed`, `capstone_completed`. Phase 6A's
|
||||
`auto_assessment` completion behavior reads `assessment_completed`/`capstone_completed` rows
|
||||
by `metadata->>'assessment_definition_id'` — no new achievement type needed.
|
||||
- **Progress/attempt model** (`progress`, `scenario_attempts`): unchanged. `progress` is what
|
||||
`curriculum-rules.js` and the new `auto_scenario` completion behavior both read.
|
||||
- **Curriculum APIs**: `curriculum.js` loads scenarios/bundles/path from disk into an in-process
|
||||
cache; `curriculum-rules.js` computes stage lock/completion/percent purely from that path +
|
||||
a progress map. Phase 6A's `course-progress.js` follows the identical shape one level higher
|
||||
(course/module/activity instead of stage/scenario).
|
||||
- **Self-paced dashboard** (`ProgramsTab.jsx`) and **classroom dashboard** (`ClassroomTab.jsx`,
|
||||
admin `ClassroomView.jsx`): both currently show curriculum STATE (percent, next scenario) with
|
||||
no notion of lessons/modules — Phase 6A's `CourseView.jsx`/`CurriculumView.jsx` add that layer
|
||||
alongside, not instead of, these.
|
||||
- **Admin curriculum/program tooling** (`admin-learning-routes.js`, `admin-programs-routes.js`):
|
||||
read-only scenario/stage views and program/enrollment CRUD; no course/module concept existed
|
||||
before this phase.
|
||||
|
||||
## 7. What this inventory rules out
|
||||
|
||||
- No init_/compile_ curriculum content exists anywhere — confirmed by a full scan of
|
||||
`scenarios/data/`, not an assumption.
|
||||
- No DevOps/CI/CD/IaC/GitOps scenario content exists (ROOT 11 is necessarily 100% PLANNED).
|
||||
- No Linux-fundamentals, Git, or Docker-specific scenario content exists (ROOT 01-04 are
|
||||
necessarily 100% PLANNED) — everything in `scenarios/data/` is Kubernetes/CKS content.
|
||||
@@ -0,0 +1,104 @@
|
||||
# ROOT_ Program Architecture
|
||||
|
||||
Phase 6A §2 — the canonical course architecture for `root_` (Linux → systems → containers →
|
||||
Kubernetes → DevOps/platform engineering, ages ~15-18+/college/adult per the youth ladder — see
|
||||
`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` §1). This document is the design; the actual seed data
|
||||
lives in `platform/api/migrations/022_course_content.sql`, generated to match this exactly.
|
||||
|
||||
All 13 rows below are `courses` under the ONE existing program, `root-kubernetes-foundations`
|
||||
(`programs` table, unchanged — see §"why one program, not thirteen" below). Every status claim
|
||||
here is grounded in `docs/ROOT_CURRICULUM_INVENTORY.md`, not aspirational.
|
||||
|
||||
## Course sequence
|
||||
|
||||
| # | Course | Status | Why |
|
||||
|---|---|---|---|
|
||||
| 1 | Computing & Linux Foundations | **PLANNED** | No lab content exists in this repository — every scenario is Kubernetes/CKS. |
|
||||
| 2 | Linux Administration & Networking | **PLANNED** | Same — no Linux-admin scenario content exists. |
|
||||
| 3 | Git & Collaborative Development | **PLANNED** | No Git scenario content exists. |
|
||||
| 4 | Containers & Docker | **PLANNED** | No Docker-specific scenario content exists (the Lab itself runs k3s, but no student-facing "learn Docker" scenarios exist). |
|
||||
| 5 | Kubernetes Foundations | **PARTIAL** | Real content: the existing "Foundation" stage's 20 scenarios, wrapped as one module (`root-05-m01-pods-and-kubectl-basics`). No teaching lesson content authored yet — bare labs only. |
|
||||
| 6 | Kubernetes Application Workloads | **PARTIAL** | The existing "Workloads" stage's 8 scenarios, one module. Bare labs only. |
|
||||
| 7 | Kubernetes Networking & Configuration | **PARTIAL** | The existing "Networking" (2) and "Configuration" (5) stages, two modules. Bare labs only. |
|
||||
| 8 | Kubernetes Storage & Scheduling | **PLANNED** | Real scenario content exists (PV/PVC, StorageClass, taints/tolerations, PDB — see inventory §5) but is not yet wired into a module. Deliberately left PLANNED rather than fabricating a module shell with no honest content behind it — Phase 6B. |
|
||||
| 9 | Kubernetes Security | **PLANNED** | A substantial 22-scenario CKS-style bank exists (RBAC, PSA, network policy, supply chain, runtime hardening) but is not yet wired into a module — Phase 6B. |
|
||||
| 10 | Kubernetes Troubleshooting & Observability | **PARTIAL** | Contains the Phase 6A gold-standard module (READY) plus the existing "Troubleshooting" stage's remaining 5 scenarios as a second, bare-lab module. |
|
||||
| 11 | DevOps & Platform Engineering Foundations | **PLANNED** | No CI/CD/IaC/GitOps scenario content exists anywhere in this repository. |
|
||||
| 12 | RootNode Real Ops | **PARTIAL** | The existing "RootNode: Real Ops" stage's 4 scenarios (3 RootNode-authored + 1 generic), one module. Bare labs, no teaching content — the gold module already gives one of these four (`rootnode-probe-port-mismatch`) a full teaching treatment elsewhere; this module exercises it again alongside the other three, deliberately (see module notes below). |
|
||||
| 13 | ROOT Capstone — Operate and Recover a Production-Like Environment | **PARTIAL** | The existing, fully working `rootnode-capstone` assessment (90 min, mixed required/random pool). No capstone orientation/briefing lesson authored yet. |
|
||||
|
||||
No course is marked READY at the course level in Phase 6A — that's honest: a course only earns
|
||||
READY once every module in it has real teaching content, and Phase 6A deliberately built exactly
|
||||
one complete module (the gold standard) as the authoring template, not a finished curriculum.
|
||||
|
||||
## Prerequisite chain
|
||||
|
||||
```
|
||||
01 Linux Foundations → 02 Linux Admin/Networking → 03 Git → 04 Containers/Docker
|
||||
│
|
||||
▼
|
||||
05 Kubernetes Foundations
|
||||
│
|
||||
▼
|
||||
06 Application Workloads
|
||||
│ │
|
||||
┌─────────────────────┤ │
|
||||
▼ ▼ ▼
|
||||
07 Networking/Config 09 Security 10 Troubleshooting
|
||||
│ │ │
|
||||
▼ │ ▼
|
||||
08 Storage/Scheduling │ 11 DevOps/Platform
|
||||
▼
|
||||
12 RootNode Real Ops
|
||||
│
|
||||
▼
|
||||
CAPSTONE — Operate & Recover
|
||||
```
|
||||
|
||||
01-04 have no prerequisite chain enforced against each other yet (all PLANNED, zero content —
|
||||
locking a PLANNED course behind another PLANNED course would just be decorative). 05 is the
|
||||
first course with a real prerequisite gate. See `docs/COURSE_CONTENT_MODEL.md`'s "prerequisite/
|
||||
locking rule" for why a PLANNED/content-free prerequisite never actually blocks anything.
|
||||
|
||||
## Deviations from the spec's suggested sequence, and why
|
||||
|
||||
The authorization's suggested sequence listed Security (09) after Storage/Scheduling (08) and
|
||||
before Troubleshooting (10). This document keeps that numbering for continuity, but the
|
||||
**prerequisite graph** branches 09 and 10 both directly off 06 (Application Workloads) rather
|
||||
than chaining 08→09→10 linearly — the existing content doesn't actually build 09 on top of 08's
|
||||
concepts (RBAC/PSA/network-policy security content is orthogonal to PV/PVC/scheduling content),
|
||||
so a straight linear chain would impose a false dependency. This is exactly the case the
|
||||
authorization's "if existing content suggests a better grouping, document the reasoning" line
|
||||
anticipated.
|
||||
|
||||
## Real Ops and Capstone placement
|
||||
|
||||
Real Ops (12) sits after Troubleshooting (10), matching the existing curriculum's own stage
|
||||
order (`rootnode-path.json`'s `real-ops` stage already comes after `troubleshooting`) — Real Ops
|
||||
scenarios assume the diagnostic skills Troubleshooting teaches. The Capstone sits after Real Ops
|
||||
alone, not after every course, because that's what the existing `rootnode-capstone` assessment
|
||||
definition's own `unlock_rule` already requires (all 6 content stages of `rootnode-path.json`
|
||||
complete — which necessarily includes 05-06-07-10-12's underlying scenarios, plus networking/
|
||||
configuration). 08, 09, and 11 are not currently gating the capstone, because their scenario
|
||||
content isn't part of `rootnode-path.json`'s stage list at all yet — see
|
||||
`docs/COURSE_CONTENT_MODEL.md` for how course-level completion is computed independently of that
|
||||
assessment-engine unlock_rule (the two systems currently agree by construction, not by a shared
|
||||
implementation).
|
||||
|
||||
## Why one program, not thirteen
|
||||
|
||||
`programs` (Phase 5B) models the commercial/academic unit a student enrolls in. root_ is
|
||||
described by this authorization as one continuous ladder — "Linux → systems → containers →
|
||||
Kubernetes → DevOps/platform engineering" — not thirteen separate purchases. Attaching all 13
|
||||
courses to the single existing `root-kubernetes-foundations` program means:
|
||||
|
||||
- Zero changes to the `programs` table, enrollment model, or entitlement logic — an enrolled
|
||||
student automatically has structured access to the whole ladder as it's built out.
|
||||
- No renaming/redefinition of the existing program (which would risk a real, if cosmetic, break
|
||||
for anyone already enrolled) — see Phase 6A's explicit "do not redesign working systems"
|
||||
constraint.
|
||||
|
||||
If a future phase needs `root_` split into separately-purchasable programs (e.g. "Kubernetes
|
||||
Foundations" sold standalone from "DevOps"), that's a `programs`-table decision to make
|
||||
deliberately later, informed by real pricing/business requirements — not something to guess at
|
||||
here.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Runbook: Google Workspace Email Configuration
|
||||
|
||||
Everything in this document happens OUTSIDE this repository, in the Google Admin Console and
|
||||
Google Cloud Console, and is marked **REQUIRES GOOGLE WORKSPACE ADMIN ACTION**. Nothing here can
|
||||
be scripted or automated from the codebase — Chase (or whoever holds Workspace super-admin) has
|
||||
to do these steps by hand, once, before RootNode Academy can send real email.
|
||||
|
||||
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §2 for why this mechanism was chosen over SMTP
|
||||
or a human login.
|
||||
|
||||
## 1. Selected mechanism
|
||||
|
||||
**Gmail API (`users.messages.send`) via a Google Cloud service account using Workspace
|
||||
domain-wide delegation**, impersonating one dedicated sending mailbox. No human password is
|
||||
ever stored by RootNode. The delegated scope is exactly `https://www.googleapis.com/auth/
|
||||
gmail.send` — nothing broader.
|
||||
|
||||
## 2. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the sending mailbox
|
||||
|
||||
Decide the address RootNode sends as — e.g. `[email protected]` or
|
||||
`[email protected]`. This repository does not assume or hard-code one; it's whatever
|
||||
you set `EMAIL_FROM_ADDRESS` to (§5).
|
||||
|
||||
1. In Google Admin Console (admin.google.com) → Directory → Users, create this mailbox as a
|
||||
real Workspace user (or a Google Group with "Collaborative Inbox" if you'd rather route
|
||||
replies to a shared team inbox — either works with this mechanism, but a Group needs its own
|
||||
delegation setup; a plain user mailbox is simpler and is what this runbook assumes).
|
||||
2. Consider giving it a strong, unique, never-reused password and enabling 2-Step Verification
|
||||
on it anyway, even though RootNode's application will never use that password — this account
|
||||
still exists as a real Workspace identity and should be secured like any other.
|
||||
|
||||
**If the sending address is an ALIAS of an existing account (a real cost-saving option — you
|
||||
don't need a whole extra paid seat just for this):** that's fine and supported, but note it for
|
||||
step 4 below. Domain-wide delegation authorizes the service account to act as a real Workspace
|
||||
**user account**, not an arbitrary alias string — if `[email protected]` is an alias of,
|
||||
say, `[email protected]`, the delegation in step 4 is granted for `chase@`'s account
|
||||
(the alias's owner), and RootNode's config keeps the alias as the visible sending address
|
||||
separately (see §5's `GOOGLE_DELEGATED_USER` vs. `EMAIL_FROM_ADDRESS`). Gmail already lets any
|
||||
account send as its own verified aliases, so this works cleanly — you just need both pieces of
|
||||
information (the alias, and which account owns it) when configuring RootNode.
|
||||
|
||||
## 3. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the service account (Google Cloud Console)
|
||||
|
||||
1. In Google Cloud Console (console.cloud.google.com), create or choose a GCP project associated
|
||||
with your Workspace organization.
|
||||
2. Enable the **Gmail API** for that project (APIs & Services → Library → search "Gmail API" →
|
||||
Enable).
|
||||
3. IAM & Admin → Service Accounts → Create Service Account. Name it something identifiable
|
||||
(e.g. `rootnode-academy-mailer`). No project-level IAM roles are needed for this — it only
|
||||
ever calls the Gmail API as the impersonated user, not as itself.
|
||||
4. On that service account, create a JSON key (Keys → Add Key → Create new key → JSON). This
|
||||
downloads a file containing `client_email` and `private_key` — **this is the sensitive
|
||||
credential**, treat it exactly like a password (see §8).
|
||||
5. Copy the service account's **Client ID** (a long numeric string, on the service account's
|
||||
detail page) — needed for the next step.
|
||||
|
||||
## 4. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: authorize domain-wide delegation
|
||||
|
||||
1. Google Admin Console → Security → Access and data control → API controls → Domain-wide
|
||||
Delegation → Add new.
|
||||
2. Client ID: the numeric ID from step 3.5 above.
|
||||
3. OAuth Scopes: `https://www.googleapis.com/auth/gmail.send` — **only this scope**. Do not add
|
||||
broader Gmail scopes (readonly, modify, etc.) — RootNode's adapter never needs them and
|
||||
granting them would violate least-privilege for no benefit.
|
||||
4. Authorize.
|
||||
|
||||
This grants the service account permission to act as **one specific Workspace user account**.
|
||||
If the sending address is that account's own primary login, that's the account. If the sending
|
||||
address is an ALIAS (§2), this authorization is inherently for the alias's owner account — there
|
||||
is no separate "authorize an alias" step, because delegation only ever targets real accounts.
|
||||
|
||||
## 5. RootNode-side environment variables
|
||||
|
||||
Set these on the Platform API's environment (production: real secret storage — see §8; local
|
||||
dev: `platform/.env`, already gitignored):
|
||||
|
||||
| Variable | Value | Notes |
|
||||
|---|---|---|
|
||||
| `EMAIL_PROVIDER` | `gws` | Unset/anything else defaults to the dev provider — never real mail. |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_JSON` | the full JSON key file's contents, as one string | The sensitive credential — see §8. |
|
||||
| `EMAIL_FROM_ADDRESS` | the address students see mail arrive from, e.g. `[email protected]` | Always the visible `From:` header. |
|
||||
| `GOOGLE_DELEGATED_USER` | **only needed if `EMAIL_FROM_ADDRESS` is an alias** — the real account that alias belongs to, e.g. `[email protected]` | This is what domain-wide delegation actually authorized in step 4. If `EMAIL_FROM_ADDRESS` is itself a primary account, leave this unset — the code falls back to `EMAIL_FROM_ADDRESS` automatically. |
|
||||
| `EMAIL_FROM_NAME` | e.g. `RootNode Academy` | Display name only, not sensitive. |
|
||||
| `EMAIL_REPLY_TO` | optional, e.g. `[email protected]` | Where a student's reply actually goes, if different from the sending address. |
|
||||
| `PUBLIC_APP_ORIGIN` | e.g. `https://learn.rootnodeacademy.com` | The origin activation/reset links are built from (§7 of the Phase 5C authorization) — required for any token-bearing email to render a working link. |
|
||||
|
||||
**Concrete example matching the alias setup described above:** `[email protected]`
|
||||
(what students see), `[email protected]` (whose account actually
|
||||
owns that alias and was granted delegation in step 4).
|
||||
|
||||
## 6. DNS: SPF, DKIM, DMARC (REQUIRES DNS ADMIN ACTION — not performed from this repository)
|
||||
|
||||
Google Workspace's own documentation is authoritative here; this is a summary of what must
|
||||
exist before mail sent via the Gmail API from a Workspace mailbox reliably lands in inboxes
|
||||
instead of spam:
|
||||
|
||||
- **SPF**: your domain's TXT record must include Google's SPF include
|
||||
(`include:_spf.google.com`) if Workspace is your MX provider — check Admin Console → Apps →
|
||||
Google Workspace → Gmail → Authenticate email for the exact current record Google recommends
|
||||
for your domain.
|
||||
- **DKIM**: Admin Console → Apps → Google Workspace → Gmail → Authenticate email → generate a
|
||||
DKIM key for the domain, then publish the TXT record it gives you, then return to that page
|
||||
and turn DKIM signing ON. Gmail API sends from a Workspace mailbox are signed the same way
|
||||
regular Gmail sends are once this is on.
|
||||
- **DMARC**: publish a `_dmarc` TXT record (start with `p=none` to monitor before enforcing) once
|
||||
SPF and DKIM both pass consistently.
|
||||
|
||||
**Do not change DNS from this repository or these instructions** — this is exactly the kind of
|
||||
external, admin-console/DNS-provider action that stays outside the codebase. Verify current
|
||||
exact record values in Google's own Workspace admin help before publishing anything.
|
||||
|
||||
## 7. Testing procedure
|
||||
|
||||
**Local/dev — no live credentials needed at all.** `EMAIL_PROVIDER` unset (or anything other
|
||||
than `gws`) uses the deterministic dev provider (`email/dev-provider.js`) — every notification
|
||||
still goes through the full queue/worker/retry pipeline, but nothing ever reaches a real
|
||||
network. `platform/test/e2e-phase5c.js` and the Phase 5C drills run entirely this way.
|
||||
|
||||
**Live delivery — opt-in, one message, once credentials exist:**
|
||||
```bash
|
||||
RUN_GWS_EMAIL_TEST=true \
|
||||
TEST_EMAIL_RECIPIENT=[email protected] \
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \
|
||||
EMAIL_FROM_ADDRESS=[email protected] \
|
||||
GOOGLE_DELEGATED_USER=[email protected] \
|
||||
EMAIL_FROM_NAME="RootNode Academy" \
|
||||
node infra/scripts/gws-email-test.js
|
||||
```
|
||||
(Omit `GOOGLE_DELEGATED_USER` entirely if `EMAIL_FROM_ADDRESS` is itself a primary account
|
||||
rather than an alias — see §5.) Sends exactly one clearly-marked test message to
|
||||
`TEST_EMAIL_RECIPIENT` — never inferred from production/student data, never run automatically as
|
||||
part of any regression suite. Without `RUN_GWS_EMAIL_TEST=true` it does nothing and exits
|
||||
cleanly.
|
||||
|
||||
## 8. Production secret handling
|
||||
|
||||
- `GOOGLE_SERVICE_ACCOUNT_JSON` is exactly as sensitive as a password — store it in whatever
|
||||
secret manager/environment-injection mechanism the production deployment uses (see
|
||||
`docs/PRODUCTION_DEPLOYMENT.md` for the general pattern this repo follows for other secrets
|
||||
like `PROVISIONER_SHARED_SECRET`). **Never commit it, never put it in a Docker image layer,
|
||||
never log it.** `gws-provider.js` reads it fresh from the environment on every send and never
|
||||
writes it to disk.
|
||||
- Nothing in this codebase ever returns `GOOGLE_SERVICE_ACCOUNT_JSON`'s contents, an access
|
||||
token, or any part of it through an API response, log line, or audit event.
|
||||
|
||||
## 9. Credential rotation
|
||||
|
||||
1. Google Cloud Console → the service account → Keys → Add Key → Create new key (JSON) — this
|
||||
creates an ADDITIONAL valid key without invalidating the old one yet.
|
||||
2. Update `GOOGLE_SERVICE_ACCOUNT_JSON` in production's secret store to the new key, redeploy/
|
||||
restart the Platform API.
|
||||
3. Confirm delivery still works (§7's live test, or just watch `/api/admin/notifications` for a
|
||||
real send after the restart).
|
||||
4. Google Cloud Console → the service account → Keys → delete the OLD key.
|
||||
|
||||
Domain-wide delegation authorization (§4) is tied to the service account's Client ID, not to any
|
||||
individual key — rotating keys does not require re-authorizing delegation.
|
||||
|
||||
## 10. Revocation (compromised credential / offboarding)
|
||||
|
||||
1. Google Cloud Console → the service account → Keys → delete the affected key immediately —
|
||||
this invalidates it instantly, before anything else.
|
||||
2. If the whole service account should stop being able to send at all: Google Admin Console →
|
||||
Security → API controls → Domain-wide Delegation → remove that Client ID's authorization.
|
||||
3. Rotate `EMAIL_FROM_ADDRESS`'s own Workspace login password as a precaution even though the
|
||||
application never used it — a compromised service-account key doesn't imply the mailbox's own
|
||||
credentials were exposed, but treat it as a "review this account" trigger.
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| Every send fails with `gws_not_configured` | `GOOGLE_SERVICE_ACCOUNT_JSON` or `EMAIL_FROM_ADDRESS` unset/malformed in the Platform API's environment. |
|
||||
| `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. **If this happens intermittently right after newly authorizing delegation** (the exact same request succeeds, then fails with `unauthorized_client`, then succeeds again, over roughly a minute) — that's Google's own auth infrastructure propagation delay, not a config error. It self-resolves; the notification worker's existing retry/backoff already absorbs it (`gws-provider.js` classifies `unauthorized_client` as retryable), so no action is needed beyond waiting. |
|
||||
| `gws_http_403` | The delegated `sub` (EMAIL_FROM_ADDRESS) isn't a real mailbox in this Workspace domain, or the Gmail API isn't enabled on the GCP project (§3.2). |
|
||||
| `gws_rate_limited` (HTTP 429) | Gmail API per-user sending limits hit — see §12. The notification worker retries this automatically with backoff. |
|
||||
| Mail sends successfully but lands in spam | SPF/DKIM/DMARC (§6) not fully configured/passing yet — this is a deliverability issue, not an application error. |
|
||||
|
||||
## 12. Google Workspace quotas/limits relevant to this mechanism
|
||||
|
||||
Gmail API sending inherits the same per-user daily sending limits as regular Gmail/Workspace
|
||||
sending (limits vary by Workspace edition — check current figures in Google's own Workspace
|
||||
admin documentation, as these change over time and are not something to hard-code here). For
|
||||
RootNode Academy's transactional volume (activation/enrollment/reset emails, not bulk/marketing
|
||||
— explicitly out of scope, see the Phase 5C authorization §5/§28), this should sit comfortably
|
||||
under any Workspace edition's per-user limit; revisit if enrollment volume grows substantially.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Runbook: Running a Remote Classroom
|
||||
|
||||
Covers scheduling, running, and recovering a live class through `/admin` → Classroom. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design. RootNode does
|
||||
**not** provide video/voice — the meeting URL field just links out to Meet/Zoom/Teams/whatever
|
||||
you already use for that.
|
||||
|
||||
**Before class: schedule it**
|
||||
|
||||
`/admin` → Classroom → pick the cohort → Schedule a class. Title is required; scheduled start,
|
||||
description, and a meeting URL are optional. Only one class can be `live` per cohort at a time —
|
||||
scheduling a second one is fine, starting it while another is live is rejected.
|
||||
|
||||
**Right before class: Prepare Labs**
|
||||
|
||||
Open the classroom → Prepare Labs. This provisions/warms every enrolled student's Lab ahead of
|
||||
time (same underlying flow as the cohort-level Prepare Labs button) so nobody is waiting on a
|
||||
cold start once class begins. If a Lab host is at capacity, that student shows as
|
||||
`capacity_waiting` in the results rather than failing silently — check back or ease off other
|
||||
concurrent load.
|
||||
|
||||
**Start class**
|
||||
|
||||
Open the classroom → Start class. Status moves to `live`; students now see a "LIVE NOW" banner
|
||||
on their Classroom tab and can join.
|
||||
|
||||
**During class**
|
||||
|
||||
- **Participants table**: presence (`online`/`recently_active`/`offline`, derived from each
|
||||
student's last heartbeat — not a hard real-time signal, allow ~30s of lag), Lab status, and a
|
||||
one-line current-work snapshot (stage, scenario, validation failures, hints used).
|
||||
- **Set current activity**: enter a scenario ID and Set. This only shows students a link — it
|
||||
never redirects their browser or marks anything complete. Use it to say "everyone open this
|
||||
next," not to force navigation.
|
||||
- **Post an announcement**: short messages, shown newest-first on the student side.
|
||||
- **Help queue**: students flag `lab_problem` / `dont_understand` / `validation_failing` /
|
||||
`other` with an optional note. Acknowledge when you've seen it, Resolve when it's handled.
|
||||
There is no chat here — if it needs back-and-forth, use your actual video call.
|
||||
|
||||
**End of class**
|
||||
|
||||
Open the classroom → End class. Status moves to `ended`; this does not touch Labs or student
|
||||
progress — destroy Labs separately from the cohort view if you're done with them for the day.
|
||||
|
||||
**If you need to cancel a scheduled (not yet started) class:** Cancel, from the classroom
|
||||
detail view. A `live` class can only be ended, not cancelled.
|
||||
|
||||
**If the Platform API restarts mid-class** (deploy, crash, host reboot): nothing to do. Sessions,
|
||||
the live classroom row, and participant presence all live in Postgres, not in the API process's
|
||||
memory — students' browsers keep their existing session and heartbeat right through it, and the
|
||||
dashboard shows the same state a few seconds after the API comes back. This is exercised for
|
||||
real in `infra/scripts/drills/test-phase5a-rehearsal.js`, which restarts the API mid-rehearsal
|
||||
and asserts on exactly this.
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
|
||||
As of Phase 5C, this also automatically emails the student — an activation link if they've never
|
||||
had credentials, or an enrollment confirmation if they already do. See
|
||||
`docs/RUNBOOK_STUDENT_ACTIVATION.md` for checking delivery status and resending.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Runbook: Student Account Lifecycle
|
||||
|
||||
Covers creating, converting, resetting, and revoking student accounts. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design and
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`/`docs/RUNBOOK_STUDENT_ACTIVATION.md` for the
|
||||
automatic-email side of this, added in Phase 5C.
|
||||
|
||||
**Create a new credentialed student** (`/admin` → Students → Add student):
|
||||
|
||||
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
|
||||
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
|
||||
link **once**: `https://<domain>/?activate=<token>`. As of Phase 5C, if the student has an
|
||||
email, RootNode also sends it to them automatically — the on-screen link remains a manual-relay
|
||||
fallback, useful when there's no deliverable address or the automatic email needs backup. Copy
|
||||
it if you might need it (print it, read it aloud, hand it over) — it is never shown again and
|
||||
never stored in plaintext anywhere. The student opens the link and sets their own password; the
|
||||
instructor never sees it.
|
||||
|
||||
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
|
||||
|
||||
Same as creation, but for a student who already exists with progress/attempts/achievements —
|
||||
none of that is touched. Give an email or login ID, get a one-time activation link the same way.
|
||||
|
||||
**Bulk import via CSV** (Students → Bulk import):
|
||||
|
||||
Columns: `display_name, email, login_identifier, cohort_id` (only `display_name` is required per
|
||||
row). The whole file is validated before anything is written — one bad row rejects the entire
|
||||
file with a list of row errors, rather than importing some rows and skipping others. Never
|
||||
include a password column; it will always be rejected if present. Activation links for every
|
||||
created row are shown once after a successful import.
|
||||
|
||||
**Reset a student's password** (Students → \[student\] → Reset password):
|
||||
|
||||
Only for already-credentialed accounts. Issues a fresh reset link (`?reset=<token>`, valid 1
|
||||
hour) and immediately revokes every existing session for that student — the instructor still
|
||||
never sees or sets the actual password.
|
||||
|
||||
**Revoke a student's sessions** (Students → \[student\] → Revoke all sessions):
|
||||
|
||||
Immediately invalidates every active login for that student without touching their password.
|
||||
Use this if a device is lost/shared or a session looks compromised.
|
||||
|
||||
**Deactivate a student** (Students → \[student\] → PATCH status: inactive, via the existing
|
||||
students list — deactivation takes effect on their very next request, not just their next
|
||||
login, because `studentSession` middleware re-checks `status='active'` every time.
|
||||
|
||||
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
|
||||
time-limited (activation 7 days, reset 1 hour). Use Students → \[student\] → **Resend
|
||||
activation** (or Reset password, for an already-activated account) to issue a new one — there's
|
||||
no way to "resend" the same token, and that's intentional (only the most recent link is ever
|
||||
valid, so there's never ambiguity about which one to use). See `docs/RUNBOOK_STUDENT_
|
||||
ACTIVATION.md` for checking whether the automatic email actually delivered.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Runbook: Student Activation Pipeline
|
||||
|
||||
Covers the day-to-day operation of the enrollment → activation → login pipeline from `/admin`.
|
||||
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` for the underlying design and
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the email delivery mechanism itself.
|
||||
|
||||
## What happens automatically
|
||||
|
||||
Every time a student account gains a new enrollment — whether the website's checkout triggered
|
||||
it (`POST /api/integrations/enrollments`) or an instructor enrolled them manually from
|
||||
`/admin` → Programs — RootNode decides and sends the right email on its own:
|
||||
|
||||
- **Never activated before** → an activation email, with a one-time link to set their password.
|
||||
- **Already has real credentials** → an enrollment confirmation email pointing them back to
|
||||
sign in — no link/token involved.
|
||||
|
||||
Nothing needs to be triggered by hand for the normal case. The controls below are for when the
|
||||
normal case didn't work.
|
||||
|
||||
## Checking delivery status
|
||||
|
||||
**Per student**: Students → \[student\] → the "Notifications" card shows every notification ever
|
||||
sent to (or attempted for) that student, with type/status/attempt count. `sent` means it
|
||||
actually reached the provider successfully; `failed` means it gave up (see below); `queued`/
|
||||
`sending` means it's still in flight — check back in a few seconds, delivery happens through a
|
||||
background worker, not the same request that triggered it.
|
||||
|
||||
**Everywhere**: `/admin` → Notifications (owner/admin only) — the same information across every
|
||||
student, most recent first.
|
||||
|
||||
## If a student never got their activation email
|
||||
|
||||
1. Check Students → \[student\] → Notifications. If it shows `failed`, note the error — a
|
||||
`no_recipient_address` failure means the student truly has no email or contact address on
|
||||
file and can only be activated by an instructor manually relaying a link (see below); other
|
||||
error codes usually mean a delivery-infrastructure issue worth checking
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting table for.
|
||||
2. Students → \[student\] → Credentials → **Resend activation**. This invalidates whatever link
|
||||
was outstanding and sends a fresh one — safe to click as many times as needed; only the most
|
||||
recent link is ever valid, so there's never a "which link do I use" confusion for the student.
|
||||
3. If the student genuinely has no deliverable address at all (a younger student with a
|
||||
login-only account and no guardian contact email on file), convert/create them without email
|
||||
in the first place (see `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md`) and relay the one-time
|
||||
activation link shown in the admin UI directly (print it, read it aloud) — this manual path
|
||||
still works exactly as it did before Phase 5C added automatic email.
|
||||
|
||||
## If a student says they didn't get an enrollment confirmation
|
||||
|
||||
Check Students → \[student\] → Notifications for the `enrollment_confirmation` row. If it's
|
||||
`sent`, the email genuinely went out — check their spam folder, or confirm the address on file
|
||||
(Students → \[student\]) is actually correct. If none exists for a given enrollment,
|
||||
Programs → \[program\] → Enrollments → find the enrollment → (student detail page also works)
|
||||
and use **Send enrollment confirmation** to trigger one explicitly.
|
||||
|
||||
## Password reset
|
||||
|
||||
Students should use "Forgot password" on the login screen themselves wherever possible — it's
|
||||
delivered the same way as everything else here and the flow is self-service end to end.
|
||||
|
||||
If a student can't complete self-service reset (e.g. they no longer have access to the email a
|
||||
guardian originally provided and it needs an instructor to re-trigger with a corrected address),
|
||||
Students → \[student\] → Credentials → **Send password reset** issues a fresh reset link and
|
||||
revokes all of that student's existing sessions immediately. In production, the instructor
|
||||
never sees the token itself — the response is a confirmation that an email was queued, not the
|
||||
link. If automatic delivery still can't reach the student, use `docs/RUNBOOK_STUDENT_ACCOUNT_
|
||||
LIFECYCLE.md`'s manual-relay procedure, which is unaffected by any of this.
|
||||
|
||||
## What "resend" does NOT do
|
||||
|
||||
Resending activation does not touch the student's enrollment status, progress, achievements, or
|
||||
attempt history — it only affects the credential/token side of their account. It's always safe.
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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.
|
||||
|
||||
## Responsibility boundary
|
||||
|
||||
**The website is responsible for:** marketing, program selection, pricing, checkout/payment (now
|
||||
or in the future), collecting enrollment/customer information, and sending ONE verified
|
||||
enrollment event to the endpoint below once payment/signup is confirmed.
|
||||
|
||||
**RootNode's platform is responsible for everything after that:** student identity, program
|
||||
entitlement, account activation, the entire password lifecycle, all operational enrollment
|
||||
email (activation, enrollment confirmation, password reset — see
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`), learning, Labs, progress, assessment, and
|
||||
completion. **The website does not send email for platform activation or any other account
|
||||
lifecycle event — RootNode owns that entirely, as of Phase 5C.** Do not build a parallel
|
||||
"welcome" or "here's how to log in" email on the website side; it would either duplicate or
|
||||
conflict with what RootNode already sends automatically.
|
||||
|
||||
## 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",
|
||||
"contact_email": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 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. |
|
||||
| `student.contact_email` | no | **Only** for a student with no email of their own (§ below) — where to send THIS enrollment's activation/confirmation email (typically a guardian's address collected at checkout). Never treated as the student's own identity/login. |
|
||||
|
||||
### 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.
|
||||
|
||||
If the enrolled student has no email at all, send `student.contact_email` too (e.g. the
|
||||
guardian's address collected at checkout) so RootNode has somewhere to deliver the activation
|
||||
email — otherwise the enrollment is created but nobody is notified automatically, and an
|
||||
instructor has to relay the activation link manually. `contact_email` is stored only against
|
||||
that specific enrollment, never copied into the student's own identity record.
|
||||
|
||||
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 (as of Phase 5C)
|
||||
|
||||
If `status` mapped to "active": RootNode sends the student (or their enrollment's
|
||||
`contact_email`) a real email automatically —an activation email with a one-time setup link if
|
||||
they're new, or an enrollment confirmation if they already have a RootNode account — and they
|
||||
can log in as soon as they act on it. **You do not need to send any email yourself for this** —
|
||||
see "Responsibility boundary" above. 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; no email goes out until activation happens.
|
||||
|
||||
If the student has neither an `email` nor a `contact_email` on file, RootNode still creates the
|
||||
account and enrollment, but nothing gets emailed automatically — an instructor has to relay the
|
||||
activation link by hand. Send whichever address you have whenever you have one.
|
||||
|
||||
## 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).
|
||||
@@ -8,6 +8,10 @@ import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import ProgramsView from './ProgramsView'
|
||||
import NotificationsView from './NotificationsView'
|
||||
import CurriculumView from './CurriculumView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
@@ -20,8 +24,12 @@ const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'curriculum', label: 'Curriculum' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
@@ -59,8 +67,12 @@ 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 === 'curriculum' && <CurriculumView />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && <NotificationsView />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||
{view === 'account' && <AccountView instructor={instructor} />}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
const DASHBOARD_POLL_MS = 5000
|
||||
|
||||
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
|
||||
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
|
||||
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
|
||||
export default function ClassroomView() {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [classrooms, setClassrooms] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!cohortId) return
|
||||
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cohortId && (
|
||||
<div className={styles.card}>
|
||||
<h3>Schedule a class</h3>
|
||||
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Classes</h3>
|
||||
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{classrooms.map((c) => (
|
||||
<tr key={c.classroom_session_id}>
|
||||
<td>{c.title}</td>
|
||||
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
|
||||
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateClassroomForm({ cohortId, onCreated }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [scheduledStart, setScheduledStart] = useState('')
|
||||
const [meetingUrl, setMeetingUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createClassroom(cohortId, {
|
||||
title: title.trim(), description: description.trim() || undefined,
|
||||
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
|
||||
})
|
||||
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Scheduled start</label>
|
||||
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
|
||||
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
|
||||
</div>
|
||||
<label className={styles.label}>Description (optional)</label>
|
||||
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ClassroomDashboard({ classroomId, onBack }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [activityScenario, setActivityScenario] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
|
||||
}, [classroomId])
|
||||
useEffect(() => {
|
||||
reload()
|
||||
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
|
||||
return () => clearInterval(t)
|
||||
}, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
|
||||
const { classroom, students, announcements, help_requests: helpRequests } = data
|
||||
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to classes</button>
|
||||
<div className={styles.card} style={{ marginTop: '1rem' }}>
|
||||
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<div className={styles.formRow}>
|
||||
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
|
||||
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
|
||||
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { results } = await adminApi.prepareClassroomLabs(classroomId)
|
||||
setNotice(`Labs prepared: ${results.length} student(s).`)
|
||||
})}>Prepare Labs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Participants ({students.length})</h3>
|
||||
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
|
||||
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
|
||||
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
|
||||
<td className={styles.muted}>{s.hints_used}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
|
||||
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{helpRequests.map((h) => (
|
||||
<tr key={h.help_request_id}>
|
||||
<td>{h.display_name || h.student_id}</td>
|
||||
<td className={styles.muted}>{h.category}</td>
|
||||
<td className={styles.muted}>{h.message || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
|
||||
<td>
|
||||
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
|
||||
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Current activity</h3>
|
||||
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
|
||||
<div className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
|
||||
)}
|
||||
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
|
||||
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ function CohortDetail({ cohortId, canManage, onBack }) {
|
||||
if (!newStudentName.trim()) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createStudent(newStudentName.trim(), cohortId)
|
||||
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
|
||||
setNewStudentName('')
|
||||
reload()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
|
||||
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
|
||||
assessment: 'Assessment', capstone: 'Capstone',
|
||||
}
|
||||
|
||||
function statusPill(status) {
|
||||
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
// Phase 6A §7 — instructor curriculum visibility: program structure with instructor notes,
|
||||
// mapped scenarios/assessments, and estimated teaching time, plus an optional per-student
|
||||
// progress lookup so a teacher can see where someone is stuck. Read-only — authoring stays
|
||||
// Git/migration-based this phase (docs/COURSE_CONTENT_MODEL.md), so there is no edit form here.
|
||||
export default function CurriculumView() {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [programId, setProgramId] = useState(null)
|
||||
const [courses, setCourses] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [studentCourses, setStudentCourses] = useState(null)
|
||||
const [studentError, setStudentError] = useState(null)
|
||||
const [openCourse, setOpenCourse] = useState(null)
|
||||
const [openModule, setOpenModule] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.programs().then((p) => { setPrograms(p); if (p[0]) setProgramId(p[0].program_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const loadCourses = useCallback(() => {
|
||||
if (!programId) return
|
||||
adminApi.programCourses(programId).then(setCourses).catch((e) => setError(e.message))
|
||||
}, [programId])
|
||||
useEffect(loadCourses, [loadCourses])
|
||||
|
||||
const lookupStudent = async (e) => {
|
||||
e.preventDefault()
|
||||
setStudentError(null); setStudentCourses(null)
|
||||
try { setStudentCourses(await adminApi.studentCourses(studentId.trim(), programId)) }
|
||||
catch (err) { setStudentError(err.message) }
|
||||
}
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const course = openCourse && courses?.find((c) => c.course_id === openCourse)
|
||||
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
|
||||
const progressFor = (activityId) => studentCourses?.flatMap((c) => c.modules).flatMap((m) => m.activities).find((a) => a.activity_id === activityId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.card}>
|
||||
<h2>Curriculum</h2>
|
||||
<select className={styles.input} value={programId || ''} onChange={(e) => { setProgramId(e.target.value); setOpenCourse(null); setOpenModule(null) }}>
|
||||
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
<form onSubmit={lookupStudent} style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<input className={styles.input} placeholder="student_id — check where they're stuck" value={studentId} onChange={(e) => setStudentId(e.target.value)} />
|
||||
<button className={styles.btnSecondary} disabled={!studentId.trim()}>Look up progress</button>
|
||||
</form>
|
||||
{studentError && <div className={styles.errorBox} style={{ marginTop: '0.5rem' }}>{studentError}</div>}
|
||||
</div>
|
||||
|
||||
{!courses ? <p className={styles.muted}>Loading…</p> : mod ? (
|
||||
<div className={styles.card}>
|
||||
<button className={styles.linkBtn} onClick={() => setOpenModule(null)}>← {course.title}</button>
|
||||
<h3 style={{ marginTop: '0.5rem' }}>{mod.title} {statusPill(mod.status)}</h3>
|
||||
{mod.description && <p className={styles.muted}>{mod.description}</p>}
|
||||
{mod.estimated_duration_minutes && <p className={styles.muted}>Estimated teaching time: ~{mod.estimated_duration_minutes} min</p>}
|
||||
{mod.objectives?.length > 0 && <ul>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Type</th><th>Title</th><th>Mapped</th><th>Status</th>{studentCourses && <th>Student</th>}</tr></thead>
|
||||
<tbody>
|
||||
{mod.activities.map((a) => (
|
||||
<tr key={a.activity_id}>
|
||||
<td className={styles.muted}>{a.sequence}</td>
|
||||
<td>{ACTIVITY_LABELS[a.activity_type] || a.activity_type}</td>
|
||||
<td>{a.title}</td>
|
||||
<td className={styles.muted}>{a.scenario_id || a.assessment_definition_id || '—'}</td>
|
||||
<td>{statusPill(a.status)}</td>
|
||||
{studentCourses && (
|
||||
<td>{progressFor(a.activity_id)?.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : <span className={styles.muted}>—</span>}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{mod.activities.some((a) => a.instructor_notes) && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<h3>Instructor notes</h3>
|
||||
{mod.activities.filter((a) => a.instructor_notes).map((a) => (
|
||||
<details key={a.activity_id} style={{ marginBottom: '0.5rem' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>{a.title}</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '0.82rem', color: 'var(--text-2)' }}>{a.instructor_notes}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : course ? (
|
||||
<div className={styles.card}>
|
||||
<button className={styles.linkBtn} onClick={() => setOpenCourse(null)}>← All courses</button>
|
||||
<h3 style={{ marginTop: '0.5rem' }}>{course.title} {statusPill(course.status)}</h3>
|
||||
{course.description && <p className={styles.muted}>{course.description}</p>}
|
||||
{course.estimated_duration_hours && <p className={styles.muted}>Estimated: ~{course.estimated_duration_hours} hours</p>}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Module</th><th>Activities</th><th>Est. min</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{course.modules.length === 0 ? (
|
||||
<tr><td colSpan={5} className={styles.muted}>No modules authored yet.</td></tr>
|
||||
) : course.modules.map((m) => (
|
||||
<tr key={m.module_id}>
|
||||
<td className={styles.muted}>{m.sequence}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setOpenModule(m.module_id)}>{m.title}</button></td>
|
||||
<td className={styles.muted}>{m.activities.length}</td>
|
||||
<td className={styles.muted}>{m.estimated_duration_minutes || '—'}</td>
|
||||
<td>{statusPill(m.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.card}>
|
||||
<h3>ROOT_ program structure</h3>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Course</th><th>Modules</th><th>Est. hours</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{courses.map((c) => (
|
||||
<tr key={c.course_id}>
|
||||
<td className={styles.muted}>{c.sequence}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setOpenCourse(c.course_id)}>{c.title}</button></td>
|
||||
<td className={styles.muted}>{c.modules.length}</td>
|
||||
<td className={styles.muted}>{c.estimated_duration_hours || '—'}</td>
|
||||
<td>{statusPill(c.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 5C §16 — owner/admin only (enforced server-side too; a plain instructor's notification
|
||||
// visibility is per-student, on the Students tab, never this global view — see
|
||||
// rbac.js's requireStudentRelationshipAccess). No recipient email in full, no token, no
|
||||
// metadata — everything here comes from notifications.js's toAdminView.
|
||||
export default function NotificationsView() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setResult(null)
|
||||
adminApi.notifications(page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [page])
|
||||
|
||||
const items = result?.items || []
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Notifications</h2>
|
||||
<p className={styles.muted}>{result ? `${result.total} total` : 'Activation, enrollment, and password-reset emails — most recent first.'}</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!result ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Attempts</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td>{new Date(n.created_at).toLocaleString()}</td>
|
||||
<td>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.attempt_count}</td>
|
||||
<td className={styles.muted}>{n.last_error_code || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.pager}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
|
||||
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
|
||||
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}>← 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>
|
||||
)
|
||||
}
|
||||
@@ -3,57 +3,190 @@ import { adminApi } from './api'
|
||||
import { StatusPill } from './CohortsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function StudentsView() {
|
||||
// Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that
|
||||
// were already here. canManage gates create/convert/reset/revoke/import the same way
|
||||
// CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary,
|
||||
// this just avoids showing controls a plain instructor would get a 403 from.
|
||||
export default function StudentsView({ role }) {
|
||||
const [students, setStudents] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canManage = role !== 'instructor'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then(setStudents).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
if (selected) return <StudentDetail studentId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Students ({students?.length ?? '…'})</h2>
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td className={styles.muted}>{s.student_id}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Add student</h2>
|
||||
<CreateStudentForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Bulk import (CSV)</h2>
|
||||
<ImportForm onImported={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Students ({students?.length ?? '…'})</h2>
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>ID</th><th>Login</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td className={styles.muted}>{s.student_id}</td>
|
||||
<td>
|
||||
{s.has_credentials
|
||||
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
|
||||
</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentDetail({ studentId, onBack }) {
|
||||
function CreateStudentForm({ onCreated }) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setCreated(null)
|
||||
try {
|
||||
const result = await adminApi.createStudent({
|
||||
display_name: displayName.trim(), cohort_id: cohortId || undefined,
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
setCreated(result)
|
||||
setDisplayName(''); setEmail(''); setLoginIdentifier('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{created?.activation_token && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
|
||||
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Display name</label>
|
||||
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Email (older/self-paced)</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID (younger/managed)</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Cohort (optional)</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
|
||||
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportForm({ onImported }) {
|
||||
const [csv, setCsv] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowErrors, setRowErrors] = useState(null)
|
||||
const [created, setCreated] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setRowErrors(null); setCreated(null)
|
||||
try {
|
||||
const { created: rows } = await adminApi.importStudents(csv)
|
||||
setCreated(rows); setCsv('')
|
||||
onImported()
|
||||
} catch (e) {
|
||||
if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below
|
||||
setError(e.message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{rowErrors && (
|
||||
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
|
||||
)}
|
||||
{created && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
|
||||
{created.filter((c) => c.activation_token).map((c) => (
|
||||
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
|
||||
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
|
||||
value={csv} onChange={(e) => setCsv(e.target.value)} required />
|
||||
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentDetail({ studentId, canManage, onBack }) {
|
||||
const [student, setStudent] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [env, setEnv] = useState(null)
|
||||
const [events, setEvents] = useState(null)
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [enrollments, setEnrollments] = useState(null)
|
||||
const [notifications, setNotifications] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
|
||||
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
|
||||
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
|
||||
// recent page (labeled "Recent attempts," not a full history browser — see
|
||||
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
|
||||
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
|
||||
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
||||
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
|
||||
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
|
||||
// instructor only sees enrollments they created or that share a cohort with them.
|
||||
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
|
||||
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
|
||||
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
|
||||
}, [studentId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
@@ -72,6 +205,53 @@ function StudentDetail({ studentId, onBack }) {
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to students</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Credentials</h3>
|
||||
{student?.has_credentials ? (
|
||||
<>
|
||||
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.</p>
|
||||
<div className={styles.formRow}>
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { reset_token } = await adminApi.resetStudentPassword(studentId)
|
||||
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
|
||||
})}>Send password reset</button>
|
||||
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
|
||||
</div>
|
||||
</>
|
||||
) : student?.email || student?.login_identifier ? (
|
||||
<>
|
||||
<p className={styles.muted}>Not yet activated. An activation link was queued — use this if the student never got it or it expired.</p>
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resendActivation(studentId))}>Resend activation</button>
|
||||
</>
|
||||
) : (
|
||||
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Notifications</h3>
|
||||
{!notifications ? <p className={styles.muted}>Loading…</p> : notifications.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Type</th><th>Status</th><th>Attempts</th></tr></thead>
|
||||
<tbody>
|
||||
{notifications.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td>{new Date(n.created_at).toLocaleString()}</td>
|
||||
<td>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.attempt_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Environment</h3>
|
||||
@@ -101,6 +281,26 @@ function StudentDetail({ studentId, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrolled programs</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.program_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
|
||||
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
|
||||
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
|
||||
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum progress</h3>
|
||||
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
||||
@@ -134,3 +334,35 @@ function StudentDetail({ studentId, onBack }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertForm({ studentId, onDone }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const { activation_token } = await adminApi.convertStudent(studentId, {
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ function getCookie(name) {
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -49,8 +51,14 @@ export const adminApi = {
|
||||
dashboard: () => get('/api/admin/dashboard'),
|
||||
|
||||
students: () => get('/api/admin/students'),
|
||||
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
|
||||
createStudent: (fields) => post('/api/admin/students', fields),
|
||||
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
|
||||
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
|
||||
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
|
||||
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
|
||||
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
|
||||
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
|
||||
importStudents: (csv) => post('/api/admin/students/import', { csv }),
|
||||
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
|
||||
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
|
||||
@@ -95,4 +103,39 @@ export const adminApi = {
|
||||
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
|
||||
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
|
||||
|
||||
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
|
||||
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
|
||||
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
|
||||
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
|
||||
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
|
||||
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
|
||||
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
|
||||
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
|
||||
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
|
||||
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
|
||||
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
|
||||
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
|
||||
|
||||
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
|
||||
programs: () => get('/api/admin/programs'),
|
||||
program: (id) => get(`/api/admin/programs/${id}`),
|
||||
createProgram: (fields) => post('/api/admin/programs', fields),
|
||||
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
|
||||
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
|
||||
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
|
||||
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
|
||||
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
|
||||
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
|
||||
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
|
||||
|
||||
// ── Phase 5C §15/§16: enrollment operations & communications ────────────────────────────
|
||||
notifications: (page = 1) => get(`/api/admin/notifications?page=${page}`),
|
||||
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
|
||||
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
|
||||
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
|
||||
|
||||
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
|
||||
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
|
||||
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
|
||||
const POLL_MS = 5000
|
||||
|
||||
const HELP_CATEGORIES = [
|
||||
{ value: 'lab_problem', label: 'Lab problem' },
|
||||
{ value: 'dont_understand', label: "Don't understand the task" },
|
||||
{ value: 'validation_failing', label: 'Validation failing' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
|
||||
// right now" state. Live -> title, current activity (guidance only — a link into the student's
|
||||
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
|
||||
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
|
||||
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
|
||||
export default function ClassroomTab() {
|
||||
const [live, setLive] = useState(null)
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [helpCategory, setHelpCategory] = useState('lab_problem')
|
||||
const [helpMessage, setHelpMessage] = useState('')
|
||||
const [helpSent, setHelpSent] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const heartbeatRef = useRef(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadLive = useCallback(() => {
|
||||
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(() => { loadLive() }, [loadLive])
|
||||
|
||||
const loadDetail = useCallback((id) => {
|
||||
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
|
||||
api.environmentStatus().then(setEnvStatus).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return
|
||||
loadDetail(live.classroom_session_id)
|
||||
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [live, loadDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!joined || !live) return
|
||||
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
|
||||
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
|
||||
return () => clearInterval(heartbeatRef.current)
|
||||
}, [joined, live])
|
||||
|
||||
const join = async () => {
|
||||
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
|
||||
catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const sendHelp = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
|
||||
setHelpSent(true); setHelpMessage('')
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const launchLab = async () => {
|
||||
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
if (!live) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<p className={styles.livePill}>LIVE NOW</p>
|
||||
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
|
||||
{live.description && <p className={styles.muted}>{live.description}</p>}
|
||||
{live.meeting_url && (
|
||||
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
|
||||
)}
|
||||
{!joined ? (
|
||||
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
|
||||
) : (
|
||||
<p className={styles.muted}>You're in this classroom.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{joined && detail && (
|
||||
<>
|
||||
{detail.classroom.current_scenario_id && (
|
||||
<div className={styles.card}>
|
||||
<h3>Current class activity</h3>
|
||||
<p>{detail.classroom.current_scenario_id}</p>
|
||||
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Your Lab</h3>
|
||||
{envStatus?.status === 'running'
|
||||
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
|
||||
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
|
||||
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>
|
||||
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Need help?</h3>
|
||||
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
|
||||
<form onSubmit={sendHelp}>
|
||||
<label className={styles.label}>What's going on?</label>
|
||||
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
|
||||
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<label className={styles.label}>Details (optional)</label>
|
||||
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
|
||||
<button className={styles.primaryBtn} type="submit">Need Help</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
|
||||
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
|
||||
assessment: 'Assessment', capstone: 'Capstone',
|
||||
}
|
||||
|
||||
// Phase 6A §6 — structured course learning, nested inside "My Programs" (not a second portal).
|
||||
// Local drill-down state only (course -> module -> activity), same "no router" convention as
|
||||
// the rest of the Platform Shell (see PlatformShell.jsx). Separate from PracticeTab — this is
|
||||
// the guided course path, Practice stays the free-form scenario list it always was.
|
||||
export default function CourseView({ enrollmentId, onLaunchLab, onClose }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [openCourse, setOpenCourse] = useState(null)
|
||||
const [openModule, setOpenModule] = useState(null)
|
||||
const [openActivity, setOpenActivity] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.courses(enrollmentId).then(setData).catch((e) => setError(e.message))
|
||||
}, [enrollmentId])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const course = openCourse && data.courses.find((c) => c.course_id === openCourse)
|
||||
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
|
||||
const activity = mod && openActivity && mod.activities.find((a) => a.activity_id === openActivity)
|
||||
|
||||
const crumb = (label, onClick) => <button key={label} className={styles.linkBtn} onClick={onClick}>{label}</button>
|
||||
|
||||
if (activity) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })}
|
||||
{crumb(course.title, () => setOpenActivity(null))}
|
||||
</div>
|
||||
<ActivityDetail activity={activity} onLaunchLab={onLaunchLab} onChange={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })}
|
||||
{crumb(course.title, () => setOpenModule(null))}
|
||||
</div>
|
||||
<h2>{mod.title}</h2>
|
||||
{mod.description && <p className={styles.muted}>{mod.description}</p>}
|
||||
<ModuleMeta mod={mod} />
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{mod.activities.map((a) => (
|
||||
<ActivityRow key={a.activity_id} activity={a} onOpen={() => setOpenActivity(a.activity_id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (course) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||
{crumb('← All courses', () => setOpenCourse(null))}
|
||||
</div>
|
||||
<h2>{course.title}</h2>
|
||||
{course.description && <p className={styles.muted}>{course.description}</p>}
|
||||
{course.objectives?.length > 0 && (
|
||||
<ul style={{ marginTop: '0.5rem' }}>
|
||||
{course.objectives.map((o, i) => <li key={i} className={styles.checklistItem}>{o}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{course.modules.length === 0 ? (
|
||||
<p className={styles.muted}>No modules published for this course yet.</p>
|
||||
) : course.modules.map((m) => (
|
||||
<ModuleRow key={m.module_id} mod={m} onOpen={() => !m.locked && setOpenModule(m.module_id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||
{onClose && crumb('← Back to program', onClose)}
|
||||
</div>
|
||||
<h2>ROOT_ Courses</h2>
|
||||
{data.resume && (
|
||||
<p className={styles.nextUp}>
|
||||
Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title}
|
||||
</p>
|
||||
)}
|
||||
{data.courses.map((c) => (
|
||||
<CourseRow key={c.course_id} course={c} onOpen={() => !c.locked && setOpenCourse(c.course_id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function statusPill(status) {
|
||||
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function CourseRow({ course, onOpen }) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.75rem 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} disabled={course.locked} onClick={onOpen} style={{ fontWeight: 600, textAlign: 'left' }}>
|
||||
{course.title}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{statusPill(course.status)}
|
||||
{course.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!course.locked && course.modules.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${course.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleRow({ mod, onOpen }) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} disabled={mod.locked} onClick={onOpen} style={{ textAlign: 'left' }}>{mod.title}</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{mod.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span> : statusPill(mod.status)}
|
||||
{mod.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleMeta({ mod }) {
|
||||
if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null
|
||||
return (
|
||||
<div className={styles.hint} style={{ marginTop: '0.5rem' }}>
|
||||
{mod.estimated_duration_minutes && <div>Estimated time: ~{mod.estimated_duration_minutes} min</div>}
|
||||
{mod.objectives?.length > 0 && (
|
||||
<ul style={{ marginTop: '0.3rem' }}>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityRow({ activity, onOpen }) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onOpen} style={{ textAlign: 'left' }}>
|
||||
<span className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}</span>{' — '}{activity.title}
|
||||
</button>
|
||||
{activity.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : activity.estimated_duration_minutes ? <span className={styles.hint}>~{activity.estimated_duration_minutes} min</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDetail({ activity, onLaunchLab, onChange }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const markComplete = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.completeActivity(activity.activity_id); onChange() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2>{activity.title}</h2>
|
||||
{activity.complete && <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>}
|
||||
</div>
|
||||
<p className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}
|
||||
{activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}</p>
|
||||
|
||||
{activity.body ? (
|
||||
<div className="md" style={{ marginTop: '1rem' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{activity.body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.muted} style={{ marginTop: '1rem' }}>No content authored for this activity yet.</p>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.errorBox}>{error}</p>}
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
{activity.activity_type === 'lab' && !activity.complete && (
|
||||
<button className={styles.primaryBtn} onClick={() => onLaunchLab?.(activity.scenario_id)}>Launch Lab</button>
|
||||
)}
|
||||
{activity.completion_behavior === 'manual_marker' && !activity.complete && (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={markComplete}>{busy ? 'Saving…' : 'Mark Complete'}</button>
|
||||
)}
|
||||
{activity.activity_type === 'capstone' && (
|
||||
<p className={styles.muted}>Continue via the Capstone tab to start or resume this assessment.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet
|
||||
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic
|
||||
// message regardless of whether the identifier exists (anti-enumeration, matches the server's
|
||||
// own response shape exactly). In dev/test, the server includes dev_reset_token in its
|
||||
// response so this screen can offer a direct link — a real deployment omits it, and completing
|
||||
// the flow needs an instructor to run the admin-side reset instead (see
|
||||
// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md).
|
||||
export default function ForgotPasswordScreen({ onCancel, onHaveToken }) {
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [sent, setSent] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.requestPasswordReset(identifier)
|
||||
setSent(result)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Reset your password</p>
|
||||
|
||||
{!sent ? (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
|
||||
{sent.dev_reset_token && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
|
||||
Dev/test only (never shown in production):{' '}
|
||||
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §6 — the real, credentialed student login. This is now the default entry point
|
||||
// (see PlatformShell.jsx); EntryScreen (pilot, no password) is still reachable for
|
||||
// not-yet-converted accounts via the link at the bottom, not removed — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7.
|
||||
export default function LoginScreen({ onLogin, onShowPilotEntry, onShowForgotPassword }) {
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!identifier || !password) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const student = await api.studentLogin(identifier, password)
|
||||
onLogin(student)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Sign in to continue your learning.</p>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" autoComplete="username" value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required />
|
||||
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowForgotPassword}>Forgot password?</button>
|
||||
</p>
|
||||
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowPilotEntry}>Pick your name instead (pilot cohorts)</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
|
||||
|
||||
.select {
|
||||
.select, .input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.6rem;
|
||||
background: var(--surface2);
|
||||
@@ -45,6 +45,7 @@
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
@@ -179,5 +180,12 @@ table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.pillErr { background: var(--red-dim); color: var(--red); }
|
||||
.pillMuted { background: var(--surface3); color: var(--text-2); }
|
||||
|
||||
/* Phase 5A — classroom/profile screens */
|
||||
.livePill {
|
||||
display: inline-block; background: var(--red-dim); color: var(--red); font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
}
|
||||
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
|
||||
|
||||
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
|
||||
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api, getCachedDisplay } from './api'
|
||||
import LoginScreen from './LoginScreen'
|
||||
import EntryScreen from './EntryScreen'
|
||||
import SetPasswordScreen from './SetPasswordScreen'
|
||||
import ForgotPasswordScreen from './ForgotPasswordScreen'
|
||||
import StudentDashboard from './StudentDashboard'
|
||||
|
||||
// Top-level Platform Shell: entry screen if no valid session, dashboard otherwise. Session
|
||||
// validity is decided by the server (GET /api/session/whoami against the rn_session cookie),
|
||||
// not by anything read from localStorage/sessionStorage — those only cache the display name
|
||||
// for a snappier first paint, per api.js.
|
||||
// Top-level Platform Shell. Phase 5A: the credentialed LoginScreen is now the default entry
|
||||
// point (was EntryScreen's pilot name-picker); an activation/reset link
|
||||
// (?activate=<token> or ?reset=<token>) short-circuits straight to SetPasswordScreen before
|
||||
// any session check, since neither flow requires an existing session. Session validity is
|
||||
// still decided by the server (GET /api/session/whoami against the rn_session cookie), not by
|
||||
// anything read from localStorage/sessionStorage.
|
||||
export default function PlatformShell() {
|
||||
const [student, setStudent] = useState(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const [view, setView] = useState(
|
||||
params.get('activate') ? 'activate' : params.get('reset') ? 'reset' : 'login'
|
||||
)
|
||||
const [resetToken, setResetToken] = useState(params.get('reset') || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'activate' || view === 'reset') { setChecking(false); return }
|
||||
const cached = getCachedDisplay()
|
||||
if (cached) setStudent(cached) // optimistic paint while we confirm with the server
|
||||
api.whoami()
|
||||
@@ -20,9 +31,29 @@ export default function PlatformShell() {
|
||||
})
|
||||
.catch(() => setStudent(null))
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
}, [view])
|
||||
|
||||
if (checking) return null
|
||||
if (!student) return <EntryScreen onLogin={setStudent} />
|
||||
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
|
||||
|
||||
if (view === 'activate') {
|
||||
return <SetPasswordScreen token={params.get('activate')} purpose="activation"
|
||||
onDone={(s) => { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'reset') {
|
||||
return <SetPasswordScreen token={resetToken} purpose="reset"
|
||||
onDone={() => { setView('login'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'forgot') {
|
||||
return <ForgotPasswordScreen onCancel={() => setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} />
|
||||
}
|
||||
if (view === 'pilot') {
|
||||
return <EntryScreen onLogin={(s) => { setStudent(s); setView('dashboard') }} />
|
||||
}
|
||||
if (!student) {
|
||||
return <LoginScreen onLogin={(s) => { setStudent(s); setView('dashboard') }}
|
||||
onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} />
|
||||
}
|
||||
return <StudentDashboard student={student} onLogout={() => { setStudent(null); setView('login') }} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §10 — minimal account/profile page. Deliberately narrow: identity, cohort, progress
|
||||
// summary, achievements, active session count, and change-password. No social/profile features.
|
||||
export default function ProfileTab() {
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { api.profile().then(setProfile).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!profile) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<h2>Account</h2>
|
||||
<p><strong>{profile.student.display_name}</strong></p>
|
||||
<p className={styles.muted}>{profile.student.email || profile.student.login_identifier || 'No login identifier set'}</p>
|
||||
<p className={styles.muted}>
|
||||
{profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'}
|
||||
</p>
|
||||
<p className={styles.muted}>{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Progress</h2>
|
||||
<p>{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed</p>
|
||||
<div className={styles.progressBarTrack}>
|
||||
<div className={styles.progressBarFill} style={{ width: `${profile.progress_summary.total ? Math.round(100 * profile.progress_summary.completed / profile.progress_summary.total) : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Achievements</h2>
|
||||
{!profile.achievements.length ? <p className={styles.muted}>None yet — keep going!</p> : (
|
||||
<ul>
|
||||
{profile.achievements.map((a, i) => (
|
||||
<li key={i}>{a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordCard hasCredentials={profile.student.has_credentials} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChangePasswordCard({ hasCredentials }) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
if (!hasCredentials) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
<p className={styles.muted}>This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword)
|
||||
setNotice('Password updated.'); setOldPassword(''); setNewPassword('')
|
||||
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Current password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={busy}>{busy ? 'Saving…' : 'Update password'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
import CourseView from './CourseView'
|
||||
|
||||
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 [showCourses, setShowCourses] = useState(false)
|
||||
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
|
||||
|
||||
// Phase 6A §6 — "View Courses" opens the structured course/module/activity tree for this
|
||||
// enrollment, right here inline (My Programs -> ROOT_ -> Course -> Module -> Activity), no
|
||||
// separate portal. "Continue Learning" keeps its original Phase 5B behavior unchanged
|
||||
// (straight to the disposable Lab) — CourseView's own Launch Lab buttons call the exact same
|
||||
// handleContinue, so there is still only one launch path underneath both entry points.
|
||||
if (showCourses) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<CourseView enrollmentId={program.enrollment_id} onLaunchLab={handleContinue} onClose={() => setShowCourses(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||
</>
|
||||
) : 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>
|
||||
) : (
|
||||
<div className={styles.btnRow}>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves:
|
||||
// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same
|
||||
// UI, different API call and success copy.
|
||||
export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) {
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message))
|
||||
}, [token, purpose])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (password.length < 10) { setError('Password must be at least 10 characters.'); return }
|
||||
if (password !== confirm) { setError('Passwords do not match.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (purpose === 'activation') {
|
||||
const student = await api.activate(token, password)
|
||||
onDone(student)
|
||||
} else {
|
||||
await api.resetPassword(token, password)
|
||||
onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
|
||||
|
||||
{previewError && (
|
||||
<div className={styles.errorBox}>
|
||||
This link is invalid or has expired. Ask your instructor for a new one.
|
||||
</div>
|
||||
)}
|
||||
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{!previewError && (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
|
||||
<label className={styles.label}>Confirm password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,19 @@ import PracticeTab from './PracticeTab'
|
||||
import AssessmentsTab from './AssessmentsTab'
|
||||
import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
import ClassroomTab from './ClassroomTab'
|
||||
import ProfileTab from './ProfileTab'
|
||||
import ProgramsTab from './ProgramsTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'programs', label: 'My Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'capstone', label: 'Capstone' },
|
||||
{ key: 'achievements', label: 'Achievements' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
]
|
||||
|
||||
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
|
||||
@@ -41,10 +47,13 @@ export default function StudentDashboard({ student, onLogout }) {
|
||||
</nav>
|
||||
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'programs' && <ProgramsTab />}
|
||||
{tab === 'classroom' && <ClassroomTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
{tab === 'profile' && <ProfileTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,4 +83,49 @@ export const api = {
|
||||
capstone: () => fetch('/api/capstone').then(json),
|
||||
|
||||
achievements: () => fetch('/api/achievements').then(json),
|
||||
|
||||
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
|
||||
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
|
||||
// above: nothing authenticated exists yet to forge into.
|
||||
studentLogin: async (identifier, password) => {
|
||||
const { student } = await fetch('/api/student/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
activate: async (token, password) => {
|
||||
const { student } = await fetch('/api/student/activate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
|
||||
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
|
||||
}).then(json),
|
||||
resetPassword: (token, password) => fetch('/api/student/reset-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json),
|
||||
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
|
||||
profile: () => fetch('/api/student/profile').then(json),
|
||||
|
||||
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
|
||||
liveClassroom: () => fetch('/api/classroom/live').then(json),
|
||||
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
|
||||
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
|
||||
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
|
||||
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
||||
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
||||
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
|
||||
|
||||
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
|
||||
programs: () => fetch('/api/student/programs').then(json),
|
||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||
|
||||
// ── Phase 6A: course/module/activity content ─────────────────────────────────────────────
|
||||
courses: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}/courses`).then(json),
|
||||
completeActivity: (activityId) => mutate(`/api/student/activities/${activityId}/complete`),
|
||||
}
|
||||
|
||||
@@ -83,11 +83,10 @@ scrape_configs:
|
||||
- target_label: __address__
|
||||
replacement: localhost:9115
|
||||
|
||||
# RootNode application metrics — see docs/OBSERVABILITY.md §"exposing /metrics" for the
|
||||
# documented, NOT-YET-BUILT follow-up (Platform API/Gateway/Provisioner don't currently
|
||||
# expose a Prometheus /metrics endpoint; this job is ready for when that lands, disabled
|
||||
# until then rather than scraping a 404 forever).
|
||||
# - job_name: rootnode-platform-api
|
||||
# static_configs:
|
||||
# - targets: ["api:4300"]
|
||||
# metrics_path: /metrics
|
||||
# RootNode application metrics — Phase 5A §34 (platform/api/src/metrics.js): classroom/help-
|
||||
# request/Lab-state gauges and student login counters. No student/instructor id or other
|
||||
# high-cardinality value is ever a label here — see that file's header comment.
|
||||
- job_name: rootnode-platform-api
|
||||
static_configs:
|
||||
- targets: ["api:4300"]
|
||||
metrics_path: /metrics
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 5A §30/§33 dress rehearsal: brings up the real dev stack, bootstraps
|
||||
# an owner, runs test-phase5a-rehearsal.js, tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P5A Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase5a-rehearsal.js
|
||||
+33
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 5C §25 enrollment/activation dress rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase5c-enrollment-activation.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] "P5C 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-phase5c-enrollment-activation.js
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 6A §12 gold-standard module dress rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase6a-course-rehearsal.js (real k3s Lab, real
|
||||
# scenario), 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] "P6A Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase6a-course-rehearsal.js
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous
|
||||
// narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the
|
||||
// real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real
|
||||
// progress/attempt/achievement history, joins a live classroom, sends a help request, and the
|
||||
// Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two
|
||||
// of the five are then converted to real credentialed accounts and their entire prior history
|
||||
// (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual
|
||||
// migration-safety check §33 asks for, run against a live stack rather than asserted in the
|
||||
// abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js).
|
||||
//
|
||||
// Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
||||
const RUN_ID = Date.now();
|
||||
const COHORT_ID = `p5a-rehearsal-${RUN_ID}`;
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
|
||||
// Seeds one realistic progress/attempt/achievement record for a pilot student directly in
|
||||
// Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full
|
||||
// Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js.
|
||||
function seedHistory(studentId) {
|
||||
const sql = `
|
||||
INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at)
|
||||
VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days');
|
||||
INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count)
|
||||
VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4);
|
||||
INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id)
|
||||
VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations');
|
||||
`;
|
||||
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5A student identity / remote classroom dress rehearsal\n');
|
||||
|
||||
const instructor = makeSession(GW);
|
||||
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez'];
|
||||
const students = {}; // name -> student_id
|
||||
const sessions = {}; // name -> pilot login session
|
||||
|
||||
await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => {
|
||||
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' });
|
||||
for (const name of names) {
|
||||
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
|
||||
assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`);
|
||||
students[name] = body.student_id;
|
||||
}
|
||||
});
|
||||
|
||||
await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => {
|
||||
for (const name of names) {
|
||||
sessions[name] = makeSession(GW);
|
||||
const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] });
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => {
|
||||
seedHistory(students['Reh-Ada']);
|
||||
seedHistory(students['Reh-Bo']);
|
||||
});
|
||||
|
||||
let classroomId;
|
||||
await step('instructor: create and start a live classroom for the cohort', async () => {
|
||||
const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' });
|
||||
classroomId = body.classroom_session_id;
|
||||
const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await step('all 5 students join the classroom and heartbeat', async () => {
|
||||
for (const name of names) {
|
||||
const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`);
|
||||
assert.equal(status, 200);
|
||||
await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
}
|
||||
});
|
||||
|
||||
await step('Reh-Cy sends a help request; instructor acknowledges it', async () => {
|
||||
const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' });
|
||||
assert.equal(status, 201);
|
||||
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const req = dash.help_requests.find((h) => h.status === 'open');
|
||||
assert.ok(req);
|
||||
await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`);
|
||||
});
|
||||
|
||||
await step('instructor posts an announcement and sets current activity', async () => {
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' });
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' });
|
||||
});
|
||||
|
||||
// ── The restart: the whole point of §30's "prove recovery" requirement. ─────────────────
|
||||
await step('Platform API restarts mid-rehearsal', () => {
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
await step('after restart: the classroom is still live and every session still works, with no re-login', async () => {
|
||||
for (const name of names) {
|
||||
const { status } = await sessions[name].request('GET', `/api/classroom/live`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(dash.classroom.status, 'live');
|
||||
assert.equal(dash.students.length, 5);
|
||||
assert.equal(dash.announcements.length, 1);
|
||||
});
|
||||
|
||||
await step('after restart: heartbeats and help requests still work normally', async () => {
|
||||
const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
// ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ───────────────
|
||||
let adaActivation, boActivation;
|
||||
await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => {
|
||||
const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` });
|
||||
const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` });
|
||||
assert.equal(a.status, 200); assert.equal(b.status, 200);
|
||||
adaActivation = a.body.activation_token; boActivation = b.body.activation_token;
|
||||
});
|
||||
|
||||
await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => {
|
||||
const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] });
|
||||
assert.equal(adaRetry.status, 409);
|
||||
const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] });
|
||||
assert.equal(cyStillWorks.status, 200);
|
||||
});
|
||||
|
||||
let adaNewSession, boNewSession;
|
||||
await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => {
|
||||
adaNewSession = makeSession(GW);
|
||||
boNewSession = makeSession(GW);
|
||||
const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' });
|
||||
const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' });
|
||||
assert.equal(a.status, 200); assert.equal(b.status, 200);
|
||||
});
|
||||
|
||||
await step('every bit of prior history survived the conversion — nothing orphaned', async () => {
|
||||
const adaProgress = await adaNewSession.request('GET', '/api/progress');
|
||||
assert.equal(adaProgress.status, 200);
|
||||
assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login');
|
||||
assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed');
|
||||
|
||||
const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']);
|
||||
assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created');
|
||||
|
||||
const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
|
||||
assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion');
|
||||
});
|
||||
|
||||
await step('cleanup: end the classroom', async () => {
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+174
@@ -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); });
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5C §25 — the enrollment/activation dress rehearsal. One continuous realistic story: a
|
||||
// simulated website enrollment event creates a brand-new student, the activation notification
|
||||
// is queued and delivered through the (deterministic, never-real-network) dev email provider,
|
||||
// the rendered email is inspected and its activation URL extracted exactly the way a real
|
||||
// inbox/click would work, the student sets their password, logs in, sees their program, uses
|
||||
// Continue Learning to launch a REAL Lab, completes one real scenario, and an admin can see
|
||||
// both the enrollment and its delivery state. Then a second enrollment for the SAME
|
||||
// (now-activated) student proves it gets an enrollment confirmation, not another activation.
|
||||
// Then a full password-reset round trip, including session revocation. Then the minor/no-email
|
||||
// contact-email path. No framework, matching project convention (see
|
||||
// test-phase5b-selfpaced-rehearsal.js).
|
||||
//
|
||||
// Run via run-phase5c-enrollment-activation-drill.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';
|
||||
|
||||
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 waitForSent(owner, studentId, notificationId, timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const row = notificationId ? body.find((n) => n.notification_id === notificationId) : body[0];
|
||||
if (row?.status === 'sent') return row;
|
||||
if (row?.status === 'failed') throw new Error(`notification failed: ${row.last_error_code}`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error('timed out waiting for delivery');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5C enrollment/activation 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 });
|
||||
|
||||
let studentId, enrollmentId;
|
||||
await step('website enrollment event creates a brand-new student + self-paced enrollment', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-rehearsal-${RUN_ID}@example.com`, display_name: 'Rehearsal Student' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
studentId = body.student_id; enrollmentId = body.enrollment_id;
|
||||
});
|
||||
|
||||
let activationUrl;
|
||||
await step('an activation notification is queued and delivered through the dev provider', async () => {
|
||||
const sent = await waitForSent(owner, studentId, null);
|
||||
assert.equal(sent.notification_type, 'activation');
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.equal(preview.to, `p5c-rehearsal-${RUN_ID}@example.com`);
|
||||
assert.ok(preview.subject.includes('Activate'));
|
||||
const match = preview.text.match(/(http:\/\/\S+\/\?activate=[A-Za-z0-9_-]+)/);
|
||||
assert.ok(match, 'expected a real activation URL in the rendered email body');
|
||||
activationUrl = match[1];
|
||||
});
|
||||
|
||||
const student = makeSession(GW);
|
||||
await step('student "clicks" the extracted URL and sets a password', async () => {
|
||||
const token = new URL(activationUrl).searchParams.get('activate');
|
||||
const { status } = await student.request('POST', '/api/student/activate', { token, password: 'RehearsalPass123!' });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
let launchEnrollmentId, envId;
|
||||
await step('student sees the Root program and Continue Learning works', async () => {
|
||||
const { body: programs } = await student.request('GET', '/api/student/programs');
|
||||
assert.equal(programs.length, 1);
|
||||
assert.equal(programs[0].can_launch, true);
|
||||
launchEnrollmentId = programs[0].enrollment_id;
|
||||
assert.equal(launchEnrollmentId, enrollmentId);
|
||||
});
|
||||
|
||||
await step('launches a REAL Lab and completes one real scenario', async () => {
|
||||
const { status, body } = await student.request('POST', `/api/student/programs/${launchEnrollmentId}/launch`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.status, 'running');
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${studentId}/environment`)).body.find((e) => e.status === 'running');
|
||||
envId = envRow.environment_id;
|
||||
const complete = await student.request('POST', '/api/progress/record', { scenario_id: 'pod-basics-mcq', status: 'completed', environment_id: envId });
|
||||
assert.equal(complete.status, 200);
|
||||
});
|
||||
|
||||
await step('progress persists and the admin sees both the enrollment and its delivery state', async () => {
|
||||
const { body } = await student.request('GET', `/api/student/programs/${launchEnrollmentId}`);
|
||||
assert.ok(body.progress.percent_complete > 0);
|
||||
const { body: adminEnrollments } = await owner.request('GET', `/api/admin/students/${studentId}/enrollments`);
|
||||
assert.equal(adminEnrollments[0].status, 'active');
|
||||
const { body: adminNotifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
assert.ok(adminNotifs.every((n) => n.status === 'sent'));
|
||||
});
|
||||
|
||||
// ── A second enrollment for the SAME, now-activated student -> confirmation, not activation ──
|
||||
await step('admin creates a second program and enrolls the SAME student — enrollment confirmation, not another activation', async () => {
|
||||
await owner.request('POST', '/api/admin/programs', { program_id: `p5c-rehearsal-second-${RUN_ID}`, name: 'Second Rehearsal Program', family: 'root' });
|
||||
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: studentId, program_id: `p5c-rehearsal-second-${RUN_ID}`, status: 'active' });
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForSent(owner, studentId, null);
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const confirmation = notifs.find((n) => n.enrollment_id === body.enrollment_id);
|
||||
assert.equal(confirmation.notification_type, 'enrollment_confirmation');
|
||||
});
|
||||
|
||||
// ── Password reset round trip ─────────────────────────────────────────────────────────────
|
||||
let resetToken;
|
||||
await step('password reset is requested, delivered, and the old session is revoked once used', async () => {
|
||||
const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-rehearsal-${RUN_ID}@example.com` });
|
||||
resetToken = res.body.dev_reset_token;
|
||||
assert.ok(resetToken);
|
||||
await waitForSent(owner, studentId, null);
|
||||
const before = await student.request('GET', '/api/student/profile');
|
||||
assert.equal(before.status, 200);
|
||||
const resetRes = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'RehearsalReset123!' });
|
||||
assert.equal(resetRes.status, 200);
|
||||
const after = await student.request('GET', '/api/student/profile');
|
||||
assert.notEqual(after.status, 200); // the OLD session is revoked
|
||||
});
|
||||
|
||||
await step('the new password works', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5c-rehearsal-${RUN_ID}@example.com`, password: 'RehearsalReset123!' });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
// ── Minor / no-email path ────────────────────────────────────────────────────────────────
|
||||
await step('minor/no-email student: guardian contact_email receives the activation, never stored as students.email', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-rehearsal-minor-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { login_identifier: `p5c-rehearsal-minor-${RUN_ID}`, display_name: 'Rehearsal Minor', contact_email: `p5c-rehearsal-guardian-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForSent(owner, body.student_id, null);
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.equal(preview.to, `p5c-rehearsal-guardian-${RUN_ID}@example.com`);
|
||||
const { body: students } = await owner.request('GET', '/api/admin/students');
|
||||
const minorRow = students.find((s) => s.student_id === body.student_id);
|
||||
assert.equal(minorRow.email, null);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6A §12 — the gold-standard module dress rehearsal. One continuous realistic story, not
|
||||
// synthetic unit assertions: a self-paced student enrolls, opens the ROOT_ course tree, reads
|
||||
// the health-probes lesson, works through the demonstration/knowledge-check/guided-exercise,
|
||||
// launches a REAL Lab, solves the REAL rootnode-probe-port-mismatch scenario against a real k3s
|
||||
// environment (no mocked KubeKosh), returns to the module, sees it complete, and the next
|
||||
// module unlocks. Same conventions as test-phase5b-selfpaced-rehearsal.js (assert-based, no
|
||||
// framework, real k3s boots).
|
||||
//
|
||||
// Run via run-phase6a-course-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;
|
||||
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';
|
||||
const GOLD_MODULE = 'root-10-m01-health-probes-and-troubleshooting';
|
||||
|
||||
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 moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 6A gold-standard module 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 Course Student', login_identifier: `p6a-course-${RUN_ID}`,
|
||||
});
|
||||
const session = makeSession(GW);
|
||||
let enrollmentId, envId, labToken;
|
||||
|
||||
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('an instructor enrolls the student directly in ROOT_ (admin-created, matching Phase 5B\'s manual-enrollment path)', async () => {
|
||||
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' });
|
||||
assert.equal(status, 201);
|
||||
enrollmentId = body.enrollment_id;
|
||||
});
|
||||
|
||||
let tree;
|
||||
await step('the student opens My Programs -> ROOT_ and sees the course tree, gold module locked behind root-05', async () => {
|
||||
const { status, body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(status, 200);
|
||||
tree = body.courses;
|
||||
const goldModule = moduleFrom(tree, GOLD_MODULE);
|
||||
assert.equal(goldModule.locked, true, 'the gold module requires root-05 first, exactly like a real learner would hit');
|
||||
});
|
||||
|
||||
// Real Foundation completion, same scenarios e2e-phase6a.js drives, via the real self-paced
|
||||
// Lab (not seeded directly) — this rehearsal is specifically about proving the REAL path.
|
||||
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'];
|
||||
|
||||
await step('student launches a REAL Lab and completes root-05\'s real scenarios to unlock the gold module', async () => {
|
||||
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
assert.equal(launch.body.status, 'running');
|
||||
labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1]; // needed to reach the real Lab's own setup endpoint below
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
envId = envRow.environment_id;
|
||||
for (const sid of FOUNDATION) {
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200, `completing ${sid} should 200`);
|
||||
}
|
||||
});
|
||||
|
||||
await step('the gold module is now unlocked', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.locked, false);
|
||||
assert.equal(goldModule.activities[0].activity_type, 'lesson');
|
||||
assert.ok(goldModule.activities[0].body.includes('liveness'), 'the real lesson content must actually be served');
|
||||
});
|
||||
|
||||
await step('student reads the lesson, demonstration, knowledge check, and guided exercise, marking each complete', async () => {
|
||||
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
||||
const { status } = await session.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('student launches the Lab activity for real and lands on rootnode-probe-port-mismatch', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
const lab = goldModule.activities.find((a) => a.activity_type === 'lab');
|
||||
assert.equal(lab.scenario_id, 'rootnode-probe-port-mismatch');
|
||||
assert.equal(lab.complete, false, 'not solved yet — this is the real incident, not pre-completed');
|
||||
});
|
||||
|
||||
await step('the Lab UI\'s own setup_commands actually stand up the broken api-gateway deployment', async () => {
|
||||
// Same route the real Lab frontend calls when a student opens this scenario — proxied
|
||||
// through the Gateway's /lab/:token/* passthrough straight to the Lab image's own backend
|
||||
// (backend/server.js), which is what actually runs setup_commands. Nothing about this is
|
||||
// simulated: this is the real mechanism, called the same way the browser would.
|
||||
const { status } = await session.request('POST', `/lab/${labToken}/api/scenarios/rootnode-probe-port-mismatch/setup`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await step('student solves the REAL rootnode-probe-port-mismatch incident against the live k3s environment', async () => {
|
||||
// Real diagnosis, matching the scenario's own hints (scenarios/data/rootnode-probe-port-mismatch.json):
|
||||
// the readiness probe was patched onto port 8080; nginx listens on 80. This patches the
|
||||
// actual live Deployment inside the student's real disposable Lab, then records the real
|
||||
// scenario completion the same way the Lab UI's own validation flow does.
|
||||
const { execSync } = require('child_process');
|
||||
// The container's real name is a SHORTENED id (lifecycle.js's shortId(), not the full
|
||||
// environment_id) — reading it straight from Postgres avoids duplicating that derivation
|
||||
// (and silently breaking if it ever changes) the way relying on our own copy would.
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
assert.ok(containerName, 'the environment must have a real container_name recorded');
|
||||
execSync(
|
||||
`docker exec ${containerName} kubectl patch deployment api-gateway --type='json' -p='[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/port","value":80}]'`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
// Give the rollout a moment to actually reach Ready before validating — the same real-world
|
||||
// timing a student would wait through.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: 'rootnode-probe-port-mismatch', status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
await step('the lab activity is now complete, but the module waits on the reflection', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, true);
|
||||
assert.equal(goldModule.complete, false);
|
||||
});
|
||||
|
||||
await step('student completes the reflection; the gold module is fully complete and the next module unlocks', async () => {
|
||||
const { status } = await session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||
assert.equal(status, 200);
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.complete, true);
|
||||
const nextModule = moduleFrom(body.courses, 'root-10-m02-general-troubleshooting');
|
||||
assert.equal(nextModule.locked, false);
|
||||
});
|
||||
|
||||
await step('the instructor Curriculum view shows this student\'s real progress through the gold module', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
const goldModule = moduleFrom(body, GOLD_MODULE);
|
||||
assert.equal(goldModule.complete, true);
|
||||
assert.ok(goldModule.activities[0].instructor_notes, 'the instructor view must see instructor notes the student view never does');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5C §24 — the OPTIONAL, opt-in Google Workspace live-delivery test. Does NOTHING unless
|
||||
// explicitly enabled — no test/regression run ever sends real email by accident.
|
||||
//
|
||||
// Usage (only when you actually have real Workspace credentials configured):
|
||||
// RUN_GWS_EMAIL_TEST=true \
|
||||
// [email protected] \
|
||||
// GOOGLE_SERVICE_ACCOUNT_JSON='{"client_email":"...","private_key":"..."}' \
|
||||
// [email protected] \
|
||||
// EMAIL_FROM_NAME="RootNode Academy" \
|
||||
// node infra/scripts/gws-email-test.js
|
||||
//
|
||||
// Sends exactly ONE clearly-marked test message to TEST_EMAIL_RECIPIENT — never a
|
||||
// production/student address, never inferred from anything in the database. Run it once after
|
||||
// setting up domain-wide delegation to confirm the adapter actually works against the real
|
||||
// Gmail API, not as part of any regular regression pass (see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md).
|
||||
|
||||
if (process.env.RUN_GWS_EMAIL_TEST !== 'true') {
|
||||
console.log('Skipped: set RUN_GWS_EMAIL_TEST=true to actually send a live test email. Nothing was sent.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const recipient = process.env.TEST_EMAIL_RECIPIENT;
|
||||
if (!recipient) {
|
||||
console.error('Set TEST_EMAIL_RECIPIENT to the ONE address this test should send to — it is never inferred from anything else.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = require('../../platform/api/src/email/gws-provider');
|
||||
|
||||
async function main() {
|
||||
console.log(`Sending one live test message via the Gmail API adapter to ${recipient}...`);
|
||||
const result = await provider.send({
|
||||
to: recipient,
|
||||
fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy',
|
||||
fromAddress: process.env.EMAIL_FROM_ADDRESS,
|
||||
replyTo: process.env.EMAIL_REPLY_TO,
|
||||
subject: '[RootNode Academy] Google Workspace adapter test',
|
||||
text: `This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation). If you received this, live delivery is working. Sent at ${new Date().toISOString()}.`,
|
||||
html: `<p>This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation).</p><p>If you received this, live delivery is working. Sent at ${new Date().toISOString()}.</p>`,
|
||||
});
|
||||
console.log('Provider accepted the message.');
|
||||
console.log('Provider message ID:', result.providerMessageId);
|
||||
console.log('\nGWS LIVE DELIVERY TEST: PASSED');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('GWS LIVE DELIVERY TEST: FAILED');
|
||||
console.error(` ${e.errorCode || 'error'}: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+29
-7
@@ -7,16 +7,24 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
|
||||
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
|
||||
`provisioner/README.md`.
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
|
||||
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
- `api/` — the lightweight Platform API: students, cohorts and cohort membership, curriculum,
|
||||
detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
capstone assessment engine, achievements, per-instructor accounts, persistent student/
|
||||
instructor sessions, RBAC, audit logging, and the instructor/admin control plane. No k3s, no
|
||||
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
|
||||
(Phase 5A), self-paced programs/enrollment + the website integration boundary (Phase 5B), and
|
||||
a provider-neutral enrollment-communications pipeline (activation/enrollment-confirmation/
|
||||
password-reset email, Phase 5C), the ROOT_ course/module/activity content model layered over
|
||||
curriculum/assessments (Phase 6A), and the instructor/admin control plane. No k3s, no
|
||||
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
|
||||
booting an unused k3s cluster just to serve these APIs. Design:
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
|
||||
reference: `api/README.md`.
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom),
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary),
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery),
|
||||
`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` (course/module/activity content model, gold-standard
|
||||
module). Operational reference: `api/README.md`.
|
||||
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param — and,
|
||||
as of Phase 3B, server-side gating of progressive hint reveal). Design:
|
||||
@@ -68,13 +76,27 @@ further instructor accounts from the Instructors tab).
|
||||
Lab reset, curriculum stage/Capstone lock-unlock, the exam engine (random draw, server
|
||||
timer, expiry, restart persistence), the Capstone workflow end to end, two-student assessment
|
||||
isolation, and instructor learning analytics/RBAC.
|
||||
- `test/e2e-phase5a.js` — real student credentials (activation/password reset/login), remote
|
||||
classroom sessions (join/heartbeat/announcements/help requests), and instructor conversion of
|
||||
a pilot student to a credentialed account.
|
||||
- `test/e2e-phase5b.js` — programs/enrollment, entitlement gating (pending/future/expired/
|
||||
cancelled), server-derived completion, disposable-Lab persistence across destroy/recreate, and
|
||||
the website/commerce integration boundary (idempotency, concurrency, RBAC).
|
||||
- `test/e2e-phase5c.js` — the notification lifecycle (queue/retry/failure/exhaustion),
|
||||
activation/enrollment-confirmation/password-reset email delivery via the deterministic dev
|
||||
provider, minor/no-email contact-email resolution, and admin notification RBAC.
|
||||
- `test/e2e-phase6a.js` — course/module/activity hierarchy and ordering, prerequisite-based
|
||||
locking, all three completion behaviors (manual marker/scenario/assessment-derived), resume,
|
||||
cross-student isolation, RBAC for self-paced (no-cohort) students, and draft-content hiding.
|
||||
|
||||
All four e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||
gone. They're slow (real k3s boots, `e2e-phase3b.js` also runs a real API restart) and need the
|
||||
full stack up. None of them substitute for the manual real-Lab-image checks (node-pty, terminal
|
||||
WebSocket, `kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 —
|
||||
those need a browser-equivalent client and aren't automated here.
|
||||
those need a browser-equivalent client and aren't automated here. The one exception is Phase
|
||||
6A's gold-standard module, which DOES get a real-Lab dress rehearsal —
|
||||
`infra/scripts/drills/run-phase6a-course-rehearsal.sh`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ COPY platform/api/migrate-sqlite.js ./
|
||||
# listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only).
|
||||
COPY scenarios/ ./scenarios/
|
||||
|
||||
# Phase 6A — course/module lesson prose (content.js). Git-tracked Markdown, read-only, same
|
||||
# reasoning as scenarios/ above: this image only ever reads it, never executes anything from it.
|
||||
COPY content/ ./content/
|
||||
|
||||
# Platform-shell + admin frontend, built in the stage above — served statically, no nginx
|
||||
# needed for a lightweight Node API+static server.
|
||||
COPY --from=frontend-build /fe/dist/ /frontend-dist/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Real student accounts — Phase 5A §2/§3/§7, docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md.
|
||||
--
|
||||
-- Additive only (check-before-alter, docs/ENGINEERING_SOP.md §6) — every existing pilot student
|
||||
-- row stays a valid academic record with all columns NULL/default. A pilot student
|
||||
-- (password_hash IS NULL) can still use the original bare student_id login
|
||||
-- (POST /api/login, unchanged) until an instructor converts them; a converted/newly-created
|
||||
-- student (password_hash IS NOT NULL) must use the new credentialed login
|
||||
-- (POST /api/student/login) instead. See src/students-auth.js.
|
||||
--
|
||||
-- email vs login_identifier (Phase 5A §3): RootNode serves minors who may not have an email
|
||||
-- address. Both are nullable, independently unique — an older/self-paced student gets `email`,
|
||||
-- a younger instructor-managed student gets `login_identifier` (any safe non-email string the
|
||||
-- instructor assigns, e.g. a classroom username). Exactly one of the two is required once an
|
||||
-- account is credentialed (enforced in application code, not a CHECK, so the exact rule can
|
||||
-- evolve without a migration). Neither column names or implies a parent/guardian relationship —
|
||||
-- that's explicitly future work (docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md), not built here;
|
||||
-- the schema deliberately doesn't need to change to add it later (a guardian would reference a
|
||||
-- student_id, not the other way around).
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS email TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS login_identifier TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
|
||||
-- The activation/reset token is ONE mechanism serving two purposes (first-login activation and
|
||||
-- forgot-password) — both are "a time-limited secure token that lets you set a new password,"
|
||||
-- functionally identical, so one column set instead of two parallel ones. Only the hash is
|
||||
-- ever stored (crypto.createHash('sha256'), same pattern as sessions.js's token_hash) — the
|
||||
-- plaintext token exists only in the instructor's API response / student's activation link,
|
||||
-- never persisted, never logged.
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_hash TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_purpose TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_expires_at TIMESTAMPTZ;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE students ADD CONSTRAINT students_credential_token_purpose_check
|
||||
CHECK (credential_token_purpose IS NULL OR credential_token_purpose IN ('activation', 'reset'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_email ON students(email) WHERE email IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_login_identifier ON students(login_identifier) WHERE login_identifier IS NOT NULL;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Live classroom sessions — Phase 5A §11/§12. A classroom session is a distinct concept from a
|
||||
-- cohort (a standing roster), an assessment session (one student's exam attempt), a Lab
|
||||
-- environment (disposable infrastructure), or a login session (one browser's auth) — it
|
||||
-- represents ONE live teaching event, e.g. "Week 3 — Kubernetes Workloads, Aug 29 6-8pm."
|
||||
--
|
||||
-- Deliberately five tables, not one JSONB blob: classroom_sessions (the event itself),
|
||||
-- classroom_participants (who's in it + presence), classroom_events (append-only audit trail),
|
||||
-- classroom_announcements and help_requests get their own tables rather than being folded into
|
||||
-- classroom_events, since both need efficient direct queries ("show me the open help queue",
|
||||
-- "show me this classroom's announcements in order") that a generic event log would make
|
||||
-- awkward. classroom_events stays the audit trail (docs/PHASE5A... §25) — it does NOT duplicate
|
||||
-- announcement/help-request content, just records that the action happened.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_sessions (
|
||||
classroom_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'scheduled'
|
||||
CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')),
|
||||
scheduled_start TIMESTAMPTZ,
|
||||
scheduled_end TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
-- "Current activity" (§20) — a scenario id, not a stage id, matching how students actually
|
||||
-- work (one scenario at a time). NULL means the instructor hasn't set one yet. This is
|
||||
-- guidance only — never used to redirect a browser or auto-mark work complete (enforced in
|
||||
-- application code: nothing reads this to gate access or grade anything).
|
||||
current_scenario_id TEXT,
|
||||
-- Optional external video-conferencing link (§23) — a plain URL, RootNode never validates
|
||||
-- which provider it points at and never proxies/embeds it.
|
||||
meeting_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_cohort ON classroom_sessions(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_status ON classroom_sessions(status);
|
||||
|
||||
-- At most one LIVE classroom per cohort at a time — an instructor starting a second live
|
||||
-- session for the same cohort while one is already live is almost certainly a mistake, not a
|
||||
-- real use case (see classroom.js's startClassroom for the app-level check this backs).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_classroom_sessions_one_live_per_cohort
|
||||
ON classroom_sessions(cohort_id) WHERE status = 'live';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_participants (
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Presence (§15) — updated by a throttled heartbeat while the student has this classroom
|
||||
-- open, never by Lab terminal activity. online/recently-active/offline is DERIVED from this
|
||||
-- at read time (see admin-classroom-routes.js), not stored as a separate status column, so
|
||||
-- there's nothing to keep in sync or go stale on its own.
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
left_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (classroom_session_id, student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_participants_student ON classroom_participants(student_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_events (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'student', 'system')),
|
||||
actor_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_events_session ON classroom_events(classroom_session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_announcements (
|
||||
announcement_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
instructor_id UUID REFERENCES instructors(instructor_id),
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_announcements_session ON classroom_announcements(classroom_session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS help_requests (
|
||||
help_request_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
category TEXT, -- free text ('lab_problem' | 'dont_understand' | 'validation_failing' | 'other' from the frontend, not a DB-enforced enum — see §18's example list)
|
||||
message TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
acknowledged_by UUID REFERENCES instructors(instructor_id),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
instructor_note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_help_requests_session_status ON help_requests(classroom_session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_help_requests_student ON help_requests(student_id);
|
||||
@@ -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');
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Phase 5C §4 — durable notification lifecycle. Every "we should email this student" moment
|
||||
-- (activation, enrollment confirmation, password reset, password-changed) becomes a row here
|
||||
-- FIRST, committed in the same transaction/request as the business action, and a separate
|
||||
-- worker (notification-worker.js) is what actually talks to the email provider — the HTTP
|
||||
-- request that triggers a notification never waits on outbound email network calls.
|
||||
--
|
||||
-- recipient_email is a SNAPSHOT taken at enqueue time, not a live join to students.email —
|
||||
-- documented privacy note: this duplicates PII already stored on `students`/in an enrollment's
|
||||
-- metadata (the minor/no-email contact address, see 020_enrollments.sql's `metadata` column,
|
||||
-- Phase 5C's `contact_email` convention) purely so the worker and any later delivery audit
|
||||
-- don't need to reconstruct "what address would this have gone to" after the fact — same
|
||||
-- sensitivity class as data already in this database, not a new exposure. Never rendered
|
||||
-- unmasked through admin APIs (see notifications.js's maskEmail).
|
||||
--
|
||||
-- metadata MAY transiently hold plaintext activation/reset token material between enqueue and
|
||||
-- the worker's terminal (sent/failed) update — see notifications.js's header comment for the
|
||||
-- exact reasoning. It is scrubbed the moment a row reaches a terminal state. NEVER a raw error
|
||||
-- stack trace, NEVER a Google credential, NEVER a full provider payload.
|
||||
CREATE TABLE IF NOT EXISTS notification_events (
|
||||
notification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
enrollment_id UUID REFERENCES enrollments(enrollment_id),
|
||||
notification_type TEXT NOT NULL CHECK (notification_type IN ('activation', 'enrollment_confirmation', 'password_reset', 'password_changed')),
|
||||
channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')),
|
||||
recipient_email TEXT,
|
||||
provider TEXT,
|
||||
provider_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sending', 'sent', 'failed', 'cancelled')),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error_code TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_student ON notification_events(student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_enrollment ON notification_events(enrollment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_status ON notification_events(status);
|
||||
-- The worker's claim query — partial index scoped to exactly the rows it ever selects from.
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_claimable ON notification_events(next_attempt_at) WHERE status = 'queued';
|
||||
@@ -0,0 +1,266 @@
|
||||
-- Phase 6A §3 — the course/module/activity content model. This is a STRUCTURAL layer on top
|
||||
-- of everything Phase 1-5 already built (scenarios/*.json, assessment_definitions, achievements,
|
||||
-- progress/scenario_attempts) — it never duplicates that content, only references it:
|
||||
--
|
||||
-- program (existing, 019_programs.sql)
|
||||
-- -> course (new)
|
||||
-- -> course_module (new)
|
||||
-- -> module_activity (new) -- type lab: scenario_id references scenarios/data/*.json
|
||||
-- -- type assessment/capstone: assessment_definition_id
|
||||
-- -- references assessment_definitions (013_assessments.sql)
|
||||
-- -- everything else: content_path references a Markdown
|
||||
-- -- file under content/root/ (git-tracked prose, not a
|
||||
-- -- Postgres blob — see docs/COURSE_CONTENT_MODEL.md)
|
||||
--
|
||||
-- Long-form lesson prose deliberately does NOT live in Postgres — only the structural metadata
|
||||
-- (ordering, type, references, status) does. See docs/COURSE_CONTENT_MODEL.md for the full
|
||||
-- rationale (git-editable content vs. a CMS) and the Markdown convention (a `## Instructor
|
||||
-- Notes` heading splits shared content from instructor-only content in the same file).
|
||||
--
|
||||
-- No CMS, no WYSIWYG editor tables, no content versioning system beyond the same plain
|
||||
-- incrementing `version` integer every other definitional table here already uses
|
||||
-- (assessment_definitions, programs) — bumped by hand when a semantic edit is made, exactly the
|
||||
-- same convention, not a new one.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
course_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-kubernetes-foundations'
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sequence INTEGER NOT NULL, -- position within the program's course list
|
||||
objectives TEXT[] NOT NULL DEFAULT '{}',
|
||||
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- course_ids; see docs/COURSE_CONTENT_MODEL.md
|
||||
-- §"prerequisite/locking rule" for how an
|
||||
-- empty-content prerequisite is treated
|
||||
estimated_duration_hours INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_program ON courses(program_id, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS course_modules (
|
||||
module_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-m01-pods-and-kubectl-basics'
|
||||
course_id TEXT NOT NULL REFERENCES courses(course_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sequence INTEGER NOT NULL, -- position within the course's module list
|
||||
objectives TEXT[] NOT NULL DEFAULT '{}',
|
||||
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- module_ids
|
||||
estimated_duration_minutes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_course ON course_modules(course_id, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS module_activities (
|
||||
activity_id TEXT PRIMARY KEY, -- slug, e.g. 'root-10-m01-...-a05-rootnode-probe-port-mismatch'
|
||||
module_id TEXT NOT NULL REFERENCES course_modules(module_id),
|
||||
sequence INTEGER NOT NULL, -- position within the module's activity list
|
||||
activity_type TEXT NOT NULL CHECK (activity_type IN
|
||||
('lesson', 'demonstration', 'knowledge_check', 'guided_exercise',
|
||||
'lab', 'reflection', 'assessment', 'capstone')),
|
||||
title TEXT NOT NULL,
|
||||
content_path TEXT, -- Markdown file under content/root/, relative to repo root; NULL for a pure lab/assessment reference with no extra prose
|
||||
scenario_id TEXT, -- 'lab' activities only — references scenarios/data/<id>.json (not a DB FK; that content isn't a DB table)
|
||||
assessment_definition_id TEXT REFERENCES assessment_definitions(assessment_definition_id), -- 'assessment'/'capstone' activities only
|
||||
completion_behavior TEXT NOT NULL DEFAULT 'manual_marker' CHECK (completion_behavior IN
|
||||
('auto_scenario', 'auto_assessment', 'manual_marker')),
|
||||
estimated_duration_minutes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT module_activity_lab_has_scenario CHECK (activity_type != 'lab' OR scenario_id IS NOT NULL),
|
||||
CONSTRAINT module_activity_assessment_has_definition CHECK (activity_type NOT IN ('assessment', 'capstone') OR assessment_definition_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_module ON module_activities(module_id, sequence);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_scenario ON module_activities(scenario_id);
|
||||
|
||||
-- Explicit completion marker — ONLY for activities whose completion_behavior is
|
||||
-- 'manual_marker' (lesson/demonstration/knowledge_check/guided_exercise/reflection). A 'lab'
|
||||
-- activity's completion is derived live from the existing progress table
|
||||
-- (scenario_id -> progress[scenario_id].status = 'completed', exactly what curriculum-rules.js
|
||||
-- already checks) and an 'assessment'/'capstone' activity's completion is derived live from the
|
||||
-- existing achievements table — this table is never a second source of truth for either. See
|
||||
-- docs/COURSE_CONTENT_MODEL.md §"completion rules".
|
||||
CREATE TABLE IF NOT EXISTS activity_completions (
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
activity_id TEXT NOT NULL REFERENCES module_activities(activity_id),
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, activity_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_completions_student ON activity_completions(student_id);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Seed: the ROOT_ course architecture (docs/ROOT_PROGRAM_ARCHITECTURE.md), all under the
|
||||
-- existing 'root-kubernetes-foundations' program — root_ is one continuous ladder a student
|
||||
-- enrolls in once, not twelve separate commercial products, so no programs-table change.
|
||||
--
|
||||
-- Every module/activity below references EXISTING scenario/assessment content only — nothing
|
||||
-- here duplicates or forks scenarios/*.json or assessment_definitions. See
|
||||
-- docs/ROOT_CURRICULUM_INVENTORY.md for the full inventory this mapping is grounded in.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
INSERT INTO courses (course_id, program_id, title, description, sequence, objectives, prerequisites, estimated_duration_hours, status) VALUES
|
||||
('root-01-computing-linux-foundations', 'root-kubernetes-foundations', 'Computing & Linux Foundations',
|
||||
'How a computer actually works, the Linux filesystem and shell, and the command-line fluency everything else in root_ builds on. No lab content exists in this repository yet.',
|
||||
1, '{}', '{}', NULL, 'planned'),
|
||||
('root-02-linux-administration-networking', 'root-kubernetes-foundations', 'Linux Administration & Networking',
|
||||
'Users/permissions, services and systemd, package management, and TCP/IP networking fundamentals. No lab content exists in this repository yet.',
|
||||
2, '{}', '{}', NULL, 'planned'),
|
||||
('root-03-git-collaborative-development', 'root-kubernetes-foundations', 'Git & Collaborative Development',
|
||||
'Version control, branching, code review, and working in a shared repository. No lab content exists in this repository yet.',
|
||||
3, '{}', '{}', NULL, 'planned'),
|
||||
('root-04-containers-and-docker', 'root-kubernetes-foundations', 'Containers & Docker',
|
||||
'What a container actually is, building images, and running/networking containers with Docker, as the on-ramp to Kubernetes. No lab content exists in this repository yet.',
|
||||
4, '{}', '{}', NULL, 'planned'),
|
||||
('root-05-kubernetes-foundations', 'root-kubernetes-foundations', 'Kubernetes Foundations',
|
||||
'Pods, kubectl fluency, labels/selectors, and the core object model — the existing "Foundation" curriculum stage.',
|
||||
5, '{"Explain what a Pod is and its lifecycle states","Use kubectl confidently to inspect and manipulate cluster objects","Apply labels and selectors to organize resources"}', '{}', 8, 'partial'),
|
||||
('root-06-kubernetes-application-workloads', 'root-kubernetes-foundations', 'Kubernetes Application Workloads',
|
||||
'Deployments, scaling, rolling updates, Jobs/CronJobs, and DaemonSets — the existing "Workloads" curriculum stage.',
|
||||
6, '{"Deploy and scale a stateless application","Perform a rolling update and understand deployment strategies","Distinguish Jobs, CronJobs, and DaemonSets"}', '{root-05-kubernetes-foundations}', 4, 'partial'),
|
||||
('root-07-kubernetes-networking-config', 'root-kubernetes-foundations', 'Kubernetes Networking & Configuration',
|
||||
'Services, exposing applications, ConfigMaps, Secrets, and environment configuration — the existing "Networking" and "Configuration" curriculum stages.',
|
||||
7, '{"Expose a workload with the right Service type","Externalize configuration with ConfigMaps and Secrets"}', '{root-06-kubernetes-application-workloads}', 4, 'partial'),
|
||||
('root-08-kubernetes-storage-scheduling', 'root-kubernetes-foundations', 'Kubernetes Storage & Scheduling',
|
||||
'PersistentVolumes/Claims, StorageClasses, taints/tolerations, priority, and disruption budgets. Real scenario content exists in this repository (see docs/ROOT_CURRICULUM_INVENTORY.md) but is not yet wired into a module — Phase 6B.',
|
||||
8, '{}', '{root-07-kubernetes-networking-config}', NULL, 'planned'),
|
||||
('root-09-kubernetes-security', 'root-kubernetes-foundations', 'Kubernetes Security',
|
||||
'RBAC, Pod Security Admission, network policy, supply-chain and runtime hardening — a substantial existing CKS-style scenario bank (22 scenarios) not yet wired into a module — Phase 6B.',
|
||||
9, '{}', '{root-06-kubernetes-application-workloads}', NULL, 'planned'),
|
||||
('root-10-kubernetes-troubleshooting-observability', 'root-kubernetes-foundations', 'Kubernetes Troubleshooting & Observability',
|
||||
'Reading logs, exec/cp into containers, node scheduling, and diagnosing real failures — health probes are this course''s gold-standard module.',
|
||||
10, '{"Diagnose a Pod stuck in a bad state using kubectl and logs","Explain liveness, readiness, and startup probes and how misconfiguring them causes real outages"}', '{root-06-kubernetes-application-workloads}', 5, 'partial'),
|
||||
('root-11-devops-platform-engineering', 'root-kubernetes-foundations', 'DevOps & Platform Engineering Foundations',
|
||||
'CI/CD, infrastructure as code, and GitOps principles. No lab content exists in this repository yet.',
|
||||
11, '{}', '{root-10-kubernetes-troubleshooting-observability}', NULL, 'planned'),
|
||||
('root-12-real-ops', 'root-kubernetes-foundations', 'RootNode Real Ops',
|
||||
'RootNode-authored incident scenarios — operate and recover systems the way a real on-call engineer would, not textbook exercises. The existing "RootNode: Real Ops" curriculum stage.',
|
||||
12, '{"Recover a cluster from a resource-exhaustion incident","Diagnose and fix a broken deployment without being told what''s wrong","Apply resource defaults that prevent a class of outage from recurring"}', '{root-10-kubernetes-troubleshooting-observability}', 3, 'partial'),
|
||||
('root-capstone', 'root-kubernetes-foundations', 'ROOT Capstone — Operate and Recover a Production-Like Environment',
|
||||
'The existing RootNode Capstone assessment, combining skills from every stage into one operational evaluation.',
|
||||
13, '{"Operate a Kubernetes environment under exam conditions without step-by-step guidance"}', '{root-12-real-ops}', 2, 'partial')
|
||||
ON CONFLICT (course_id) DO NOTHING;
|
||||
|
||||
INSERT INTO course_modules (module_id, course_id, title, description, sequence, objectives, prerequisites, estimated_duration_minutes, status) VALUES
|
||||
('root-05-m01-pods-and-kubectl-basics', 'root-05-kubernetes-foundations', 'Pods & kubectl Basics',
|
||||
'The 20 scenarios in the existing "Foundation" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-06-m01-deployments-and-workloads', 'root-06-kubernetes-application-workloads', 'Deployments & Workloads',
|
||||
'The 8 scenarios in the existing "Workloads" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-07-m01-services-and-networking', 'root-07-kubernetes-networking-config', 'Services & Networking',
|
||||
'The 2 scenarios in the existing "Networking" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-07-m02-configuration-and-secrets', 'root-07-kubernetes-networking-config', 'Configuration & Secrets',
|
||||
'The 5 scenarios in the existing "Configuration" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
2, '{}', '{}', NULL, 'partial'),
|
||||
('root-10-m01-health-probes-and-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'Kubernetes Health Probes & Troubleshooting',
|
||||
'The Phase 6A gold-standard module: liveness/readiness/startup probe concepts taught BEFORE the student is asked to fix a real probe misconfiguration in rootnode-probe-port-mismatch. See docs/COURSE_AUTHORING_STANDARD.md — this module is the template for all future RootNode lesson authoring.',
|
||||
1, '{"Explain why Kubernetes health checks exist and what problem each probe type solves","Distinguish liveness, readiness, and startup probes and choose the right one","Diagnose a probe misconfiguration from Pod status and events, not guesswork"}',
|
||||
'{root-05-m01-pods-and-kubectl-basics}', 75, 'ready'),
|
||||
('root-10-m02-general-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'General Troubleshooting',
|
||||
'The 5 scenarios in the existing "Troubleshooting" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
2, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
|
||||
('root-12-m01-real-ops-incidents', 'root-12-real-ops', 'Real Ops Incidents',
|
||||
'The 4 scenarios in the existing "RootNode: Real Ops" curriculum stage, as disposable-Lab activities. rootnode-probe-port-mismatch also appears here — deliberate reuse: the gold module teaches the concept in depth, this module exercises it again alongside three other unrelated incident types, the same way the RootNode Capstone pool also reuses it. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
|
||||
('root-capstone-m01-operate-and-recover', 'root-capstone', 'Operate and Recover',
|
||||
'The existing RootNode Capstone assessment (90 minutes, mixed required/random incident pool). No capstone orientation/briefing lesson authored yet — Phase 6B.',
|
||||
1, '{}', '{root-12-m01-real-ops-incidents}', 90, 'partial')
|
||||
ON CONFLICT (module_id) DO NOTHING;
|
||||
|
||||
-- ── Gold-standard module activities (root-10-m01-health-probes-and-troubleshooting) ──────────
|
||||
-- Content lives in content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/
|
||||
-- — see docs/COURSE_CONTENT_MODEL.md for the Markdown convention these files follow.
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-10-m01-a01-lesson-health-check-concepts', 'root-10-m01-health-probes-and-troubleshooting', 1, 'lesson',
|
||||
'Why Health Checks Exist & Probe Types',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/01-lesson-health-check-concepts.md',
|
||||
NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-10-m01-a02-demonstration-probes-in-action', 'root-10-m01-health-probes-and-troubleshooting', 2, 'demonstration',
|
||||
'Demonstration: Probes in Action',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/02-demonstration-probes-in-action.md',
|
||||
NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-10-m01-a03-knowledge-check', 'root-10-m01-health-probes-and-troubleshooting', 3, 'knowledge_check',
|
||||
'Knowledge Check: Health Probes',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/03-knowledge-check.md',
|
||||
NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-10-m01-a04-guided-exercise-readiness-probe', 'root-10-m01-health-probes-and-troubleshooting', 4, 'guided_exercise',
|
||||
'Guided Exercise: Configure a Readiness Probe',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/04-guided-exercise-readiness-probe.md',
|
||||
NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-10-m01-a05-lab-probe-port-mismatch', 'root-10-m01-health-probes-and-troubleshooting', 5, 'lab',
|
||||
'Launch Lab: rootnode-probe-port-mismatch',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/05-lab-intro.md',
|
||||
'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-10-m01-a06-reflection-real-ops-takeaway', 'root-10-m01-health-probes-and-troubleshooting', 6, 'reflection',
|
||||
'Root-Cause Reflection & Real Ops Takeaway',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/06-reflection-real-ops-takeaway.md',
|
||||
NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ── Mechanically-mapped lab activities for the other partial modules ────────────────────────
|
||||
-- Generated 1:1 from scenarios/rootnode-path.json's existing stage scenario_ids (see
|
||||
-- docs/ROOT_CURRICULUM_INVENTORY.md) — no new lab content invented, titles taken from each
|
||||
-- scenario's own JSON. Estimated duration is a rough heuristic from the scenario's declared
|
||||
-- difficulty (15/20/30 min); refine once real per-activity completion-time data exists.
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status, visibility, version) VALUES
|
||||
('root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq', 'root-05-m01-pods-and-kubectl-basics', 1, 'lab', 'Lab: Pod Lifecycle States', NULL, 'pod-basics-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a02-kubectl-essentials-mcq', 'root-05-m01-pods-and-kubectl-basics', 2, 'lab', 'Lab: Essential kubectl Commands', NULL, 'kubectl-essentials-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a03-labels-selectors-mcq', 'root-05-m01-pods-and-kubectl-basics', 3, 'lab', 'Lab: Labels and Selectors', NULL, 'labels-selectors-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a04-init-containers-mcq', 'root-05-m01-pods-and-kubectl-basics', 4, 'lab', 'Lab: Init Containers Behaviour', NULL, 'init-containers-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a05-namespaces-basics', 'root-05-m01-pods-and-kubectl-basics', 5, 'lab', 'Lab: Working with Namespaces', NULL, 'namespaces-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a06-multi-container-pod-basics', 'root-05-m01-pods-and-kubectl-basics', 6, 'lab', 'Lab: Create a Multi-Container Pod', NULL, 'multi-container-pod-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a07-pod-labeling-basics', 'root-05-m01-pods-and-kubectl-basics', 7, 'lab', 'Lab: Labelling Resources', NULL, 'pod-labeling-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a08-jsonpath-basics', 'root-05-m01-pods-and-kubectl-basics', 8, 'lab', 'Lab: JSONPath Data Extraction', NULL, 'jsonpath-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a09-dry-run-manifest-basics', 'root-05-m01-pods-and-kubectl-basics', 9, 'lab', 'Lab: Generating YAML Manifests', NULL, 'dry-run-manifest-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a10-delete-by-label-basics', 'root-05-m01-pods-and-kubectl-basics', 10, 'lab', 'Lab: Bulk Deletion by Label', NULL, 'delete-by-label-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a11-annotate-resource-basics', 'root-05-m01-pods-and-kubectl-basics', 11, 'lab', 'Lab: Annotating Kubernetes Resources', NULL, 'annotate-resource-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a12-init-container-task-basics', 'root-05-m01-pods-and-kubectl-basics', 12, 'lab', 'Lab: Pod with Init Container', NULL, 'init-container-task-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a13-restart-policy-mcq', 'root-05-m01-pods-and-kubectl-basics', 13, 'lab', 'Lab: Pod Restart Policies', NULL, 'restart-policy-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a14-kubectl-output-format-mcq', 'root-05-m01-pods-and-kubectl-basics', 14, 'lab', 'Lab: kubectl Output Formats', NULL, 'kubectl-output-format-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a15-kubectl-explain-basics', 'root-05-m01-pods-and-kubectl-basics', 15, 'lab', 'Lab: Exploring the API with kubectl explain', NULL, 'kubectl-explain-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a16-kubectl-patch-basics', 'root-05-m01-pods-and-kubectl-basics', 16, 'lab', 'Lab: Patching Resources with kubectl patch', NULL, 'kubectl-patch-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a17-field-selector-basics', 'root-05-m01-pods-and-kubectl-basics', 17, 'lab', 'Lab: Filtering Resources with Field Selectors', NULL, 'field-selector-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a18-sort-by-basics', 'root-05-m01-pods-and-kubectl-basics', 18, 'lab', 'Lab: Sorting kubectl Output', NULL, 'sort-by-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a19-kubectl-diff-basics', 'root-05-m01-pods-and-kubectl-basics', 19, 'lab', 'Lab: Previewing Changes with kubectl diff', NULL, 'kubectl-diff-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a20-static-pods-mcq', 'root-05-m01-pods-and-kubectl-basics', 20, 'lab', 'Lab: Static Pods in Kubernetes', NULL, 'static-pods-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a01-deploy-nginx', 'root-06-m01-deployments-and-workloads', 1, 'lab', 'Lab: Deploy and Expose Nginx', NULL, 'deploy-nginx', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a02-scale-deployment', 'root-06-m01-deployments-and-workloads', 2, 'lab', 'Lab: Scaling a Deployment', NULL, 'scale-deployment', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a03-rolling-update-task', 'root-06-m01-deployments-and-workloads', 3, 'lab', 'Lab: Perform a Rolling Update', NULL, 'rolling-update-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a04-liveness-probe-task', 'root-06-m01-deployments-and-workloads', 4, 'lab', 'Lab: Configure a Liveness Probe', NULL, 'liveness-probe-task', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a05-jobs-cronjobs-mcq', 'root-06-m01-deployments-and-workloads', 5, 'lab', 'Lab: Jobs vs CronJobs', NULL, 'jobs-cronjobs-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a06-daemonset-mcq', 'root-06-m01-deployments-and-workloads', 6, 'lab', 'Lab: Understanding DaemonSets', NULL, 'daemonset-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a07-edit-deployment-basics', 'root-06-m01-deployments-and-workloads', 7, 'lab', 'Lab: Updating Deployments', NULL, 'edit-deployment-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a08-deployment-strategy-mcq', 'root-06-m01-deployments-and-workloads', 8, 'lab', 'Lab: Deployment Update Strategies', NULL, 'deployment-strategy-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-07-m01-services-and-networking-a01-services-mcq', 'root-07-m01-services-and-networking', 1, 'lab', 'Lab: Kubernetes Service Types', NULL, 'services-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m01-services-and-networking-a02-expose-service-basics', 'root-07-m01-services-and-networking', 2, 'lab', 'Lab: Exposing an Application', NULL, 'expose-service-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a01-configmap-volume', 'root-07-m02-configuration-and-secrets', 1, 'lab', 'Lab: Mount ConfigMap as Volume', NULL, 'configmap-volume', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a02-resource-limits-task', 'root-07-m02-configuration-and-secrets', 2, 'lab', 'Lab: Resource Requests and Limits', NULL, 'resource-limits-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a03-env-vars-configmap', 'root-07-m02-configuration-and-secrets', 3, 'lab', 'Lab: Environment Variables from ConfigMap', NULL, 'env-vars-configmap', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a04-secrets-basics', 'root-07-m02-configuration-and-secrets', 4, 'lab', 'Lab: Creating and Using Secrets', NULL, 'secrets-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a05-pod-env-vars-inline', 'root-07-m02-configuration-and-secrets', 5, 'lab', 'Lab: Pod with Inline Environment Variables', NULL, 'pod-env-vars-inline', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a01-extract-logs-basics', 'root-10-m02-general-troubleshooting', 1, 'lab', 'Lab: Extract Container Logs', NULL, 'extract-logs-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a02-kubectl-cp-basics', 'root-10-m02-general-troubleshooting', 2, 'lab', 'Lab: Copy Files to Containers', NULL, 'kubectl-cp-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a03-exec-command-basics', 'root-10-m02-general-troubleshooting', 3, 'lab', 'Lab: Execute Commands in Pods', NULL, 'exec-command-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a04-node-cordon-basics', 'root-10-m02-general-troubleshooting', 4, 'lab', 'Lab: Cordoning and Uncordoning a Node', NULL, 'node-cordon-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a05-pod-node-assignment-mcq', 'root-10-m02-general-troubleshooting', 5, 'lab', 'Lab: Assigning Pods to Nodes', NULL, 'pod-node-assignment-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a01-rootnode-runaway-monitor', 'root-12-m01-real-ops-incidents', 1, 'lab', 'Lab: Recover the Monitoring Stack', NULL, 'rootnode-runaway-monitor', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a02-broken-deployment', 'root-12-m01-real-ops-incidents', 2, 'lab', 'Lab: Debug a Failing Deployment', NULL, 'broken-deployment', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a03-rootnode-probe-port-mismatch', 'root-12-m01-real-ops-incidents', 3, 'lab', 'Lab: Stuck Not-Ready: The Probe Port Mismatch', NULL, 'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a04-rootnode-enforce-limits', 'root-12-m01-real-ops-incidents', 4, 'lab', 'Lab: Stop the Next Outage: Enforce Resource Defaults', NULL, 'rootnode-enforce-limits', NULL, 'auto_scenario', 30, 'ready', 'published', 1)
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ── Capstone activity (references the existing assessment engine, no duplicate definition) ──
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-capstone-m01-a01-capstone', 'root-capstone-m01-operate-and-recover', 1, 'capstone', 'RootNode Capstone', NULL, NULL, 'rootnode-capstone', 'auto_assessment', 90, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
@@ -0,0 +1,175 @@
|
||||
// Instructor-facing classroom API — Phase 5A §14/§21. RBAC via rbac.js's
|
||||
// requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolve
|
||||
// through to the classroom's cohort, same owner/admin/instructor/viewer rules as everything
|
||||
// else cohort-scoped — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §19 for role_in_cohort).
|
||||
//
|
||||
// The dashboard aggregation (GET /api/admin/classroom-sessions/:id) is the one place in this
|
||||
// file that reaches beyond classroom.js — it pulls in Provisioner Lab state (§16) and Phase 3B
|
||||
// academic records (§17) alongside classroom.js's own participant/presence/help-queue data,
|
||||
// because that combined view is the entire point of a live classroom dashboard. Neither the
|
||||
// Provisioner nor the academic-records shape is duplicated here — both are read exactly the
|
||||
// way admin-routes.js/admin-learning-routes.js already read them.
|
||||
|
||||
const classroom = require('./classroom');
|
||||
const { prepareLabsForCohort } = require('./prepare-labs');
|
||||
|
||||
module.exports = function registerAdminClassroomRoutes(app, deps) {
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
const { requireAnyInstructor, requireCohortAccess, requireCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// Maps classroom.js's INVALID_STATE/NOT_FOUND/ALREADY_LIVE error codes to HTTP status —
|
||||
// every lifecycle route below funnels through this instead of repeating the same try/catch.
|
||||
function classroomErrorStatus(code) {
|
||||
if (code === 'NOT_FOUND') return 404;
|
||||
if (code === 'ALREADY_LIVE' || code === 'INVALID_STATE') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { title, description, scheduled_start, scheduled_end, meeting_url } = req.body || {};
|
||||
if (!title) return res.status(400).json({ error: 'title is required' });
|
||||
const created = await classroom.createClassroom(getPool(), {
|
||||
cohortId: req.params.id, title, description, scheduledStart: scheduled_start, scheduledEnd: scheduled_end,
|
||||
meetingUrl: meeting_url, createdBy: req.instructor.instructor_id,
|
||||
});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_created', resourceType: 'classroom_session', resourceId: created.classroom_session_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
res.json(await classroom.listClassroomsForCohort(getPool(), req.params.id));
|
||||
});
|
||||
|
||||
// The dashboard itself — §14's "ROOTNODE LIVE CLASSROOM" view. Built from real available
|
||||
// data, not a fixed mock: participant presence (classroom.js), each participant's Lab status
|
||||
// (Provisioner, reusing the SAME environment-state values everywhere else in the app uses —
|
||||
// no second state system, §16), and a lightweight per-student learning snapshot (§17) —
|
||||
// current stage, most recent scenario worked, its status/validation-failure count, hints
|
||||
// used. Never inspects terminal command history.
|
||||
admin.get('/api/admin/classroom-sessions/:id', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const classroomRow = await classroom.getClassroom(pool, req.params.id);
|
||||
const [participants, announcements, helpRequests] = await Promise.all([
|
||||
classroom.listParticipants(pool, req.params.id),
|
||||
classroom.listAnnouncements(pool, req.params.id),
|
||||
classroom.listHelpRequests(pool, req.params.id),
|
||||
]);
|
||||
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const students = await Promise.all(participants.map(async (p) => {
|
||||
const [envRes, progress, lastAttempt] = await Promise.all([
|
||||
provisioner.listForStudent(p.student_id),
|
||||
loadProgress(pool, p.student_id),
|
||||
pool.query(
|
||||
`SELECT scenario_id, status, validation_fail_count, hint_count FROM scenario_attempts
|
||||
WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 1`,
|
||||
[p.student_id]
|
||||
),
|
||||
]);
|
||||
const env = envRes.body?.find((e) => e.status !== 'destroyed');
|
||||
const completed = Object.values(progress).filter((pr) => pr.status === 'completed').length;
|
||||
const currentStage = path ? path.stages.find((s) => (s.scenario_ids || []).some((sid) => progress[sid]?.status !== 'completed'))?.id || null : null;
|
||||
return {
|
||||
...p,
|
||||
lab_status: env ? env.status : 'none', // 'none'|'requested'|'provisioning'|'running'|'resetting'|'error'|'destroying'|'destroyed' — the real Provisioner enum, unchanged
|
||||
current_stage: currentStage,
|
||||
current_scenario: lastAttempt.rows[0]?.scenario_id || null,
|
||||
attempt_status: lastAttempt.rows[0]?.status || null,
|
||||
validation_failures: lastAttempt.rows[0]?.validation_fail_count || 0,
|
||||
hints_used: lastAttempt.rows[0]?.hint_count || 0,
|
||||
scenarios_completed: completed,
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({ classroom: classroomRow, students, announcements, help_requests: helpRequests });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/start', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.startClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_started', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/end', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.endClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_ended', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/cancel', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.cancelClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_cancelled', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// §21 — the same underlying prepareLabsForCohort() the cohort-level route uses, just entered
|
||||
// from the classroom dashboard and scoped to req.classroomCohortId (set by
|
||||
// requireClassroomWriteAccess). Respects admission control exactly the same way (Phase 4.1
|
||||
// §26) — a full host surfaces as lab_status: 'capacity_waiting' per student, not a crash.
|
||||
admin.post('/api/admin/classroom-sessions/:id/prepare-labs', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const results = await prepareLabsForCohort(getPool(), provisioner, req.classroomCohortId);
|
||||
await classroom.emitEvent(getPool(), req.params.id, 'instructor', req.instructor.instructor_id, 'labs_prepared', { student_count: results.length });
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_labs_prepared', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/activity', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const { scenario_id } = req.body || {};
|
||||
try {
|
||||
const updated = await classroom.setCurrentActivity(getPool(), req.params.id, scenario_id, req.instructor.instructor_id);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/announcements', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const { message } = req.body || {};
|
||||
if (!message || !message.trim()) return res.status(400).json({ error: 'message is required' });
|
||||
const created = await classroom.postAnnouncement(getPool(), req.params.id, req.instructor.instructor_id, message.trim());
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/classroom-sessions/:id/help-requests', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
|
||||
res.json(await classroom.listHelpRequests(getPool(), req.params.id, req.query.status));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/help-requests/:id/acknowledge', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.acknowledgeHelpRequest(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/help-requests/:id/resolve', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
|
||||
const { note } = req.body || {};
|
||||
try {
|
||||
const updated = await classroom.resolveHelpRequest(getPool(), req.params.id, req.instructor.instructor_id, note);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// Phase 6A §7 — instructor/admin curriculum visibility. Read-only: course/module/activity
|
||||
// authoring stays Git/migration-based this phase (see docs/COURSE_CONTENT_MODEL.md — no
|
||||
// WYSIWYG CMS), so there is deliberately no POST/PATCH here, only the views an instructor needs
|
||||
// to actually teach from: program structure with instructor notes, and per-student progress
|
||||
// through it so a teacher can see where someone is stuck.
|
||||
|
||||
const { buildCourseTree } = require('./course-progress');
|
||||
|
||||
module.exports = function registerAdminCourseRoutes(app, deps) {
|
||||
const { instructorSession, rbac, getPool } = deps;
|
||||
const { requireAnyInstructor, requireStudentRelationshipAccess } = rbac;
|
||||
|
||||
// Same local-wrap convention as admin-programs-routes.js/admin-learning-routes.js — RBAC
|
||||
// middleware here is plain sync `(req,res,next)`, so this Promise.resolve() guard lets it
|
||||
// share the same mw.map(wrap) list as the async route handlers below.
|
||||
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)) };
|
||||
|
||||
// Full structure (courses -> modules -> activities), instructor notes and draft content
|
||||
// included, with no completion overlay — studentId is deliberately null here (course-
|
||||
// progress.js's queries tolerate that and just return complete:false everywhere), since this
|
||||
// is "what does the curriculum look like," not "how is any one student doing."
|
||||
admin.get('/api/admin/programs/:programId/courses', requireAnyInstructor, async (req, res) => {
|
||||
const courses = await buildCourseTree(getPool(), { studentId: null, programId: req.params.programId, forInstructor: true });
|
||||
res.json(courses);
|
||||
});
|
||||
|
||||
// One student's real progress through the same tree. Uses requireStudentRelationshipAccess,
|
||||
// not requireStudentCohortAccess — a self-paced root_ student typically has NO cohort at all
|
||||
// (Phase 5B), so the cohort-only check would 403 a plain instructor out of every self-paced
|
||||
// student's progress. Same rule Phase 5C's notification routes already established: a plain
|
||||
// instructor may see a student they share a cohort with OR whose enrollment they personally
|
||||
// created; owner/admin always bypass.
|
||||
admin.get('/api/admin/students/:id/courses', requireAnyInstructor, requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const programId = req.query.program_id;
|
||||
if (!programId) return res.status(400).json({ error: 'program_id is required' });
|
||||
const courses = await buildCourseTree(getPool(), { studentId: req.params.id, programId, forInstructor: true });
|
||||
res.json(courses);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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 notificationsLib = require('./notifications');
|
||||
const studentsAuth = require('./students-auth');
|
||||
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, contact_email, metadata } = req.body || {};
|
||||
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
|
||||
// Phase 5C §10 — same transitional contact-email convention as the integration endpoint;
|
||||
// folded into the same metadata JSONB an admin could already pass, not a new column.
|
||||
const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata;
|
||||
let created;
|
||||
try {
|
||||
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: mergedMetadata, 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 } });
|
||||
} catch (e) {
|
||||
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
// §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation
|
||||
// for a not-yet-credentialed student, enrollment confirmation for an already-active one.
|
||||
const program = await programsLib.getProgram(getPool(), program_id);
|
||||
const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, {
|
||||
studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name,
|
||||
});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -16,6 +16,9 @@
|
||||
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
|
||||
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
|
||||
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
|
||||
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
|
||||
|
||||
module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
@@ -95,33 +98,191 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────────────────
|
||||
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
|
||||
// credential_token_hash (migration 017), which must never reach an API response
|
||||
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
|
||||
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
|
||||
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
|
||||
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = req.instructor.role === 'instructor'
|
||||
? await pool.query(
|
||||
`SELECT DISTINCT s.* FROM students s
|
||||
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
|
||||
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
|
||||
FROM students s
|
||||
JOIN cohort_enrollments e ON e.student_id = s.student_id
|
||||
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
|
||||
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
|
||||
: await pool.query(
|
||||
`SELECT student_id, display_name, status, email, login_identifier,
|
||||
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
|
||||
FROM students ORDER BY display_name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
|
||||
// self-paced student gets email, a younger instructor-managed one gets login_identifier
|
||||
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
|
||||
// an activation token immediately (§5's "instructor creates account -> temporary credential"
|
||||
// flow) and returns it ONCE in this response — the instructor relays it to the student
|
||||
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
|
||||
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
|
||||
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
|
||||
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
|
||||
const { display_name, cohort_id } = req.body || {};
|
||||
const { display_name, cohort_id, email, login_identifier } = req.body || {};
|
||||
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
|
||||
const pool = getPool();
|
||||
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
|
||||
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
|
||||
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
||||
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
||||
throw e;
|
||||
}
|
||||
if (cohort_id) {
|
||||
await pool.query(
|
||||
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[cohort_id, studentId]
|
||||
);
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
|
||||
let activationToken = null;
|
||||
if (email || login_identifier) {
|
||||
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
||||
activationToken = issued.token;
|
||||
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
|
||||
// which would silently invalidate the one this response is about to hand the admin for
|
||||
// manual relay). A student with only login_identifier and no email has no deliverable
|
||||
// address here — enqueue() records that as an already-failed row (never silently
|
||||
// dropped), and the admin's manual-relay token in the response still works either way.
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId, notificationType: 'activation', plaintextToken: activationToken,
|
||||
metadata: { display_name: display_name.trim() },
|
||||
});
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
||||
});
|
||||
|
||||
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
|
||||
// account: assigns email/login_identifier and issues an activation token, same mechanism as
|
||||
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
|
||||
// membership is touched — student_id, the FK every other table hangs off of, never changes.
|
||||
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
|
||||
const { email, login_identifier } = req.body || {};
|
||||
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
|
||||
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
||||
throw e;
|
||||
}
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
||||
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
|
||||
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
|
||||
metadata: { display_name: nameRows[0]?.display_name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, activation_token: token });
|
||||
});
|
||||
|
||||
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
||||
// token and revokes every existing session, same "instructor never needs to know the
|
||||
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
|
||||
// password-reset email whenever the student has a deliverable address — the response still
|
||||
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
|
||||
// token" for the population with no deliverable address at all — see notification-routes.js's
|
||||
// header comment).
|
||||
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
||||
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
|
||||
metadata: { display_name: rows[0].display_name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, reset_token: token });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
|
||||
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
|
||||
res.json(sessionsList);
|
||||
});
|
||||
|
||||
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
|
||||
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
|
||||
// returned, rather than silently importing the valid rows and skipping the rest (the safer
|
||||
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
|
||||
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
|
||||
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
|
||||
// accepts a password/password_hash column — activation tokens are always generated server-side.
|
||||
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
|
||||
const { csv } = req.body || {};
|
||||
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
|
||||
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
|
||||
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
const created = [];
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
|
||||
let nextN = countRows[0].n + 1;
|
||||
for (const row of parsedRows) {
|
||||
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
|
||||
await client.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
||||
[studentId, row.display_name, row.email || null, row.login_identifier || null]
|
||||
);
|
||||
if (row.cohort_id) {
|
||||
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
|
||||
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
|
||||
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
|
||||
}
|
||||
let activationToken = null;
|
||||
if (row.email || row.login_identifier) {
|
||||
// issueCredentialToken only ever calls .query() on what it's given — a transaction
|
||||
// client works identically to a pool here, so the whole import (including every
|
||||
// token) commits or rolls back together with everything else.
|
||||
const issued = await issueCredentialToken(client, studentId, 'activation');
|
||||
activationToken = issued.token;
|
||||
}
|
||||
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
|
||||
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
|
||||
res.status(201).json({ created });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
|
||||
@@ -306,23 +467,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
|
||||
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[req.params.id]
|
||||
);
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
|
||||
const { status, body } = await provisioner.provision(s.student_id);
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
|
||||
res.json({
|
||||
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
|
||||
});
|
||||
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Student-facing classroom API — Phase 5A §22/§24. Every route here requires studentSession
|
||||
// (session-derived student_id, never trusted from the request body/params — same rule as
|
||||
// everywhere else a student acts) AND that the student is actually enrolled in the classroom's
|
||||
// cohort (cohort_enrollments) — a student can never join/view/act on a classroom for a cohort
|
||||
// they don't belong to, regardless of whether they know or can guess its id.
|
||||
|
||||
const classroom = require('./classroom');
|
||||
|
||||
module.exports = function registerClassroomRoutes(app, deps) {
|
||||
const { studentSession, getPool, curriculum } = deps;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
|
||||
// Resolves the classroom AND checks cohort enrollment in one place — every route below uses
|
||||
// this instead of repeating the join. Sets req.classroom on success.
|
||||
async function requireEnrolledClassroom(req, res, next) {
|
||||
const classroomRow = await classroom.getClassroom(getPool(), req.params.id);
|
||||
if (!classroomRow) return res.status(404).json({ error: 'not found' });
|
||||
const { rowCount } = await getPool().query(
|
||||
`SELECT 1 FROM cohort_enrollments WHERE cohort_id = $1 AND student_id = $2 AND enrollment_status = 'enrolled'`,
|
||||
[classroomRow.cohort_id, req.studentId]
|
||||
);
|
||||
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
|
||||
req.classroom = classroomRow;
|
||||
next();
|
||||
}
|
||||
|
||||
// §22 — "LIVE NOW" banner on the student dashboard. Null if nothing is live for any cohort
|
||||
// this student belongs to (deliberately not "their one cohort" — §27 self-paced compatible).
|
||||
app.get('/api/classroom/live', studentSession, wrap(async (req, res) => {
|
||||
const live = await classroom.getLiveClassroomForStudent(getPool(), req.studentId);
|
||||
res.json({ classroom: live });
|
||||
}));
|
||||
|
||||
app.get('/api/classroom/:id', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
const [announcements, own] = await Promise.all([
|
||||
classroom.listAnnouncements(getPool(), req.params.id),
|
||||
getPool().query(`SELECT * FROM help_requests WHERE classroom_session_id = $1 AND student_id = $2 ORDER BY created_at DESC LIMIT 5`, [req.params.id, req.studentId]),
|
||||
]);
|
||||
res.json({ classroom: req.classroom, announcements, my_help_requests: own.rows });
|
||||
}));
|
||||
|
||||
app.get('/api/classroom/:id/announcements', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
res.json(await classroom.listAnnouncements(getPool(), req.params.id));
|
||||
}));
|
||||
|
||||
app.post('/api/classroom/:id/join', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
if (req.classroom.status !== 'live') return res.status(409).json({ error: 'not_live', message: 'This classroom is not currently live.' });
|
||||
const participant = await classroom.joinClassroom(getPool(), req.params.id, req.studentId);
|
||||
res.json(participant);
|
||||
}));
|
||||
|
||||
app.post('/api/classroom/:id/leave', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
await classroom.leaveClassroom(getPool(), req.params.id, req.studentId);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
// §15 — throttled server-side (classroom.js's heartbeat only writes if the last recorded
|
||||
// beat is old enough), so the frontend can just call this every 20-30s without either side
|
||||
// needing to track timing beyond "did the interval fire."
|
||||
app.post('/api/classroom/:id/heartbeat', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
await classroom.heartbeat(getPool(), req.params.id, req.studentId);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
// §18 — no chat, no video, no remote shell takeover: exactly one action (raise a flag with
|
||||
// an optional category/message), visible only to instructors managing this cohort.
|
||||
app.post('/api/classroom/:id/help-request', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
const { category, message } = req.body || {};
|
||||
const created = await classroom.createHelpRequest(getPool(), req.params.id, req.studentId, category, message);
|
||||
res.status(201).json(created);
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// Classroom session lifecycle — Phase 5A §11-§20. A classroom session is a live teaching event,
|
||||
// distinct from a cohort (standing roster), an assessment session (one exam attempt), a Lab
|
||||
// environment (disposable infra), or a login session. This module owns the pure
|
||||
// classroom_sessions/classroom_participants/classroom_events/classroom_announcements/
|
||||
// help_requests CRUD and lifecycle rules; route files (admin-classroom-routes.js,
|
||||
// classroom-routes.js) own RBAC/session/HTTP shape and the dashboard aggregation that pulls in
|
||||
// Provisioner Lab state and Phase 3B academic records (this module doesn't know about either).
|
||||
|
||||
const PRESENCE_ONLINE_MS = 45 * 1000; // ~1.5x the 20-30s heartbeat interval the frontend uses
|
||||
const PRESENCE_RECENTLY_ACTIVE_MS = 5 * 60 * 1000;
|
||||
const HEARTBEAT_WRITE_THROTTLE_MS = 15 * 1000; // §15 "avoid excessive database writes" — coalesce heartbeats closer together than this into a no-op
|
||||
|
||||
async function emitEvent(pool, classroomSessionId, actorType, actorId, eventType, metadata) {
|
||||
await pool.query(
|
||||
`INSERT INTO classroom_events (classroom_session_id, actor_type, actor_id, event_type, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[classroomSessionId, actorType, actorId ? String(actorId) : null, eventType, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
}
|
||||
|
||||
async function createClassroom(pool, { cohortId, title, description, scheduledStart, scheduledEnd, meetingUrl, createdBy }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_sessions (cohort_id, title, description, scheduled_start, scheduled_end, meeting_url, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[cohortId, title, description || null, scheduledStart || null, scheduledEnd || null, meetingUrl || null, createdBy]
|
||||
);
|
||||
const classroom = rows[0];
|
||||
await emitEvent(pool, classroom.classroom_session_id, 'instructor', createdBy, 'created', { title });
|
||||
return classroom;
|
||||
}
|
||||
|
||||
async function getClassroom(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM classroom_sessions WHERE classroom_session_id = $1`, [classroomSessionId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listClassroomsForCohort(pool, cohortId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM classroom_sessions WHERE cohort_id = $1 ORDER BY COALESCE(scheduled_start, created_at) DESC`,
|
||||
[cohortId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// §13 lifecycle: scheduled -> live -> ended, or -> cancelled from scheduled/live. The DB's
|
||||
// partial unique index (idx_classroom_sessions_one_live_per_cohort) is the actual enforcement
|
||||
// for "at most one live classroom per cohort" — this pre-check just gives a clean error
|
||||
// instead of a raw constraint-violation message.
|
||||
async function startClassroom(pool, classroomSessionId, instructorId) {
|
||||
const classroom = await getClassroom(pool, classroomSessionId);
|
||||
if (!classroom) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
|
||||
if (classroom.status !== 'scheduled') throw Object.assign(new Error(`cannot start a classroom in status '${classroom.status}'`), { code: 'INVALID_STATE' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'live', started_at = now(), updated_at = now() WHERE classroom_session_id = $1 RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'started', {});
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') throw Object.assign(new Error('this cohort already has a live classroom'), { code: 'ALREADY_LIVE' });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Ending a classroom is purely a status change — never touches Labs, progress, attempts, or
|
||||
// any academic data (§13/§26). Labs are only ever destroyed by an explicit, separate action
|
||||
// (the existing cohort Destroy Labs / Prepare Labs machinery), never implicitly by this.
|
||||
async function endClassroom(pool, classroomSessionId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'ended', ended_at = now(), updated_at = now()
|
||||
WHERE classroom_session_id = $1 AND status = 'live' RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found or not live'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'ended', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function cancelClassroom(pool, classroomSessionId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'cancelled', updated_at = now()
|
||||
WHERE classroom_session_id = $1 AND status IN ('scheduled', 'live') RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found or already ended/cancelled'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'cancelled', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// §20 — guidance only. Never redirects a browser, never marks anything complete; students just
|
||||
// see this value and choose to open that scenario themselves (see classroom-routes.js).
|
||||
async function setCurrentActivity(pool, classroomSessionId, scenarioId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET current_scenario_id = $1, updated_at = now() WHERE classroom_session_id = $2 RETURNING *`,
|
||||
[scenarioId || null, classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'activity_changed', { scenario_id: scenarioId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function postAnnouncement(pool, classroomSessionId, instructorId, message) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_announcements (classroom_session_id, instructor_id, message) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[classroomSessionId, instructorId, message]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'announcement_posted', { announcement_id: rows[0].announcement_id });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function listAnnouncements(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT announcement_id, message, created_at FROM classroom_announcements
|
||||
WHERE classroom_session_id = $1 ORDER BY created_at DESC LIMIT 50`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// §11/§22 — join is idempotent: re-joining an already-joined classroom just clears left_at and
|
||||
// bumps last_seen_at, it doesn't create a second row or duplicate joined_at (PRIMARY KEY is
|
||||
// (classroom_session_id, student_id) — there can only ever be one participation row per
|
||||
// student per classroom, matching how a student can leave and rejoin the same live session).
|
||||
async function joinClassroom(pool, classroomSessionId, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_participants (classroom_session_id, student_id, last_seen_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (classroom_session_id, student_id)
|
||||
DO UPDATE SET last_seen_at = now(), left_at = NULL
|
||||
RETURNING *`,
|
||||
[classroomSessionId, studentId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'joined', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function leaveClassroom(pool, classroomSessionId, studentId) {
|
||||
await pool.query(
|
||||
`UPDATE classroom_participants SET left_at = now() WHERE classroom_session_id = $1 AND student_id = $2`,
|
||||
[classroomSessionId, studentId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'left', {});
|
||||
}
|
||||
|
||||
// §15 presence — throttled: a heartbeat within HEARTBEAT_WRITE_THROTTLE_MS of the last recorded
|
||||
// one is a silent no-op (still returns success to the client, just skips the write) rather than
|
||||
// hammering Postgres every 20s per online student. Deliberately NOT a full audit-logged event
|
||||
// (§25 "do not turn presence heartbeats into audit spam") — no classroom_events row per beat.
|
||||
async function heartbeat(pool, classroomSessionId, studentId) {
|
||||
await pool.query(
|
||||
`UPDATE classroom_participants SET last_seen_at = now()
|
||||
WHERE classroom_session_id = $1 AND student_id = $2
|
||||
AND (last_seen_at IS NULL OR last_seen_at < now() - ($3 || ' milliseconds')::interval)`,
|
||||
[classroomSessionId, studentId, HEARTBEAT_WRITE_THROTTLE_MS]
|
||||
);
|
||||
}
|
||||
|
||||
function derivePresence(lastSeenAt, leftAt) {
|
||||
if (leftAt) return 'offline';
|
||||
if (!lastSeenAt) return 'offline';
|
||||
const age = Date.now() - new Date(lastSeenAt).getTime();
|
||||
if (age <= PRESENCE_ONLINE_MS) return 'online';
|
||||
if (age <= PRESENCE_RECENTLY_ACTIVE_MS) return 'recently_active';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
async function listParticipants(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cp.student_id, s.display_name, cp.joined_at, cp.last_seen_at, cp.left_at
|
||||
FROM classroom_participants cp JOIN students s ON s.student_id = cp.student_id
|
||||
WHERE cp.classroom_session_id = $1 ORDER BY s.display_name`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
return rows.map((r) => ({ ...r, presence: derivePresence(r.last_seen_at, r.left_at) }));
|
||||
}
|
||||
|
||||
// §27 — a student may be enrolled in more than one cohort eventually (self-paced compatible);
|
||||
// this returns the FIRST live classroom across any cohort they're enrolled in, not "their one
|
||||
// cohort's classroom" — matches "My Courses, not My Cohort" (§27) as the eventual shape.
|
||||
async function getLiveClassroomForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cs.* FROM classroom_sessions cs
|
||||
JOIN cohort_enrollments e ON e.cohort_id = cs.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND cs.status = 'live'
|
||||
ORDER BY cs.started_at DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// §18 help requests
|
||||
async function createHelpRequest(pool, classroomSessionId, studentId, category, message) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO help_requests (classroom_session_id, student_id, category, message) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[classroomSessionId, studentId, category || null, message || null]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'help_requested', { help_request_id: rows[0].help_request_id, category });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function listHelpRequests(pool, classroomSessionId, statusFilter) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hr.*, s.display_name FROM help_requests hr JOIN students s ON s.student_id = hr.student_id
|
||||
WHERE hr.classroom_session_id = $1 AND ($2::text IS NULL OR hr.status = $2)
|
||||
ORDER BY hr.created_at ASC`,
|
||||
[classroomSessionId, statusFilter || null]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function acknowledgeHelpRequest(pool, helpRequestId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE help_requests SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $1
|
||||
WHERE help_request_id = $2 AND status = 'open' RETURNING *`,
|
||||
[instructorId, helpRequestId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('help request not found or not open'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_acknowledged', { help_request_id: helpRequestId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function resolveHelpRequest(pool, helpRequestId, instructorId, note) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE help_requests SET status = 'resolved', resolved_at = now(), instructor_note = COALESCE($1, instructor_note)
|
||||
WHERE help_request_id = $2 AND status IN ('open', 'acknowledged') RETURNING *`,
|
||||
[note || null, helpRequestId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('help request not found or already resolved'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_resolved', { help_request_id: helpRequestId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createClassroom, getClassroom, listClassroomsForCohort,
|
||||
startClassroom, endClassroom, cancelClassroom, setCurrentActivity,
|
||||
postAnnouncement, listAnnouncements,
|
||||
joinClassroom, leaveClassroom, heartbeat, derivePresence, listParticipants,
|
||||
getLiveClassroomForStudent,
|
||||
createHelpRequest, listHelpRequests, acknowledgeHelpRequest, resolveHelpRequest,
|
||||
emitEvent,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Phase 6A §3 — loads a module_activity's Markdown content file. Same read-only cache pattern
|
||||
// as curriculum.js (scenarios/bundles): loaded from disk, cached, exposed via a reload for
|
||||
// tests/dev. Long-form lesson prose lives in git-tracked Markdown (content/root/...), not
|
||||
// Postgres — see docs/COURSE_CONTENT_MODEL.md for why.
|
||||
//
|
||||
// A single convention splits shared (self-paced-visible) content from instructor-only content
|
||||
// in the SAME file, instead of two files that can drift apart: everything before the first
|
||||
// "## Instructor Notes" heading is shared; that heading and everything after it is
|
||||
// instructor-only. Self-paced/student responses never include the instructor_notes field
|
||||
// regardless of any request parameter — this mirrors the RBAC convention used everywhere else
|
||||
// in this codebase (identity decides visibility, never a client-supplied flag).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Same convention as curriculum.js's SCENARIOS_DIR: one level up from src/, where the
|
||||
// Dockerfile COPYs both scenarios/ and content/ (see platform/api/Dockerfile) — 'content/...'
|
||||
// paths stored in module_activities.content_path resolve against this, not the actual repo
|
||||
// root, since that's what the running image actually has on disk.
|
||||
const ROOT_DIR = process.env.CONTENT_ROOT_DIR || path.join(__dirname, '..');
|
||||
const INSTRUCTOR_HEADING = /\n## Instructor Notes\b/;
|
||||
|
||||
const cache = new Map(); // content_path -> { shared, instructorNotes } | null (missing file)
|
||||
|
||||
function splitContent(raw) {
|
||||
const match = raw.match(INSTRUCTOR_HEADING);
|
||||
if (!match) return { shared: raw.trim(), instructorNotes: null };
|
||||
return {
|
||||
shared: raw.slice(0, match.index).trim(),
|
||||
instructorNotes: raw.slice(match.index).replace(/^\n/, '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// contentPath is always a repo-relative path (module_activities.content_path, e.g.
|
||||
// 'content/root/root-10-.../m01-health-probes/01-lesson-....md') — resolved against the repo
|
||||
// root, never against anything a request supplies, so there's no path-traversal surface here.
|
||||
function loadContent(contentPath) {
|
||||
if (!contentPath) return null;
|
||||
if (cache.has(contentPath)) return cache.get(contentPath);
|
||||
const abs = path.join(ROOT_DIR, contentPath);
|
||||
let result = null;
|
||||
try {
|
||||
result = splitContent(fs.readFileSync(abs, 'utf8'));
|
||||
} catch {
|
||||
result = null; // missing file — a PARTIAL/PLANNED activity may legitimately have no content yet
|
||||
}
|
||||
cache.set(contentPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function reloadCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { loadContent, reloadCache };
|
||||
@@ -0,0 +1,177 @@
|
||||
// Phase 6A §3/§8 — course/module/activity completion and prerequisite locking, derived
|
||||
// entirely from EXISTING sources of truth: progress (scenario completion, same table
|
||||
// curriculum-rules.js already reads), achievements (assessment/capstone passes), and
|
||||
// activity_completions (the ONLY new state this phase adds, and only for lesson-only
|
||||
// activities that have nothing else to derive completion from). See
|
||||
// docs/COURSE_CONTENT_MODEL.md §"completion rules" for the full rationale — this file must
|
||||
// never become a second, independently-drifting copy of scenario/assessment completion.
|
||||
|
||||
const { loadProgress } = require('./progress');
|
||||
const { loadContent } = require('./content');
|
||||
|
||||
function isActivityComplete(activity, ctx) {
|
||||
switch (activity.completion_behavior) {
|
||||
case 'auto_scenario':
|
||||
return ctx.progress[activity.scenario_id]?.status === 'completed';
|
||||
case 'auto_assessment':
|
||||
return ctx.passedAssessmentIds.has(activity.assessment_definition_id);
|
||||
case 'manual_marker':
|
||||
default:
|
||||
return ctx.completedActivityIds.has(activity.activity_id);
|
||||
}
|
||||
}
|
||||
|
||||
// One query pass per student, reused across every activity in every course — same shape as
|
||||
// curriculum-rules.js's caller-supplied progress map, just extended with the two new sources.
|
||||
async function loadCompletionContext(pool, studentId) {
|
||||
const progress = await loadProgress(pool, studentId);
|
||||
const { rows: achievementRows } = await pool.query(
|
||||
`SELECT metadata->>'assessment_definition_id' AS assessment_definition_id FROM achievements
|
||||
WHERE student_id = $1 AND achievement_type IN ('assessment_completed', 'capstone_completed')
|
||||
AND metadata->>'assessment_definition_id' IS NOT NULL`,
|
||||
[studentId]
|
||||
);
|
||||
const passedAssessmentIds = new Set(achievementRows.map((r) => r.assessment_definition_id));
|
||||
const { rows: completionRows } = await pool.query(
|
||||
`SELECT activity_id FROM activity_completions WHERE student_id = $1`, [studentId]
|
||||
);
|
||||
const completedActivityIds = new Set(completionRows.map((r) => r.activity_id));
|
||||
return { progress, passedAssessmentIds, completedActivityIds };
|
||||
}
|
||||
|
||||
// forInstructor: includes draft (unpublished) rows and each activity's instructor_notes.
|
||||
// A student-facing caller must NEVER pass true — this is the one flag in this module that
|
||||
// controls what an instructor sees vs. a student, and it comes from server-resolved identity
|
||||
// (which route called this), never from a request parameter.
|
||||
function shapeActivity(activity, ctx, forInstructor) {
|
||||
const content = loadContent(activity.content_path);
|
||||
return {
|
||||
activity_id: activity.activity_id,
|
||||
sequence: activity.sequence,
|
||||
activity_type: activity.activity_type,
|
||||
title: activity.title,
|
||||
status: activity.status,
|
||||
estimated_duration_minutes: activity.estimated_duration_minutes,
|
||||
scenario_id: activity.scenario_id,
|
||||
assessment_definition_id: activity.assessment_definition_id,
|
||||
completion_behavior: activity.completion_behavior,
|
||||
complete: isActivityComplete(activity, ctx),
|
||||
body: content?.shared || null,
|
||||
...(forInstructor ? { instructor_notes: content?.instructorNotes || null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function totals(items) {
|
||||
const total = items.length;
|
||||
const completed = items.filter((i) => i.complete).length;
|
||||
return { total, completed, pct: total ? Math.round((completed / total) * 100) : 0, complete: total > 0 && completed === total };
|
||||
}
|
||||
|
||||
// A prerequisite with zero content (a PLANNED module/course with nothing built yet) can never
|
||||
// block anything — otherwise an honestly-empty PLANNED entry would permanently lock every real
|
||||
// module/course that lists it as a prerequisite. See 022_course_content.sql's header comment
|
||||
// and docs/COURSE_CONTENT_MODEL.md §"prerequisite/locking rule".
|
||||
function prerequisitesSatisfied(prereqIds, byId) {
|
||||
return (prereqIds || []).every((id) => {
|
||||
const target = byId.get(id);
|
||||
if (!target) return true; // an unknown/removed prerequisite id never blocks
|
||||
return target._totals.total === 0 || target._totals.complete;
|
||||
});
|
||||
}
|
||||
|
||||
// Builds the full course -> module -> activity tree for one student against one program,
|
||||
// with real completion and prerequisite-based locking. Shared by the student self-paced route
|
||||
// and the instructor curriculum view — forInstructor controls draft visibility and
|
||||
// instructor_notes only, never the completion/locking logic itself (both surfaces must agree on
|
||||
// what's actually true).
|
||||
async function buildCourseTree(pool, { studentId, programId, forInstructor = false }) {
|
||||
const ctx = await loadCompletionContext(pool, studentId);
|
||||
const visClause = forInstructor ? '' : `AND visibility = 'published'`;
|
||||
|
||||
const { rows: courseRows } = await pool.query(
|
||||
`SELECT * FROM courses WHERE program_id = $1 ${visClause} ORDER BY sequence`, [programId]
|
||||
);
|
||||
const { rows: moduleRows } = await pool.query(
|
||||
`SELECT * FROM course_modules WHERE course_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
|
||||
[courseRows.map((c) => c.course_id)]
|
||||
);
|
||||
const { rows: activityRows } = await pool.query(
|
||||
`SELECT * FROM module_activities WHERE module_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
|
||||
[moduleRows.map((m) => m.module_id)]
|
||||
);
|
||||
|
||||
const activitiesByModule = new Map();
|
||||
for (const a of activityRows) {
|
||||
const shaped = shapeActivity(a, ctx, forInstructor);
|
||||
if (!activitiesByModule.has(a.module_id)) activitiesByModule.set(a.module_id, []);
|
||||
activitiesByModule.get(a.module_id).push(shaped);
|
||||
}
|
||||
|
||||
const modulesByCourse = new Map();
|
||||
const moduleById = new Map();
|
||||
for (const m of moduleRows) {
|
||||
const activities = activitiesByModule.get(m.module_id) || [];
|
||||
const shaped = {
|
||||
module_id: m.module_id, sequence: m.sequence, title: m.title, description: m.description,
|
||||
objectives: m.objectives, estimated_duration_minutes: m.estimated_duration_minutes,
|
||||
status: m.status, prerequisites: m.prerequisites,
|
||||
activities, _totals: totals(activities),
|
||||
};
|
||||
moduleById.set(m.module_id, shaped);
|
||||
if (!modulesByCourse.has(m.course_id)) modulesByCourse.set(m.course_id, []);
|
||||
modulesByCourse.get(m.course_id).push(shaped);
|
||||
}
|
||||
// Locking depends on OTHER modules' _totals, so this has to be two separate passes over the
|
||||
// map, not one — computing and stripping _totals in the same iteration would make the result
|
||||
// depend on Map iteration order (a module whose _totals got stripped before a dependent
|
||||
// module's turn would look like it has zero content). Found by testing.
|
||||
for (const m of moduleById.values()) {
|
||||
m.locked = !prerequisitesSatisfied(m.prerequisites, moduleById);
|
||||
}
|
||||
for (const m of moduleById.values()) {
|
||||
m.complete = m._totals.complete;
|
||||
m.percent_complete = m._totals.pct;
|
||||
delete m._totals;
|
||||
}
|
||||
|
||||
const courseById = new Map();
|
||||
const courses = courseRows.map((c) => {
|
||||
const modules = modulesByCourse.get(c.course_id) || [];
|
||||
const allActivities = modules.flatMap((m) => m.activities);
|
||||
const shaped = {
|
||||
course_id: c.course_id, sequence: c.sequence, title: c.title, description: c.description,
|
||||
objectives: c.objectives, estimated_duration_hours: c.estimated_duration_hours,
|
||||
status: c.status, prerequisites: c.prerequisites,
|
||||
modules, _totals: totals(allActivities),
|
||||
};
|
||||
courseById.set(c.course_id, shaped);
|
||||
return shaped;
|
||||
});
|
||||
for (const c of courses) {
|
||||
c.locked = !prerequisitesSatisfied(c.prerequisites, courseById);
|
||||
}
|
||||
for (const c of courses) {
|
||||
c.complete = c._totals.complete;
|
||||
c.percent_complete = c._totals.pct;
|
||||
delete c._totals;
|
||||
}
|
||||
|
||||
return courses;
|
||||
}
|
||||
|
||||
// Deterministic "resume learning" target: the first non-locked, incomplete activity in
|
||||
// sequence order across the whole tree. Mirrors curriculum-rules.js's nextIncompleteScenario,
|
||||
// just one level higher (activity, not scenario).
|
||||
function findResumeTarget(courses) {
|
||||
for (const course of courses) {
|
||||
if (course.locked || course.complete) continue;
|
||||
for (const mod of course.modules) {
|
||||
if (mod.locked || mod.complete) continue;
|
||||
const next = mod.activities.find((a) => !a.complete);
|
||||
if (next) return { course_id: course.course_id, module_id: mod.module_id, activity_id: next.activity_id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { buildCourseTree, findResumeTarget, loadCompletionContext, isActivityComplete };
|
||||
@@ -0,0 +1,60 @@
|
||||
// Phase 6A §6 — student-facing course/module/activity API. Mounted alongside
|
||||
// selfpaced-routes.js (My Programs) — a course tree is always viewed THROUGH an enrollment the
|
||||
// student actually holds, never a public catalog, same boundary selfpaced-routes.js already
|
||||
// established for programs themselves.
|
||||
|
||||
const enrollment = require('./enrollment');
|
||||
const { buildCourseTree, findResumeTarget } = require('./course-progress');
|
||||
|
||||
module.exports = function registerCourseRoutes(app, deps) {
|
||||
const { studentSession, getPool, wrap } = deps;
|
||||
|
||||
// The full course/module/activity tree for one enrollment, with real completion + locking.
|
||||
app.get('/api/student/programs/:enrollmentId/courses', 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 courses = await buildCourseTree(getPool(), { studentId: req.studentId, programId: row.program_id, forInstructor: false });
|
||||
res.json({ courses, resume: findResumeTarget(courses) });
|
||||
}));
|
||||
|
||||
// Phase 5B §9's rule applies here too: an activity is only ever reachable through an
|
||||
// enrollment the student actually holds — resolved server-side from the activity's own
|
||||
// course, never trusted from anything the client asserts about which program it belongs to.
|
||||
async function requireEnrolledActivity(req, res) {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ma.*, c.program_id FROM module_activities ma
|
||||
JOIN course_modules cm ON cm.module_id = ma.module_id
|
||||
JOIN courses c ON c.course_id = cm.course_id
|
||||
WHERE ma.activity_id = $1 AND ma.visibility = 'published'`,
|
||||
[req.params.activityId]
|
||||
);
|
||||
const activity = rows[0];
|
||||
if (!activity) { res.status(404).json({ error: 'not_found' }); return null; }
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status = 'active'`,
|
||||
[req.studentId, activity.program_id]
|
||||
);
|
||||
if (!rowCount) { res.status(403).json({ error: 'not_entitled' }); return null; }
|
||||
return activity;
|
||||
}
|
||||
|
||||
// §9 — the explicit completion marker, valid ONLY for lesson-only ('manual_marker') activity
|
||||
// types. A 'lab' or 'assessment'/'capstone' activity's completion is derived automatically
|
||||
// (course-progress.js) and can never be set this way — marking one "complete" here would be a
|
||||
// second, forgeable source of truth for something the platform already knows how to verify
|
||||
// for real.
|
||||
app.post('/api/student/activities/:activityId/complete', studentSession, wrap(async (req, res) => {
|
||||
const activity = await requireEnrolledActivity(req, res);
|
||||
if (!activity) return;
|
||||
if (activity.completion_behavior !== 'manual_marker') {
|
||||
return res.status(409).json({ error: 'not_manually_completable', message: 'This activity\'s completion is derived automatically.' });
|
||||
}
|
||||
await getPool().query(
|
||||
`INSERT INTO activity_completions (student_id, activity_id) VALUES ($1, $2)
|
||||
ON CONFLICT (student_id, activity_id) DO NOTHING`,
|
||||
[req.studentId, activity.activity_id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
// Phase 5C §20 — the deterministic development/test email provider. NEVER makes a network
|
||||
// call, NEVER reaches a real inbox, no matter what recipient it's given — this is the fail-safe
|
||||
// default (see provider.js) precisely so a dev machine or a CI run can never accidentally email
|
||||
// a real student. Returns the fully rendered message back to the caller so
|
||||
// notifications.js can stash a preview (dev/test only — see its isProduction gate, mirroring
|
||||
// student-auth-routes.js's existing dev_reset_token pattern) for tests/admins to inspect the
|
||||
// activation/reset URL without a real mailbox.
|
||||
|
||||
async function send({ to, subject, text, html }) {
|
||||
if (!to) {
|
||||
const e = new Error('no recipient address');
|
||||
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
|
||||
throw e;
|
||||
}
|
||||
// Deterministic failure-injection for the permanent test suite (e2e-phase5c.js) — magic
|
||||
// recipient markers, the same pattern test payment sandboxes use for "this card always
|
||||
// declines." Only recognized by THIS provider; a real GWS send never looks at the address
|
||||
// this way, so there's no risk of this leaking into production behavior.
|
||||
if (to.includes('+forcetempfail')) {
|
||||
const e = new Error('simulated temporary provider failure'); e.errorClass = 'temporary'; e.errorCode = 'simulated_temporary_failure';
|
||||
throw e;
|
||||
}
|
||||
if (to.includes('+forcepermfail')) {
|
||||
const e = new Error('simulated permanent provider failure'); e.errorClass = 'permanent'; e.errorCode = 'simulated_permanent_failure';
|
||||
throw e;
|
||||
}
|
||||
console.log(`[dev-email] to=${to} subject="${subject}"`);
|
||||
return {
|
||||
ok: true,
|
||||
providerMessageId: `dev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
preview: { to, subject, text, html },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { send };
|
||||
@@ -0,0 +1,132 @@
|
||||
// Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API
|
||||
// (users.messages.send), authenticated as a Google Cloud service account using Workspace
|
||||
// domain-wide delegation to impersonate a Workspace user.
|
||||
//
|
||||
// GOOGLE_DELEGATED_USER vs. EMAIL_FROM_ADDRESS — two different things, deliberately kept
|
||||
// separate: domain-wide delegation authorizes the service account to act as one real Workspace
|
||||
// USER ACCOUNT (the "sub" claim below) — if the sending address is an ALIAS of a real account
|
||||
// rather than its own paid seat (a deliberate cost-saving choice, not a mistake), the delegated
|
||||
// user must be the ALIAS'S OWNER (the real account it belongs to), not the alias string itself.
|
||||
// EMAIL_FROM_ADDRESS is only ever the visible From: header — Gmail lets any account send as its
|
||||
// own verified aliases, so this can safely be the alias while GOOGLE_DELEGATED_USER is the real
|
||||
// account underneath it. If the sending address IS a primary account already,
|
||||
// GOOGLE_DELEGATED_USER can be left unset and this falls back to EMAIL_FROM_ADDRESS.
|
||||
//
|
||||
// WHY this mechanism, not SMTP or a human login: Google's own guidance for a backend
|
||||
// application sending mail as a specific Workspace address is a service account + domain-wide
|
||||
// delegation + the Gmail API — no human password is ever involved, nothing is stored that
|
||||
// could log in to the Workspace Admin Console or read the mailbox's other mail (the delegated
|
||||
// scope here is exactly `gmail.send`, nothing broader), and it never touches or weakens the
|
||||
// impersonated mailbox's own login credentials. Plain SMTP with a username/password (or an app
|
||||
// password) was explicitly ruled out — see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md §2 for the
|
||||
// full comparison and Google Admin Console setup steps this requires (REQUIRES GOOGLE
|
||||
// WORKSPACE ADMIN ACTION — cannot be completed from this repository).
|
||||
//
|
||||
// No googleapis/google-auth-library dependency — this is a small, auditable amount of code
|
||||
// (RS256 JWT assertion -> OAuth2 token exchange -> one REST call), consistent with this
|
||||
// codebase's existing "hand-roll before adding a dependency" convention (CSRF tokens,
|
||||
// Prometheus exposition, and CSV parsing all do the same — see their own header comments).
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function configError(message) {
|
||||
return Object.assign(new Error(message), { errorClass: 'permanent', errorCode: 'gws_not_configured' });
|
||||
}
|
||||
|
||||
// GOOGLE_SERVICE_ACCOUNT_JSON holds the standard JSON key Google issues for a service account —
|
||||
// an environment/secret-manager value, loaded fresh on every send (no key material cached to
|
||||
// disk by this code). Never a file committed to git, never a human password.
|
||||
function loadServiceAccount() {
|
||||
const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
||||
if (!raw) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not set');
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON'); }
|
||||
if (!parsed.client_email || !parsed.private_key) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is missing client_email/private_key');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// A short-lived (1 hour) OAuth2 access token via the JWT-bearer grant — minted fresh per send
|
||||
// rather than cached, since this codebase's send volume doesn't justify the added state a
|
||||
// cache would need to get right (token refresh races, expiry skew). See RFC 7523 / Google's
|
||||
// "Using OAuth 2.0 for Server to Server Applications" documentation for the exact flow this
|
||||
// implements.
|
||||
async function getAccessToken() {
|
||||
const sa = loadServiceAccount();
|
||||
const impersonate = process.env.GOOGLE_DELEGATED_USER || process.env.EMAIL_FROM_ADDRESS;
|
||||
if (!impersonate) throw configError('Set GOOGLE_DELEGATED_USER (or EMAIL_FROM_ADDRESS if the sending address is itself a primary account) — required as the delegated-user "sub" claim');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
iss: sa.client_email, sub: impersonate, scope: 'https://www.googleapis.com/auth/gmail.send',
|
||||
aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600,
|
||||
})).toString('base64url');
|
||||
const signature = crypto.createSign('RSA-SHA256').update(`${header}.${payload}`).sign(sa.private_key).toString('base64url');
|
||||
const assertion = `${header}.${payload}.${signature}`;
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`);
|
||||
// 'unauthorized_client' is retried, not treated as permanent — real-world observation
|
||||
// during Phase 5C setup: freshly-authorized domain-wide delegation can take a few minutes
|
||||
// to propagate across Google's auth infrastructure, producing this EXACT error
|
||||
// intermittently (the identical request succeeded, then failed, then failed again, over a
|
||||
// span of about a minute) even though the delegation is correctly configured. A config
|
||||
// that's genuinely wrong still ends up 'failed' once MAX_ATTEMPTS is exhausted — this only
|
||||
// buys the propagation-delay case a real chance to self-resolve instead of giving up on
|
||||
// the very first attempt.
|
||||
e.errorClass = (res.status >= 500 || body.error === 'unauthorized_client') ? 'temporary' : 'permanent';
|
||||
e.errorCode = 'gws_auth_failed';
|
||||
throw e;
|
||||
}
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
// A minimal, correct-enough RFC 2822 multipart/alternative message (text + HTML) — no MIME
|
||||
// library dependency for the same "small and auditable" reason as the rest of this file.
|
||||
function buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html }) {
|
||||
const boundary = `rootnode-${crypto.randomBytes(12).toString('hex')}`;
|
||||
const headers = [
|
||||
`From: ${fromName} <${fromAddress}>`,
|
||||
`To: ${to}`,
|
||||
replyTo ? `Reply-To: ${replyTo}` : null,
|
||||
`Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
].filter(Boolean).join('\r\n');
|
||||
const body = [
|
||||
`--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', text, '',
|
||||
`--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', html, '',
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
return `${headers}\r\n\r\n${body}`;
|
||||
}
|
||||
|
||||
async function send({ to, fromName, fromAddress, replyTo, subject, text, html }) {
|
||||
if (!to) {
|
||||
const e = new Error('no recipient address');
|
||||
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
|
||||
throw e;
|
||||
}
|
||||
const accessToken = await getAccessToken();
|
||||
const raw = Buffer.from(buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html })).toString('base64url');
|
||||
|
||||
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ raw }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const e = new Error(`Gmail API send failed: ${body.error?.message || res.status}`);
|
||||
e.errorClass = res.status >= 500 || res.status === 429 ? 'temporary' : 'permanent';
|
||||
e.errorCode = res.status === 429 ? 'gws_rate_limited' : `gws_http_${res.status}`;
|
||||
throw e;
|
||||
}
|
||||
return { ok: true, providerMessageId: body.id };
|
||||
}
|
||||
|
||||
module.exports = { send };
|
||||
@@ -0,0 +1,20 @@
|
||||
// Phase 5C §1 — the provider-neutral seam. Business logic (notifications.js, the worker) never
|
||||
// imports a concrete provider directly; it asks getEmailProvider() for whichever one
|
||||
// EMAIL_PROVIDER selects. Swapping Google Workspace for a different provider later never
|
||||
// touches enrollment/auth/notification code — only this file's require() target changes.
|
||||
//
|
||||
// Fail-safe default: EMAIL_PROVIDER unset -> 'dev', REGARDLESS of any other environment
|
||||
// setting. Misconfiguring this in a real deployment means email silently doesn't send for
|
||||
// real, never that a dev/test run accidentally reaches a real student's inbox (§20's explicit
|
||||
// safety requirement) — the failure direction is deliberately the safe one.
|
||||
const EMAIL_PROVIDER = (process.env.EMAIL_PROVIDER || 'dev').toLowerCase();
|
||||
|
||||
function getEmailProvider() {
|
||||
if (EMAIL_PROVIDER === 'gws') return require('./gws-provider');
|
||||
if (EMAIL_PROVIDER !== 'dev') {
|
||||
console.error(`[email] Unknown EMAIL_PROVIDER "${EMAIL_PROVIDER}" — falling back to the dev provider (no real mail will send).`);
|
||||
}
|
||||
return require('./dev-provider');
|
||||
}
|
||||
|
||||
module.exports = { getEmailProvider, EMAIL_PROVIDER };
|
||||
@@ -0,0 +1,134 @@
|
||||
// Phase 5C §9 — RootNode-branded transactional email templates. Every template returns
|
||||
// { subject, text, html }: plain-text is never a second-class fallback, both are written
|
||||
// deliberately. No external images, no tracking pixels, no JavaScript, no marketing content —
|
||||
// this module is transactional/operational only (§5's explicit boundary).
|
||||
//
|
||||
// escapeHtml matters here more than in most of this codebase's server-rendered HTML: displayName
|
||||
// can come straight from a website integration event's `student.display_name` (§21 "no
|
||||
// arbitrary email template injection" — this is genuinely external, caller-controlled text, not
|
||||
// admin-typed), and programName from an admin-created program name. Every value interpolated
|
||||
// into an HTML template goes through this first; the plain-text bodies deliberately do NOT
|
||||
// escape (there's no markup to break there — escaping would just show a student literal "&").
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
const BRAND = 'RootNode Academy';
|
||||
const GREEN = '#2ee6a6';
|
||||
const BG = '#0b0f0e';
|
||||
const TEXT = '#e8f5ef';
|
||||
const MUTED = '#8fa89c';
|
||||
|
||||
function wrap(bodyHtml) {
|
||||
return `<!doctype html><html><body style="margin:0;padding:0;background:${BG};font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${BG};padding:2rem 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#121815;border:1px solid #1e2b26;border-radius:12px;padding:2rem;">
|
||||
<tr><td style="font-family:monospace;color:${GREEN};font-size:1.1rem;font-weight:600;padding-bottom:1.25rem;">⎈ ${BRAND}</td></tr>
|
||||
<tr><td style="color:${TEXT};font-size:0.95rem;line-height:1.6;">${bodyHtml}</td></tr>
|
||||
<tr><td style="color:${MUTED};font-size:0.75rem;padding-top:2rem;border-top:1px solid #1e2b26;margin-top:1.5rem;">
|
||||
This is an automated message from ${BRAND}. If you weren't expecting it, you can safely ignore it — no action is required.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// href is always a URL this module itself built from a token/origin, never caller-supplied text
|
||||
// — still escaped for attribute-safety hygiene, not because it's expected to contain markup.
|
||||
function button(url, label) {
|
||||
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${GREEN};color:#05130a;font-weight:600;padding:0.65rem 1.25rem;border-radius:6px;text-decoration:none;margin:1rem 0;">${label}</a>`;
|
||||
}
|
||||
|
||||
// A. Account activation / welcome — §9A. Fires for a brand-new credentialed account OR an
|
||||
// existing pilot/unconverted student gaining their first real credential, always via the same
|
||||
// existing Phase 5A activation-token mechanism.
|
||||
function activation({ displayName, programName, activationUrl, expiresInDays }) {
|
||||
const subject = `Welcome to ${BRAND} — Activate Your Account`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const safeProgram = escapeHtml(programName);
|
||||
const programLine = programName ? `You've been enrolled in <strong>${safeProgram}</strong>. ` : '';
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
You've been given access to ${BRAND}. ${programName ? `You've been enrolled in ${programName}. ` : ''}To get started, set your password using the link below:
|
||||
|
||||
${activationUrl}
|
||||
|
||||
This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.
|
||||
|
||||
If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>You've been given access to ${BRAND}. ${programLine}To get started, set your password:</p>
|
||||
<p>${button(activationUrl, 'Activate Your Account')}</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// B. Existing student, new program enrollment — §9B. No token, no link needed beyond "go sign in."
|
||||
function enrollmentConfirmation({ displayName, programName, signInUrl }) {
|
||||
const subject = `You're Enrolled — ${programName}`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const safeProgram = escapeHtml(programName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
You've been enrolled in ${programName} at ${BRAND}. Sign in to get started:
|
||||
|
||||
${signInUrl}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>You've been enrolled in <strong>${safeProgram}</strong> at ${BRAND}.</p>
|
||||
<p>${button(signInUrl, 'Sign In and Start Learning')}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// C. Password reset — §9C. Same anti-enumeration principle as the API itself: this template
|
||||
// only ever renders for an account that actually exists and requested it — the enumeration
|
||||
// protection lives in whether this notification gets queued at all (student-auth-routes.js),
|
||||
// not in the template.
|
||||
function passwordReset({ displayName, resetUrl, expiresInMinutes }) {
|
||||
const subject = `Reset Your ${BRAND} Password`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
We received a request to reset your ${BRAND} password. Use the link below to set a new one:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>We received a request to reset your ${BRAND} password.</p>
|
||||
<p>${button(resetUrl, 'Reset Your Password')}</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// D. Password changed / security notice — §9D. No link at all, purely informational.
|
||||
function passwordChanged({ displayName }) {
|
||||
const subject = `Your ${BRAND} Password Was Changed`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your ${BRAND} password was just changed. If this was you, no action is needed.
|
||||
|
||||
If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>Your ${BRAND} password was just changed. If this was you, no action is needed.</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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, contactEmail, 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';
|
||||
|
||||
// §10 — contactEmail (an integration-event-supplied "who to notify about THIS enrollment,"
|
||||
// e.g. a guardian's address for a minor with no email of their own) is stored on the
|
||||
// enrollment's existing metadata JSONB, never written to students.email — see
|
||||
// enrollmentContactEmail() below and notifications.js's resolveRecipient, the only two places
|
||||
// this value is ever read back out. Not a guardian account, not a linked identity — just a
|
||||
// delivery address for this one enrollment's notifications.
|
||||
const metadata = contactEmail ? { contact_email: String(contactEmail).trim() } : null;
|
||||
|
||||
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, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
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, metadata ? JSON.stringify(metadata) : 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;
|
||||
}
|
||||
|
||||
// Phase 5C §10 — the one place `enrollments.metadata.contact_email` is read back out (its only
|
||||
// writer is createFromIntegrationEvent/createManualEnrollment above). Kept as a named accessor
|
||||
// rather than inline `metadata->>'contact_email'` scattered across notifications.js so this
|
||||
// transitional, non-identity field has exactly one documented meaning and one call site per
|
||||
// direction (write here, read here).
|
||||
function enrollmentContactEmail(enrollment) {
|
||||
return enrollment?.metadata?.contact_email || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
|
||||
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
|
||||
maybeCompleteEnrollment, enrollmentContactEmail,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 notifications = require('./notifications');
|
||||
const programsLib = require('./programs');
|
||||
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;
|
||||
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
|
||||
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
|
||||
// identity — see enrollment.js's enrollmentContactEmail.
|
||||
const contactEmail = student?.contact_email ? String(student.contact_email).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, contactEmail, 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 },
|
||||
});
|
||||
|
||||
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
|
||||
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
|
||||
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
|
||||
// guarantees at most one caller ever sees created:true for a given event/race, so at most
|
||||
// one notification is ever queued, with no separate notification-level dedup needed.
|
||||
if (result.created) {
|
||||
const program = await programsLib.getProgram(getPool(), program_slug);
|
||||
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
|
||||
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
// Prometheus text-exposition metrics — Phase 5A §34. Hand-rolled, not prom-client: the metric
|
||||
// set here is small (a handful of counters/gauges), and the exposition format is trivial text
|
||||
// (`# HELP`, `# TYPE`, `name value`) — not worth a new dependency for this (ponytail:
|
||||
// stdlib/hand-rolled first). Deliberately NO student/instructor id (or any other
|
||||
// high-cardinality value) ever appears as a label — every metric here is an aggregate count,
|
||||
// matching the explicit "avoid high-cardinality student IDs... do not expose PII" requirement.
|
||||
//
|
||||
// Counters (login success/failure) are real in-process Prometheus counters — incremented at
|
||||
// the exact point the event happens (student-auth-routes.js), not derived from a windowed SQL
|
||||
// COUNT(*), which would behave like a gauge with a rolling window rather than a real counter.
|
||||
// They reset on process restart, which is the normal, expected way Prometheus counters behave
|
||||
// (rate()/increase() handle a counter reset transparently) — no persistence needed.
|
||||
|
||||
let studentLoginSuccessTotal = 0;
|
||||
let studentLoginFailureTotal = 0;
|
||||
|
||||
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
|
||||
function recordStudentLoginFailure() { studentLoginFailureTotal++; }
|
||||
|
||||
// Phase 5B §16 — the integration endpoint's result counter. `result` is a fixed 3-value enum
|
||||
// ('created' | 'duplicate' | 'rejected'), never anything from the request body, so it can never
|
||||
// become high-cardinality no matter what a caller sends.
|
||||
const enrollmentEventTotals = { created: 0, duplicate: 0, rejected: 0 };
|
||||
function recordEnrollmentEvent(result) {
|
||||
if (result in enrollmentEventTotals) enrollmentEventTotals[result]++;
|
||||
}
|
||||
|
||||
// Phase 5C §18 — per-attempt delivery outcomes (a real counter, since "how many send attempts
|
||||
// happened" is an ephemeral event count, not something a DB GROUP BY can reconstruct after the
|
||||
// fact the way notifications_total below can). `provider` is 'dev'|'gws' (bounded), `result` is
|
||||
// 'sent'|'retry'|'failed' (bounded) — never a notification_id/student_id/provider message id.
|
||||
const notificationAttemptTotals = {};
|
||||
function recordNotificationAttempt(provider, result) {
|
||||
const key = `${provider}|${result}`;
|
||||
notificationAttemptTotals[key] = (notificationAttemptTotals[key] || 0) + 1;
|
||||
}
|
||||
|
||||
// student.activate outcomes — 'success' | 'invalid_or_expired_token'. Bounded, no student id.
|
||||
const studentActivationTotals = {};
|
||||
function recordStudentActivation(result) {
|
||||
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
|
||||
}
|
||||
|
||||
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
|
||||
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
|
||||
// keep in sync with reality).
|
||||
async function collectGauges(pool, provisioner) {
|
||||
const [{ rows: liveClassrooms }, { rows: helpOpen }, envRes] = await Promise.all([
|
||||
pool.query(`SELECT classroom_session_id FROM classroom_sessions WHERE status = 'live'`),
|
||||
pool.query(`SELECT count(*)::int AS n FROM help_requests WHERE status = 'open'`),
|
||||
// Reuses the exact same environments-by-status query the admin dashboard already runs
|
||||
// (admin-routes.js's /api/admin/dashboard) — one source of truth for Lab state counts.
|
||||
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`).catch(() => ({ rows: [] })),
|
||||
]);
|
||||
|
||||
let onlineParticipants = 0;
|
||||
if (liveClassrooms.length) {
|
||||
const classroom = require('./classroom');
|
||||
for (const c of liveClassrooms) {
|
||||
const participants = await classroom.listParticipants(pool, c.classroom_session_id);
|
||||
onlineParticipants += participants.filter((p) => p.presence === 'online').length;
|
||||
}
|
||||
}
|
||||
|
||||
const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n]));
|
||||
|
||||
// Phase 5B — enrollments by status/delivery_mode (both fixed enums, safe labels) and
|
||||
// completions by program (program_id is an admin-curated slug, small and bounded by design —
|
||||
// the same trust level assessment_definition_id already has, unlike student_id/email/
|
||||
// external_reference, which must NEVER appear as a label — see the file header).
|
||||
const [{ rows: enrollmentBreakdown }, { rows: selfPacedActive }, { rows: completionsByProgram }] = await Promise.all([
|
||||
pool.query(`SELECT status, delivery_mode, count(*)::int AS n FROM enrollments GROUP BY status, delivery_mode`),
|
||||
pool.query(`SELECT count(*)::int AS n FROM enrollments WHERE status = 'active' AND delivery_mode = 'self_paced'`),
|
||||
pool.query(`SELECT program_id, count(*)::int AS n FROM enrollments WHERE status = 'completed' GROUP BY program_id`),
|
||||
]);
|
||||
|
||||
// Phase 5C — notification lifecycle, computed fresh from the DB (like enrollment_breakdown
|
||||
// above) rather than an in-process counter, so "how many notifications are in each
|
||||
// type/status right now" can never drift from what's actually stored.
|
||||
const [{ rows: notificationBreakdown }, { rows: queueStats }] = await Promise.all([
|
||||
pool.query(`SELECT notification_type, status, count(*)::int AS n FROM notification_events GROUP BY notification_type, status`),
|
||||
pool.query(`SELECT count(*)::int AS depth, COALESCE(extract(epoch FROM now() - min(created_at)), 0)::int AS oldest_seconds
|
||||
FROM notification_events WHERE status = 'queued'`),
|
||||
]);
|
||||
|
||||
return {
|
||||
classroom_sessions_live: liveClassrooms.length,
|
||||
classroom_participants_online: onlineParticipants,
|
||||
help_requests_open: helpOpen[0].n,
|
||||
labs_running: envCounts.running || 0,
|
||||
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
||||
labs_error: envCounts.error || 0,
|
||||
enrollment_breakdown: enrollmentBreakdown,
|
||||
selfpaced_active_enrollments: selfPacedActive[0].n,
|
||||
completions_by_program: completionsByProgram,
|
||||
notification_breakdown: notificationBreakdown,
|
||||
notification_queue_depth: queueStats[0].depth,
|
||||
notification_oldest_queued_seconds: queueStats[0].oldest_seconds,
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// Same idea as labeledGauge, for the flat "label-key -> count" dicts the in-process counters
|
||||
// above use (e.g. notificationAttemptTotals's `${provider}|${result}` keys).
|
||||
function labeledCounter(name, help, totals, labelNames) {
|
||||
const header = `# HELP ${name} ${help}\n# TYPE ${name} counter\n`;
|
||||
const keys = Object.keys(totals);
|
||||
if (!keys.length) return header;
|
||||
const body = keys.map((key) => {
|
||||
const values = key.split('|');
|
||||
const labels = labelNames.map((n, i) => `${n}="${values[i].replace(/"/g, '\\"')}"`).join(',');
|
||||
return `${name}{${labels}} ${totals[key]}`;
|
||||
}).join('\n');
|
||||
return header + body + '\n';
|
||||
}
|
||||
|
||||
async function renderMetrics(pool, provisioner) {
|
||||
const gauges = await collectGauges(pool, provisioner);
|
||||
return [
|
||||
line('rootnode_classroom_sessions_live', 'Number of classroom sessions currently live.', 'gauge', gauges.classroom_sessions_live),
|
||||
line('rootnode_classroom_participants_online', 'Number of classroom participants currently online across all live sessions.', 'gauge', gauges.classroom_participants_online),
|
||||
line('rootnode_help_requests_open', 'Number of open (not yet acknowledged/resolved) help requests.', 'gauge', gauges.help_requests_open),
|
||||
line('rootnode_labs_running', 'Number of student Lab environments currently running.', 'gauge', gauges.labs_running),
|
||||
line('rootnode_labs_provisioning', 'Number of student Lab environments currently being created/reset.', 'gauge', gauges.labs_provisioning),
|
||||
line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error),
|
||||
line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal),
|
||||
line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal),
|
||||
labeledGauge('rootnode_enrollments_total', 'Number of enrollments by lifecycle status and delivery mode.', gauges.enrollment_breakdown, ['status', 'delivery_mode'], 'n'),
|
||||
line('rootnode_selfpaced_active_enrollments', 'Number of currently active self-paced enrollments.', 'gauge', gauges.selfpaced_active_enrollments),
|
||||
labeledGauge('rootnode_program_completions_total', 'Number of completed enrollments by program.', gauges.completions_by_program, ['program_id'], 'n'),
|
||||
line('rootnode_enrollment_events_created_total', 'Integration enrollment events that created a new enrollment.', 'counter', enrollmentEventTotals.created),
|
||||
line('rootnode_enrollment_events_duplicate_total', 'Integration enrollment events that were idempotent replays of an already-processed event.', 'counter', enrollmentEventTotals.duplicate),
|
||||
line('rootnode_enrollment_events_rejected_total', 'Integration enrollment events rejected (bad auth, unknown program, invalid payload).', 'counter', enrollmentEventTotals.rejected),
|
||||
labeledGauge('rootnode_notifications_total', 'Number of notification rows by type and lifecycle status.', gauges.notification_breakdown, ['notification_type', 'status'], 'n'),
|
||||
line('rootnode_notification_queue_depth', 'Number of notifications currently queued for delivery.', 'gauge', gauges.notification_queue_depth),
|
||||
line('rootnode_notification_oldest_queued_seconds', 'Age in seconds of the oldest still-queued notification (0 if none queued).', 'gauge', gauges.notification_oldest_queued_seconds),
|
||||
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']),
|
||||
line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0),
|
||||
line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
|
||||
recordNotificationAttempt, recordStudentActivation, renderMetrics,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
// Phase 5C §15/§16 — instructor/admin communication operations. Global notification visibility
|
||||
// is owner/admin only (§16: "Do not grant global notification visibility to every instructor");
|
||||
// a plain instructor can still see/act on notifications for a SPECIFIC student they're
|
||||
// authorized to manage via rbac.js's requireStudentRelationshipAccess (cohort-shared or an
|
||||
// enrollment they created — the same rule admin-programs-routes.js already established for
|
||||
// enrollment visibility). Every response here goes through notifications.js's toAdminView,
|
||||
// which masks the recipient address and omits `metadata` entirely — no token, no full email,
|
||||
// ever, through any route in this file.
|
||||
|
||||
const notificationsLib = require('./notifications');
|
||||
const enrollmentLib = require('./enrollment');
|
||||
const programsLib = require('./programs');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const { parsePagination, paginatedResponse } = require('./pagination');
|
||||
|
||||
module.exports = function registerNotificationRoutes(app, deps) {
|
||||
const { instructorSession, rbac, getPool, recordAudit } = deps;
|
||||
const { requireAdminOrOwner, requireStudentRelationshipAccess } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// Global operational view — owner/admin only.
|
||||
admin.get('/api/admin/notifications', requireAdminOrOwner, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { page, pageSize, offset } = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM notification_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM notification_events`),
|
||||
]);
|
||||
res.json(paginatedResponse(rows.map(notificationsLib.toAdminView), countRows[0].n, { page, pageSize }));
|
||||
});
|
||||
|
||||
// Per-student view — the scoped surface a plain instructor can actually reach.
|
||||
admin.get('/api/admin/students/:id/notifications', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const rows = await notificationsLib.listForStudent(getPool(), req.params.id);
|
||||
res.json(rows.map(notificationsLib.toAdminView));
|
||||
});
|
||||
|
||||
// §20 — dev/test ONLY: lets a test script (or a developer) see exactly what the dev provider
|
||||
// would have sent, including the activation/reset URL, without a real inbox. Unconditionally
|
||||
// refuses in production regardless of what's stored (mirrors student-auth-routes.js's
|
||||
// dev_reset_token gate on the same COOKIE_SECURE-derived signal) — this is never how an
|
||||
// admin inspects a real delivery, only how a dev/CI run verifies one.
|
||||
admin.get('/api/admin/notifications/:id/dev-preview', requireAdminOrOwner, async (req, res) => {
|
||||
if (process.env.COOKIE_SECURE === '1') return res.status(404).json({ error: 'not_found' });
|
||||
const preview = await notificationsLib.getDevPreview(getPool(), req.params.id);
|
||||
if (!preview) return res.status(404).json({ error: 'no_preview_available' });
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
// §15 — one action covers both "resend" and "regenerate": Phase 5A's issueCredentialToken
|
||||
// already unconditionally invalidates any previous outstanding token on mint, so there is
|
||||
// never a moment with two simultaneously valid activation links — a second button that did
|
||||
// something different would have nothing different to do. See
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6 for the full reasoning.
|
||||
admin.post('/api/admin/students/:id/resend-activation', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (rows[0].password_hash) return res.status(409).json({ error: 'already_activated', message: 'This student already has real credentials — use Send Password Reset instead.' });
|
||||
|
||||
const { rows: enrollmentRows } = await pool.query(
|
||||
`SELECT enrollment_id, program_id FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
|
||||
);
|
||||
const enrollmentId = enrollmentRows[0]?.enrollment_id || null;
|
||||
const program = enrollmentRows[0] ? await programsLib.getProgram(pool, enrollmentRows[0].program_id) : null;
|
||||
|
||||
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
|
||||
studentId: req.params.id, enrollmentId, programName: program?.name, isResend: true,
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'activation_resent', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(notificationsLib.toAdminView(notification));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/send-enrollment-confirmation', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { enrollment_id } = req.body || {};
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_activated', message: 'This student has no credentials yet — use Resend Activation instead.' });
|
||||
|
||||
let targetEnrollmentId = enrollment_id;
|
||||
if (!targetEnrollmentId) {
|
||||
const { rows: latest } = await pool.query(
|
||||
`SELECT enrollment_id FROM enrollments WHERE student_id = $1 AND status = 'active' ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
|
||||
);
|
||||
targetEnrollmentId = latest[0]?.enrollment_id;
|
||||
}
|
||||
if (!targetEnrollmentId) return res.status(400).json({ error: 'no_active_enrollment', message: 'This student has no active enrollment to confirm.' });
|
||||
const enrollmentRow = await enrollmentLib.getEnrollment(pool, targetEnrollmentId);
|
||||
const program = await programsLib.getProgram(pool, enrollmentRow.program_id);
|
||||
|
||||
const notification = await notificationsLib.enqueue(pool, {
|
||||
studentId: req.params.id, enrollmentId: targetEnrollmentId, notificationType: 'enrollment_confirmation',
|
||||
metadata: { display_name: rows[0].display_name, program_name: program?.name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_confirmation_sent', resourceType: 'enrollment', resourceId: targetEnrollmentId, result: 'success', sourceIp: req.ip });
|
||||
res.json(notificationsLib.toAdminView(notification));
|
||||
});
|
||||
|
||||
// §8/§15's "admin may need a safe SEND PASSWORD RESET operation" is the EXISTING
|
||||
// POST /api/admin/students/:id/reset-password (admin-routes.js, Phase 5A) — Phase 5C extended
|
||||
// it to also enqueue a real email (see that route) rather than duplicating a second endpoint
|
||||
// here. It still returns `reset_token` in its response, unchanged from Phase 5A: for a
|
||||
// student with no deliverable address at all (no email, no enrollment contact), manually
|
||||
// relaying that token is the ONLY way they can ever get in, so removing it would leave that
|
||||
// population with no recovery path. §21's "admin cannot view token" is enforced for
|
||||
// resend-activation/send-enrollment-confirmation above, which have no legitimate reason to
|
||||
// ever expose one — reset-password's token-in-response is a deliberate, documented,
|
||||
// unchanged exception for exactly the case automatic delivery can't cover. See
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6.
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Phase 5C §14 — a simple durable worker, same pattern this codebase already uses for
|
||||
// sessions.js's session sweeper and security.js's login-attempt sweeper: a plain setInterval
|
||||
// poll against Postgres, no Kafka/RabbitMQ/Redis. notifications.js's claimNext() does the
|
||||
// actual concurrency-safety work (FOR UPDATE SKIP LOCKED) — this file just calls it on a timer
|
||||
// and hands the claimed row to the configured email provider.
|
||||
|
||||
const notifications = require('./notifications');
|
||||
const templates = require('./email/templates');
|
||||
const { getEmailProvider } = require('./email/provider');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const POLL_MS = Number(process.env.NOTIFICATION_WORKER_POLL_MS) || 3000;
|
||||
|
||||
function buildAppUrl(path) {
|
||||
const origin = process.env.PUBLIC_APP_ORIGIN;
|
||||
if (!origin) return null;
|
||||
try {
|
||||
const u = new URL(origin);
|
||||
return `${u.origin}${path}`;
|
||||
} catch {
|
||||
return null; // malformed origin — treated as unconfigured, not a crash
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the right template for a queued row. Returns null (a permanent, non-retryable
|
||||
// failure) if required config (PUBLIC_APP_ORIGIN for a token-bearing type) is missing — §7's
|
||||
// "production should fail safely if required public-origin configuration is missing."
|
||||
function renderFor(row) {
|
||||
const displayName = row.metadata?.display_name || 'there';
|
||||
if (row.notification_type === 'activation') {
|
||||
const url = buildAppUrl(`/?activate=${encodeURIComponent(row.metadata?.token || '')}`);
|
||||
if (!url || !row.metadata?.token) return null;
|
||||
return templates.activation({ displayName, programName: row.metadata?.program_name, activationUrl: url, expiresInDays: 7 });
|
||||
}
|
||||
if (row.notification_type === 'password_reset') {
|
||||
const url = buildAppUrl(`/?reset=${encodeURIComponent(row.metadata?.token || '')}`);
|
||||
if (!url || !row.metadata?.token) return null;
|
||||
return templates.passwordReset({ displayName, resetUrl: url, expiresInMinutes: 60 });
|
||||
}
|
||||
if (row.notification_type === 'enrollment_confirmation') {
|
||||
const url = buildAppUrl('/') || '/';
|
||||
return templates.enrollmentConfirmation({ displayName, programName: row.metadata?.program_name || 'your program', signInUrl: url });
|
||||
}
|
||||
if (row.notification_type === 'password_changed') {
|
||||
return templates.passwordChanged({ displayName });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processOne(pool) {
|
||||
const row = await notifications.claimNext(pool);
|
||||
if (!row) return false;
|
||||
|
||||
const { EMAIL_PROVIDER } = require('./email/provider');
|
||||
const rendered = renderFor(row);
|
||||
if (!rendered) {
|
||||
await notifications.markFailedOrRetry(pool, row, { errorCode: 'template_or_config_error', retryable: false });
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'failed');
|
||||
return true;
|
||||
}
|
||||
|
||||
const provider = getEmailProvider();
|
||||
try {
|
||||
const result = await provider.send({
|
||||
to: row.recipient_email,
|
||||
fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy',
|
||||
fromAddress: process.env.EMAIL_FROM_ADDRESS,
|
||||
replyTo: process.env.EMAIL_REPLY_TO,
|
||||
subject: rendered.subject, text: rendered.text, html: rendered.html,
|
||||
});
|
||||
// §20 — a dev-only preview (never in production, regardless of provider) so a test/admin
|
||||
// can inspect exactly what would have been sent, including the activation/reset URL,
|
||||
// through the dedicated dev-only route — never through the normal admin notification view.
|
||||
const isProduction = process.env.COOKIE_SECURE === '1';
|
||||
const devPreview = (!isProduction && result.preview) ? result.preview : null;
|
||||
await notifications.markSent(pool, row.notification_id, result.providerMessageId, EMAIL_PROVIDER, devPreview);
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'sent');
|
||||
} catch (e) {
|
||||
const retryable = e.errorClass !== 'permanent';
|
||||
const outcome = await notifications.markFailedOrRetry(pool, row, { errorCode: e.errorCode || 'unknown_error', retryable });
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, outcome === 'retrying' ? 'retry' : 'failed');
|
||||
console.error(`[notification-worker] send failed (${row.notification_type}, ${outcome}): ${e.errorCode || e.message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function startNotificationWorker(pool) {
|
||||
const tick = async () => {
|
||||
try {
|
||||
// Drain everything currently claimable before waiting out the next poll interval —
|
||||
// keeps a burst (e.g. a CSV import's worth of activations) from trickling out one per
|
||||
// POLL_MS when they could all go immediately.
|
||||
while (await processOne(pool)) { /* keep draining */ }
|
||||
} catch (e) {
|
||||
console.error('[notification-worker] tick failed:', e.message);
|
||||
}
|
||||
};
|
||||
const handle = setInterval(tick, POLL_MS);
|
||||
handle.unref(); // never keeps the process alive on its own, matching sessions.js's sweeper
|
||||
tick(); // don't wait a full interval for the first pass
|
||||
return handle;
|
||||
}
|
||||
|
||||
module.exports = { startNotificationWorker, processOne, renderFor, buildAppUrl };
|
||||
@@ -0,0 +1,189 @@
|
||||
// Phase 5C — durable notification lifecycle: enqueue (fast, synchronous, no network I/O),
|
||||
// claim (worker-side, concurrency-safe), and terminal-state transitions.
|
||||
//
|
||||
// Transient plaintext token handling: activation and password-reset emails need the ACTUAL
|
||||
// token value to build a working link, but Phase 5A's invariant is that only the HASH is ever
|
||||
// persisted long-term (students.credential_token_hash). The token is minted synchronously by
|
||||
// whichever request triggers it (admin create/convert/reset-password, self-service
|
||||
// forgot-password) — exactly as Phase 5A already does — and its plaintext is handed to
|
||||
// enqueueWithToken() here, which stores it in this row's `metadata` ONLY until the worker
|
||||
// reaches a terminal state (sent/failed), then scrubs it (see markSent/markFailedOrRetry).
|
||||
// This is a deliberate, narrow exception to "hashed at rest" for the row that exists
|
||||
// specifically to deliver the plaintext once — not the canonical credential store, which
|
||||
// remains hash-only and untouched by any of this. It is never returned by the admin
|
||||
// notification API regardless of whether it's still present (see toAdminView).
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRYABLE_BACKOFF_BASE_SEC = 30;
|
||||
const RETRYABLE_BACKOFF_MAX_SEC = 3600;
|
||||
|
||||
function backoffSeconds(attemptCount) {
|
||||
return Math.min(RETRYABLE_BACKOFF_BASE_SEC * 2 ** attemptCount, RETRYABLE_BACKOFF_MAX_SEC);
|
||||
}
|
||||
|
||||
// §10 — minor/no-email recipient resolution. A student's OWN email always wins when present.
|
||||
// Otherwise, for an enrollment-scoped notification, fall back to that ENROLLMENT's contact
|
||||
// email (enrollments.metadata.contact_email — see enrollment.js's enrollmentContactEmail) —
|
||||
// deliberately never written to students.email, which stays reserved for the student's own
|
||||
// identity. A password-reset/password-changed notification has no enrollment to fall back to
|
||||
// and, correctly, no fallback recipient for a no-email student — those actions require the
|
||||
// student to already be able to log in, which requires an email or a login_identifier they use
|
||||
// directly, not a guardian contact.
|
||||
async function resolveRecipient(pool, studentId, enrollmentId) {
|
||||
const { rows: studentRows } = await pool.query(`SELECT email FROM students WHERE student_id = $1`, [studentId]);
|
||||
if (studentRows[0]?.email) return studentRows[0].email;
|
||||
if (enrollmentId) {
|
||||
const { enrollmentContactEmail } = require('./enrollment');
|
||||
const { rows: enrollmentRows } = await pool.query(`SELECT metadata FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
|
||||
const contact = enrollmentContactEmail(enrollmentRows[0]);
|
||||
if (contact) return contact;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
|
||||
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
|
||||
// state. A notification with no resolvable recipient is still recorded (never silently
|
||||
// dropped, §13) but created already-failed, so the worker never wastes an attempt on it.
|
||||
async function enqueue(pool, { studentId, enrollmentId, notificationType, plaintextToken, metadata }) {
|
||||
const recipient = await resolveRecipient(pool, studentId, enrollmentId);
|
||||
const meta = { ...(metadata || {}) };
|
||||
if (plaintextToken) meta.token = plaintextToken;
|
||||
|
||||
if (!recipient) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
|
||||
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
|
||||
[studentId, enrollmentId || null, notificationType, JSON.stringify({})] // no token stored — nothing to deliver, nothing to leak
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[studentId, enrollmentId || null, notificationType, recipient, JSON.stringify(meta)]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Atomic claim: one UPDATE, no separate transaction/client management needed. FOR UPDATE SKIP
|
||||
// LOCKED inside the subquery is what makes this safe if two worker processes ever poll
|
||||
// concurrently — each gets a disjoint set of rows, never the same one twice.
|
||||
async function claimNext(pool) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE notification_events SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
||||
WHERE notification_id = (
|
||||
SELECT notification_id FROM notification_events
|
||||
WHERE status = 'queued' AND next_attempt_at <= now()
|
||||
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
) RETURNING *`
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// devPreview is ONLY ever populated by the caller when both the dev provider was used AND
|
||||
// production is off (see notification-worker.js) — it replaces the row's metadata entirely
|
||||
// (the token is gone either way), so a dev/test run can recover exactly what would have been
|
||||
// sent without a real inbox. Never populated, never present, in a production deployment — see
|
||||
// the dedicated dev-only GET route this exists for (notification-routes.js), which itself
|
||||
// refuses to run in production regardless of what's actually stored.
|
||||
async function markSent(pool, notificationId, providerMessageId, provider, devPreview) {
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'sent', sent_at = now(), updated_at = now(),
|
||||
provider = $2, provider_message_id = $3, metadata = $4
|
||||
WHERE notification_id = $1`,
|
||||
[notificationId, provider, providerMessageId || null, devPreview ? JSON.stringify({ dev_preview: devPreview }) : '{}']
|
||||
);
|
||||
}
|
||||
|
||||
// Permanent failures (bad recipient, misconfiguration) skip straight to 'failed' on the first
|
||||
// attempt — retrying can't fix them and would just burn the attempt budget. Temporary failures
|
||||
// (network/provider hiccup) go back to 'queued' with bounded exponential backoff until
|
||||
// MAX_ATTEMPTS is exhausted, then 'failed' too — never an infinite loop.
|
||||
async function markFailedOrRetry(pool, row, { errorCode, retryable }) {
|
||||
const exhausted = row.attempt_count >= MAX_ATTEMPTS;
|
||||
if (retryable && !exhausted) {
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'queued', next_attempt_at = now() + ($2 || ' seconds')::interval,
|
||||
last_error_code = $3, updated_at = now() WHERE notification_id = $1`,
|
||||
[row.notification_id, backoffSeconds(row.attempt_count), errorCode]
|
||||
);
|
||||
return 'retrying';
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'failed', failed_at = now(), last_error_code = $2,
|
||||
updated_at = now(), metadata = metadata - 'token' WHERE notification_id = $1`,
|
||||
[row.notification_id, errorCode]
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// §10/§5 — the shared decision point every enrollment-creation path (integration + admin
|
||||
// manual enroll) calls right after a NEW enrollment is actually created (never on an idempotent
|
||||
// replay — see integration-routes.js/admin-programs-routes.js, which only call this when their
|
||||
// own creation call reported created:true). Reused for the admin "Resend Activation"/"Send
|
||||
// Enrollment Confirmation" actions too, parameterized by isResend for audit-action naming only.
|
||||
async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId, programName, isResend }) {
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [studentId]);
|
||||
const student = rows[0];
|
||||
if (!student) return null;
|
||||
|
||||
if (student.password_hash) {
|
||||
return enqueue(pool, {
|
||||
studentId, enrollmentId, notificationType: 'enrollment_confirmation',
|
||||
metadata: { display_name: student.display_name, program_name: programName },
|
||||
});
|
||||
}
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
|
||||
return enqueue(pool, {
|
||||
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
|
||||
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
|
||||
});
|
||||
}
|
||||
|
||||
async function listForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
async function getById(pool, notificationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Phase 5C §20 — the ONLY function that ever returns a dev preview (recipient/subject/text/
|
||||
// activation-or-reset URL), and only the caller (notification-routes.js's dedicated dev-only
|
||||
// route) decides whether to expose it at all — this function itself does no production check,
|
||||
// it just returns whatever's there, which is nothing at all once a real (non-dev) provider was
|
||||
// used or the row has moved past 'sent' via markFailedOrRetry (which never writes dev_preview).
|
||||
async function getDevPreview(pool, notificationId) {
|
||||
const row = await getById(pool, notificationId);
|
||||
return row?.metadata?.dev_preview || null;
|
||||
}
|
||||
|
||||
// §16 — safe display: the local part is truncated, never the full address, and this is the
|
||||
// ONLY shape any admin API ever returns for recipient_email. Nothing this module exports ever
|
||||
// hands back an unmasked address or the metadata column at all.
|
||||
function maskEmail(email) {
|
||||
if (!email) return null;
|
||||
const [local, domain] = email.split('@');
|
||||
if (!domain) return '***';
|
||||
const visible = local.slice(0, 1);
|
||||
return `${visible}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`;
|
||||
}
|
||||
|
||||
// The one and only shape returned by any notification-related API — deliberately excludes
|
||||
// `metadata` entirely (which is where a still-in-flight token transiently lives) and masks the
|
||||
// recipient address.
|
||||
function toAdminView(row) {
|
||||
return {
|
||||
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
|
||||
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
|
||||
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
|
||||
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
|
||||
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Shared "Prepare Labs for a cohort's enrolled students" logic — used by both the existing
|
||||
// cohort-level admin route and the Phase 5A classroom-scoped one (a classroom's Prepare Labs is
|
||||
// exactly this, just entered from the classroom dashboard instead of the cohort page — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §21). Extracted so there's one place this
|
||||
// logic lives, not two copies that could drift.
|
||||
|
||||
async function prepareLabsForCohort(pool, provisioner, cohortId) {
|
||||
const { rows: students } = await pool.query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[cohortId]
|
||||
);
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
|
||||
const { status, body } = await provisioner.provision(s.student_id);
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: 'capacity_waiting' };
|
||||
}
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
return results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message });
|
||||
}
|
||||
|
||||
module.exports = { prepareLabsForCohort };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
+146
-31
@@ -30,27 +30,34 @@ module.exports = function createRbac(pool) {
|
||||
const requireAdminOrOwner = requireRole('owner', 'admin');
|
||||
const requireAnyInstructor = requireRole('owner', 'admin', 'instructor');
|
||||
|
||||
// Core check, factored out so both the cohort_id-param routes below AND the
|
||||
// classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead
|
||||
// of two copies of the same query drifting apart over time.
|
||||
async function checkCohortAccess(req, res, cohortId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
|
||||
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
|
||||
// same as any other authorization failure, no distinction made between "not your cohort" and
|
||||
// "cohort doesn't exist" in the response.
|
||||
function requireCohortAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,24 +91,27 @@ module.exports = function createRbac(pool) {
|
||||
// vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above,
|
||||
// but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still
|
||||
// bypass entirely, same as every other cohort-scoped check.
|
||||
async function checkCohortWriteAccess(req, res, cohortId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
|
||||
});
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function requireCohortWriteAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,9 +140,114 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly,
|
||||
// so this resolves the classroom's cohort first (404 if it doesn't exist — a route param
|
||||
// isn't proof the resource exists) and then applies the exact same rule
|
||||
// requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the
|
||||
// request (req.classroomCohortId) so handlers that need it don't re-query.
|
||||
function requireClassroomAccess(classroomIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
if (await checkCohortAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
function requireClassroomWriteAccess(classroomIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
|
||||
// Same idea, one join further — help_requests routes are keyed by help_request_id, resolved
|
||||
// through classroom_sessions to the cohort. Used by the acknowledge/resolve routes.
|
||||
function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr
|
||||
JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id
|
||||
WHERE hr.help_request_id = $1`,
|
||||
[req.params[helpRequestIdParam]]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
req.classroomSessionId = rows[0].classroom_session_id;
|
||||
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id
|
||||
// directly instead of resolving through one enrollment first: an instructor may see/manage a
|
||||
// student's notifications if they share a cohort with that student OR they created at least
|
||||
// one of that student's enrollments — otherwise 403, never blanket visibility into every
|
||||
// self-paced student's notifications ("Do not grant global notification visibility to every
|
||||
// instructor," §16). Used by the per-student notification list and the resend-activation/
|
||||
// send-enrollment-confirmation/send-password-reset admin actions.
|
||||
async function checkStudentRelationshipAccess(req, res, studentId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
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
|
||||
UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $2`,
|
||||
[studentId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function requireStudentRelationshipAccess(studentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
|
||||
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
|
||||
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
|
||||
// they already have cohort-based access to that student (the existing relationship, reused
|
||||
// as-is) OR they personally created the enrollment (enrollments.created_by) — never blanket
|
||||
// global visibility into every self-paced student. owner/admin bypass, same as everywhere
|
||||
// else. Resolves the enrollment first (404 if it doesn't exist) and stashes the owning
|
||||
// student_id on the request.
|
||||
function requireEnrollmentAccess(enrollmentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT student_id, created_by FROM enrollments WHERE enrollment_id = $1`, [req.params[enrollmentIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.enrollmentStudentId = rows[0].student_id;
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
if (rows[0].created_by === instructor.instructor_id) return next();
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`,
|
||||
[rows[0].student_id, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'enrollment', resourceId: req.params[enrollmentIdParam], result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||
requireEnrollmentAccess, requireStudentRelationshipAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
};
|
||||
+111
-34
@@ -43,10 +43,20 @@ app.use(express.static(FRONTEND_DIST));
|
||||
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
|
||||
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
|
||||
// controls. CSRF is checked here too, for every state-changing student request.
|
||||
// Phase 5A: now re-checks the student is still 'active' on every request, matching
|
||||
// instructorSession's existing pattern below — a deactivation takes effect on the very next
|
||||
// request instead of waiting out the session's natural expiry. Previously this middleware only
|
||||
// checked the session itself existed, which was fine when every student was pilot/trusted-roster
|
||||
// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist.
|
||||
async function studentSession(req, res, next) {
|
||||
const token = parseCookies(req)[STUDENT_COOKIE];
|
||||
const studentId = await sessions.resolveStudentSession(getPool(), token);
|
||||
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
|
||||
const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]);
|
||||
if (!rows.length || rows[0].status !== 'active') {
|
||||
await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued
|
||||
return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
|
||||
}
|
||||
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
req.studentId = studentId;
|
||||
req.sessionToken = token;
|
||||
@@ -88,6 +98,19 @@ app.get('/healthz', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry
|
||||
// credentials here, matching every other exporter in infra/monitoring/) but restricted at the
|
||||
// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already
|
||||
// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public
|
||||
// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js.
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner));
|
||||
} catch (e) {
|
||||
res.status(503).type('text/plain').send('# metrics collection failed\n');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as
|
||||
// minimal: an app version, the deployed Git SHA (or 'dev' locally — see
|
||||
// infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same
|
||||
@@ -140,11 +163,26 @@ app.get('/api/roster', async (req, res) => {
|
||||
// can't be forged/switched by editing client state, and is checked on every Lab request. See
|
||||
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
|
||||
// institutional SSO) that upgrades identification without touching this session system.
|
||||
// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real,
|
||||
// credentialed account (password_hash set), this route refuses them and points at the real
|
||||
// login, so a converted account can never be accessed by just knowing its bare student_id.
|
||||
// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working
|
||||
// exactly as before — no pilot data or access is lost.
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { student_id } = req.body || {};
|
||||
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT student_id, display_name, status, password_hash, credential_token_hash FROM students WHERE student_id = $1`,
|
||||
[student_id]
|
||||
);
|
||||
const student = rows[0];
|
||||
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
|
||||
// Blocks on credential_token_hash too, not just password_hash — a student mid-conversion
|
||||
// (instructor has issued an activation token but the student hasn't set a password yet) must
|
||||
// not be reachable via the old insecure bare-id path either, or conversion doesn't actually
|
||||
// close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js).
|
||||
if (student.password_hash || student.credential_token_hash) {
|
||||
return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' });
|
||||
}
|
||||
await touchStudent(getPool(), student.student_id);
|
||||
const token = await sessions.createStudentSession(getPool(), student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
@@ -207,11 +245,22 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
|
||||
res.json({ total, completed, details: progress });
|
||||
});
|
||||
|
||||
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
// Phase 5B — found by testing: this route's handler was never passed through wrap() (a
|
||||
// leftover from Phase 1/2A, before that convention existed), so any unexpected internal error
|
||||
// here — including Postgres rejecting a malformed environment_id, a 22P02 "invalid input
|
||||
// syntax for type uuid" — became an unhandled promise rejection that CRASHED THE ENTIRE
|
||||
// PLATFORM API PROCESS for every student, not just a 500 for this one request. Now wrapped
|
||||
// like every route added since Phase 3B, plus explicit UUID-shape validation so a bad
|
||||
// environment_id is a clean 400 rather than ever reaching Postgres as a type error at all.
|
||||
const ENV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
app.post('/api/progress/record', wrap(studentSession), wrap(async (req, res) => {
|
||||
const { scenario_id, status, environment_id } = req.body || {};
|
||||
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
|
||||
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
|
||||
}
|
||||
if (environment_id && !ENV_ID_RE.test(environment_id)) {
|
||||
return res.status(400).json({ error: 'environment_id must be a valid UUID' });
|
||||
}
|
||||
const pool = getPool();
|
||||
const passed = status === 'completed';
|
||||
|
||||
@@ -255,26 +304,17 @@ app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
// STUDENT-FACING: environment lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function publicEnvView(env) {
|
||||
if (!env) return { status: 'none' };
|
||||
const view = { status: env.status };
|
||||
if (env.status === 'running') {
|
||||
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
|
||||
if (!token) {
|
||||
const { status, body } = await provisioner.getToken(env.environment_id);
|
||||
if (status === 200) token = body.routing_token;
|
||||
}
|
||||
if (token) view.launch_path = `/lab/${token}/`;
|
||||
}
|
||||
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
|
||||
return view;
|
||||
}
|
||||
// Phase 5B — publicEnvView/the launch sequence now live in environment-view.js so
|
||||
// selfpaced-routes.js's entitlement-gated launch route can call the identical logic instead of
|
||||
// a second copy. Behavior here is unchanged from before the extraction.
|
||||
const { publicEnvView: publicEnvViewFor, launchEnvironmentForStudent } = require('./environment-view');
|
||||
const publicEnvView = (env) => publicEnvViewFor(provisioner, env);
|
||||
|
||||
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
|
||||
const { status, body } = await provisioner.listForStudent(req.studentId);
|
||||
@@ -284,23 +324,8 @@ app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
|
||||
const existing = await provisioner.listForStudent(req.studentId);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return res.json(await publicEnvView(active));
|
||||
|
||||
const { status, body } = await provisioner.provision(req.studentId);
|
||||
if (status === 409) {
|
||||
const retry = await provisioner.listForStudent(req.studentId);
|
||||
return res.json(await publicEnvView(retry.body?.[0]));
|
||||
}
|
||||
// Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic
|
||||
// provision_failed 502 below. The student sees "temporarily full, try again shortly," never
|
||||
// a raw 500/502 — this is expected operational backpressure, not an application error.
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' });
|
||||
}
|
||||
if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error });
|
||||
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
|
||||
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
|
||||
@@ -329,6 +354,15 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
|
||||
|
||||
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
|
||||
|
||||
// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal
|
||||
// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding
|
||||
// a second env var, to decide whether /api/student/request-password-reset may echo the reset
|
||||
// token back for local/test use (never in a real deployment).
|
||||
require('./student-auth-routes')(app, {
|
||||
pool: getPool(), sessions, studentSession, curriculum, loadProgress,
|
||||
isProduction: process.env.COOKIE_SECURE === '1',
|
||||
});
|
||||
|
||||
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
|
||||
|
||||
const rbac = createRbac(getPool());
|
||||
@@ -340,6 +374,45 @@ require('./admin-learning-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half
|
||||
// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor
|
||||
// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/
|
||||
// admin-learning-routes.js — a classroom session is a distinct concept from curriculum
|
||||
// progress, even though its dashboard reads curriculum/progress data.
|
||||
require('./classroom-routes')(app, { studentSession, getPool, curriculum });
|
||||
require('./admin-classroom-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 5B — programs/enrollment. selfpaced-routes.js is the student-facing half (My
|
||||
// Programs, program detail, entitlement-gated launch); admin-programs-routes.js is the
|
||||
// instructor/admin half (program authoring, manual enrollment, activate/cancel);
|
||||
// integration-routes.js is the separate, non-session website/commerce boundary — see
|
||||
// docs/PHASE5B_SELF_PACED_ENROLLMENT.md and docs/WEBSITE_ENROLLMENT_INTEGRATION.md.
|
||||
require('./selfpaced-routes')(app, { studentSession, getPool, curriculum, loadProgress, provisioner, wrap });
|
||||
require('./admin-programs-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
require('./integration-routes')(app, { getPool, recordAudit, wrap });
|
||||
|
||||
// Phase 6A — course/module/activity content model, layered on top of the existing
|
||||
// scenario/assessment/achievement systems (never a duplicate of them — see
|
||||
// docs/COURSE_CONTENT_MODEL.md). course-routes.js is the student-facing half (course tree
|
||||
// through an enrollment, lesson completion marker); admin-course-routes.js is the instructor
|
||||
// curriculum view (structure + per-student progress).
|
||||
require('./course-routes')(app, { studentSession, getPool, wrap });
|
||||
require('./admin-course-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool,
|
||||
});
|
||||
|
||||
// Phase 5C — enrollment operations & communications. notification-routes.js is the
|
||||
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
|
||||
// confirmation); the actual delivery worker starts in main() below, alongside the existing
|
||||
// session/login-attempt sweepers — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md.
|
||||
require('./notification-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||
@@ -359,6 +432,10 @@ async function main() {
|
||||
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
||||
sessions.startSessionSweeper(getPool());
|
||||
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
|
||||
const { startNotificationWorker } = require('./notification-worker');
|
||||
const { EMAIL_PROVIDER } = require('./email/provider');
|
||||
console.log(`[notification-worker] starting (EMAIL_PROVIDER=${EMAIL_PROVIDER})`);
|
||||
startNotificationWorker(getPool()); // Phase 5C §14
|
||||
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,27 @@ async function resolveStudentSession(pool, token) {
|
||||
}
|
||||
const destroyStudentSession = revokeSession;
|
||||
|
||||
// Phase 5A §6/§10 — mirrors revokeAllInstructorSessions/listInstructorSessions exactly. Used
|
||||
// by admin-triggered "revoke student sessions", and automatically after a student password
|
||||
// change/reset (except the session that just made the change, if any).
|
||||
async function revokeAllStudentSessions(pool, studentId, exceptToken) {
|
||||
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
|
||||
await pool.query(
|
||||
`UPDATE sessions SET revoked_at = now()
|
||||
WHERE student_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
|
||||
[studentId, exceptHash]
|
||||
);
|
||||
}
|
||||
|
||||
async function listStudentSessions(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 20`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
|
||||
function createInstructorSession(pool, instructorId) {
|
||||
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
|
||||
@@ -131,6 +152,7 @@ function startSessionSweeper(pool) {
|
||||
|
||||
module.exports = {
|
||||
createStudentSession, resolveStudentSession, destroyStudentSession,
|
||||
revokeAllStudentSessions, listStudentSessions,
|
||||
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
|
||||
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// POST /api/student/login, /activate, /change-password, /request-password-reset,
|
||||
// /reset-password, GET /api/student/profile — Phase 5A §2/§5/§6/§9/§10. Logout reuses the
|
||||
// existing POST /api/logout (session-mechanism-agnostic already, no change needed there).
|
||||
//
|
||||
// A pilot student (password_hash IS NULL) keeps using the original POST /api/login
|
||||
// (bare student_id, unchanged) until an instructor converts them — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7. This file is exclusively the NEW
|
||||
// credentialed path.
|
||||
|
||||
const {
|
||||
verifyPassword, hashPassword, publicStudent, getStudentByIdentifier, getActiveStudent,
|
||||
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
|
||||
} = require('./students-auth');
|
||||
const { recordAudit } = require('./audit');
|
||||
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
const { recordStudentLoginSuccess, recordStudentLoginFailure, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C
|
||||
const notifications = require('./notifications');
|
||||
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||
const LOGIN_MAX_ATTEMPTS = 8;
|
||||
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_SEC = 8 * 3600;
|
||||
|
||||
module.exports = function registerStudentAuthRoutes(app, { pool, sessions, studentSession, curriculum, loadProgress, isProduction }) {
|
||||
app.post('/api/student/login', async (req, res) => {
|
||||
const { identifier, password } = req.body || {};
|
||||
const rateKey = `student:${req.ip}:${String(identifier || '').toLowerCase()}`;
|
||||
if (await tooManyAttempts(pool, rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
|
||||
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
|
||||
}
|
||||
if (!identifier || !password) return res.status(400).json({ error: 'identifier and password are required' });
|
||||
|
||||
const student = await getStudentByIdentifier(pool, identifier);
|
||||
// Same generic response whether the identifier doesn't exist, has no credentials yet
|
||||
// (pilot/unconverted), or the password is wrong — no account enumeration either way.
|
||||
const passwordOk = student && student.password_hash && verifyPassword(password, student.password_hash);
|
||||
if (!passwordOk) {
|
||||
recordStudentLoginFailure();
|
||||
await recordAudit(pool, {
|
||||
actorType: 'student', actorId: student?.student_id, action: 'login', result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid login or password.' });
|
||||
}
|
||||
if (student.status !== 'active') {
|
||||
recordStudentLoginFailure();
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' } });
|
||||
return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' });
|
||||
}
|
||||
|
||||
const token = await sessions.createStudentSession(pool, student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
|
||||
recordStudentLoginSuccess();
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'success', sourceIp: req.ip });
|
||||
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: SESSION_MAX_AGE_SEC }));
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
|
||||
res.json({ student: publicStudent({ ...student, last_login_at: new Date() }) });
|
||||
});
|
||||
|
||||
// Consumes an activation token issued by GET/POST /api/admin/students (creation) or
|
||||
// /api/admin/students/:id/convert — sets the student's first password and logs them straight
|
||||
// in, same as any other login (§5's "temporary credential becomes invalid" is the token being
|
||||
// single-use — consumeCredentialToken clears it atomically with setting the password).
|
||||
app.post('/api/student/activate', async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password || password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||
}
|
||||
const student = await consumeCredentialToken(pool, token, 'activation', password);
|
||||
if (!student) {
|
||||
recordStudentActivation('invalid_or_expired_token');
|
||||
return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
||||
}
|
||||
recordStudentActivation('success');
|
||||
|
||||
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'account_activated', result: 'success', sourceIp: req.ip });
|
||||
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, sessionToken, { maxAgeSec: SESSION_MAX_AGE_SEC }));
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
|
||||
res.json({ student: publicStudent(student) });
|
||||
});
|
||||
|
||||
// Lets a not-yet-logged-in student preview whose activation/reset link this is before they
|
||||
// submit a new password (e.g. "Activating account for Alice") — never reveals anything if
|
||||
// the token is invalid/expired/wrong-purpose, same shape either way.
|
||||
app.get('/api/student/credential-token/:purpose', async (req, res) => {
|
||||
const { purpose } = req.params;
|
||||
if (!['activation', 'reset'].includes(purpose)) return res.status(400).json({ error: 'invalid_purpose' });
|
||||
const student = await findStudentByCredentialToken(pool, req.query.token, purpose);
|
||||
if (!student) return res.status(404).json({ error: 'invalid_or_expired_token' });
|
||||
res.json({ display_name: student.display_name });
|
||||
});
|
||||
|
||||
app.post('/api/student/change-password', studentSession, async (req, res) => {
|
||||
const { old_password, new_password } = req.body || {};
|
||||
if (!old_password || !new_password || new_password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'new_password must be at least 10 characters' });
|
||||
}
|
||||
const student = await getActiveStudent(pool, req.studentId);
|
||||
if (!student || !student.password_hash || !verifyPassword(old_password, student.password_hash)) {
|
||||
await recordAudit(pool, { actorType: 'student', actorId: req.studentId, action: 'password_change', result: 'failure', sourceIp: req.ip });
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' });
|
||||
}
|
||||
const newHash = hashPassword(new_password);
|
||||
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
|
||||
// Phase 5C §5D — a security-notice email, no token, purely informational.
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the
|
||||
// SAME generic response returns regardless of whether the identifier exists, is inactive, or
|
||||
// is a genuine hit — the only difference a caller can ever observe is whether an email
|
||||
// eventually arrives, never anything in this response. Production NEVER returns the token
|
||||
// (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see
|
||||
// server.js); the dev-only echo exists purely so a local/CI run can complete the loop without
|
||||
// a real inbox, exactly as before. What Phase 5C actually closes: the token is now also
|
||||
// handed to notifications.enqueue() so a real email goes out — see
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here
|
||||
// (synchronously, as before) rather than by the worker.
|
||||
app.post('/api/student/request-password-reset', async (req, res) => {
|
||||
const { identifier } = req.body || {};
|
||||
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
|
||||
if (!identifier) return res.json(generic);
|
||||
const student = await getStudentByIdentifier(pool, identifier);
|
||||
if (!student || student.status !== 'active' || !student.password_hash) return res.json(generic);
|
||||
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
|
||||
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
|
||||
res.json(generic);
|
||||
});
|
||||
|
||||
app.post('/api/student/reset-password', async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password || password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||
}
|
||||
const student = await consumeCredentialToken(pool, token, 'reset', password);
|
||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// §10 — minimal profile page data: identity, cohort(s), progress summary, achievements,
|
||||
// active session count. Reuses the same loadProgress/curriculum modules the existing
|
||||
// /api/progress and /api/curriculum/path routes use — no parallel progress-reading logic.
|
||||
app.get('/api/student/profile', studentSession, async (req, res) => {
|
||||
const student = await getActiveStudent(pool, req.studentId);
|
||||
if (!student) return res.status(401).json({ error: 'no_session' });
|
||||
const [{ rows: cohorts }, progress, { rows: achievements }, sessionsList] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT c.cohort_id, c.name FROM cohort_enrollments e JOIN cohorts c ON c.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled'`,
|
||||
[student.student_id]
|
||||
),
|
||||
loadProgress(pool, student.student_id),
|
||||
pool.query(
|
||||
`SELECT achievement_type, stage_id, achieved_at FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`,
|
||||
[student.student_id]
|
||||
),
|
||||
sessions.listStudentSessions(pool, student.student_id),
|
||||
]);
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
const completed = Object.values(progress).filter((p) => p.status === 'completed').length;
|
||||
res.json({
|
||||
student: publicStudent(student),
|
||||
cohorts,
|
||||
progress_summary: { completed, total: scenarios.length },
|
||||
achievements,
|
||||
active_sessions: sessionsList.filter((s) => !s.revoked_at && new Date(s.expires_at) > new Date()).length,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.STUDENT_COOKIE = STUDENT_COOKIE;
|
||||
module.exports.STUDENT_CSRF_COOKIE = STUDENT_CSRF_COOKIE;
|
||||
@@ -0,0 +1,70 @@
|
||||
// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the
|
||||
// expected input is simple (no quoted fields containing commas expected in practice for
|
||||
// names/identifiers), and pulling in a dependency for a three-column parser isn't justified
|
||||
// (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level
|
||||
// error at once — the caller decides whether to import (transactional, all-or-nothing).
|
||||
|
||||
const REQUIRED_HEADERS = ['display_name'];
|
||||
const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id'];
|
||||
const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment
|
||||
|
||||
function splitCsvLine(line) {
|
||||
// No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't
|
||||
// contain commas in practice) — a plain split covers the real input shape.
|
||||
return line.split(',').map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function parseStudentCsv(csv) {
|
||||
const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
const errors = [];
|
||||
if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] };
|
||||
|
||||
const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
|
||||
for (const required of REQUIRED_HEADERS) {
|
||||
if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` });
|
||||
}
|
||||
for (const forbidden of FORBIDDEN_HEADERS) {
|
||||
if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` });
|
||||
}
|
||||
const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h));
|
||||
if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` });
|
||||
if (errors.length) return { rows: [], errors };
|
||||
|
||||
const seenEmails = new Set();
|
||||
const seenIdentifiers = new Set();
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees
|
||||
const cells = splitCsvLine(lines[i]);
|
||||
if (cells.length !== headers.length) {
|
||||
errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` });
|
||||
continue;
|
||||
}
|
||||
const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]]));
|
||||
if (!raw.display_name) {
|
||||
errors.push({ row: rowNum, message: 'display_name is required' });
|
||||
continue;
|
||||
}
|
||||
const email = raw.email ? raw.email.toLowerCase() : null;
|
||||
const loginIdentifier = raw.login_identifier || null;
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.push({ row: rowNum, message: `invalid email: ${raw.email}` });
|
||||
continue;
|
||||
}
|
||||
if (email && seenEmails.has(email)) {
|
||||
errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` });
|
||||
continue;
|
||||
}
|
||||
if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) {
|
||||
errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` });
|
||||
continue;
|
||||
}
|
||||
if (email) seenEmails.add(email);
|
||||
if (loginIdentifier) seenIdentifiers.add(loginIdentifier);
|
||||
rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null });
|
||||
}
|
||||
if (errors.length) return { rows: [], errors };
|
||||
return { rows, errors: [] };
|
||||
}
|
||||
|
||||
module.exports = { parseStudentCsv };
|
||||
@@ -0,0 +1,90 @@
|
||||
// Student credential/account lifecycle — Phase 5A §2/§4/§5/§7. Parallel to instructors.js
|
||||
// (same scrypt hashing from ./auth/password.js, same "row minus password_hash/token_hash"
|
||||
// public-view pattern), not a duplicate auth system: student and instructor accounts share the
|
||||
// exact same session store (sessions.js), CSRF pattern, and rate limiter — only the identity
|
||||
// table and the specific login identifier (email/login_identifier vs. instructor email) differ.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { hashPassword, verifyPassword } = require('./auth/password');
|
||||
|
||||
const CREDENTIAL_TOKEN_BYTES = 32;
|
||||
const ACTIVATION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // a week — an instructor may hand this to a student who isn't logging in same-day
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // an hour — shorter, since a reset implies "I have access right now"
|
||||
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// Never returns password_hash or credential_token_hash — the one and only place a student row
|
||||
// is shaped for an API response. Every route below returns THIS, never the raw row.
|
||||
function publicStudent(row) {
|
||||
if (!row) return null;
|
||||
const { password_hash, credential_token_hash, ...rest } = row;
|
||||
return { ...rest, has_credentials: !!password_hash };
|
||||
}
|
||||
|
||||
async function getActiveStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM students WHERE student_id = $1 AND status = 'active'`, [studentId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Case-insensitive on email (matches instructors.js's convention); login_identifier is matched
|
||||
// exactly as stored — it's instructor-assigned, not self-chosen, so there's no "did they mean
|
||||
// to capitalize it differently" ambiguity to normalize away.
|
||||
async function getStudentByIdentifier(pool, identifier) {
|
||||
const id = String(identifier || '').trim();
|
||||
if (!id) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM students WHERE lower(email) = lower($1) OR login_identifier = $1`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Generates a credential token (activation or reset), returns the PLAINTEXT once — callers
|
||||
// must hand it to the instructor/student and never persist or log it themselves. Only the hash
|
||||
// goes to the database. Overwrites any previous outstanding token for this student (a new
|
||||
// activation/reset link invalidates an older unused one — only the latest is ever valid).
|
||||
async function issueCredentialToken(pool, studentId, purpose) {
|
||||
const token = crypto.randomBytes(CREDENTIAL_TOKEN_BYTES).toString('base64url');
|
||||
const ttlMs = purpose === 'reset' ? RESET_TOKEN_TTL_MS : ACTIVATION_TOKEN_TTL_MS;
|
||||
const expiresAt = new Date(Date.now() + ttlMs);
|
||||
await pool.query(
|
||||
`UPDATE students SET credential_token_hash = $1, credential_token_purpose = $2, credential_token_expires_at = $3
|
||||
WHERE student_id = $4`,
|
||||
[hashToken(token), purpose, expiresAt, studentId]
|
||||
);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
// Looks up the student a token belongs to, WITHOUT consuming it — callers that need to show
|
||||
// "who is this token for" before the student submits a new password (e.g. to greet them by
|
||||
// name) use this; consumeCredentialToken is the one that actually spends it.
|
||||
async function findStudentByCredentialToken(pool, token, purpose) {
|
||||
if (!token) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM students WHERE credential_token_hash = $1 AND credential_token_purpose = $2
|
||||
AND credential_token_expires_at > now()`,
|
||||
[hashToken(token), purpose]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Sets a new password and clears the token in one statement — a token can only ever be used
|
||||
// once, whether it succeeds or the caller retries with a stale value afterward.
|
||||
async function consumeCredentialToken(pool, token, purpose, newPassword) {
|
||||
const passwordHash = hashPassword(newPassword);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE students SET password_hash = $1, credential_token_hash = NULL, credential_token_purpose = NULL,
|
||||
credential_token_expires_at = NULL, updated_at = now()
|
||||
WHERE credential_token_hash = $2 AND credential_token_purpose = $3 AND credential_token_expires_at > now()
|
||||
RETURNING *`,
|
||||
[passwordHash, hashToken(token), purpose]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword, verifyPassword, publicStudent, getActiveStudent, getStudentByIdentifier,
|
||||
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
// Fast, isolated unit test for student-csv.js — no Postgres, no HTTP. No framework, matching
|
||||
// project convention (see platform/lab-router/test/run.js).
|
||||
|
||||
const assert = require('assert');
|
||||
const { parseStudentCsv } = require('../src/student-csv');
|
||||
|
||||
function main() {
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email,login_identifier,cohort_id\nAlice,[email protected],,root-fall\nBob,,bob01,root-fall');
|
||||
assert.equal(r.errors.length, 0);
|
||||
assert.equal(r.rows.length, 2);
|
||||
assert.equal(r.rows[0].email, '[email protected]');
|
||||
assert.equal(r.rows[1].login_identifier, 'bob01');
|
||||
console.log('ok — valid file parses');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('email\[email protected]');
|
||||
assert.ok(r.errors.length > 0);
|
||||
console.log('ok — missing display_name column rejected');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,password\nAlice,hunter2');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('password')));
|
||||
console.log('ok — password column never accepted from a file');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,[email protected],extra');
|
||||
assert.ok(r.errors.length > 0);
|
||||
assert.equal(r.rows.length, 0);
|
||||
console.log('ok — a malformed row rejects the whole file (transactional, no partial import)');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,[email protected]\nBob,[email protected]');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('duplicate email')));
|
||||
console.log('ok — duplicate email within one file rejected');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,not-an-email');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('invalid email')));
|
||||
console.log('ok — invalid email format rejected');
|
||||
}
|
||||
console.log('\nAll student-csv unit tests passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -68,10 +68,33 @@ 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:-}
|
||||
# Phase 5C — activation/reset email links are built from this origin (see
|
||||
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
|
||||
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
|
||||
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
|
||||
# network call no matter what recipient it's given.
|
||||
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
|
||||
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
|
||||
# Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js).
|
||||
# Set all four in platform/.env only for a deliberate one-off live-delivery test; unset
|
||||
# again afterward so routine dev/test runs never risk a real send.
|
||||
EMAIL_PROVIDER: ${EMAIL_PROVIDER:-}
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-}
|
||||
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
|
||||
GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-}
|
||||
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.
|
||||
# COOKIE_SECURE: "1" # set once served over real HTTPS
|
||||
# Empty by default — unset/empty means the exact same dev behavior as before this was
|
||||
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
|
||||
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
|
||||
# real, then unsets it again — see that test's own comment.
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5A — student identity & remote classroom. Permanent regression test replacing the
|
||||
// throwaway smoke5a/smoke5b scripts used during development. Covers §31 (two-student + RBAC
|
||||
// matrix) and §32 (password security), plus a real classroom lifecycle smoke pass. Same
|
||||
// conventions as e2e-phase3a.js: assert-based, no framework, runs against a live
|
||||
// docker-compose.dev.yml stack, shells out for the restart-persistence test.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase5a.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5A student identity / remote classroom acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.ok(owner.hasCookie('rn_instructor'), 'owner login must succeed before anything else can run');
|
||||
|
||||
// ── Fixtures: two cohorts, an assigned instructor, an unassigned instructor, a viewer,
|
||||
// two students (Alice/Bob) in cohort A. ────────────────────────────────────────────────
|
||||
const cohortA = `p5a-cohort-a-${RUN_ID}`;
|
||||
const cohortB = `p5a-cohort-b-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortA, name: 'P5A Cohort A' });
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortB, name: 'P5A Cohort B' });
|
||||
|
||||
const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned Instructor', role: 'instructor', password: 'AssignedPass123!',
|
||||
});
|
||||
const { body: unassignedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-unassigned-${RUN_ID}@rootnode.test`, display_name: 'Unassigned Instructor', role: 'instructor', password: 'UnassignedPass123!',
|
||||
});
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: assignedInstr.instructor_id, role_in_cohort: 'teacher' });
|
||||
const { body: viewerInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-viewer-${RUN_ID}@rootnode.test`, display_name: 'Viewer Instructor', role: 'instructor', password: 'ViewerPass123!',
|
||||
});
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: viewerInstr.instructor_id, role_in_cohort: 'viewer' });
|
||||
|
||||
const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', {
|
||||
display_name: 'Alice', cohort_id: cohortA, login_identifier: `p5a-alice-${RUN_ID}`,
|
||||
});
|
||||
const { body: bobCreate } = await owner.request('POST', '/api/admin/students', {
|
||||
display_name: 'Bob', cohort_id: cohortA, login_identifier: `p5a-bob-${RUN_ID}`,
|
||||
});
|
||||
const alicePassword = 'AlicePassword123!';
|
||||
const bobPassword = 'BobPassword123!';
|
||||
const alice = makeSession(GW);
|
||||
const bob = makeSession(GW);
|
||||
await test('activation: token sets password and logs the student in', async () => {
|
||||
const { status, body } = await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: alicePassword });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.student.display_name, 'Alice');
|
||||
assert.ok(alice.hasCookie('rn_session'));
|
||||
});
|
||||
await test('activation: the same token cannot be used twice (single-use)', async () => {
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'SomeOtherPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: bobPassword });
|
||||
|
||||
const { body: classroomCreate } = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'P5A Live Class' });
|
||||
const classroomId = classroomCreate.classroom_session_id;
|
||||
|
||||
// ── §32 Password security ───────────────────────────────────────────────────────────────
|
||||
console.log('\nPassword security (§32)');
|
||||
|
||||
await test('correct identifier + password logs in', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('wrong password is rejected generically', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status, body } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
|
||||
assert.equal(status, 401);
|
||||
assert.equal(body.error, 'invalid_credentials');
|
||||
});
|
||||
|
||||
await test('unknown identifier fails with the SAME response as a wrong password (no enumeration)', async () => {
|
||||
const s1 = makeSession(GW), s2 = makeSession(GW);
|
||||
const unknown = await s1.request('POST', '/api/student/login', { identifier: `nobody-${RUN_ID}`, password: 'whatever12345' });
|
||||
const wrongPw = await s2.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
|
||||
assert.equal(unknown.status, wrongPw.status);
|
||||
assert.deepEqual(unknown.body, wrongPw.body);
|
||||
});
|
||||
|
||||
await test('deactivated account is blocked at login', async () => {
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
assert.equal(status, 403);
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' }); // restore for later tests
|
||||
});
|
||||
|
||||
await test('deactivation takes effect immediately on an EXISTING session, not just at next login', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
const before = await s.request('GET', '/api/student/profile');
|
||||
assert.equal(before.status, 200);
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
|
||||
const after = await s.request('GET', '/api/student/profile');
|
||||
assert.notEqual(after.status, 200); // studentSession middleware re-checks status='active' every request
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' });
|
||||
});
|
||||
|
||||
await test('rate limiting: repeated failures against the same identifier eventually 429s', async () => {
|
||||
const key = `p5a-ratelimit-${RUN_ID}`;
|
||||
let sawTooMany = false;
|
||||
for (let i = 0; i < 12 && !sawTooMany; i++) {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
if (status === 429) sawTooMany = true;
|
||||
}
|
||||
assert.ok(sawTooMany, 'expected a 429 within 12 attempts (LOGIN_MAX_ATTEMPTS=8)');
|
||||
});
|
||||
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('rate limit state survives a Platform API restart (Postgres-backed, not in-memory)', async () => {
|
||||
const key = `p5a-ratelimit-restart-${RUN_ID}`;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
}
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
assert.equal(status, 429, 'the attempt count must have survived the restart');
|
||||
});
|
||||
} else {
|
||||
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
|
||||
}
|
||||
|
||||
await test('change-password: wrong current password is rejected', async () => {
|
||||
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: 'not-the-real-password', new_password: 'NewAlicePass123!' });
|
||||
assert.equal(status, 401);
|
||||
});
|
||||
|
||||
await test('change-password: correct current password succeeds and revokes OTHER sessions', async () => {
|
||||
const otherSession = makeSession(GW);
|
||||
await otherSession.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
|
||||
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: alicePassword, new_password: 'NewAlicePass123!' });
|
||||
assert.equal(status, 200);
|
||||
const { status: otherStatus } = await otherSession.request('GET', '/api/student/profile');
|
||||
assert.notEqual(otherStatus, 200); // the OTHER session (not the one that made the change) is revoked
|
||||
const { status: selfStatus } = await alice.request('GET', '/api/student/profile');
|
||||
assert.equal(selfStatus, 200); // the session that made the change is untouched
|
||||
});
|
||||
|
||||
await test('forgot-password: identical generic response for a real vs unknown identifier (anti-enumeration)', async () => {
|
||||
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5a-alice-${RUN_ID}` });
|
||||
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}` });
|
||||
assert.equal(real.body.message, fake.body.message);
|
||||
});
|
||||
|
||||
await test('admin-issued reset token: sets a new password and revokes ALL sessions', async () => {
|
||||
const { body } = await owner.request('POST', `/api/admin/students/${aliceCreate.student_id}/reset-password`);
|
||||
assert.ok(body.reset_token);
|
||||
const preCheck = await alice.request('GET', '/api/student/profile');
|
||||
assert.notEqual(preCheck.status, 200); // reset-password revokes existing sessions immediately
|
||||
const fresh = makeSession(GW);
|
||||
const { status } = await fresh.request('POST', '/api/student/reset-password', { token: body.reset_token, password: 'ResetAlicePass123!' });
|
||||
assert.equal(status, 200);
|
||||
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'ResetAlicePass123!' });
|
||||
assert.equal(login.status, 200);
|
||||
});
|
||||
|
||||
await test('CSRF: a mutating request without the header is rejected even with a valid session cookie', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
const res = await fetch(`${GW}/api/student/change-password`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${s.getCookie('rn_session')}` },
|
||||
body: JSON.stringify({ old_password: bobPassword, new_password: 'ShouldNotWork123!' }),
|
||||
});
|
||||
assert.notEqual(res.status, 200);
|
||||
});
|
||||
|
||||
// ── §31 Two-student + RBAC matrix ───────────────────────────────────────────────────────
|
||||
console.log('\nTwo-student and RBAC matrix (§31)');
|
||||
|
||||
const bobSession = makeSession(GW);
|
||||
await bobSession.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
|
||||
await test('a student session cannot reach ANY /api/admin/* route', async () => {
|
||||
const { status } = await bobSession.request('GET', '/api/admin/students');
|
||||
assert.ok([401, 403].includes(status));
|
||||
});
|
||||
|
||||
await test('a student profile request only ever returns that student\'s own identity', async () => {
|
||||
const { body } = await bobSession.request('GET', '/api/student/profile');
|
||||
assert.equal(body.student.display_name, 'Bob');
|
||||
});
|
||||
|
||||
await test('assigned instructor CAN view and manage cohort A (students, classroom)', async () => {
|
||||
const assigned = makeSession(GW);
|
||||
await assigned.request('POST', '/api/auth/instructor/login', { email: `p5a-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
|
||||
const view = await assigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
|
||||
assert.equal(view.status, 200);
|
||||
const write = await assigned.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Assigned-created class' });
|
||||
assert.equal(write.status, 201);
|
||||
});
|
||||
|
||||
await test('instructor NOT assigned to cohort A is DENIED (403) on its resources', async () => {
|
||||
const unassigned = makeSession(GW);
|
||||
await unassigned.request('POST', '/api/auth/instructor/login', { email: `p5a-unassigned-${RUN_ID}@rootnode.test`, password: 'UnassignedPass123!' });
|
||||
const view = await unassigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
|
||||
assert.equal(view.status, 403);
|
||||
const dashboard = await unassigned.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(dashboard.status, 403);
|
||||
});
|
||||
|
||||
await test('viewer role_in_cohort CAN view but is DENIED mutation on cohort A', async () => {
|
||||
const viewer = makeSession(GW);
|
||||
await viewer.request('POST', '/api/auth/instructor/login', { email: `p5a-viewer-${RUN_ID}@rootnode.test`, password: 'ViewerPass123!' });
|
||||
const view = await viewer.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(view.status, 200);
|
||||
const write = await viewer.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'should be denied' });
|
||||
assert.equal(write.status, 403);
|
||||
});
|
||||
|
||||
// ── Classroom lifecycle smoke pass ──────────────────────────────────────────────────────
|
||||
console.log('\nClassroom lifecycle');
|
||||
|
||||
await test('classroom: start -> join -> heartbeat -> dashboard reflects presence -> end', async () => {
|
||||
const { status: startStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
assert.equal(startStatus, 200);
|
||||
await bobSession.request('POST', `/api/classroom/${classroomId}/join`);
|
||||
await bobSession.request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
const { body: dash } = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const bobRow = dash.students.find((s) => s.student_id === bobCreate.student_id);
|
||||
assert.ok(bobRow, 'Bob must appear as a participant after joining');
|
||||
assert.equal(bobRow.presence, 'online');
|
||||
const { status: endStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
|
||||
assert.equal(endStatus, 200);
|
||||
});
|
||||
|
||||
await test('classroom: only one LIVE classroom per cohort at a time', async () => {
|
||||
const c2 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Second class' });
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/start`);
|
||||
const c3 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Third class' });
|
||||
const { status } = await owner.request('POST', `/api/admin/classroom-sessions/${c3.body.classroom_session_id}/start`);
|
||||
assert.equal(status, 409);
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/end`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5C — enrollment operations & communications acceptance test. Covers the notification
|
||||
// lifecycle (queue/claim/sent/failed/retry), activation/enrollment-confirmation/password-reset/
|
||||
// password-changed delivery, duplicate-webhook idempotency, retry/failure classification and
|
||||
// exhaustion, minor/no-email contact-email resolution, admin operations (resend, RBAC scoping),
|
||||
// security (token secrecy, production gating), and a real Platform API restart. Uses the
|
||||
// deterministic dev email provider throughout — no real network call is ever made by this
|
||||
// suite. Same conventions as e2e-phase5b.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack.
|
||||
//
|
||||
// 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-phase5c.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';
|
||||
|
||||
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++; }
|
||||
}
|
||||
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 waitForStatus(owner, studentId, wantStatus, timeoutMs = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const match = body.find((n) => n.status === wantStatus);
|
||||
if (match) return match;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`timed out waiting for a notification with status=${wantStatus}`);
|
||||
}
|
||||
// Same idea, but for one SPECIFIC notification_id — waitForStatus alone isn't enough once a
|
||||
// student has more than one notification (e.g. right after a resend), since it would happily
|
||||
// match an EARLIER notification that already reached the wanted status instead of the new one.
|
||||
async function waitForNotificationStatus(owner, studentId, notificationId, wantStatus, timeoutMs = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const row = body.find((n) => n.notification_id === notificationId);
|
||||
if (row?.status === wantStatus) return row;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`timed out waiting for notification ${notificationId} to reach status=${wantStatus}`);
|
||||
}
|
||||
// Nudges a still-queued row's next_attempt_at to now, so a test doesn't have to sit through
|
||||
// real exponential backoff (up to an hour) to observe retry/exhaustion behavior.
|
||||
function nudgeRetry() {
|
||||
execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`,
|
||||
{ cwd: `${__dirname}/..`, stdio: 'pipe' }
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5C enrollment operations & communications acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── §1-2: activation vs enrollment-confirmation ─────────────────────────────────────────
|
||||
let newStudentId, newEnrollmentId;
|
||||
await test('new student integration enrollment queues an activation notification', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-new-${RUN_ID}@example.com`, display_name: 'New Student' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
newStudentId = body.student_id; newEnrollmentId = body.enrollment_id;
|
||||
const sent = await waitForStatus(owner, newStudentId, 'sent');
|
||||
assert.equal(sent.notification_type, 'activation');
|
||||
});
|
||||
|
||||
await test('existing active student gets enrollment_confirmation instead of activation on a second program', async () => {
|
||||
// Activate the student from the previous test first (credentials required for this branch).
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
const activationNotif = preview.find((n) => n.notification_type === 'activation');
|
||||
const { body: devPreview } = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
const token = devPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
const student = makeSession(GW);
|
||||
await student.request('POST', '/api/student/activate', { token, password: 'NewStudentPass123!' });
|
||||
|
||||
await owner.request('POST', '/api/admin/programs', { program_id: `p5c-second-${RUN_ID}`, name: 'Second Program', family: 'root' });
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-second`, source: 'website', program_slug: `p5c-second-${RUN_ID}`, status: 'paid',
|
||||
student: { email: `p5c-new-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, newStudentId, 'sent', 8000);
|
||||
// waitForStatus returns the FIRST sent row it finds — there are now two (activation +
|
||||
// confirmation); assert the confirmation specifically exists among this student's notifications.
|
||||
const { body: all } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
const confirmation = all.find((n) => n.notification_type === 'enrollment_confirmation' && n.enrollment_id === body.enrollment_id);
|
||||
assert.ok(confirmation, 'expected an enrollment_confirmation notification for the already-activated student');
|
||||
});
|
||||
|
||||
// ── §3-4: duplicate/concurrent webhook -> no notification storm ────────────────────────
|
||||
await test('duplicate integration event sends no duplicate notification', async () => {
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com` } });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const { body: after } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
assert.equal(after.length, before.length);
|
||||
});
|
||||
|
||||
await test('concurrent duplicate integration events send exactly one logical initial notification', async () => {
|
||||
const eventId = `p5c-${RUN_ID}-race`;
|
||||
const email = `p5c-racer-${RUN_ID}@example.com`;
|
||||
const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email } });
|
||||
const [a, b, c] = await Promise.all([fire(), fire(), fire()]);
|
||||
const studentId = a.body.student_id;
|
||||
await waitForStatus(owner, studentId, 'sent');
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
assert.equal(notifs.length, 1, `expected exactly one notification, got ${notifs.length}`);
|
||||
});
|
||||
|
||||
// ── §5-10: worker delivery, retry, permanent failure, exhaustion ────────────────────────
|
||||
await test('temporary provider failure retries (does not immediately fail)', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-temp`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-temp-${RUN_ID}[email protected]` },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3500)); // let the first (failing) attempt run
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
|
||||
const notif = notifs[0];
|
||||
assert.ok(['queued', 'sending'].includes(notif.status), `expected still-retrying status, got ${notif.status}`);
|
||||
assert.ok(notif.attempt_count >= 1);
|
||||
assert.equal(notif.last_error_code, 'simulated_temporary_failure');
|
||||
});
|
||||
|
||||
await test('permanent failure becomes failed on the first attempt (no wasted retries)', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-perm`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-perm-${RUN_ID}[email protected]` },
|
||||
});
|
||||
const failed = await waitForStatus(owner, body.student_id, 'failed');
|
||||
assert.equal(failed.attempt_count, 1);
|
||||
assert.equal(failed.last_error_code, 'simulated_permanent_failure');
|
||||
});
|
||||
|
||||
await test('retry exhaustion: a persistently-failing temporary error eventually becomes failed, bounded', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-exhaust`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-exhaust-${RUN_ID}[email protected]` },
|
||||
});
|
||||
let finalStatus = null, finalAttempts = null;
|
||||
// MAX_ATTEMPTS is 5 (notifications.js) and the worker polls every NOTIFICATION_WORKER_POLL_MS
|
||||
// (default 3000ms) — each loop pass must outlast one poll cycle for the nudge to actually be
|
||||
// picked up before the next nudge, or this just re-nudges a row the worker hasn't touched yet.
|
||||
for (let i = 0; i < 10 && finalStatus !== 'failed'; i++) {
|
||||
nudgeRetry();
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
|
||||
finalStatus = notifs[0]?.status;
|
||||
finalAttempts = notifs[0]?.attempt_count;
|
||||
}
|
||||
assert.equal(finalStatus, 'failed', `expected exhaustion to reach failed within the nudge loop (last status: ${finalStatus}, attempts: ${finalAttempts})`);
|
||||
assert.ok(finalAttempts <= 5, `expected a bounded attempt count, got ${finalAttempts}`);
|
||||
});
|
||||
|
||||
// ── §11-15: admin visibility, resend/regenerate ─────────────────────────────────────────
|
||||
await test('admin sees delivery status via both the global and per-student views', async () => {
|
||||
const { body: global_ } = await owner.request('GET', '/api/admin/notifications');
|
||||
assert.ok(global_.items.length > 0);
|
||||
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
assert.ok(perStudent.length > 0);
|
||||
assert.ok(!('metadata' in perStudent[0]));
|
||||
assert.ok(!('recipient_email' in perStudent[0])); // only the masked field is ever present
|
||||
});
|
||||
|
||||
let pendingStudentId, firstActivationNotifId, resentNotifId;
|
||||
await test('resend creates an intentional NEW notification attempt (not a mutation of the old row)', async () => {
|
||||
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Resend Test', email: `p5c-resend-${RUN_ID}@example.com` });
|
||||
pendingStudentId = created.student_id;
|
||||
await waitForStatus(owner, pendingStudentId, 'sent');
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
firstActivationNotifId = before[0].notification_id;
|
||||
const { status, body: resent } = await owner.request('POST', `/api/admin/students/${pendingStudentId}/resend-activation`);
|
||||
assert.equal(status, 200);
|
||||
assert.notEqual(resent.notification_id, firstActivationNotifId);
|
||||
resentNotifId = resent.notification_id;
|
||||
});
|
||||
|
||||
let oldToken, newToken;
|
||||
await test('regenerate (resend) invalidates the previous activation token; old token now fails, new one works', async () => {
|
||||
const { status: previewStatus, body: firstPreview } = await owner.request('GET', `/api/admin/notifications/${firstActivationNotifId}/dev-preview`);
|
||||
if (previewStatus !== 200) throw new Error(`dev-preview for ${firstActivationNotifId} returned ${previewStatus}: ${JSON.stringify(firstPreview)}`);
|
||||
oldToken = firstPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
await waitForNotificationStatus(owner, pendingStudentId, resentNotifId, 'sent'); // the resend's OWN delivery, not just "any" sent notification
|
||||
const { status: secondPreviewStatus, body: secondPreview } = await owner.request('GET', `/api/admin/notifications/${resentNotifId}/dev-preview`);
|
||||
if (secondPreviewStatus !== 200) throw new Error(`dev-preview for ${resentNotifId} returned ${secondPreviewStatus}: ${JSON.stringify(secondPreview)}`);
|
||||
newToken = secondPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
assert.notEqual(oldToken, newToken);
|
||||
|
||||
const failWithOld = await makeSession(GW).request('POST', '/api/student/activate', { token: oldToken, password: 'ShouldNotWork123!' });
|
||||
assert.equal(failWithOld.status, 400);
|
||||
const okWithNew = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'ResendPass123!' });
|
||||
assert.equal(okWithNew.status, 200);
|
||||
});
|
||||
|
||||
await test('activation token is single-use (the new token, already consumed above, fails a second time)', async () => {
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'AnotherPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
await test('activation token expiration is enforced server-side', async () => {
|
||||
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Expiry Test', email: `p5c-expiry-${RUN_ID}@example.com` });
|
||||
execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE students SET credential_token_expires_at = now() - interval '1 hour' WHERE student_id = '${created.student_id}'"`,
|
||||
{ cwd: `${__dirname}/..` }
|
||||
);
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: created.activation_token, password: 'ExpiredPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
// ── §18/§20/§21: token secrecy ───────────────────────────────────────────────────────────
|
||||
await test('no activation/reset plaintext ever appears in any admin notification API response', async () => {
|
||||
const { body: list } = await owner.request('GET', '/api/admin/notifications');
|
||||
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
const serialized = JSON.stringify(list) + JSON.stringify(perStudent);
|
||||
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken));
|
||||
});
|
||||
|
||||
// ── §19-24: password reset ───────────────────────────────────────────────────────────────
|
||||
await test('reset anti-enumeration preserved (identical response for real vs unknown identifier)', async () => {
|
||||
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
|
||||
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}@example.com` });
|
||||
assert.equal(real.body.message, fake.body.message);
|
||||
});
|
||||
|
||||
let resetToken;
|
||||
await test('reset email is queued and delivered for a valid account', async () => {
|
||||
const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
|
||||
resetToken = res.body.dev_reset_token;
|
||||
assert.ok(resetToken);
|
||||
const sent = await waitForStatus(owner, pendingStudentId, 'sent', 8000);
|
||||
});
|
||||
|
||||
await test('reset token is single-use and expiration is enforced', async () => {
|
||||
const good = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'ResetPassNew123!' });
|
||||
assert.equal(good.status, 200);
|
||||
const reuse = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'AnotherOne123!' });
|
||||
assert.equal(reuse.status, 400);
|
||||
});
|
||||
|
||||
await test('password reset revokes prior sessions and the new password works', async () => {
|
||||
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
assert.equal(login.status, 200);
|
||||
});
|
||||
|
||||
// ── §25-26: minor/no-email contact-email path ───────────────────────────────────────────
|
||||
await test('minor/no-email recipient flow: contact_email delivers the activation notification', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { login_identifier: `p5c-minor-${RUN_ID}`, display_name: 'Minor Student', contact_email: `p5c-guardian-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, body.student_id, 'sent');
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.equal(preview.to, `p5c-guardian-${RUN_ID}@example.com`);
|
||||
});
|
||||
|
||||
await test('the guardian contact email is NEVER written to students.email', async () => {
|
||||
const out = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -tA -c "SELECT email FROM students WHERE login_identifier = 'p5c-minor-${RUN_ID}'"`,
|
||||
{ cwd: `${__dirname}/..` }
|
||||
).toString().trim();
|
||||
assert.equal(out, '');
|
||||
});
|
||||
|
||||
// ── §27-30: integration auth, cross-student isolation, RBAC scoping ────────────────────
|
||||
await test('integration auth remains separate from student/admin session auth', async () => {
|
||||
const res = await fetch(`${GW}/api/integrations/enrollments`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
|
||||
body: JSON.stringify({ event_id: 'x', program_slug: PROGRAM, status: 'paid', student: { email: '[email protected]' } }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
await test('a student session cannot invoke any admin notification route', async () => {
|
||||
const student = makeSession(GW);
|
||||
await student.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
const { status } = await student.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.ok([401, 403].includes(status));
|
||||
});
|
||||
|
||||
// ── Regression: no arbitrary email template injection (§21) ────────────────────────────
|
||||
// Found and fixed during this phase — display_name flows straight from a website
|
||||
// integration event (genuinely external, caller-controlled text) into the activation
|
||||
// email's HTML. templates.js now escapes every interpolated value in the HTML body.
|
||||
await test('a display_name containing HTML markup is escaped in the rendered email, not injected', async () => {
|
||||
const maliciousName = '<img src=x onerror=alert(1)>Injected';
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-injection`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-injection-${RUN_ID}@example.com`, display_name: maliciousName },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, body.student_id, 'sent');
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.ok(!preview.html.includes('<img src=x onerror=alert(1)>'), 'raw markup must never appear unescaped in the HTML body');
|
||||
assert.ok(preview.html.includes('<img'), 'the escaped form must be present instead');
|
||||
});
|
||||
|
||||
const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5c-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!',
|
||||
});
|
||||
await test('instructor RBAC/scoping preserved for notification visibility', async () => {
|
||||
const unrelated = makeSession(GW);
|
||||
await unrelated.request('POST', '/api/auth/instructor/login', { email: `p5c-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' });
|
||||
const scopedList = await unrelated.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.equal(scopedList.status, 403);
|
||||
const globalList = await unrelated.request('GET', '/api/admin/notifications');
|
||||
assert.equal(globalList.status, 403); // global view is owner/admin only
|
||||
});
|
||||
|
||||
// ── §33-34: metrics / audit hygiene ──────────────────────────────────────────────────────
|
||||
await test('metrics contain notification gauges/counters with no PII', async () => {
|
||||
const res = await fetch(`${API}/metrics`);
|
||||
const text = await res.text();
|
||||
assert.ok(text.includes('rootnode_notifications_total'));
|
||||
assert.ok(text.includes('rootnode_notification_delivery_attempts_total'));
|
||||
assert.ok(!text.includes(`p5c-resend-${RUN_ID}@example.com`));
|
||||
assert.ok(!text.includes(pendingStudentId));
|
||||
assert.ok(!text.includes(newToken || ''));
|
||||
});
|
||||
|
||||
await test('audit events contain no tokens/secrets', async () => {
|
||||
const { body } = await owner.request('GET', '/api/admin/audit-events?page=1');
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken) && !serialized.includes(resetToken));
|
||||
assert.ok(!/password_hash|credential_token_hash/i.test(serialized));
|
||||
});
|
||||
|
||||
// ── production gating (§8/§21) — recreate the SAME api service with COOKIE_SECURE=1 via the
|
||||
// standard compose env-file mechanism (no ad hoc container/port juggling under
|
||||
// network_mode: host, which doesn't tolerate a second container easily) ─────────────────
|
||||
await test('production mode never returns dev_reset_token or exposes the dev-preview route', async () => {
|
||||
const cwd = `${__dirname}/..`;
|
||||
execSync(`bash -c "echo COOKIE_SECURE=1 >> .env"`, { cwd, stdio: 'pipe' });
|
||||
try {
|
||||
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const reset = await fetch(`${API}/api/student/request-password-reset`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: `p5c-resend-${RUN_ID}@example.com` }),
|
||||
}).then((r) => r.json());
|
||||
assert.ok(!('dev_reset_token' in reset), 'production must never echo the reset token');
|
||||
// The dev-preview route also refuses outright once COOKIE_SECURE=1, regardless of what a
|
||||
// notification row happens to hold.
|
||||
const preview = await fetch(`${API}/api/admin/notifications/${firstActivationNotifId}/dev-preview`, { headers: { Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` } });
|
||||
assert.equal(preview.status, 404);
|
||||
} finally {
|
||||
execSync(`bash -c "grep -v '^COOKIE_SECURE=' .env > .env.tmp && mv .env.tmp .env"`, { cwd, stdio: 'pipe' });
|
||||
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
});
|
||||
|
||||
// ── §31-32/§35-36: restart persistence + regression smoke ───────────────────────────────
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('a real Platform API restart preserves the notification queue and does not duplicate an already-sent notification', async () => {
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const reLogin = makeSession(GW);
|
||||
await reLogin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
const { body: after } = await reLogin.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.equal(after.length, before.length, 'restart must not duplicate any notification row');
|
||||
const sentCount = after.filter((n) => n.status === 'sent').length;
|
||||
const sentCountBefore = before.filter((n) => n.status === 'sent').length;
|
||||
assert.equal(sentCount, sentCountBefore);
|
||||
});
|
||||
} else {
|
||||
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
|
||||
}
|
||||
|
||||
await test('Phase 5B enrollment/resume still works unmodified', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
const { status, body } = await s.request('GET', '/api/student/programs');
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('Phase 5A instructor authentication still works unmodified', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6A — course/module/activity content model acceptance test. Covers course/module/
|
||||
// activity hierarchy and ordering, prerequisite-based locking, all three completion behaviors
|
||||
// (manual_marker/auto_scenario/auto_assessment) derived from the EXISTING progress/achievements
|
||||
// tables (never a second source of truth), resume-next-activity, cross-student isolation, RBAC
|
||||
// (self-paced no-cohort students), draft-content hiding, and persistence across logout/login.
|
||||
// Same conventions as e2e-phase5b.js/e2e-phase5c.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack. No real Lab container is launched here — that's
|
||||
// infra/scripts/drills/test-phase6a-course-rehearsal.js's job (uses the real Lab environment);
|
||||
// this suite proves the API/data model, using /api/progress/record directly the same way
|
||||
// e2e-phase3b.js/e2e-phase5b.js already do for scenario completion.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase6a.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;
|
||||
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 real curriculum snapshot e2e-phase3b.js/e2e-phase5b.js already drive full completion
|
||||
// against — see those files' headers 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 REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
||||
|
||||
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 completeScenario(session, scenarioId) {
|
||||
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' });
|
||||
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
||||
}
|
||||
function courseById(courses, id) { return courses.find((c) => c.course_id === id); }
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
function findActivity(courses, id) {
|
||||
for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; }
|
||||
return null;
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 6A ROOT_ course architecture & content delivery acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── Hierarchy, ordering, honest states ──────────────────────────────────────────────────
|
||||
let structure;
|
||||
await test('the ROOT_ course architecture exists with 13 courses in sequence', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.length, 13);
|
||||
for (let i = 1; i < body.length; i++) assert.ok(body[i].sequence > body[i - 1].sequence, 'courses must be in ascending sequence order');
|
||||
structure = body;
|
||||
});
|
||||
|
||||
await test('courses use honest ready/partial/planned states, no fabricated content', async () => {
|
||||
const plannedWithModules = structure.filter((c) => c.status === 'planned' && c.modules.length > 0);
|
||||
assert.equal(plannedWithModules.length, 0, 'a PLANNED course must have zero modules, not placeholder content');
|
||||
const gold = courseById(structure, 'root-10-kubernetes-troubleshooting-observability');
|
||||
assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules
|
||||
});
|
||||
|
||||
await test('the gold-standard module is fully READY with 6 ordered activities and real content', async () => {
|
||||
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.status, 'ready');
|
||||
assert.equal(goldModule.activities.length, 6);
|
||||
const types = goldModule.activities.map((a) => a.activity_type);
|
||||
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'reflection']);
|
||||
for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence);
|
||||
const lesson = goldModule.activities[0];
|
||||
assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty');
|
||||
assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes'), 'instructor view must see instructor notes');
|
||||
const lab = goldModule.activities.find((a) => a.activity_type === 'lab');
|
||||
assert.equal(lab.scenario_id, 'rootnode-probe-port-mismatch');
|
||||
assert.equal(lab.completion_behavior, 'auto_scenario');
|
||||
});
|
||||
|
||||
await test('the capstone activity references the existing assessment engine, not a duplicate definition', async () => {
|
||||
const capstoneModule = moduleById(structure, 'root-capstone-m01-operate-and-recover');
|
||||
const activity = capstoneModule.activities[0];
|
||||
assert.equal(activity.activity_type, 'capstone');
|
||||
assert.equal(activity.assessment_definition_id, 'rootnode-capstone');
|
||||
assert.equal(activity.completion_behavior, 'auto_assessment');
|
||||
});
|
||||
|
||||
// ── A real self-paced student, enrolled and walked through the gold module ──────────────
|
||||
const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Casey-${RUN_ID}`, login_identifier: `e2e6a-casey-${RUN_ID}` });
|
||||
let casey = makeSession(GW);
|
||||
await casey.request('POST', '/api/student/activate', { token: caseyCreate.activation_token, password: 'CaseyPassword123!' });
|
||||
const { body: enrollCreate } = await owner.request('POST', '/api/admin/enrollments', { student_id: caseyCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const enrollmentId = enrollCreate.enrollment_id;
|
||||
|
||||
let tree;
|
||||
await test('a freshly-enrolled student sees the course tree with nothing complete and root-06 locked', async () => {
|
||||
const { status, body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(status, 200);
|
||||
tree = body.courses;
|
||||
const root05 = courseById(tree, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.locked, false); // the first real course is never locked
|
||||
assert.equal(root05.complete, false);
|
||||
const root06 = courseById(tree, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, true, 'root-06 must be locked until root-05 is complete');
|
||||
});
|
||||
|
||||
await test('resume points at the first activity of the first unlocked course', async () => {
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(body.resume.course_id, 'root-05-kubernetes-foundations');
|
||||
assert.equal(body.resume.module_id, 'root-05-m01-pods-and-kubectl-basics');
|
||||
assert.equal(body.resume.activity_id, 'root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq');
|
||||
});
|
||||
|
||||
await test('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => {
|
||||
const { status, body } = await casey.request('POST', '/api/student/activities/root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq/complete');
|
||||
assert.equal(status, 409);
|
||||
assert.equal(body.error, 'not_manually_completable');
|
||||
});
|
||||
|
||||
await test('completing root-05\'s 20 real scenarios marks the course complete and unlocks root-06', async () => {
|
||||
for (const sid of FOUNDATION) await completeScenario(casey, sid);
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, true);
|
||||
assert.equal(root05.percent_complete, 100);
|
||||
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, false, 'root-06 must unlock once root-05 is complete');
|
||||
});
|
||||
|
||||
// ── The gold module: lesson/demo/knowledge-check/guided-exercise (manual) + lab (auto) + reflection (manual) ──
|
||||
await test('lesson-only activities complete via the explicit marker, not automatically', async () => {
|
||||
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.filter((a) => a.complete).length, 4);
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, false, 'the lab must not be complete yet — nothing was solved');
|
||||
});
|
||||
|
||||
await test('marking the same lesson complete twice is idempotent, not an error', async () => {
|
||||
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('the lab activity completes automatically from real scenario completion, not a manual marker', async () => {
|
||||
await completeScenario(casey, 'rootnode-probe-port-mismatch');
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, true);
|
||||
assert.equal(goldModule.complete, false, 'the module is not complete until the reflection is also marked');
|
||||
});
|
||||
|
||||
await test('completing the reflection finishes the gold module and unlocks the next one', async () => {
|
||||
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||
assert.equal(status, 200);
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.complete, true);
|
||||
assert.equal(goldModule.percent_complete, 100);
|
||||
const nextModule = moduleById(body.courses, 'root-10-m02-general-troubleshooting');
|
||||
assert.equal(nextModule.locked, false, 'root-10-m02 must unlock once the gold module is complete');
|
||||
});
|
||||
|
||||
// ── Assessment/capstone-derived completion (auto_assessment) ────────────────────────────
|
||||
await test('completing every remaining curriculum scenario and the real capstone marks the capstone activity complete', async () => {
|
||||
for (const sid of [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING]) await completeScenario(casey, sid);
|
||||
for (const sid of REAL_OPS) if (sid !== 'rootnode-probe-port-mismatch') await completeScenario(casey, sid); // already done above
|
||||
const { status, body } = await casey.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 201, `capstone should start once every stage is complete: ${JSON.stringify(body)}`);
|
||||
for (const s of body.scenarios) await completeScenario(casey, s.scenario_id);
|
||||
const capstoneCheck = await casey.request('GET', '/api/capstone');
|
||||
assert.equal(capstoneCheck.body.status, 'completed');
|
||||
|
||||
const { body: tree2 } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const capstoneActivity = findActivity(tree2.courses, 'root-capstone-m01-a01-capstone');
|
||||
assert.equal(capstoneActivity.complete, true, 'the capstone course activity must derive completion from the real achievement, not a separate marker');
|
||||
});
|
||||
|
||||
// ── Persistence across logout/login (no real Lab needed — this proves the DATA persists) ──
|
||||
await test('course progress survives logout and a fresh login', async () => {
|
||||
await casey.request('POST', '/api/logout');
|
||||
casey = makeSession(GW);
|
||||
await casey.request('POST', '/api/student/login', { identifier: `e2e6a-casey-${RUN_ID}`, password: 'CaseyPassword123!' });
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, true, 'progress must survive a real logout/login, not just an in-memory session');
|
||||
});
|
||||
|
||||
// ── Cross-student isolation ──────────────────────────────────────────────────────────────
|
||||
const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Isolated-${RUN_ID}`, login_identifier: `e2e6a-isolated-${RUN_ID}` });
|
||||
const isolated = makeSession(GW);
|
||||
await isolated.request('POST', '/api/student/activate', { token: isolatedCreate.activation_token, password: 'IsolatedPassword123!' });
|
||||
const { body: isolatedEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: isolatedCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
|
||||
await test('a second student on the same program sees their own independent, empty progress', async () => {
|
||||
const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`);
|
||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, false, 'a different student\'s completion must never leak across');
|
||||
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, true);
|
||||
});
|
||||
|
||||
const { body: strangerCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Stranger-${RUN_ID}`, login_identifier: `e2e6a-stranger-${RUN_ID}` });
|
||||
const stranger = makeSession(GW);
|
||||
await stranger.request('POST', '/api/student/activate', { token: strangerCreate.activation_token, password: 'StrangerPassword123!' });
|
||||
await test('a student with NO enrollment anywhere cannot mark any activity complete', async () => {
|
||||
const { status, body } = await stranger.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
||||
assert.equal(status, 403);
|
||||
assert.equal(body.error, 'not_entitled');
|
||||
});
|
||||
|
||||
// ── RBAC: self-paced students have no cohort — requireStudentRelationshipAccess must apply ──
|
||||
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6a-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor', role: 'instructor', password: 'StrayInstructorPass123!' });
|
||||
const strayInstructorSession = makeSession(GW);
|
||||
await strayInstructorSession.request('POST', '/api/auth/instructor/login', { email: `e2e6a-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' });
|
||||
|
||||
await test('a plain instructor with no relationship to a self-paced student is forbidden from their course progress', async () => {
|
||||
const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
// Note: self-paced enrollment creation is admin/owner-only (admin-programs-routes.js), so a
|
||||
// plain 'instructor' can never become an enrollment's created_by for a self-paced student —
|
||||
// requireStudentRelationshipAccess's positive path for a plain instructor is necessarily via
|
||||
// a shared cohort, already covered by e2e-phase5c.js's equivalent notification-visibility
|
||||
// tests against the same rbac.js helper. Not re-tested here to avoid duplicating that
|
||||
// coverage against unrelated cohort machinery.
|
||||
await test('owner/admin can see any self-paced student\'s course progress regardless of relationship', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
const root05 = courseById(body, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, true);
|
||||
});
|
||||
|
||||
// ── Draft/inactive content never leaks to students ──────────────────────────────────────
|
||||
await test('a draft activity is invisible to the student endpoint but visible to instructors', async () => {
|
||||
psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
||||
try {
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const workloadsModule = moduleById(studentView.courses, 'root-06-m01-deployments-and-workloads');
|
||||
assert.ok(!workloadsModule.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'a draft activity must never appear in a student response');
|
||||
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const workloadsModuleAdmin = moduleById(instructorView, 'root-06-m01-deployments-and-workloads');
|
||||
assert.ok(workloadsModuleAdmin.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'instructors must still see draft content');
|
||||
} finally {
|
||||
psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('a draft module is invisible to students but visible to instructors', async () => {
|
||||
psql(`UPDATE course_modules SET visibility = 'draft' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
||||
try {
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const root07 = courseById(studentView.courses, 'root-07-kubernetes-networking-config');
|
||||
assert.ok(!root07.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
||||
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const root07Admin = courseById(instructorView, 'root-07-kubernetes-networking-config');
|
||||
assert.ok(root07Admin.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
||||
} finally {
|
||||
psql(`UPDATE course_modules SET visibility = 'published' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Content version stability ────────────────────────────────────────────────────────────
|
||||
await test('bumping a module\'s version does not break or drop its content', async () => {
|
||||
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`);
|
||||
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.length, 6, 'a version bump must never drop activities');
|
||||
assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user