Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a950afb | ||
|
|
7baf7074e5 | ||
|
|
777efd168d | ||
|
|
b38c46aaec | ||
|
|
be92be81cd | ||
|
|
f25535027c | ||
|
|
3c7f0ca59c | ||
|
|
9d382c95c5 | ||
|
|
5183a6af09 | ||
|
|
1c08a30c7f | ||
|
|
23f881884d | ||
|
|
f89a769540 | ||
|
|
4bc202e5f3 | ||
|
|
1b6da0a966 | ||
|
|
17160da89e | ||
|
|
611e1ac4ba | ||
|
|
895e16fea4 | ||
|
|
896c230142 | ||
|
|
bfa16d9162 | ||
|
|
7557fdc753 | ||
|
|
5c7e49c399 | ||
|
|
ee8ccb2ab6 | ||
|
|
d34ddae458 | ||
|
|
2f09b1a498 | ||
|
|
5bc0743b08 | ||
|
|
f3c356951c | ||
|
|
f5110d3c1b | ||
|
|
a8e543234c | ||
|
|
addf474b70 | ||
|
|
6dda13b837 | ||
|
|
eae8e48d43 |
@@ -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,12 +212,17 @@ 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
|
||||
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`. Built and locally validated now; deployment is
|
||||
pending purchase of the target Hetzner dedicated server.
|
||||
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` and `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` (the
|
||||
Gateway-to-Lab cross-VM routing fix, plus application hardening — real pagination, abandonment
|
||||
handling, a Postgres-backed rate limiter, a structured assessment editor, admission control).
|
||||
Built and locally validated now; deployment is pending purchase of the target Hetzner dedicated
|
||||
server.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-1
@@ -37,10 +37,19 @@ requires typing `RESTORE` to confirm, then decrypts and pipes into `psql`.
|
||||
|
||||
**Genuinely tested**, not just written: `infra/backup/test-backup-restore.sh` runs the full
|
||||
cycle against two disposable local Postgres containers, with the actual production migrations
|
||||
applied (all 17 — 3 Provisioner + 14 Platform API, in the correct cross-service order), a
|
||||
applied (3 Provisioner + Platform API, in the correct cross-service order), a
|
||||
sample row inserted, backed up, restored into the second container, and verified byte-for-byte.
|
||||
Confirmed passing, exit 0.
|
||||
|
||||
**Phase 4.1's destructive drill goes one step further**
|
||||
(`infra/backup/test-destructive-restore-drill.sh`): realistic data (owner, admin instructor,
|
||||
cohort, 3 students, progress, attempts, an assessment definition, achievements) seeded through
|
||||
the REAL Platform API, backed up, the source Postgres **container genuinely destroyed**
|
||||
(`docker rm`, not just left running alongside a second one), a fresh Postgres created from
|
||||
nothing, restored, and the real Platform API booted again against the restored database and
|
||||
used to log in as the restored admin instructor over HTTP — proving the whole stack recovers,
|
||||
not just the SQL. Never touches the developer's real persistent database. Confirmed passing.
|
||||
|
||||
## Disaster recovery scenarios
|
||||
|
||||
| Scenario | RPO | RTO (estimate) | What happens |
|
||||
|
||||
+149
-35
@@ -90,35 +90,34 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
|
||||
## 5. Student identity — what's built, what isn't
|
||||
|
||||
**Built (backend only, as of this doc):**
|
||||
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
|
||||
`created_at`, `last_active_at`.
|
||||
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
|
||||
a student (instructor-only action, currently unauthenticated at the API level — see
|
||||
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
|
||||
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
|
||||
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
|
||||
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
|
||||
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
|
||||
answering the same scenario don't affect each other's state, and pre-migration data was
|
||||
preserved under `student_id = 'unassigned'`.
|
||||
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
|
||||
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
|
||||
whoever's browser hits the API next.
|
||||
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
|
||||
full design):**
|
||||
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
|
||||
of and never changes across an account's lifecycle, including conversion.
|
||||
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
|
||||
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
|
||||
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
|
||||
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
|
||||
{ student_id }`, no password, instructor-roster-only.
|
||||
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
|
||||
(`students-auth.js`) — instructors never see or set a student's actual password.
|
||||
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
|
||||
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
|
||||
instructor login (Phase 4.1), now also applied to students.
|
||||
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
|
||||
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
|
||||
already in place well before this — Phase 5A only changed how a student proves who they are,
|
||||
not how their data or Labs are isolated.
|
||||
|
||||
**Not built yet — do not assume these exist:**
|
||||
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
|
||||
nothing calls it yet. This is the next slice.
|
||||
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
|
||||
Fine for a dev instance, not fine once this is reachable by actual students — needs an
|
||||
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
|
||||
item 3).
|
||||
- **No concurrency / isolation.** Exam sessions are still global-single-active
|
||||
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
|
||||
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
|
||||
cannot yet use this platform at the same time without stepping on each other.** That's
|
||||
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
|
||||
out — this SOP is not claiming that gate is cleared.
|
||||
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
|
||||
- No public self-registration — accounts are still instructor-created/converted/imported only.
|
||||
- No email delivery infrastructure — password reset works via an instructor-issued token today;
|
||||
self-service reset returns a real token only in non-production, by design.
|
||||
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
|
||||
|
||||
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
|
||||
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
|
||||
reflects any deployed version of this codebase; that content has been superseded by the above.
|
||||
|
||||
## 6. Schema migration pattern
|
||||
|
||||
@@ -146,9 +145,11 @@ Order agreed 2026-08-17/18. Status as of this doc:
|
||||
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
|
||||
`frontend/src/platform/`.
|
||||
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
|
||||
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
|
||||
identification, not strong student auth — documented, not silently glossed over
|
||||
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
|
||||
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
|
||||
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
|
||||
name" gap as of Phase 5A — see §5 above and
|
||||
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
|
||||
unchanged for anyone not yet converted.
|
||||
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
|
||||
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
|
||||
owner/admin/instructor roles, audit log.
|
||||
@@ -296,12 +297,125 @@ Compose, firewall, monitoring, backup, CI, load-test tooling).
|
||||
- **Where the boundary held:** the Platform VM / Lab Host VM security split from
|
||||
`docs/LAB_ARCHITECTURE.md` is codified in firewall rulesets and mutual-TLS Docker config, not
|
||||
weakened anywhere.
|
||||
- **Known gap surfaced, not silently fixed:** the Gateway's Lab-reachability mechanism doesn't
|
||||
survive the move to two real separate VMs — see `docs/PRODUCTION_NETWORK_SECURITY.md`'s
|
||||
"Known gap" section for what's built (firewall policy) vs. what's an explicit, reviewed,
|
||||
not-yet-built application follow-up (Provisioner/Gateway address handling).
|
||||
- **Known gap surfaced, then resolved in Phase 4.1:** the Gateway's Lab-reachability mechanism
|
||||
didn't survive the move to two real separate VMs — flagged explicitly rather than silently
|
||||
fixed, then actually built and simulation-tested (a Lab Host Router,
|
||||
`platform/lab-router/`) in Phase 4.1. See §13 below.
|
||||
- **Validated without the hardware:** real `tofu validate`, `ansible-playbook --syntax-check`,
|
||||
`docker compose config`, `caddy validate`, `promtool`/`amtool`, and a genuine end-to-end
|
||||
backup→encrypt→restore→verify cycle against disposable local Postgres with the real
|
||||
production migrations applied. None of this proves the real Hetzner network boundary — that
|
||||
needs `docs/PRODUCTION_VALIDATION_CHECKLIST.md` walked against the real server.
|
||||
|
||||
## 13. Pre-Production Hardening (Phase 4.1)
|
||||
|
||||
Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md` (routing resolution), `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
|
||||
`docs/SECURITY_SCANNING.md`.
|
||||
|
||||
- **What it is:** closes the Gateway-to-Lab cross-VM gap Phase 4 flagged (a Lab Host Router,
|
||||
`platform/lab-router/`, plus a routing abstraction in the Gateway,
|
||||
`platform/gateway/src/lab-endpoint.js`), then hardens the application layer found along the
|
||||
way: abandoned-attempt handling (`scenario_attempts.status = 'abandoned'`, never a punitive
|
||||
failure), a Postgres-backed login rate limiter (survives a restart, ready to be
|
||||
shared/multi-instance), real pagination on audit/attempt/assessment-session history, a
|
||||
structured assessment authoring UI (owner-only raw JSON remains as a fallback), a minimal
|
||||
`role_in_cohort` enforcement (`teacher`/`viewer`), a `/api/version` endpoint, and admission
|
||||
control (a clean 503 "capacity full" instead of a 500 once a configured cap is reached).
|
||||
- **Tested, not just designed:** a real two-Docker-network routing simulation
|
||||
(`infra/scripts/simulate-two-host/`, 13/13), a destructive Postgres restore drill
|
||||
(`infra/backup/test-destructive-restore-drill.sh`), a Lab Host loss/rebuild drill
|
||||
(`infra/scripts/drills/test-lab-host-loss.js`), a deployment rollback drill, a CI-fails-closed
|
||||
drill (which found and fixed a real pre-existing `set -e` bug in both smoke-test scripts —
|
||||
see that drill's own output), and a full fake-cohort dress rehearsal
|
||||
(`infra/scripts/drills/test-fake-cohort-rehearsal.js`) — all passing, all in
|
||||
`infra/scripts/drills/` or `infra/backup/`.
|
||||
- **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. **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 itself is implemented and unit-level ready
|
||||
(`infra/scripts/gws-email-test.js`, opt-in) but not yet validated against a real Workspace
|
||||
domain — see that doc's final section.
|
||||
|
||||
@@ -491,8 +491,31 @@ networking, egress, resource policy, reaper) against these decisions.
|
||||
|
||||
Phase 4 (`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`) codifies the Platform/Lab-Host split this
|
||||
document designed as two real, separately-firewalled Proxmox VMs (`rn-platform-01`,
|
||||
`rn-lab-host-01`) connected only by mutual-TLS Docker and a tightly scoped firewall allowance —
|
||||
see `docs/PRODUCTION_NETWORK_SECURITY.md`. That document also records a gap Phase 4 surfaced but
|
||||
did not resolve: the Gateway's `env.internal_ip` mechanism for reaching a Lab container assumes
|
||||
Gateway and Lab share a Docker daemon, which breaks once they're genuinely on separate VMs. Read
|
||||
that document's "Known gap" section before treating two-VM production as fully wired.
|
||||
`rn-lab-host-01`) connected by two separate mTLS channels (Provisioner↔dockerd CONTROL plane,
|
||||
Gateway↔Lab Host Router DATA plane) and a tightly scoped firewall allowance — see
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`. Phase 4 surfaced a gap in the Gateway's `env.internal_ip`
|
||||
mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docker daemon, which
|
||||
breaks once they're genuinely on separate VMs); Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
|
||||
Router (`platform/lab-router/`).
|
||||
|
||||
## Phase 5A: reused, not modified
|
||||
|
||||
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
|
||||
remote classroom control center on top of everything this document describes, without changing
|
||||
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
|
||||
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
|
||||
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
|
||||
the same state enum) rather than inventing a second one.
|
||||
|
||||
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
|
||||
|
||||
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
|
||||
of the exact same disposable Lab this document describes — again without changing the
|
||||
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
|
||||
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
|
||||
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
|
||||
Lab container out-of-band and restarting the Provisioner to trigger its actual
|
||||
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
|
||||
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
|
||||
is completely unaffected and a fresh Lab boots normally.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Phase 4.1 — Pre-Production Hardening
|
||||
|
||||
**Status: PHASE 4.1 COMPLETE. PRE-PRODUCTION HARDENING COMPLETE. READY FOR HARDWARE DEPLOYMENT.**
|
||||
|
||||
Phase 4 built the production infrastructure package with the real Hetzner/Proxmox hardware not
|
||||
yet purchased, and flagged one genuine architectural gap: the Gateway's mechanism for reaching
|
||||
a student Lab assumed Gateway and Lab shared a Docker daemon, which is false the moment Labs run
|
||||
on a genuinely separate `rn-lab-host-01` VM. Phase 4.1's job was to close that gap and harden
|
||||
everything else discovered along the way, still without the real hardware.
|
||||
|
||||
## 1. The core fix: a Lab Host Router
|
||||
|
||||
**Before:** Gateway → `http://${env.internal_ip}:${env.internal_port}` directly — a Docker
|
||||
bridge-local address, unreachable across a real VM boundary.
|
||||
|
||||
**After:** Gateway → (mode-aware) → either `direct` (unchanged, dev/single-host) or `router`
|
||||
(production): a small new service, `platform/lab-router/`, runs on the Lab Host itself,
|
||||
authenticates the Gateway via mTLS (the same private CA the Docker TLS control plane already
|
||||
used, extended with two new leaf certs), resolves an opaque routing token to a local container
|
||||
purely from `docker ps` label state (no Postgres access, no call back to the Provisioner), and
|
||||
reverse-proxies HTTP and WebSocket traffic to it.
|
||||
|
||||
The decision of which mode a given `lab_host` uses is data-driven
|
||||
(`lab_hosts.routing_mode`/`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`),
|
||||
resolved once per request in `platform/gateway/src/lab-endpoint.js`'s `labTarget()` — the only
|
||||
place in the Gateway that knows this distinction exists. Every proxy/fetch call site in
|
||||
`platform/gateway/src/server.js` goes through it.
|
||||
|
||||
**Exact `env.internal_ip` replacement:** `labTarget(env, token)` returns `{ url, headers, agent,
|
||||
mode }`. In `direct` mode, `url` is exactly the old `http://internal_ip:internal_port`. In
|
||||
`router` mode, `url` is the lab_host's `router_endpoint`, `headers` carries
|
||||
`X-RootNode-Route-Token: <token>` (computed server-side from the Provisioner-verified
|
||||
environment row — never trusted from the browser), and `agent` is an `https.Agent` configured
|
||||
with the Gateway's mTLS client identity.
|
||||
|
||||
## 2. No per-student host port
|
||||
|
||||
Deliberately not solved by publishing a host port per Lab (the originally-flagged, rejected
|
||||
approach). The Router resolves entirely from Docker's own label state on its own host — no
|
||||
port-per-student anything, no dynamic firewall rule per Lab.
|
||||
|
||||
## 3. Lab Host Router — design
|
||||
|
||||
See `platform/lab-router/src/server.js`'s header comment for the full "narrow by design" list:
|
||||
one route (proxy-by-token), requires mTLS (no bearer-token fallback), never exposes Docker API/
|
||||
container IDs/network info to its caller, never provides student-to-student routing (each
|
||||
request resolves to exactly the one container matching its own token).
|
||||
|
||||
## 4. Multi-Lab-Host ready
|
||||
|
||||
Routing is resolved per `lab_host_id` — adding `rn-lab-host-02` later is one new `lab_hosts` row
|
||||
(its own `routing_mode`/`router_endpoint`/TLS material), no Gateway code change. No scheduler
|
||||
was built (explicitly out of scope) — the Provisioner still picks "the one enabled lab_host"
|
||||
(`selectLabHost`), unchanged from Phase 1.
|
||||
|
||||
## 5. Local development compatibility
|
||||
|
||||
`dev-local` (the Unix-socket lab_host used by `docker-compose.dev.yml`) stays `routing_mode:
|
||||
direct` — zero behavior change for the T480/any local dev setup, confirmed by the full existing
|
||||
regression suite passing unmodified (§29 below) and by every new drill in this phase running
|
||||
against the same dev stack. The standalone single-container Lab image build/boot is unaffected
|
||||
(it never touches the Gateway or Provisioner at all).
|
||||
|
||||
## 6-9. Simulation, HTTP/WS validation, security tests, routing token security
|
||||
|
||||
`infra/scripts/simulate-two-host/` — a real two-Docker-network simulation (not mocks): Gateway
|
||||
attached ONLY to an isolated bridge with no route to any Lab's bridge network, Lab Host Router
|
||||
host-networked (mirrors `egress-proxy`'s existing pattern) so it can reach whatever per-Lab
|
||||
bridge the Provisioner creates. 13/13 real assertions pass, including:
|
||||
|
||||
- Gateway genuinely cannot open a TCP connection to a Lab's Docker-internal IP (proven from
|
||||
inside the Gateway container's own network namespace via a raw connect attempt).
|
||||
- Gateway reaches the Lab correctly through the Router — HTTP AND a real WebSocket terminal
|
||||
session running a genuine `kubectl get nodes` inside the Lab.
|
||||
- Alice/Bob isolation (own lab ALLOW, other's DENY — HTTP and WebSocket both), garbage tokens,
|
||||
anonymous requests, and destroyed-environment tokens are all rejected through the real code
|
||||
path.
|
||||
- The Router refuses any connection with no client certificate — mTLS is the real
|
||||
authentication mechanism.
|
||||
|
||||
Routing token security reviewed: already CSPRNG since Phase 1 (`generateRoutingToken`), never
|
||||
logged in plaintext (the Router's `tokenFingerprint` truncates), redacted in every API response
|
||||
except the two narrow internal reads that legitimately need it, and — critically — the header
|
||||
the Router trusts is computed server-side from the Provisioner-verified environment row, never
|
||||
read from the browser's own request, so a browser cannot forge routing by supplying its own
|
||||
`X-RootNode-Route-Token`.
|
||||
|
||||
## 10. Control plane vs data plane
|
||||
|
||||
Kept strictly separate — see `docs/PRODUCTION_NETWORK_SECURITY.md`'s dedicated section. Student
|
||||
browser traffic is never proxied through the Docker API at any point.
|
||||
|
||||
## 11. Firewall update
|
||||
|
||||
`infra/firewall/rn-lab-host-01.rules`'s old speculative wide port-range allowance replaced with
|
||||
one narrow rule: `platform_ip` → the Router's port only (`8443` default, configurable).
|
||||
|
||||
## 12. Self-paced / remote delivery architecture check
|
||||
|
||||
`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`. Short version: student-driven provisioning,
|
||||
cohort-less students, and "idle reaper destroys → progress survives → student returns → fresh
|
||||
Lab → curriculum continues" all already worked before this phase and are now proven end-to-end
|
||||
by two new drills. The one real gap for actual remote/self-paced delivery is student
|
||||
authentication (today's bare `student_id` login assumes an instructor-controlled roster) — not
|
||||
built here, explicitly Phase 5 scope.
|
||||
|
||||
## 13. Abandoned attempt cleanup
|
||||
|
||||
`scenario_attempts.status = 'abandoned'` (already a valid value since Phase 3B, never
|
||||
previously set) now gets set on: environment destroy/reset (student- or instructor-triggered),
|
||||
logout, session expiry (piggybacked on the existing hourly session sweeper — no new background
|
||||
job), and switching to a different scenario while one is still open. Never a punitive marker —
|
||||
`result` stays NULL, history is preserved, instructors see an honest "abandoned (reason)"
|
||||
instead of a phantom "still in progress."
|
||||
|
||||
## 14. Gateway session cache
|
||||
|
||||
Reviewed — already a tight 2-second TTL since Phase 3A, specifically to bound the
|
||||
revocation-propagation window. Decision: **keep it, don't add push-based invalidation** — the
|
||||
added coupling (Platform API would need to know how to reach the Gateway) isn't justified by
|
||||
shrinking an already-2-second window further. Documented reasoning, not a silently-accepted gap.
|
||||
|
||||
## 15. Rate limiting
|
||||
|
||||
Login rate limiting moved from an in-memory `Map` (reset on every restart, not shareable across
|
||||
instances) to Postgres (`login_attempts` table, `migrations/016_login_attempts.sql`) —
|
||||
`platform/api/src/security.js`'s `tooManyAttempts()`. No Redis added. Restart behavior
|
||||
confirmed for real: `platform/test/e2e-phase3a.js`'s rate-limiting test passes after a genuine
|
||||
Platform API restart in the same run.
|
||||
|
||||
## 16. Pagination
|
||||
|
||||
Real server-side pagination (`platform/api/src/pagination.js`) on: the audit log (was a flat
|
||||
`LIMIT 200`), student attempt history (was `LIMIT 100`), and assessment session history (was
|
||||
**fully unbounded** — the one genuine growth risk found). Scenario analytics was deliberately
|
||||
left as-is — grouped by scenario id, naturally bounded around ~100 rows, no real growth risk to
|
||||
paginate against. Frontend (`AuditView.jsx`, `AssessmentsView.jsx`, `StudentsView.jsx`) updated
|
||||
to match; `frontend` build confirmed clean.
|
||||
|
||||
## 17. Needs-attention configuration
|
||||
|
||||
`NEEDS_ATTENTION_FAIL_THRESHOLD`/`NEEDS_ATTENTION_STALE_DAYS` moved from hardcoded constants to
|
||||
env-var-configurable (matching the existing session-TTL pattern), sensible defaults unchanged.
|
||||
|
||||
## 18. Assessment authoring UX
|
||||
|
||||
Raw-JSON `pool_spec` textarea replaced with a structured editor
|
||||
(`frontend/src/admin/AssessmentsView.jsx`'s `PoolSpecEditor`): required scenarios and random
|
||||
pools picked from a real scenario catalog (`GET /api/admin/scenario-catalog`, new), grouped by
|
||||
curriculum stage, via native multi-select — no new UI framework. An owner-only "advanced" raw
|
||||
JSON view remains for anything the structured editor doesn't cover.
|
||||
|
||||
## 19. Cohort role-in-cohort
|
||||
|
||||
`cohort_instructors.role_in_cohort` existed since Phase 2B but was never read. Implemented
|
||||
minimally: `teacher` (unchanged full capability) vs `viewer` (read-only — dashboards/analytics
|
||||
still visible, but Prepare/Destroy Labs and provisioning a student's environment are now 403'd
|
||||
server-side, `rbac.js`'s `requireCohortWriteAccess`/`requireStudentCohortWriteAccess`).
|
||||
|
||||
## 20. Backup / restore destructive drill
|
||||
|
||||
`infra/backup/test-destructive-restore-drill.sh` — real owner/admin/cohort/3-students/progress/
|
||||
attempts/assessment/achievements seeded through the real Platform API, encrypted-backed-up, the
|
||||
disposable Postgres container **genuinely destroyed** (not just restored into a second
|
||||
container), a fresh Postgres created from nothing, restored, and the real Platform API booted
|
||||
again against the restored database and used to log in as the restored admin over HTTP. All
|
||||
data verified present; never touches the developer's real database.
|
||||
|
||||
## 21. Lab Host rebuild drill
|
||||
|
||||
`infra/scripts/drills/test-lab-host-loss.js` — a student's Lab container is destroyed out from
|
||||
under the Provisioner (no clean destroy call, simulating total Lab Host loss). Proves: Platform
|
||||
stays up throughout, the stale environment is never presented as healthy once the Provisioner's
|
||||
real `reconcileOnStartup` reconciliation runs, academic progress is untouched, and the student
|
||||
can launch a genuinely new Lab and continue.
|
||||
|
||||
## 22. Deployment rollback test
|
||||
|
||||
`infra/scripts/drills/test-deployment-rollback.sh` — builds a known-good and a deliberately
|
||||
broken gateway image, "deploys" each via the real deterministic-tag mechanism, proves the smoke
|
||||
test catches the regression, computes the rollback target with the exact same
|
||||
`deploy-history.log` logic `rollback-production.sh` uses, and proves the rollback recovers.
|
||||
Documents explicitly that image rollback never implies a DB migration rollback.
|
||||
|
||||
## 23. CI/CD failure tests
|
||||
|
||||
`infra/scripts/drills/test-ci-fails-closed.sh` — proves all six required failure modes (test/
|
||||
syntax failure, frontend build failure, image build failure, config validation failure,
|
||||
migration preflight failure, staging smoke test failure) genuinely exit nonzero. **Found and
|
||||
fixed two real pre-existing bugs**: `staging-smoke-test.sh` and `production-smoke-test.sh` both
|
||||
had a classic `set -e` gotcha (`curl ... && echo "OK"` as one statement — a failing command
|
||||
that isn't the last in an `&&` chain doesn't trigger `-e`) that let an unreachable/broken
|
||||
endpoint fall through silently instead of failing the smoke test. Fixed in both files.
|
||||
|
||||
## 24. Security scanning
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml`'s new `security-scan` job — npm audit, gitleaks
|
||||
(secrets), hadolint (Dockerfiles), shellcheck (shell scripts), trivy (container image CVEs).
|
||||
All free/open-source. Severity policy documented in `docs/SECURITY_SCANNING.md`: CRITICAL/HIGH
|
||||
blocks the build, MEDIUM/LOW is reported only. `security-scan` gates `build-images` the same
|
||||
way `regression` does.
|
||||
|
||||
## 25. Version / release visibility
|
||||
|
||||
`GET /api/version` (Platform API) — app version (`package.json`), deployed Git SHA
|
||||
(`GIT_SHA` env var, set to the same value as the image tag in the production compose file), and
|
||||
the most recently applied migration as a schema-version stand-in. Unauthenticated, minimal, no
|
||||
infrastructure detail.
|
||||
|
||||
## 26. Admission control failure mode
|
||||
|
||||
`resource-policy.yaml`'s new (disabled-by-default) `admission_control.max_concurrent_labs` —
|
||||
when set, the Provisioner checks it BEFORE touching Docker at all, so a full host fails cleanly
|
||||
(503, `CAPACITY_EXCEEDED`) instead of failing mid-provision. Relayed by the Platform API as a
|
||||
clean "Lab capacity is temporarily full. Try again shortly." — never a raw 500. A new
|
||||
`GET /capacity` Provisioner endpoint feeds the instructor dashboard's capacity display.
|
||||
|
||||
## 27. Fake cohort dress rehearsal
|
||||
|
||||
`infra/scripts/drills/test-fake-cohort-rehearsal.js` — one continuous narrative: instructor
|
||||
login, cohort + 4 students, curriculum assignment, Prepare Labs, students working different
|
||||
scenarios, hints, a deliberate failed validation, instructor analytics, a Lab reset, a Lab
|
||||
reaped (same consequence as the real idle reaper — see the drill's own comment on why the real
|
||||
multi-minute timer isn't waited out), the student returning and continuing, an exam
|
||||
assigned/taken, and end-of-class Lab destruction — progress verified intact after every
|
||||
destructive step. 14/14 passing.
|
||||
|
||||
## 28. Documentation
|
||||
|
||||
This file, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`, `docs/SECURITY_SCANNING.md` (new).
|
||||
Updated: `docs/LAB_ARCHITECTURE.md`, `docs/PRODUCTION_NETWORK_SECURITY.md`,
|
||||
`docs/PRODUCTION_DEPLOYMENT.md`, `docs/PRODUCTION_VALIDATION_CHECKLIST.md`, `docs/BACKUP_DR.md`,
|
||||
`docs/ENGINEERING_SOP.md`, `README.md`, `platform/README.md`.
|
||||
|
||||
## 29. Regression
|
||||
|
||||
Re-ran the full existing suite after every application-code change in this phase:
|
||||
`e2e-phase2a.js` (10/10), `e2e-phase2b.js` (12/12), `e2e-phase3a.js` (21/21 — including a real
|
||||
audit-log pagination-shape fix and confirming rate-limit persistence across a genuine restart),
|
||||
`e2e-phase3b.js` (24/24). Frontend production build clean. Real Lab image builds/boots (both
|
||||
dev-compose and standalone single-container). `tofu validate`, `ansible-playbook
|
||||
--syntax-check`, production Compose config (both platform and lab-host), `caddy validate`,
|
||||
`promtool`/`amtool`, and the backup/restore mechanism all re-validated clean after the
|
||||
infrastructure changes in this phase (new `lab-router` service, new firewall rule, new Ansible
|
||||
tasks). No knowingly-unexplained regression was left in this codebase at the end of this phase.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT build
|
||||
|
||||
Production deployment itself, Hetzner/Proxmox installation, public course signup, payments,
|
||||
subscriptions, parent portal, marketing site, full LMS, video hosting, email automation,
|
||||
certificates, course marketplace, mobile app, a multi-host scheduler, an AI tutor, invasive
|
||||
proctoring, held scenario content, student email/password authentication (documented as the one
|
||||
real gap for self-paced delivery, not built). All explicit non-goals per this phase's
|
||||
authorization.
|
||||
@@ -170,15 +170,18 @@ unchanged. No parent portal, billing, held scenarios, or production OIDC provide
|
||||
RootNode production infrastructure is dedicated to RootNode Academy — never folded into
|
||||
existing Synthos production servers, and canonical source stays Gitea, never GitHub `kubekosh`.
|
||||
|
||||
## Known gap surfaced during this phase (not silently papered over)
|
||||
## Known gap surfaced during this phase (not silently papered over) — RESOLVED in Phase 4.1
|
||||
|
||||
Building the real production Compose/network config surfaced that the Gateway's existing
|
||||
mechanism for reaching a Lab container (`env.internal_ip`, a Docker-bridge-local address) only
|
||||
works when Gateway and Lab share a Docker daemon — true in every dev/single-host setup so far,
|
||||
**not** true once Labs run on a genuinely separate `rn-lab-host-01`. The network-policy half is
|
||||
built (a firewall allowance for Gateway→Lab-Host on a published port range,
|
||||
`infra/firewall/rn-lab-host-01.rules`); the application half (the Provisioner recording a
|
||||
reachable external address for each Lab, and the Gateway using it) is an explicit, reviewed,
|
||||
NOT-YET-BUILT follow-up — see `docs/PRODUCTION_NETWORK_SECURITY.md` for the two candidate
|
||||
designs and why neither was silently chosen here. This must land before real two-VM go-live;
|
||||
flagging it clearly was judged better than guessing at an architecture change mid-infra-phase.
|
||||
**not** true once Labs run on a genuinely separate `rn-lab-host-01`. At the time this phase
|
||||
closed, the network-policy half was built but the application half was flagged as an explicit,
|
||||
not-yet-built follow-up rather than guessed at mid-infra-phase.
|
||||
|
||||
**Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) built and proved the application
|
||||
half** — a Lab Host Router (`platform/lab-router/`) plus a `labTarget()` routing abstraction in
|
||||
the Gateway, real-tested against a genuine two-network Docker simulation
|
||||
(`infra/scripts/simulate-two-host/`), not just designed. See
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "Gateway-to-Lab reachability" section for the full
|
||||
resolution. This item no longer blocks real two-VM go-live.
|
||||
|
||||
@@ -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.
|
||||
@@ -31,7 +31,7 @@ tofu apply
|
||||
Creates `rn-platform-01` and `rn-lab-host-01` from the cloud-init template. Confirm both boot
|
||||
and are SSH-reachable from the management network before continuing.
|
||||
|
||||
## 2. Generate Docker TLS certificates (offline, once)
|
||||
## 2. Generate TLS certificates (offline, once)
|
||||
|
||||
```bash
|
||||
export ROOTNODE_TLS_BUNDLE_DIR=/secure/offline/path # never inside the repo
|
||||
@@ -39,8 +39,14 @@ export ROOTNODE_TLS_BUNDLE_DIR=/secure/offline/path # never inside the repo
|
||||
./infra/scripts/docker-tls/verify-docker-tls.sh
|
||||
```
|
||||
|
||||
The CA private key stays in `ROOTNODE_TLS_BUNDLE_DIR` and never touches either VM — only the CA
|
||||
cert (public) and the relevant leaf cert+key go to each host.
|
||||
Generates FOUR leaf certs under one CA (Phase 4.1 extended this from two): the CONTROL-plane
|
||||
pair (`server-*`/`provisioner-client-*`, for Provisioner↔dockerd) and the DATA-plane pair
|
||||
(`router-server-*`/`gateway-client-*`, for Gateway↔Lab Host Router — see
|
||||
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "control plane vs data plane" section). The CA private
|
||||
key stays in `ROOTNODE_TLS_BUNDLE_DIR` and never touches either VM — only the CA cert (public)
|
||||
and each host's own leaf cert+key go to each host, both pairs deployed by the same `docker-tls`
|
||||
Ansible role into two separate directories (`/etc/rootnode/docker-tls/` and
|
||||
`/etc/rootnode/lab-router-tls/`).
|
||||
|
||||
## 3. Configure both hosts
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ management_cidr ──SSH──▶ rn-platform-01 │
|
||||
management_cidr ──SSH──▶ rn-lab-host-01 │
|
||||
proxmox_mgmt_cidr ──22/8006──▶ Proxmox host │
|
||||
│
|
||||
rn-platform-01 (Provisioner) ──TLS:2376──▶ rn-lab-host-01 (dockerd)
|
||||
rn-platform-01 (Provisioner) ──TLS:2376──▶ rn-lab-host-01 (dockerd) [CONTROL plane]
|
||||
rn-platform-01 (Gateway) ──mTLS:8443──▶ rn-lab-host-01 (Lab Host Router) [DATA plane]
|
||||
rn-lab-host-01 (promtail) ──3100──▶ rn-platform-01 (Loki)
|
||||
rn-lab-host-01 (node-exporter/cadvisor) ◀──scrape── rn-platform-01 (Prometheus)
|
||||
```
|
||||
@@ -37,6 +38,25 @@ Firewall source of truth: `infra/firewall/{proxmox-host,rn-platform-01,rn-lab-ho
|
||||
(Grafana is reachable only via Caddy's `/grafana/*` path, itself restricted to
|
||||
`management_cidr` by a `caddy` matcher — see `infra/docker/platform/Caddyfile.j2`).
|
||||
|
||||
## Control plane vs data plane (Phase 4.1 §10)
|
||||
|
||||
Two separate, never-conflated mTLS connections between the two VMs, deliberately kept apart
|
||||
both conceptually and operationally:
|
||||
|
||||
| | Control plane | Data plane |
|
||||
|---|---|---|
|
||||
| What | Provisioner manages Lab containers | Gateway serves student browser traffic |
|
||||
| Path | Provisioner → dockerd (port 2376) | Gateway → Lab Host Router (port 8443) |
|
||||
| Who initiates | Provisioner only | Gateway only |
|
||||
| What it can do | Create/start/stop/remove containers, full Docker API | Reverse-proxy one HTTP/WS request to one already-running container by opaque token — nothing else |
|
||||
| Certs | `provisioner-client-*` / `server-*` | `gateway-client-*` / `router-server-*` |
|
||||
| Exposes Docker API to caller? | Yes (that's its job — Provisioner IS a Docker client) | **Never** — the Router never exposes Docker API, container IDs, or network info to its caller |
|
||||
|
||||
The student browser's traffic is proxied at the Gateway/Router layer and never touches the
|
||||
Docker API at any point — the Router deliberately has no route that echoes back anything
|
||||
Docker-internal (see `platform/lab-router/src/server.js`'s header comment for the full "narrow
|
||||
by design" list).
|
||||
|
||||
## Docker TLS (Provisioner ↔ Lab Host)
|
||||
|
||||
Mutual TLS on port 2376, restricted at the firewall to `platform_ip` only. Application support
|
||||
@@ -57,34 +77,51 @@ front this with Cloudflare or expose the origin directly is left as a deployment
|
||||
being able to see plaintext unless "Full (strict)" mode is used) — not decided here since it
|
||||
doesn't affect anything else in this document.
|
||||
|
||||
## Known gap: Gateway-to-Lab reachability across two real VMs
|
||||
## Gateway-to-Lab reachability across two real VMs — RESOLVED in Phase 4.1
|
||||
|
||||
**This is the most important item in this document.**
|
||||
Phase 4 flagged this as the single most important open item; Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) closed it. Kept here as the historical record of
|
||||
what the gap was and how it was closed, since the reasoning matters for anyone extending this
|
||||
later (e.g. adding a second Lab Host).
|
||||
|
||||
The Gateway currently reaches a running Lab container via `env.internal_ip` — a Docker
|
||||
bridge-local address recorded by the Provisioner when it creates the container
|
||||
(`platform/gateway/src/server.js`, `platform/provisioner`, built in Phase 2A). This works when
|
||||
Gateway and the Lab share a Docker daemon, which has been true in every setup so far, including
|
||||
dev and any single-host deployment. **It stops being true once Labs run on a genuinely separate
|
||||
`rn-lab-host-01`** — a Docker bridge-internal IP on one host is not routable from a different
|
||||
host by default.
|
||||
**The gap, as originally found:** the Gateway used to reach a running Lab container via
|
||||
`env.internal_ip` — a Docker bridge-local address recorded by the Provisioner when it creates
|
||||
the container. That only works when Gateway and the Lab share a Docker daemon, true in dev and
|
||||
any single-host setup, false the moment Labs run on a genuinely separate `rn-lab-host-01`.
|
||||
|
||||
What's built now (network-policy half only): a firewall allowance in
|
||||
`infra/firewall/rn-lab-host-01.rules` for `platform_ip` → `lab_gateway_port_range`
|
||||
(`30000:32767` by default, configurable), so that once Lab containers publish ports on the Lab
|
||||
Host's own interface, the Gateway VM is permitted to reach them.
|
||||
**The fix — a Lab Host Router (`platform/lab-router/`):** a small, narrow service that runs on
|
||||
rn-lab-host-01 (host-networked, like `egress-proxy`, so it can reach whatever per-Lab bridge
|
||||
network the Provisioner creates) and does exactly one thing: given an opaque routing token over
|
||||
mTLS, find the ONE local container labeled with that token (`docker ps --filter
|
||||
label=rootnode.routing-token=<token>` — resolved purely from local Docker state, no Postgres,
|
||||
no call back to the Provisioner) and reverse-proxy the request to it, HTTP and WebSocket both.
|
||||
|
||||
What's **not yet built** (application half — explicitly flagged, not silently decided here):
|
||||
the Provisioner needs to either (a) publish each Lab container's relevant port(s) on the Lab
|
||||
Host's interface and record a reachable `external_ip`/`external_port` alongside (or instead of)
|
||||
`internal_ip`/`internal_port`, or (b) route Gateway→Lab traffic through a proxy/tunnel that
|
||||
lives on the Lab Host and forwards to the container's internal address. Option (a) is simpler
|
||||
and consistent with how the egress proxy and cadvisor are already handled in this phase's
|
||||
compose files; option (b) avoids publishing a wide port range but adds a moving part. This is a
|
||||
small, reviewable, application-level change — not a network-policy change — and should land as
|
||||
its own reviewed commit before real two-VM production traffic depends on it. Until it lands,
|
||||
Gateway↔Lab terminal/scenario traffic will not work correctly across two separate VMs, even
|
||||
though every other production networking, firewall, and TLS boundary is in place and correct.
|
||||
The Gateway now decides `direct` vs `router` per `lab_host` (`lab_hosts.routing_mode` +
|
||||
`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`) via
|
||||
`platform/gateway/src/lab-endpoint.js` — a single function, `labTarget(env, token)`, that every
|
||||
proxy/fetch call site in `server.js` goes through instead of building
|
||||
`http://${env.internal_ip}:${env.internal_port}` itself. `dev-local` stays `direct` (T480/local
|
||||
dev unaffected, zero behavior change there); a real `rn-lab-host-01` is configured `router`.
|
||||
|
||||
**Firewall (`infra/firewall/rn-lab-host-01.rules`):** the old speculative
|
||||
`lab_gateway_port_range` allowance (which assumed option (a), publishing per-Lab ports) was
|
||||
replaced with a single, narrow rule: `platform_ip` → port `{{ lab_router_port | default('8443')
|
||||
}}` only. No wide port range, no per-Lab port publishing at all — the Router is the only thing
|
||||
the Gateway ever talks to on rn-lab-host-01 for Lab traffic.
|
||||
|
||||
**mTLS:** the Gateway↔Router pair uses the SAME private CA as the Provisioner↔dockerd pair
|
||||
(`infra/scripts/docker-tls/generate-docker-tls.sh`, extended in Phase 4.1 to also issue
|
||||
`router-server`/`gateway-client` leaf certs), deployed to a separate directory
|
||||
(`/etc/rootnode/lab-router-tls/`) by the same `docker-tls` Ansible role.
|
||||
|
||||
**Proven, not just designed:** `infra/scripts/simulate-two-host/` builds a real two-network
|
||||
Docker simulation (Gateway genuinely isolated on its own bridge, no route to any Lab's bridge)
|
||||
and runs 13 real assertions against it — Gateway cannot reach a Lab's Docker-internal IP
|
||||
directly, but reaches it correctly through the Router (HTTP and a real WebSocket
|
||||
`kubectl get nodes`), Alice/Bob isolation holds, garbage/destroyed tokens are rejected, and the
|
||||
Router itself refuses any connection without the Gateway's client certificate. See that
|
||||
directory's README for exactly what is and isn't proven by a same-daemon simulation versus real
|
||||
separate hardware.
|
||||
|
||||
## Cookie / auth production settings
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ can prove in advance.
|
||||
|
||||
## Lab Host VM
|
||||
- [ ] Docker daemon TLS verified: `docker --tlsverify --tlscacert=... -H tcp://<lab_host_ip>:2376 info` succeeds from the Platform VM, fails from anywhere else
|
||||
- [ ] Lab Host Router mTLS verified: a request to `https://<lab_host_ip>:8443` with no client cert is rejected; with the Gateway's real client cert it isn't (mirrors `infra/scripts/simulate-two-host/test-remote-routing.js`'s "direct public access: NOT AVAILABLE" case, now against the real host)
|
||||
- [ ] Provisioner successfully creates a real Lab against the real Lab Host
|
||||
- [ ] Gateway can actually reach that Lab's terminal — **this depends on the not-yet-built application change documented in `docs/PRODUCTION_NETWORK_SECURITY.md`'s "Known gap" section; do not mark this item done until that change has landed and been verified for real**
|
||||
- [ ] Gateway can actually reach that Lab's terminal (HTTP UI load + WebSocket `kubectl` command) — application mechanism built and simulation-tested in Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1/§6/§7); this line is the real-hardware confirmation that the same result holds across the actual two VMs, not two Docker networks on one daemon
|
||||
- [ ] Egress proxy correctly restricts a Lab's outbound traffic (Provisioner's per-Lab `DOCKER-USER` chain)
|
||||
|
||||
## Security
|
||||
@@ -38,7 +39,7 @@ can prove in advance.
|
||||
|
||||
## Backup
|
||||
- [ ] Manual `backup-postgres.sh` run succeeds against real Postgres, real off-host target reachable
|
||||
- [ ] A real restore (`restore-postgres.sh` + `verify-restore.sh`) has been rehearsed at least once against a throwaway target, not just the local test harness
|
||||
- [ ] A real restore (`restore-postgres.sh` + `verify-restore.sh`) has been rehearsed at least once against a throwaway target, not just the local test harness — Phase 4.1's `infra/backup/test-destructive-restore-drill.sh` proved the full mechanism (realistic data, real storage destruction, restore, real Platform API boot) locally; this line is the real-hardware confirmation
|
||||
- [ ] Cron job confirmed scheduled (`crontab -l`)
|
||||
|
||||
## Observability
|
||||
@@ -46,5 +47,60 @@ can prove in advance.
|
||||
- [ ] Trigger one alert deliberately (e.g. stop a service) and confirm it fires and reaches the webhook
|
||||
- [ ] Loki has logs flowing from both VMs
|
||||
|
||||
## Pre-production hardening (Phase 4.1 — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md)
|
||||
- [ ] `resource-policy.yaml`'s `admission_control.max_concurrent_labs` set from a real measured
|
||||
`infra/scripts/capacity-model.js` run against the real Lab Host, not left disabled
|
||||
- [ ] A deliberate over-capacity attempt returns the clean 503 "Lab capacity is temporarily
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Remote & Self-Paced Delivery Architecture
|
||||
|
||||
Phase 4.1 §12. This is an architecture review, not a new product — no self-registration,
|
||||
payments, or public enrollment is built here (explicit non-goal). The question this document
|
||||
answers: does the infrastructure and application assume "an instructor always provisions the
|
||||
Lab," and if so, where, and what would need to change for self-paced delivery to work.
|
||||
|
||||
## The short answer: less of that assumption exists than expected
|
||||
|
||||
RootNode Academy's Lab lifecycle was built student-driven from Phase 1 — `POST
|
||||
/api/environment/launch` is called by the STUDENT'S OWN session, not by an instructor action.
|
||||
"Prepare Labs" (`POST /api/admin/cohorts/:id/prepare-labs`) is a classroom **convenience** that
|
||||
front-loads several students' launches at once before a synchronous class session, not the only
|
||||
path to a running Lab. This was true before Phase 4.1 and confirmed still true by this phase's
|
||||
own regression suites (`platform/test/e2e-phase2a.js` roster's two students with `cohort_id:
|
||||
null` and takes them through login → curriculum → launch → scenario work with zero cohort
|
||||
involvement) and by two new Phase 4.1 drills built specifically to exercise the remote/returning
|
||||
-student narrative:
|
||||
|
||||
- `infra/scripts/drills/test-lab-host-loss.js` — student launches, makes progress, their Lab is
|
||||
destroyed out from under them (standing in for an idle-reaper destroy), Platform stays up,
|
||||
progress is untouched, they launch again unprompted and continue.
|
||||
- `infra/scripts/drills/test-fake-cohort-rehearsal.js` — the same "returns later, fresh Lab,
|
||||
curriculum continues" narrative as part of a fuller classroom rehearsal.
|
||||
|
||||
Both pass today, with zero application code changes needed for the self-paced-relevant parts of
|
||||
this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
|
||||
## What already works, unmodified
|
||||
|
||||
- **Student-initiated provisioning** — no instructor step is required between "student wants to
|
||||
work" and "Lab exists." (`platform/api/src/server.js`'s `/api/environment/launch`.)
|
||||
- **Cohort-less students** — `cohort_id` is nullable on student creation
|
||||
(`POST /api/admin/students`); a student with no cohort logs in, sees the curriculum, launches
|
||||
Labs, and makes progress identically to a cohorted one. Confirmed by inspection AND by the
|
||||
existing regression suite, not just by schema reading.
|
||||
- **Idle reaper → academic state survives → return → continue** — the exact lifecycle this
|
||||
section describes as the target self-paced experience is what Phase 1's reaper +
|
||||
Phase 2B's Postgres-backed academic data (separate from the disposable Lab) were already
|
||||
designed to produce. Phase 4.1 proved it end-to-end, twice (see above).
|
||||
- **Curriculum content itself is not cohort-gated** — `GET /api/curriculum/path` reads the one
|
||||
curriculum path file directly (`curriculum.loadCurriculumPath()`); it never checks
|
||||
`curriculum_assignments` or cohort membership at all. A self-paced student sees the full
|
||||
curriculum with no instructor action required to "unlock" it.
|
||||
|
||||
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
|
||||
|
||||
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
|
||||
built the boundary a *separate* public website will call
|
||||
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
|
||||
row can now be created automatically by a trusted server-to-server enrollment event, but there
|
||||
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
|
||||
|
||||
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
|
||||
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
|
||||
`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.**~~ **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.**~~ **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`.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT do here
|
||||
|
||||
No signup flow, no student password/email auth implementation, no per-student curriculum
|
||||
assignment route, no payments, no public course catalog, no held/premium scenario content. All
|
||||
explicit non-goals per this phase's authorization. Student authentication (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,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,47 @@
|
||||
# Security Scanning (Phase 4.1 §24)
|
||||
|
||||
`infra/ci/gitea-workflows/build-and-test.yml`'s `security-scan` job. All free, open-source,
|
||||
no account/signup required for any of them — no paid scanner was added.
|
||||
|
||||
## What's scanned
|
||||
|
||||
| Tool | Checks | Scope |
|
||||
|---|---|---|
|
||||
| `npm audit --audit-level=high` | Known-vulnerable dependencies | Every first-party Node service |
|
||||
| [gitleaks](https://github.com/gitleaks/gitleaks) | Committed secrets (keys, tokens, credentials) | Whole repo |
|
||||
| [hadolint](https://github.com/hadolint/hadolint) | Dockerfile best-practice/security lint | Every first-party Dockerfile |
|
||||
| [shellcheck](https://www.shellcheck.net/) | Shell script correctness/security | `infra/**/*.sh` |
|
||||
| [trivy](https://github.com/aquasecurity/trivy) | OS/library CVEs in a built container image | `platform-api` image (representative — same base/layers as the other Node services) |
|
||||
|
||||
## Severity policy
|
||||
|
||||
- **CRITICAL / HIGH** — fails the build (`security-scan` blocks `build-images`, which blocks
|
||||
`deploy-staging`, which blocks production promotion — see the job dependency chain in the
|
||||
workflow file).
|
||||
- **MEDIUM / LOW** — reported in the job output, never blocks. A build that's permanently red
|
||||
over a low-severity transitive dependency finding nobody can immediately fix trains everyone
|
||||
to ignore red builds, which is worse than not scanning at all.
|
||||
|
||||
`npm audit --audit-level=high` and trivy's `--severity HIGH,CRITICAL` both implement this
|
||||
directly at the tool level, not via manual triage.
|
||||
|
||||
## What's NOT in the automated job (and why)
|
||||
|
||||
- **Dedicated IaC security linting** (checkov/tflint) — not added. `infra/scripts/validate-compose.sh`,
|
||||
`infra/firewall/validate-firewall.sh`, `ansible-playbook --syntax-check`, and `tofu validate`
|
||||
(all in the `regression` job) already catch structural/syntax problems in the infra code;
|
||||
a dedicated security-policy linter for Terraform/Ansible is a reasonable Phase 5 addition once
|
||||
there's a real cloud footprint to misconfigure, not before.
|
||||
- **DAST/penetration testing** — out of scope for CI; this is a build-time gate, not a running-system
|
||||
test. Covered separately by `docs/PRODUCTION_VALIDATION_CHECKLIST.md`'s security section once
|
||||
real hardware exists.
|
||||
- **SAST beyond hadolint/shellcheck** — the codebase is small enough (a handful of first-party
|
||||
Node services) that `npm audit` + secret scanning + Dockerfile/shell lint cover the realistic
|
||||
risk surface; a dedicated SAST tool (Semgrep, CodeQL) is a reasonable addition once the
|
||||
codebase or team grows enough to justify the added CI time.
|
||||
|
||||
## Local reproduction
|
||||
|
||||
Every check above can be run locally with the exact same free tools — no CI-only dependency.
|
||||
See the `security-scan` job's steps in `infra/ci/gitea-workflows/build-and-test.yml` for the
|
||||
exact commands (they're copy-pasteable).
|
||||
@@ -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).
|
||||
@@ -57,3 +57,6 @@ tr:hover td { background: var(--surface2); }
|
||||
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
|
||||
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.muted { color: var(--text-2); font-size: 0.85rem; }
|
||||
|
||||
/* Phase 4.1 §16 — shared pagination control (AuditView, StudentsView attempts). */
|
||||
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
|
||||
|
||||
@@ -8,6 +8,9 @@ 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 styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
@@ -20,8 +23,11 @@ const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
@@ -59,8 +65,11 @@ export default function AdminPortal() {
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === '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} />}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AssessmentsView({ role }) {
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create assessment</h2>
|
||||
<CreateAssessmentForm onCreated={reload} />
|
||||
<CreateAssessmentForm onCreated={reload} role={role} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
@@ -56,20 +56,86 @@ export default function AssessmentsView({ role }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAssessmentForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
|
||||
// Phase 4.1 §18 — pool_spec is built from structured picks (required scenarios + named random
|
||||
// pools), not hand-typed JSON. `catalog` ({ stages: [{ id, name, scenarios: [{id,title}] }] })
|
||||
// comes from GET /api/admin/scenario-catalog. An owner can still drop into a raw-JSON view for
|
||||
// anything the structured editor doesn't cover — see the toggle below.
|
||||
function PoolSpecEditor({ catalog, required, setRequired, pools, setPools }) {
|
||||
const allScenarios = (catalog?.stages || []).flatMap((s) => s.scenarios.map((sc) => ({ ...sc, stageName: s.name })))
|
||||
const titleFor = (id) => allScenarios.find((s) => s.id === id)?.title || id
|
||||
|
||||
const addPool = () => setPools([...pools, { count: 1, from: [] }])
|
||||
const removePool = (i) => setPools(pools.filter((_, idx) => idx !== i))
|
||||
const updatePool = (i, patch) => setPools(pools.map((p, idx) => (idx === i ? { ...p, ...patch } : p)))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={styles.label}>Required scenarios (always included)</label>
|
||||
<select className={styles.select} multiple size={6} style={{ width: '100%' }}
|
||||
value={required} onChange={(e) => setRequired([...e.target.selectedOptions].map((o) => o.value))}>
|
||||
{catalog?.stages.map((stage) => (
|
||||
<optgroup key={stage.id} label={stage.name}>
|
||||
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<p className={styles.muted}>{required.length ? `${required.length} selected: ${required.map(titleFor).join(', ')}` : 'None selected — cmd/ctrl-click to pick multiple.'}</p>
|
||||
|
||||
<label className={styles.label} style={{ marginTop: '1rem', display: 'block' }}>Random pools (draw N at random from a set, every attempt)</label>
|
||||
{pools.map((pool, i) => (
|
||||
<div key={i} className={styles.card} style={{ marginBottom: '0.5rem' }}>
|
||||
<div className={styles.formRow}>
|
||||
<div style={{ maxWidth: '100px' }}><label className={styles.label}>Draw count</label>
|
||||
<input className={styles.input} type="number" min="1" value={pool.count}
|
||||
onChange={(e) => updatePool(i, { count: Number(e.target.value) })} /></div>
|
||||
<div><button type="button" className={styles.linkBtn} onClick={() => removePool(i)}>Remove pool</button></div>
|
||||
</div>
|
||||
<label className={styles.label}>Draw from</label>
|
||||
<select className={styles.select} multiple size={5} style={{ width: '100%' }}
|
||||
value={pool.from} onChange={(e) => updatePool(i, { from: [...e.target.selectedOptions].map((o) => o.value) })}>
|
||||
{catalog?.stages.map((stage) => (
|
||||
<optgroup key={stage.id} label={stage.name}>
|
||||
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{pool.count > pool.from.length && <p className={styles.muted} style={{ color: 'var(--amber, #b58900)' }}>Draw count ({pool.count}) exceeds the pool size ({pool.from.length}) — every draw will use every scenario, not a random subset.</p>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className={styles.linkBtn} onClick={addPool}>+ Add random pool</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAssessmentForm({ onCreated, role }) {
|
||||
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [required, setRequired] = useState([])
|
||||
const [pools, setPools] = useState([])
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const [rawPoolSpec, setRawPoolSpec] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => { adminApi.scenarioCatalog().then(setCatalog).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
|
||||
// Owner's raw-JSON view, when open, is the source of truth — otherwise built from the
|
||||
// structured pickers above.
|
||||
const pool_spec = advanced
|
||||
? JSON.parse(rawPoolSpec || '{"required":[],"random":[]}')
|
||||
: { required, random: pools.filter((p) => p.count > 0 && p.from.length > 0) };
|
||||
if (!advanced && !required.length && !pool_spec.random.length) {
|
||||
throw new Error('Pick at least one required scenario or one random pool.')
|
||||
}
|
||||
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
|
||||
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
|
||||
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
|
||||
setRequired([]); setPools([]); setRawPoolSpec('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
|
||||
} catch (e) { setError(e.message?.includes('JSON') ? 'Raw pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -91,26 +157,47 @@ function CreateAssessmentForm({ onCreated }) {
|
||||
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label className={styles.label}>Pool spec (JSON)</label>
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
|
||||
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
|
||||
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
|
||||
|
||||
{role === 'owner' && (
|
||||
<p className={styles.muted}>
|
||||
<button type="button" className={styles.linkBtn} onClick={() => setAdvanced(!advanced)}>
|
||||
{advanced ? '← Back to the structured editor' : 'Advanced: edit pool_spec as raw JSON →'}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{advanced ? (
|
||||
<>
|
||||
<label className={styles.label}>Pool spec (JSON)</label>
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
|
||||
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
|
||||
value={rawPoolSpec} onChange={(e) => setRawPoolSpec(e.target.value)} required />
|
||||
</>
|
||||
) : !catalog ? <p className={styles.muted}>Loading scenario catalog…</p> : (
|
||||
<PoolSpecEditor catalog={catalog} required={required} setRequired={setRequired} pools={pools} setPools={setPools} />
|
||||
)}
|
||||
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AssessmentDetail({ definition, cohorts, onBack }) {
|
||||
const [sessions, setSessions] = useState(null)
|
||||
// Phase 4.1 §16 — assessmentSessions is now paginated (was unbounded); `result` holds the
|
||||
// full { items, page, total, total_pages } envelope, `sessions` stays the plain array for
|
||||
// the table below so its render logic doesn't need to change.
|
||||
const [result, setResult] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
|
||||
}, [definition])
|
||||
adminApi.assessmentSessions(definition.assessment_definition_id, page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [definition, page])
|
||||
useEffect(reload, [reload])
|
||||
const sessions = result?.items || null
|
||||
|
||||
const assign = async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
@@ -158,6 +245,13 @@ function AssessmentDetail({ definition, cohorts, onBack }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{result && result.total_pages > 1 && (
|
||||
<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} ({result.total} total)</span>
|
||||
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -2,36 +2,47 @@ import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
|
||||
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
|
||||
// admin/owner only (enforced server-side too). Phase 4.1 §16 — real server-side pagination
|
||||
// (platform/api/src/pagination.js) replaces the old flat "latest 200, nothing older" cap.
|
||||
export default function AuditView() {
|
||||
const [events, setEvents] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
setResult(null)
|
||||
adminApi.auditEvents(page).then(setResult).catch((e) => setError(e.message))
|
||||
}, [page])
|
||||
|
||||
const events = result?.items || []
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Audit log</h2>
|
||||
<p className={styles.muted}>Most recent 200 security/admin events.</p>
|
||||
<p className={styles.muted}>{result ? `${result.total} total events` : 'Security/admin events, most recent first.'}</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!events ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.audit_event_id}>
|
||||
<td>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
|
||||
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!result ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.audit_event_id}>
|
||||
<td>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
|
||||
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></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'
|
||||
|
||||
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,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,54 +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(() => {})
|
||||
adminApi.studentAttempts(studentId).then(setAttempts).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])
|
||||
|
||||
@@ -69,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>
|
||||
@@ -98,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) => (
|
||||
@@ -131,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,10 +51,16 @@ 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) => get(`/api/admin/students/${id}/attempts`),
|
||||
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`),
|
||||
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
|
||||
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
|
||||
@@ -79,19 +87,51 @@ export const adminApi = {
|
||||
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: () => get('/api/admin/audit-events'),
|
||||
auditEvents: (page = 1) => get(`/api/admin/audit-events?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
|
||||
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
|
||||
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
|
||||
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
|
||||
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
|
||||
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
|
||||
scenarioCatalog: () => get('/api/admin/scenario-catalog'), // Phase 4.1 §18 — structured assessment editor
|
||||
|
||||
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
|
||||
assessments: () => get('/api/admin/assessments'),
|
||||
createAssessment: (fields) => post('/api/admin/assessments', fields),
|
||||
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
|
||||
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
|
||||
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
|
||||
const POLL_MS = 5000
|
||||
|
||||
const HELP_CATEGORIES = [
|
||||
{ value: 'lab_problem', label: 'Lab problem' },
|
||||
{ value: 'dont_understand', label: "Don't understand the task" },
|
||||
{ value: 'validation_failing', label: 'Validation failing' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
|
||||
// right now" state. Live -> title, current activity (guidance only — a link into the student's
|
||||
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
|
||||
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
|
||||
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
|
||||
export default function ClassroomTab() {
|
||||
const [live, setLive] = useState(null)
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [helpCategory, setHelpCategory] = useState('lab_problem')
|
||||
const [helpMessage, setHelpMessage] = useState('')
|
||||
const [helpSent, setHelpSent] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const heartbeatRef = useRef(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadLive = useCallback(() => {
|
||||
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(() => { loadLive() }, [loadLive])
|
||||
|
||||
const loadDetail = useCallback((id) => {
|
||||
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
|
||||
api.environmentStatus().then(setEnvStatus).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return
|
||||
loadDetail(live.classroom_session_id)
|
||||
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [live, loadDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!joined || !live) return
|
||||
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
|
||||
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
|
||||
return () => clearInterval(heartbeatRef.current)
|
||||
}, [joined, live])
|
||||
|
||||
const join = async () => {
|
||||
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
|
||||
catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const sendHelp = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
|
||||
setHelpSent(true); setHelpMessage('')
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
const launchLab = async () => {
|
||||
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
if (!live) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<p className={styles.livePill}>LIVE NOW</p>
|
||||
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
|
||||
{live.description && <p className={styles.muted}>{live.description}</p>}
|
||||
{live.meeting_url && (
|
||||
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
|
||||
)}
|
||||
{!joined ? (
|
||||
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
|
||||
) : (
|
||||
<p className={styles.muted}>You're in this classroom.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{joined && detail && (
|
||||
<>
|
||||
{detail.classroom.current_scenario_id && (
|
||||
<div className={styles.card}>
|
||||
<h3>Current class activity</h3>
|
||||
<p>{detail.classroom.current_scenario_id}</p>
|
||||
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Your Lab</h3>
|
||||
{envStatus?.status === 'running'
|
||||
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
|
||||
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
|
||||
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>
|
||||
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Need help?</h3>
|
||||
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
|
||||
<form onSubmit={sendHelp}>
|
||||
<label className={styles.label}>What's going on?</label>
|
||||
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
|
||||
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<label className={styles.label}>Details (optional)</label>
|
||||
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
|
||||
<button className={styles.primaryBtn} type="submit">Need Help</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet
|
||||
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic
|
||||
// message regardless of whether the identifier exists (anti-enumeration, matches the server's
|
||||
// own response shape exactly). In dev/test, the server includes dev_reset_token in its
|
||||
// response so this screen can offer a direct link — a real deployment omits it, and completing
|
||||
// the flow needs an instructor to run the admin-side reset instead (see
|
||||
// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md).
|
||||
export default function ForgotPasswordScreen({ onCancel, onHaveToken }) {
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [sent, setSent] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.requestPasswordReset(identifier)
|
||||
setSent(result)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Reset your password</p>
|
||||
|
||||
{!sent ? (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
|
||||
{sent.dev_reset_token && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
|
||||
Dev/test only (never shown in production):{' '}
|
||||
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,140 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
const BLOCKED_MESSAGES = {
|
||||
pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
|
||||
not_yet_started: 'This program opens on the date below. Check back then.',
|
||||
access_expired: 'Access to this program has ended. Your progress is still here for reference.',
|
||||
cancelled: 'This enrollment was cancelled.',
|
||||
}
|
||||
|
||||
// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
|
||||
// already carries real access/progress/next-activity per enrollment — see program-status.js),
|
||||
// split into Active and Completed. Continue Learning delegates to the entitlement-gated
|
||||
// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
|
||||
// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
|
||||
export default function ProgramsTab() {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.programs().then(setPrograms).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
|
||||
const completed = programs.filter((p) => p.effective_status === 'completed')
|
||||
const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
|
||||
|
||||
if (!programs.length) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
<p className={styles.muted}>You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{active.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{completed.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Completed Programs</h2>
|
||||
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{cancelled.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Other</h2>
|
||||
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramCard({ program, onChange }) {
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const poll = useCallback(() => {
|
||||
api.environmentStatus().then((s) => {
|
||||
setEnvStatus(s)
|
||||
if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
|
||||
clearInterval(pollRef.current); pollRef.current = null
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleContinue = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchProgram(program.enrollment_id)
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
|
||||
if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
|
||||
pollRef.current = setInterval(poll, POLL_MS)
|
||||
}
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const p = program.progress
|
||||
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<strong>{program.program_name}</strong>
|
||||
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
|
||||
{program.effective_status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{p && (
|
||||
<div style={{ margin: '0.6rem 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{program.effective_status === 'completed' ? (
|
||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||
) : program.blocked_reason ? (
|
||||
<p className={styles.muted}>
|
||||
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
|
||||
{error && <p className={styles.errorBox}>{error}</p>}
|
||||
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves:
|
||||
// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same
|
||||
// UI, different API call and success copy.
|
||||
export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) {
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message))
|
||||
}, [token, purpose])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (password.length < 10) { setError('Password must be at least 10 characters.'); return }
|
||||
if (password !== confirm) { setError('Passwords do not match.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (purpose === 'activation') {
|
||||
const student = await api.activate(token, password)
|
||||
onDone(student)
|
||||
} else {
|
||||
await api.resetPassword(token, password)
|
||||
onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
|
||||
|
||||
{previewError && (
|
||||
<div className={styles.errorBox}>
|
||||
This link is invalid or has expired. Ask your instructor for a new one.
|
||||
</div>
|
||||
)}
|
||||
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{!previewError && (
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
|
||||
<label className={styles.label}>Confirm password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,19 @@ import PracticeTab from './PracticeTab'
|
||||
import AssessmentsTab from './AssessmentsTab'
|
||||
import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
import ClassroomTab from './ClassroomTab'
|
||||
import ProfileTab from './ProfileTab'
|
||||
import ProgramsTab from './ProgramsTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'programs', label: 'My Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'capstone', label: 'Capstone' },
|
||||
{ key: 'achievements', label: 'Achievements' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
]
|
||||
|
||||
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
|
||||
@@ -41,10 +47,13 @@ export default function StudentDashboard({ student, onLogout }) {
|
||||
</nav>
|
||||
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'programs' && <ProgramsTab />}
|
||||
{tab === 'classroom' && <ClassroomTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
{tab === 'profile' && <ProfileTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,4 +83,45 @@ export const api = {
|
||||
capstone: () => fetch('/api/capstone').then(json),
|
||||
|
||||
achievements: () => fetch('/api/achievements').then(json),
|
||||
|
||||
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
|
||||
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
|
||||
// above: nothing authenticated exists yet to forge into.
|
||||
studentLogin: async (identifier, password) => {
|
||||
const { student } = await fetch('/api/student/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
activate: async (token, password) => {
|
||||
const { student } = await fetch('/api/student/activate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json)
|
||||
cacheDisplay(student)
|
||||
return student
|
||||
},
|
||||
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
|
||||
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
|
||||
}).then(json),
|
||||
resetPassword: (token, password) => fetch('/api/student/reset-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
|
||||
}).then(json),
|
||||
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
|
||||
profile: () => fetch('/api/student/profile').then(json),
|
||||
|
||||
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
|
||||
liveClassroom: () => fetch('/api/classroom/live').then(json),
|
||||
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
|
||||
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
|
||||
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
|
||||
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
||||
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
||||
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
|
||||
|
||||
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
|
||||
programs: () => fetch('/api/student/programs').then(json),
|
||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||
}
|
||||
|
||||
+20
-15
@@ -4,26 +4,31 @@
|
||||
|
||||
# Docker TLS material — CA/server/client certs and keys, generated by
|
||||
# infra/scripts/docker-tls/generate-ca.sh and friends. Templates/READMEs stay tracked.
|
||||
infra/docker/tls/*.pem
|
||||
infra/docker/tls/*.key
|
||||
infra/docker/tls/*.crt
|
||||
infra/docker/tls/*.srl
|
||||
!infra/docker/tls/**/README.md
|
||||
docker/tls/*.pem
|
||||
docker/tls/*.key
|
||||
docker/tls/*.crt
|
||||
docker/tls/*.srl
|
||||
!docker/tls/**/README.md
|
||||
|
||||
# Ansible Vault password files and any decrypted vars
|
||||
infra/ansible/.vault_pass
|
||||
infra/ansible/**/vault_plain.yml
|
||||
ansible/.vault_pass
|
||||
ansible/**/vault_plain.yml
|
||||
|
||||
# Terraform/OpenTofu state and generated vars (may contain resource IDs/IPs)
|
||||
infra/terraform/**/*.tfstate
|
||||
infra/terraform/**/*.tfstate.*
|
||||
infra/terraform/**/.terraform/
|
||||
infra/terraform/**/terraform.tfvars
|
||||
!infra/terraform/**/terraform.tfvars.example
|
||||
# Phase 4.1: these patterns were previously written repo-root-relative (e.g.
|
||||
# "infra/terraform/...") inside THIS file, which git resolves relative to the .gitignore's own
|
||||
# directory (infra/) — so they silently matched nothing at all. Found via `git status` showing
|
||||
# a real .terraform/ provider cache as untracked when it should have been ignored. Fixed by
|
||||
# dropping the redundant leading "infra/" from every pattern below.
|
||||
terraform/**/*.tfstate
|
||||
terraform/**/*.tfstate.*
|
||||
terraform/**/.terraform/
|
||||
terraform/**/terraform.tfvars
|
||||
!terraform/**/terraform.tfvars.example
|
||||
|
||||
# Backup output and restore scratch space
|
||||
infra/backup/output/
|
||||
infra/backup/restore-test/
|
||||
backup/output/
|
||||
backup/restore-test/
|
||||
|
||||
# Load test results (local artifacts, not source)
|
||||
infra/scripts/loadtest/results/
|
||||
scripts/loadtest/results/
|
||||
|
||||
@@ -13,7 +13,7 @@ lab_host_ip: __LAB_HOST_VM_IP__
|
||||
management_cidr: __YOUR_ADMIN_ACCESS_CIDR__ # e.g. a VPN/WireGuard subnet, never 0.0.0.0/0
|
||||
proxmox_mgmt_cidr: __PROXMOX_HOST_MGMT_CIDR__
|
||||
lab_subnet_range: "10.42.0.0/16" # matches NO_PROXY_CIDRS already used by the app
|
||||
lab_gateway_port_range: "30000:32767" # see docs/PRODUCTION_NETWORK_SECURITY.md — Gateway-to-Lab reachability follow-up
|
||||
lab_router_port: "8443" # Gateway -> Lab Host Router (data plane) — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3
|
||||
|
||||
# ── Storage (Phase 4 §4 — a decision framework, not a final layout; see docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §4)
|
||||
rootnode_data_root: /srv/rootnode-data # expected to be a ZFS dataset mountpoint on the real host
|
||||
|
||||
@@ -4,3 +4,19 @@
|
||||
name: docker
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
# Phase 4.1 — these restart the whole per-host compose wrapper unit (no per-container systemd
|
||||
# units exist in this project's convention, see the `platform`/`lab-host` roles), which is
|
||||
# heavier than a surgical single-container restart but consistent with how every other
|
||||
# config-changed-so-restart-the-stack case is already handled here.
|
||||
- name: restart lab-router
|
||||
ansible.builtin.systemd:
|
||||
name: rootnode-lab-host.service
|
||||
state: restarted
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
|
||||
- name: restart gateway
|
||||
ansible.builtin.systemd:
|
||||
name: rootnode-platform.service
|
||||
state: restarted
|
||||
when: inventory_hostname in groups['platform']
|
||||
|
||||
@@ -87,3 +87,50 @@
|
||||
mode: "0644"
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart docker with tls
|
||||
|
||||
# ── Phase 4.1: the DATA-plane mTLS pair (Gateway <-> Lab Host Router). Same CA as the
|
||||
# CONTROL-plane pair above, separate directory (/etc/rootnode/lab-router-tls) so the two
|
||||
# concerns stay bind-mount-separable — the `lab-router`/`gateway` containers only ever need
|
||||
# their own pair mounted in, never the Docker TLS one. See
|
||||
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3.
|
||||
- name: Create /etc/rootnode/lab-router-tls
|
||||
ansible.builtin.file:
|
||||
path: /etc/rootnode/lab-router-tls
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Deploy CA cert for the data-plane pair (public, both hosts)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/ca.pem"
|
||||
dest: /etc/rootnode/lab-router-tls/ca.pem
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0444"
|
||||
|
||||
- name: Deploy Lab Host Router server cert/key (rn-lab-host-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: router-server-cert.pem, dest: router-server-cert.pem, mode: "0444" }
|
||||
- { src: router-server-key.pem, dest: router-server-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['lab_host']
|
||||
notify: restart lab-router
|
||||
|
||||
- name: Deploy Gateway client cert/key (rn-platform-01 only)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
|
||||
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: gateway-client-cert.pem, dest: gateway-client-cert.pem, mode: "0444" }
|
||||
- { src: gateway-client-key.pem, dest: gateway-client-key.pem, mode: "0400" }
|
||||
when: inventory_hostname in groups['platform']
|
||||
notify: restart gateway
|
||||
|
||||
@@ -3,3 +3,11 @@ PLATFORM_API_URL=http://api:4300
|
||||
PROVISIONER_URL=http://provisioner:4100
|
||||
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
|
||||
GATEWAY_PORT=4200
|
||||
# Phase 4.1 — mTLS client identity for the Lab Host Router (data plane). Only used when a
|
||||
# lab_host's routing_mode is 'router' (see platform/gateway/src/lab-endpoint.js); harmless if
|
||||
# every configured lab_host is still 'direct'. Paths are inside the container — mounted from
|
||||
# /etc/rootnode/lab-router-tls on the host by the `docker-tls` role, same pattern as the
|
||||
# Provisioner's Docker TLS client cert.
|
||||
LAB_ROUTER_TLS_CA=/tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT=/tls/gateway-client-cert.pem
|
||||
LAB_ROUTER_TLS_KEY=/tls/gateway-client-key.pem
|
||||
|
||||
@@ -16,3 +16,9 @@ INSTRUCTOR_SESSION_IDLE_HOURS=2
|
||||
|
||||
COOKIE_SECURE=1
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Phase 4.1 §17 — instructor "needs attention" thresholds. Commented out (code defaults apply:
|
||||
# 3 failures, 7 stale days) — uncomment and override in group_vars/{{ env }}.yml only if a
|
||||
# cohort's real usage shows the defaults are wrong for it.
|
||||
# NEEDS_ATTENTION_FAIL_THRESHOLD=3
|
||||
# NEEDS_ATTENTION_STALE_DAYS=7
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// Seeds realistic academy data for the Phase 4.1 §20 destructive restore drill — an owner, an
|
||||
// admin instructor, a cohort, 3 students, progress, attempts, an assessment definition +
|
||||
// session, and an achievement. Uses the REAL Platform API over HTTP for everything that
|
||||
// doesn't need a live Provisioner/k3s Lab (owner login, instructor/cohort/student/assessment
|
||||
// creation — genuinely exercises the real RBAC/creation code paths), and direct SQL inserts
|
||||
// only for progress/attempts/achievements (those need real scenario activity in the full app;
|
||||
// re-running the whole learning engine here would just re-prove what e2e-phase3b.js already
|
||||
// proves — this drill is about DATA DURABILITY, not re-testing scenario validation).
|
||||
//
|
||||
// Usage: PLATFORM_API_URL=http://localhost:PORT PLATFORM_DB_URL=postgres://... node seed-realistic-data.js
|
||||
|
||||
const { Pool } = require('../../platform/api/node_modules/pg');
|
||||
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4390';
|
||||
const DB_URL = process.env.PLATFORM_DB_URL;
|
||||
if (!DB_URL) { console.error('Set PLATFORM_DB_URL'); process.exit(1); }
|
||||
|
||||
function makeSession() {
|
||||
const jar = new Map();
|
||||
function cookieHeader() { return [...jar].map(([k, v]) => `${k}=${v}`).join('; '); }
|
||||
function absorb(res) {
|
||||
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
for (const c of cookies) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
return {
|
||||
async req(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cookieHeader()) headers.Cookie = cookieHeader();
|
||||
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = jar.get('rn_csrf_i') || '';
|
||||
const res = await fetch(`${API}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
absorb(res);
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const owner = makeSession();
|
||||
await owner.req('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'DrillOwnerPass!2026' });
|
||||
console.log(' owner login ok');
|
||||
|
||||
const admin = await owner.req('POST', '/api/admin/instructors', {
|
||||
email: '[email protected]', display_name: 'Drill Admin', password: 'DrillAdminPass!2026', role: 'admin',
|
||||
});
|
||||
console.log(' admin instructor created:', admin.instructor_id);
|
||||
|
||||
const cohort = await owner.req('POST', '/api/admin/cohorts', { cohort_id: 'drill-cohort-01', name: 'Restore Drill Cohort' });
|
||||
console.log(' cohort created');
|
||||
|
||||
const students = [];
|
||||
for (const name of ['Drill Alice', 'Drill Bob', 'Drill Carlos']) {
|
||||
const s = await owner.req('POST', '/api/admin/students', { display_name: name, cohort_id: 'drill-cohort-01' });
|
||||
students.push(s.student_id);
|
||||
}
|
||||
console.log(' 3 students rostered and enrolled:', students.join(', '));
|
||||
|
||||
const assessment = await owner.req('POST', '/api/admin/assessments', {
|
||||
id: 'drill-exam-01', type: 'exam', title: 'Restore Drill Exam', duration_minutes: 30, hint_policy: 'disabled',
|
||||
pool_spec: { required: ['pod-basics-mcq'], random: [] },
|
||||
});
|
||||
console.log(' assessment definition created:', assessment.assessment_definition_id || 'drill-exam-01');
|
||||
|
||||
// Direct SQL for progress/attempts/achievements — see file header for why.
|
||||
const pool = new Pool({ connectionString: DB_URL });
|
||||
for (const studentId of students) {
|
||||
// environment_id is a real FK to the Provisioner's environments table (nullable) — left
|
||||
// NULL here rather than faking a row that doesn't exist, same as how a genuinely old
|
||||
// attempt whose environment has long since been cleaned up would look in real data.
|
||||
await pool.query(
|
||||
`INSERT INTO scenario_attempts (student_id, scenario_id, status, result, started_at, completed_at, validation_attempt_count, validation_pass_count)
|
||||
VALUES ($1, 'pod-basics-mcq', 'completed', 'passed', now() - interval '1 hour', now(), 1, 1)`,
|
||||
[studentId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO achievements (student_id, achievement_type, stage_id, achieved_at) VALUES ($1, 'stage_completed', 'foundation', now())`,
|
||||
[studentId]
|
||||
);
|
||||
}
|
||||
await pool.end();
|
||||
console.log(' progress/attempts/achievements seeded for all 3 students');
|
||||
|
||||
console.log('\nSeed complete.');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('SEED FAILED:', e.message); process.exit(1); });
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 4.1 §20 — the DESTRUCTIVE disaster-recovery drill, one level up from
|
||||
# test-backup-restore.sh's mechanism-only test: seeds realistic academy data through the REAL
|
||||
# Platform API (owner, admin instructor, cohort, 3 students, progress, attempts, an assessment
|
||||
# definition, achievements), backs it up, DESTROYS the disposable Postgres storage entirely
|
||||
# (docker rm — not just "restore into a second container," the actual source is gone),
|
||||
# recreates Postgres from nothing, restores, and boots the REAL Platform API again against the
|
||||
# restored database to prove the whole stack — not just the SQL — comes back.
|
||||
#
|
||||
# Never touches the developer's real persistent database — every Postgres here is a disposable
|
||||
# container on a throwaway port, created and destroyed entirely within this script.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.." # repo root
|
||||
|
||||
CONTAINER=rootnode-drill-postgres
|
||||
PORT=55434
|
||||
API_PORT=4390
|
||||
TESTDIR="$(mktemp -d)"
|
||||
# curriculum.js defaults to platform/api/scenarios/{data,bundles} — only present inside the
|
||||
# Docker image build (COPY scenarios/ ./scenarios/). Running src/server.js directly from
|
||||
# source (as this drill does, to avoid needing a full container build) needs these pointed at
|
||||
# the repo's real scenario content instead.
|
||||
export SCENARIOS_DIR="$PWD/scenarios/data"
|
||||
export BUNDLES_DIR="$PWD/scenarios/bundles"
|
||||
export CURRICULUM_PATH_FILE="$PWD/scenarios/rootnode-path.json"
|
||||
export BACKUP_ENCRYPTION_PASSPHRASE="drill-passphrase-not-for-production"
|
||||
export PROVISIONER_SHARED_SECRET="drill-shared-secret-not-for-production"
|
||||
|
||||
API_PID=""
|
||||
cleanup() {
|
||||
[ -n "$API_PID" ] && kill "$API_PID" 2>/dev/null || true
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
rm -rf "$TESTDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1 || ! command -v pg_dump >/dev/null 2>&1; then
|
||||
echo "==> psql/pg_dump not found locally — reusing the same docker-exec shim as test-backup-restore.sh"
|
||||
SHIM_BIN="$TESTDIR/bin"; mkdir -p "$SHIM_BIN"
|
||||
# Same shim as test-backup-restore.sh (see that file's comment for the full "why") — the URL
|
||||
# is DROPPED, not kept, since the host-side port it names doesn't exist inside the
|
||||
# container's own network namespace; -tAc counts as a self-contained call (needs no stdin)
|
||||
# the same way -c does, or the admin-verification queries below would hang waiting on stdin.
|
||||
cat > "$SHIM_BIN/psql" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
args=(); skip_next=0
|
||||
for a in "$@"; do
|
||||
if [ "$skip_next" = "1" ]; then skip_next=0; continue; fi
|
||||
if [ "$a" = "-p" ] || [ "$a" = "-h" ]; then skip_next=1; continue; fi
|
||||
if [[ "$a" =~ ^postgres://[^:]+:[^@]+@[^:]+:([0-9]+)/ ]]; then continue; fi # drop the URL — reconnect locally inside the container instead
|
||||
args+=("$a")
|
||||
done
|
||||
container="rootnode-drill-postgres"
|
||||
needs_stdin=1
|
||||
for a in "${args[@]}"; do { [ "$a" = "-c" ] || [ "$a" = "-tAc" ]; } && needs_stdin=0; done
|
||||
if [ "$needs_stdin" = "1" ]; then
|
||||
exec docker exec -e PGPASSWORD=postgres -i "$container" psql -h localhost -U postgres "${args[@]}"
|
||||
else
|
||||
exec docker exec -e PGPASSWORD=postgres "$container" psql -h localhost -U postgres "${args[@]}"
|
||||
fi
|
||||
EOF
|
||||
cat > "$SHIM_BIN/pg_dump" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
if [[ "$a" =~ ^postgres://[^:]+:[^@]+@[^:]+:([0-9]+)/(.*)$ ]]; then args+=("postgres://postgres:postgres@localhost:5432/${BASH_REMATCH[2]}"); continue; fi
|
||||
args+=("$a")
|
||||
done
|
||||
exec docker exec -e PGPASSWORD=postgres rootnode-drill-postgres pg_dump "${args[@]}"
|
||||
EOF
|
||||
chmod +x "$SHIM_BIN/psql" "$SHIM_BIN/pg_dump"
|
||||
export PATH="$SHIM_BIN:$PATH"
|
||||
fi
|
||||
|
||||
DB_URL="postgres://postgres:postgres@localhost:$PORT/postgres"
|
||||
|
||||
echo "==> [1/9] Starting disposable Postgres"
|
||||
docker run -d --name "$CONTAINER" -e POSTGRES_PASSWORD=postgres -p "$PORT:5432" postgres:16-alpine >/dev/null
|
||||
for i in $(seq 1 30); do PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -c "SELECT 1" >/dev/null 2>&1 && break; sleep 1; done
|
||||
|
||||
echo "==> [2/9] Applying real Provisioner + Platform API migrations"
|
||||
( cd platform/provisioner && [ -d node_modules ] || npm install --no-audit --no-fund >/dev/null
|
||||
PLATFORM_DB_URL="$DB_URL" node -e "require('./src/db').runMigrations().then((m)=>console.log(' Provisioner:', m.join(', ')))" )
|
||||
( cd platform/api && [ -d node_modules ] || npm install --no-audit --no-fund >/dev/null
|
||||
PLATFORM_DB_URL="$DB_URL" node -e "require('./src/db').runMigrations().then((m)=>console.log(' Platform API:', m.join(', ')))" )
|
||||
|
||||
echo "==> [3/9] Bootstrapping the drill owner and starting a REAL Platform API"
|
||||
( cd platform/api && PLATFORM_DB_URL="$DB_URL" node bootstrap-owner.js [email protected] "Drill Owner" 'DrillOwnerPass!2026' )
|
||||
( cd platform/api && PLATFORM_DB_URL="$DB_URL" PROVISIONER_URL="http://localhost:1" PLATFORM_API_PORT="$API_PORT" node src/server.js > "$TESTDIR/api.log" 2>&1 & echo $! > "$TESTDIR/api.pid" )
|
||||
API_PID=$(cat "$TESTDIR/api.pid")
|
||||
for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/healthz" >/dev/null 2>&1 && break; sleep 1; done
|
||||
curl -sf "http://localhost:$API_PORT/healthz" >/dev/null || { echo "FAIL: Platform API never came up"; cat "$TESTDIR/api.log"; exit 1; }
|
||||
echo " Platform API up on :$API_PORT"
|
||||
|
||||
echo "==> [4/9] Seeding realistic academy data through the real API (owner/admin/cohort/3 students/progress/attempts/assessment/achievements)"
|
||||
( cd infra/backup && PLATFORM_API_URL="http://localhost:$API_PORT" PLATFORM_DB_URL="$DB_URL" node seed-realistic-data.js )
|
||||
|
||||
echo "==> [5/9] Backing up (real backup-postgres.sh)"
|
||||
BACKUP_DIR="$TESTDIR/backups" PLATFORM_DB_URL="$DB_URL" ./infra/backup/backup-postgres.sh
|
||||
ENC_FILE=$(ls "$TESTDIR"/backups/*.sql.gz.gpg | head -1)
|
||||
[ -f "$ENC_FILE" ] || { echo "FAIL: no encrypted backup produced"; exit 1; }
|
||||
|
||||
echo "==> [6/9] DESTROYING the Postgres storage entirely (this is the destructive part — not restoring into a second container, the original is gone)"
|
||||
kill "$API_PID" 2>/dev/null || true; API_PID=""
|
||||
docker rm -f "$CONTAINER" >/dev/null
|
||||
echo " source container + its storage are gone"
|
||||
|
||||
echo "==> [7/9] Recreating Postgres from nothing"
|
||||
docker run -d --name "$CONTAINER" -e POSTGRES_PASSWORD=postgres -p "$PORT:5432" postgres:16-alpine >/dev/null
|
||||
for i in $(seq 1 30); do PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -c "SELECT 1" >/dev/null 2>&1 && break; sleep 1; done
|
||||
|
||||
echo "==> [8/9] Restoring from the encrypted backup (schema + data both come from the dump — no separate migration step)"
|
||||
echo "RESTORE" | BACKUP_ENCRYPTION_PASSPHRASE="$BACKUP_ENCRYPTION_PASSPHRASE" ./infra/backup/restore-postgres.sh "$ENC_FILE" "$DB_URL"
|
||||
./infra/backup/verify-restore.sh "$DB_URL"
|
||||
|
||||
echo "==> [9/9] Booting the REAL Platform API again against the RESTORED database"
|
||||
( cd platform/api && PLATFORM_DB_URL="$DB_URL" PROVISIONER_URL="http://localhost:1" PLATFORM_API_PORT="$API_PORT" node src/server.js > "$TESTDIR/api2.log" 2>&1 & echo $! > "$TESTDIR/api2.pid" )
|
||||
API_PID=$(cat "$TESTDIR/api2.pid")
|
||||
for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/healthz" >/dev/null 2>&1 && break; sleep 1; done
|
||||
curl -sf "http://localhost:$API_PORT/healthz" >/dev/null || { echo "FAIL: Platform API did not come back up against the restored DB"; cat "$TESTDIR/api2.log"; exit 1; }
|
||||
echo " Platform API is back up, against the RESTORED database — no fresh migration run needed"
|
||||
|
||||
echo "==> Verifying accounts/RBAC/cohorts/students/progress/attempts/achievements survived"
|
||||
FAIL=0
|
||||
check() { local desc="$1" want="$2" got="$3"; if [ "$want" = "$got" ]; then echo " ok — $desc"; else echo " FAIL — $desc (want $want, got $got)"; FAIL=1; fi; }
|
||||
|
||||
check "owner account (role) restored" "owner" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT role FROM instructors WHERE email='[email protected]'" | tr -d '[:space:]')"
|
||||
check "admin instructor restored" "admin" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT role FROM instructors WHERE email='[email protected]'" | tr -d '[:space:]')"
|
||||
check "cohort restored" "1" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM cohorts WHERE cohort_id='drill-cohort-01'" | tr -d '[:space:]')"
|
||||
check "3 students restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM cohort_enrollments WHERE cohort_id='drill-cohort-01'" | tr -d '[:space:]')"
|
||||
check "3 completed attempts restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM scenario_attempts WHERE result='passed' AND scenario_id='pod-basics-mcq'" | tr -d '[:space:]')"
|
||||
check "3 achievements restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM achievements WHERE stage_id='foundation'" | tr -d '[:space:]')"
|
||||
check "assessment definition restored" "1" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM assessment_definitions WHERE assessment_definition_id='drill-exam-01'" | tr -d '[:space:]')"
|
||||
|
||||
echo "==> Verifying RBAC still works against the restored DB (real login through the real API)"
|
||||
LOGIN_STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://localhost:$API_PORT/api/auth/instructor/login" \
|
||||
-H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"DrillAdminPass!2026"}')
|
||||
check "restored admin instructor can log in" "200" "$LOGIN_STATUS"
|
||||
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo; echo "DRILL FAILED — see above."; exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "OK: full destructive restore drill passed — owner/admin/cohort/3 students/progress/attempts/"
|
||||
echo "assessment/achievements all survived a real Postgres storage loss + recreation + restore, and"
|
||||
echo "the real Platform API booted and authenticated against the restored database with no fresh"
|
||||
echo "migration run required."
|
||||
echo "NOT TESTED HERE (needs real credentials/hardware): the actual off-host Storage Box/S3 copy."
|
||||
@@ -40,16 +40,19 @@ jobs:
|
||||
|
||||
- name: Install dependencies (all first-party services)
|
||||
run: |
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner; do
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
(cd "$d" && npm ci)
|
||||
done
|
||||
|
||||
- name: Syntax-check every service
|
||||
run: |
|
||||
for d in backend frontend/src platform/api platform/gateway platform/provisioner; do
|
||||
for d in backend frontend/src platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
find "$d" -name '*.js' -not -path '*/node_modules/*' -exec node --check {} \;
|
||||
done
|
||||
|
||||
- name: Lab Host Router unit tests (Phase 4.1 — fast, no Docker daemon needed)
|
||||
run: (cd platform/lab-router && npm test)
|
||||
|
||||
- name: Build frontend
|
||||
run: (cd frontend && npm run build)
|
||||
|
||||
@@ -75,8 +78,56 @@ jobs:
|
||||
# can't grant safely — they stay a LOCAL/staging pre-promotion step
|
||||
# (infra/scripts/staging-smoke-test.sh), not this job. See docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §18.
|
||||
|
||||
# Phase 4.1 §24 — free/open-source security scanning, no paid tooling, no signup required for
|
||||
# any of these. Severity policy (docs/SECURITY_SCANNING.md): CRITICAL/HIGH findings fail the
|
||||
# build; MEDIUM/LOW are reported but never block a merge — a build that's permanently red over
|
||||
# an unrelated low-severity transitive dependency finding trains everyone to ignore red builds.
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: npm audit (dependency vulnerabilities) — fails only on high/critical
|
||||
run: |
|
||||
FAIL=0
|
||||
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
|
||||
echo "== $d =="
|
||||
(cd "$d" && npm audit --audit-level=high) || FAIL=1
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
- name: Secret scanning (gitleaks — free, no account needed)
|
||||
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v
|
||||
|
||||
- name: Dockerfile linting (hadolint — free)
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in Dockerfile platform/api/Dockerfile platform/gateway/Dockerfile platform/provisioner/Dockerfile platform/egress-proxy/Dockerfile platform/lab-router/Dockerfile; do
|
||||
echo "== $f =="
|
||||
docker run --rm -i hadolint/hadolint < "$f" || FAIL=1
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
# -S error, not warning — matches docs/SECURITY_SCANNING.md's severity policy. Real run
|
||||
# against this tree at -S warning surfaces only style nits (unused loop vars, a missing
|
||||
# `cd ... || exit` guard) on scripts that are otherwise correct; failing the build on
|
||||
# those trains everyone to ignore the check.
|
||||
- name: Shell linting (shellcheck — free, standard)
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
|
||||
find infra -name '*.sh' -exec shellcheck -S error {} \+
|
||||
|
||||
- name: Container image scanning (trivy — free, no account needed)
|
||||
run: |
|
||||
docker build -q -t rootnode-scan/platform-api -f platform/api/Dockerfile .
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL --exit-code 1 rootnode-scan/platform-api
|
||||
|
||||
build-images:
|
||||
needs: regression
|
||||
needs: [regression, security-scan]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| `rootnode/gateway` | `platform/gateway/Dockerfile` | `{{ registry }}/rootnode/gateway` |
|
||||
| `rootnode/provisioner` | `platform/provisioner/Dockerfile` | `{{ registry }}/rootnode/provisioner` |
|
||||
| `rootnode/egress-proxy` | `platform/egress-proxy/Dockerfile` | `{{ registry }}/rootnode/egress-proxy` |
|
||||
| `rootnode/lab-router` | `platform/lab-router/Dockerfile` (Phase 4.1) | `{{ registry }}/rootnode/lab-router` |
|
||||
| `rootnode/lab` | root `Dockerfile` (the student Lab image) | `{{ registry }}/rootnode/lab` |
|
||||
|
||||
## Tagging
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Production compose for rn-lab-host-01. Deployed by infra/ansible/roles/lab-host/ to
|
||||
# /opt/rootnode/lab-host/docker-compose.yml, run as a systemd unit
|
||||
# (rootnode-lab-host.service.j2). Runs exactly one long-lived service: the shared egress proxy
|
||||
# every student Lab's outbound traffic passes through (platform/egress-proxy/, unchanged since
|
||||
# Phase 1). Student Lab containers themselves are NOT part of this compose file — they're
|
||||
# created/destroyed individually by the Provisioner over the remote Docker TLS endpoint
|
||||
# (dockerd itself, not compose), same mechanism as dev, just remote.
|
||||
# (rootnode-lab-host.service.j2). Runs the shared egress proxy every student Lab's outbound
|
||||
# traffic passes through (platform/egress-proxy/, unchanged since Phase 1) and, as of Phase
|
||||
# 4.1, the Lab Host Router (platform/lab-router/) that gives the Gateway on rn-platform-01 a
|
||||
# way to reach a running Lab without ever touching a Docker-internal address — see
|
||||
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3. Student Lab containers themselves are NOT
|
||||
# part of this compose file — they're created/destroyed individually by the Provisioner over
|
||||
# the remote Docker TLS endpoint (dockerd itself, not compose), same mechanism as dev, just
|
||||
# remote; the Router finds them by label, purely from local Docker state (see its own README).
|
||||
|
||||
networks:
|
||||
rn-egress:
|
||||
@@ -22,6 +25,26 @@ services:
|
||||
# is the actual control, see platform/egress-proxy/allowlist.txt)
|
||||
networks: [rn-egress]
|
||||
|
||||
lab-router:
|
||||
image: "{{ registry }}/rootnode/lab-router:{{ image_tag }}"
|
||||
restart: unless-stopped
|
||||
# Needs the host's own routing table to reach whichever per-Lab bridge network
|
||||
# (rootnode-lab-net-<id>) the Provisioner created for a given container — those networks
|
||||
# aren't known ahead of time and aren't attachable one-by-one, the same reason egress-proxy
|
||||
# would need this too if it initiated connections outward instead of just accepting them.
|
||||
# Restricted to platform_ip on port {{ lab_router_port | default('8443') }} at the host
|
||||
# firewall (infra/firewall/rn-lab-host-01.rules) — this is the ONLY thing that makes host
|
||||
# networking here safe, same defense-in-depth pattern as the Docker TLS port.
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /etc/rootnode/lab-router-tls:/tls:ro
|
||||
environment:
|
||||
LAB_ROUTER_PORT: "{{ lab_router_port | default('8443') }}"
|
||||
LAB_ROUTER_TLS_CA: /tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
|
||||
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:latest
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -88,6 +88,11 @@ services:
|
||||
image: "{{ registry }}/rootnode/platform-api:{{ image_tag }}"
|
||||
restart: unless-stopped
|
||||
env_file: /etc/rootnode/platform.env
|
||||
environment:
|
||||
# Phase 4.1 §25 — not a secret, just the deployed tag; set directly here rather than in
|
||||
# the env template since it's the same value already used for the image tag above, not a
|
||||
# separately-managed setting.
|
||||
GIT_SHA: "{{ image_tag }}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -99,6 +104,10 @@ services:
|
||||
image: "{{ registry }}/rootnode/gateway:{{ image_tag }}"
|
||||
restart: unless-stopped
|
||||
env_file: /etc/rootnode/gateway.env
|
||||
volumes:
|
||||
# mTLS client identity for the Lab Host Router (data plane) — see gateway.env.j2's
|
||||
# LAB_ROUTER_TLS_* vars. Read-only; the private key never leaves this one bind mount.
|
||||
- /etc/rootnode/lab-router-tls:/tls:ro
|
||||
depends_on: [provisioner, api]
|
||||
networks: [rn-platform-internal]
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev
|
||||
# Unix socket. Same mechanism the Provisioner has had since Phase 1
|
||||
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint —
|
||||
# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7.
|
||||
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at real remote endpoints.
|
||||
#
|
||||
# Phase 4.1 correction: routing_mode/router_endpoint is REQUIRED here — Phase 4's original
|
||||
# claim that "no application code changes required" was true for the Provisioner's CONTROL
|
||||
# plane (Docker mTLS) but false for the Gateway's DATA plane, which used to assume
|
||||
# env.internal_ip was directly reachable. It is not, across two real VMs. See
|
||||
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md.
|
||||
#
|
||||
# {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml.
|
||||
# Rendered by the `platform` role; the raw file here keeps the placeholder for readability.
|
||||
@@ -9,8 +14,14 @@
|
||||
lab_hosts:
|
||||
- lab_host_id: labhost-01
|
||||
display_name: "RootNode Lab Host VM 01 (production)"
|
||||
# CONTROL plane: Provisioner -> dockerd, mTLS, port 2376.
|
||||
endpoint: "tcp://{{ lab_host_ip }}:2376"
|
||||
tls_ca_path: /etc/rootnode/docker-tls/ca.pem
|
||||
tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem
|
||||
tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem
|
||||
enabled: true
|
||||
# DATA plane: Gateway -> Lab Host Router, mTLS, router's own port (8443 by default —
|
||||
# infra/docker/lab-host/docker-compose.production.yml). Gateway never touches a
|
||||
# Docker-internal address once routing_mode is 'router'.
|
||||
routing_mode: router
|
||||
router_endpoint: "https://{{ lab_host_ip }}:8443"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev
|
||||
# Unix socket. Same mechanism the Provisioner has had since Phase 1
|
||||
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint —
|
||||
# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7.
|
||||
#
|
||||
# {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml.
|
||||
# Rendered by the `platform` role; the raw file here keeps the placeholder for readability.
|
||||
# Staging lab_hosts seed — see infra/environments/production/provisioner-config/lab-hosts.yaml
|
||||
# for the full explanation; this is the same shape, lower stakes. Staging's `lab_host_ip` is
|
||||
# distinct from production's own (infra/ansible/group_vars/staging.yml), so it needs the same
|
||||
# router routing_mode as production once staging genuinely runs on two hosts. If staging is
|
||||
# ever collapsed onto a single host instead, change routing_mode to `direct` and drop
|
||||
# router_endpoint — that's the only edit needed either way.
|
||||
|
||||
lab_hosts:
|
||||
- lab_host_id: labhost-01
|
||||
display_name: "RootNode Lab Host VM 01 (production)"
|
||||
display_name: "RootNode Lab Host VM 01 (staging)"
|
||||
endpoint: "tcp://{{ lab_host_ip }}:2376"
|
||||
tls_ca_path: /etc/rootnode/docker-tls/ca.pem
|
||||
tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem
|
||||
tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem
|
||||
enabled: true
|
||||
routing_mode: router
|
||||
router_endpoint: "https://{{ lab_host_ip }}:8443"
|
||||
|
||||
@@ -38,13 +38,14 @@
|
||||
# endpoint: ALLOW" example in the Phase 4 spec, encoded literally here.
|
||||
-A INPUT -p tcp --dport 2376 -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
|
||||
|
||||
# Gateway -> per-Lab published port range, rn-platform-01 only. REQUIRED so the Gateway can
|
||||
# reach each student's Lab UI/terminal over the network once Labs run on a genuinely separate
|
||||
# host — see docs/PRODUCTION_NETWORK_SECURITY.md §"Gateway-to-Lab cross-VM reachability" for
|
||||
# why this exists and the small Provisioner/Gateway code change it depends on (NOT YET BUILT —
|
||||
# this firewall rule is the network-policy half; the port-publishing half is an explicit,
|
||||
# reviewed application-code follow-up, tracked there rather than silently assumed here).
|
||||
-A INPUT -p tcp --dport {{ lab_gateway_port_range | default('30000:32767') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
|
||||
# Lab Host Router (8443) — rn-platform-01 (the Gateway) only. This is the DATA-plane
|
||||
# equivalent of the Docker TLS rule above: the Gateway never reaches a Lab's Docker-internal
|
||||
# address directly (no per-Lab port publishing, no wide port range needed) — it reaches this
|
||||
# one mTLS-authenticated Router instead, which resolves the request to a local container on
|
||||
# ITS OWN host. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and
|
||||
# docs/PRODUCTION_NETWORK_SECURITY.md — this closes the "Gateway-to-Lab cross-VM reachability"
|
||||
# gap Phase 4 flagged as a required follow-up; it is no longer open.
|
||||
-A INPUT -p tcp --dport {{ lab_router_port | default('8443') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
|
||||
|
||||
# Prometheus (rn-platform-01) scraping this host's node-exporter/cadvisor — metrics only,
|
||||
# read-only exposition endpoints, still restricted to the one legitimate scraper.
|
||||
|
||||
@@ -21,7 +21,7 @@ render() {
|
||||
-e 's/{{ lab_host_ip }}/10.10.10.20/g' \
|
||||
-e 's/{{ lab_subnet_range }}/10.42.0.0\/16/g' \
|
||||
-e 's/{{ proxmox_mgmt_cidr }}/203.0.113.0\/24/g' \
|
||||
-e "s/{{ lab_gateway_port_range | default('30000:32767') }}/30000:32767/g" \
|
||||
-e "s/{{ lab_router_port | default('8443') }}/8443/g" \
|
||||
"$1" > "$2"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ declare -A IMAGES=(
|
||||
[rootnode/gateway]="platform/gateway/Dockerfile platform/gateway"
|
||||
[rootnode/provisioner]="platform/provisioner/Dockerfile platform/provisioner"
|
||||
[rootnode/egress-proxy]="platform/egress-proxy/Dockerfile platform/egress-proxy"
|
||||
[rootnode/lab-router]="platform/lab-router/Dockerfile platform/lab-router"
|
||||
[rootnode/lab]="Dockerfile ."
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates a private CA and a mutual-TLS cert/key pair for the Provisioner <-> rn-lab-host-01
|
||||
# Docker Engine connection (docs/PRODUCTION_NETWORK_SECURITY.md §7). The application already
|
||||
# supports this (platform/provisioner/src/docker.js buildClient() has required mTLS since
|
||||
# Phase 1) — this script only ever needed to exist for the CERTIFICATES, not new app code.
|
||||
# Generates a private CA and TWO mutual-TLS cert/key pairs, both under that one CA:
|
||||
# 1. Provisioner <-> rn-lab-host-01 Docker Engine (CONTROL plane, port 2376). The application
|
||||
# already supports this (platform/provisioner/src/docker.js buildClient() has required
|
||||
# mTLS since Phase 1) — this script only ever needed to exist for the CERTIFICATES.
|
||||
# 2. Gateway <-> rn-lab-host-01 Lab Host Router (DATA plane, port 8443) — Phase 4.1, see
|
||||
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and docs/PRODUCTION_NETWORK_SECURITY.md.
|
||||
# One CA for both keeps the PKI surface small (one thing to protect, one rotation runbook) —
|
||||
# see docs/RUNBOOK_DOCKER_TLS_ROTATION.md, which now covers all four leaf certs, not just two.
|
||||
#
|
||||
# Output goes to $OUT_DIR (default ./out, gitignored) — never into a tracked path. Deploy the
|
||||
# resulting files with the `docker-tls` Ansible role, which places:
|
||||
# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd's --tlsverify config
|
||||
# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem
|
||||
# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd --tlsverify
|
||||
# ca.pem, router-server-cert.pem, router-server-key.pem -> lab-router (data plane)
|
||||
# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem
|
||||
# -> /etc/rootnode/docker-tls/ (referenced by lab-hosts.yaml)
|
||||
# ca.pem, gateway-client-cert.pem, gateway-client-key.pem
|
||||
# -> /etc/rootnode/lab-router-tls/ (referenced by gateway's LAB_ROUTER_TLS_*)
|
||||
#
|
||||
# Usage:
|
||||
# ./generate-docker-tls.sh [--out-dir DIR] [--lab-host-ip IP] [--lab-host-dns NAME] [--days N]
|
||||
@@ -61,13 +68,37 @@ EOF
|
||||
openssl x509 -req -days "$DAYS" -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \
|
||||
-CAcreateserial -out provisioner-client-cert.pem -extfile client-extfile.cnf
|
||||
|
||||
rm -f server.csr client.csr server-extfile.cnf client-extfile.cnf
|
||||
chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem
|
||||
chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem
|
||||
echo "==> Generating server cert for rn-lab-host-01 (Lab Host Router, data plane)"
|
||||
openssl genrsa -out router-server-key.pem 4096
|
||||
openssl req -subj "/CN=$LAB_HOST_DNS" -sha256 -new -key router-server-key.pem -out router-server.csr
|
||||
|
||||
cat > router-server-extfile.cnf <<EOF
|
||||
subjectAltName = DNS:$LAB_HOST_DNS,IP:$LAB_HOST_IP
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
|
||||
openssl x509 -req -days "$DAYS" -sha256 -in router-server.csr -CA ca.pem -CAkey ca-key.pem \
|
||||
-CAcreateserial -out router-server-cert.pem -extfile router-server-extfile.cnf
|
||||
|
||||
echo "==> Generating client cert for the Gateway (data plane)"
|
||||
openssl genrsa -out gateway-client-key.pem 4096
|
||||
openssl req -subj "/CN=rootnode-gateway" -new -key gateway-client-key.pem -out gateway-client.csr
|
||||
|
||||
openssl x509 -req -days "$DAYS" -sha256 -in gateway-client.csr -CA ca.pem -CAkey ca-key.pem \
|
||||
-CAcreateserial -out gateway-client-cert.pem -extfile client-extfile.cnf
|
||||
|
||||
rm -f server.csr client.csr router-server.csr gateway-client.csr server-extfile.cnf client-extfile.cnf router-server-extfile.cnf
|
||||
chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem router-server-key.pem gateway-client-key.pem
|
||||
chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem router-server-cert.pem gateway-client-cert.pem
|
||||
|
||||
echo "==> Done. Generated in $OUT_DIR:"
|
||||
ls -la
|
||||
echo
|
||||
echo "Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)."
|
||||
echo "Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)."
|
||||
echo "Verify with: ../verify-docker-tls.sh $OUT_DIR <lab-host-ip>"
|
||||
echo "CONTROL plane (Provisioner -> dockerd, port 2376):"
|
||||
echo " Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)."
|
||||
echo " Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)."
|
||||
echo "DATA plane (Gateway -> Lab Host Router, port 8443):"
|
||||
echo " Deploy router-server-{cert,key}.pem + ca.pem to rn-lab-host-01 (lab-router Ansible role)."
|
||||
echo " Deploy gateway-client-{cert,key}.pem + ca.pem to rn-platform-01 (platform Ansible role)."
|
||||
echo
|
||||
echo "Verify Docker control-plane TLS with: ../verify-docker-tls.sh $OUT_DIR <lab-host-ip>"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 §27 fake cohort dress rehearsal: brings up the real dev stack,
|
||||
# bootstraps an owner, runs test-fake-cohort-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] "Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-fake-cohort-rehearsal.js
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 §21 Lab Host loss/rebuild drill: brings up the real dev stack,
|
||||
# bootstraps an owner, runs test-lab-host-loss.js, tears down. See that file's header for what
|
||||
# it actually proves.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-drill-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 i 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 drill owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "LabHost Drill Owner" 'LabHostDrillPass!2026' )
|
||||
|
||||
echo "==> Running the drill"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='LabHostDrillPass!2026' \
|
||||
node ../infra/scripts/drills/test-lab-host-loss.js
|
||||
+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
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 4.1 §23 — proves the CI pipeline (infra/ci/gitea-workflows/build-and-test.yml) fails
|
||||
# CLOSED on every listed failure mode, i.e. the underlying command genuinely exits nonzero on
|
||||
# broken input, which is what makes a Gitea/GitHub Actions step (and therefore the job, and
|
||||
# therefore `needs: regression` gating build-images/deploy-staging) fail. Doesn't need a real
|
||||
# CI run — checks the actual commands the workflow calls, directly, the same way Actions would
|
||||
# invoke them.
|
||||
set -uo pipefail # NOT -e — this script's whole point is deliberately triggering failures and checking their exit codes
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../.." # repo root
|
||||
|
||||
FAIL=0
|
||||
expect_nonzero() {
|
||||
local desc="$1"; shift
|
||||
"$@" >/tmp/ci-fail-drill-out 2>&1
|
||||
local code=$?
|
||||
if [ "$code" -eq 0 ]; then
|
||||
echo " FAIL — $desc (command succeeded, but it should have failed)"; cat /tmp/ci-fail-drill-out; FAIL=1
|
||||
else
|
||||
echo " ok — $desc correctly failed (exit $code)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Phase 4.1 CI-fails-closed drill"
|
||||
echo
|
||||
|
||||
echo "== 1. Test/syntax-check failure =="
|
||||
BAD_JS="$(mktemp).js"
|
||||
echo 'function broken( {' > "$BAD_JS"
|
||||
expect_nonzero "node --check on syntactically broken JS" node --check "$BAD_JS"
|
||||
rm -f "$BAD_JS"
|
||||
|
||||
echo "== 2. Frontend build failure =="
|
||||
cp frontend/src/App.jsx /tmp/App.jsx.bak
|
||||
echo 'this is not valid jsx <<<' >> frontend/src/App.jsx
|
||||
(cd frontend && expect_nonzero "npm run build with a broken component" npm run build)
|
||||
cp /tmp/App.jsx.bak frontend/src/App.jsx; rm -f /tmp/App.jsx.bak
|
||||
|
||||
echo "== 3. Image build failure =="
|
||||
TMPDOCKERFILE="$(mktemp -d)/Dockerfile"
|
||||
cat > "$TMPDOCKERFILE" <<'EOF'
|
||||
FROM node:20-slim
|
||||
COPY this-file-does-not-exist.js /app/
|
||||
EOF
|
||||
expect_nonzero "docker build with a COPY of a nonexistent file" docker build -q -f "$TMPDOCKERFILE" "$(dirname "$TMPDOCKERFILE")"
|
||||
|
||||
echo "== 4. Config validation failure =="
|
||||
BAD_COMPOSE="$(mktemp -d)/docker-compose.yml"
|
||||
cat > "$BAD_COMPOSE" <<'EOF'
|
||||
services:
|
||||
a:
|
||||
image: alpine
|
||||
a:
|
||||
image: alpine
|
||||
EOF
|
||||
expect_nonzero "docker compose config on a file with a duplicate service key" docker compose -f "$BAD_COMPOSE" config
|
||||
|
||||
echo "== 5. Migration preflight failure =="
|
||||
( cd platform/api && expect_nonzero "runMigrations() against an unreachable Postgres" \
|
||||
env PLATFORM_DB_URL="postgres://nobody:[email protected]:1/nope" \
|
||||
node -e "require('./src/db').runMigrations().then(()=>process.exit(0)).catch((e)=>{console.error(e.message);process.exit(1)})" )
|
||||
|
||||
echo "== 6. Staging smoke test failure =="
|
||||
expect_nonzero "staging-smoke-test.sh with no STAGING_GATEWAY_URL set" \
|
||||
env -u STAGING_GATEWAY_URL bash infra/scripts/staging-smoke-test.sh
|
||||
expect_nonzero "staging-smoke-test.sh against an unreachable staging URL" \
|
||||
env STAGING_GATEWAY_URL="http://127.0.0.1:1" bash infra/scripts/staging-smoke-test.sh
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "DRILL FAILED — at least one failure mode did NOT fail closed. See above."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: every listed failure mode genuinely exits nonzero — CI fails closed on all of them."
|
||||
echo "NOT TESTED HERE: an actual Gitea Actions run (needs Gitea Actions enabled on the real"
|
||||
echo "instance and this workflow file activated at .gitea/workflows/ — see build-and-test.yml's"
|
||||
echo "own header comment). This proves the underlying commands, which is what actually"
|
||||
echo "determines pass/fail once a real run happens."
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 4.1 §22 — deployment rollback drill. Builds two real gateway image versions (a known
|
||||
# good build, and a deliberately broken one), "deploys" each in turn to a local container,
|
||||
# proves the smoke test genuinely catches the regression, then rolls back using the EXACT same
|
||||
# decision logic rollback-production.sh uses against deploy-history.log (tail -2 | head -1 |
|
||||
# awk '{print $2}' — the tag before the current one), and proves the rollback recovers.
|
||||
#
|
||||
# What this proves: the deterministic-tag + deploy-history.log rollback MECHANISM is real and
|
||||
# correct. What it does NOT prove: the actual `ansible-playbook site.yml -e image_tag=...` step
|
||||
# rollback-production.sh runs against a real host — that needs the real hardware
|
||||
# (docs/PRODUCTION_VALIDATION_CHECKLIST.md). This drill swaps a local Docker container's image
|
||||
# tag directly instead, as the closest local equivalent.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform/gateway"
|
||||
|
||||
CONTAINER=rootnode-rollback-drill
|
||||
PORT=4295
|
||||
DEPLOY_LOG="$(mktemp)"
|
||||
trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; rm -f "$DEPLOY_LOG"; rm -rf /tmp/rootnode-rollback-drill-broken' EXIT
|
||||
|
||||
smoke_test() {
|
||||
curl -sf "http://localhost:$PORT/healthz" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "==> [1/6] Building the known-good gateway image (current source)"
|
||||
docker build -q -t rootnode-gateway-drill:known-good -f Dockerfile . >/dev/null
|
||||
|
||||
echo "==> [2/6] Building a deliberately broken version (healthz returns 500)"
|
||||
rm -rf /tmp/rootnode-rollback-drill-broken
|
||||
cp -r . /tmp/rootnode-rollback-drill-broken
|
||||
sed -i "s#app.get('/healthz', (req, res) => res.json({ ok: true }));#app.get('/healthz', (req, res) => res.status(500).json({ ok: false, broken: 'DELIBERATE_DRILL_REGRESSION' }));#" \
|
||||
/tmp/rootnode-rollback-drill-broken/src/server.js
|
||||
grep -q DELIBERATE_DRILL_REGRESSION /tmp/rootnode-rollback-drill-broken/src/server.js || { echo "FAIL: sed didn't apply — healthz line text must have changed upstream"; exit 1; }
|
||||
docker build -q -t rootnode-gateway-drill:broken -f /tmp/rootnode-rollback-drill-broken/Dockerfile /tmp/rootnode-rollback-drill-broken >/dev/null
|
||||
|
||||
deploy() {
|
||||
local tag="$1"
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CONTAINER" -p "$PORT:4200" -e GATEWAY_PORT=4200 -e PROVISIONER_URL=http://127.0.0.1:1 \
|
||||
-e PROVISIONER_SHARED_SECRET=drill -e PLATFORM_API_URL=http://127.0.0.1:1 \
|
||||
"rootnode-gateway-drill:$tag" >/dev/null
|
||||
sleep 2
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $tag" >> "$DEPLOY_LOG"
|
||||
}
|
||||
|
||||
echo "==> [3/6] Deploying known-good (deploy #1)"
|
||||
deploy known-good
|
||||
smoke_test && echo " smoke test: PASS (as expected)" || { echo "FAIL: known-good should have passed"; exit 1; }
|
||||
|
||||
echo "==> [4/6] Deploying the broken version (deploy #2 — the 'bad release' this drill catches)"
|
||||
deploy broken
|
||||
if smoke_test; then echo "FAIL: the broken deploy's regression was NOT caught by the smoke test — that defeats the whole point"; exit 1; fi
|
||||
echo " smoke test: FAIL (correctly caught — this is exactly what should stop a real promotion, see docs/RUNBOOK_ROLLBACK.md)"
|
||||
|
||||
echo "==> [5/6] Computing the rollback target — same logic as infra/scripts/rollback-production.sh"
|
||||
TARGET_TAG=$(tail -2 "$DEPLOY_LOG" | head -1 | awk '{print $2}')
|
||||
[ "$TARGET_TAG" = "known-good" ] || { echo "FAIL: expected rollback target 'known-good', computed '$TARGET_TAG'"; exit 1; }
|
||||
echo " rollback target: $TARGET_TAG (correctly identified as the tag before the broken one)"
|
||||
|
||||
echo "==> [6/6] Rolling back"
|
||||
deploy "$TARGET_TAG"
|
||||
smoke_test && echo " smoke test: PASS — rollback recovered service" || { echo "FAIL: rollback did not recover"; exit 1; }
|
||||
|
||||
echo
|
||||
echo "OK: deployment rollback drill passed. Deterministic image tags + deploy-history.log"
|
||||
echo "correctly identify and recover from a bad release."
|
||||
echo
|
||||
echo "On migration compatibility (docs/RUNBOOK_FAILED_MIGRATION.md, docs/RUNBOOK_ROLLBACK.md):"
|
||||
echo "this script rolls back APPLICATION IMAGES ONLY. There is no automatic down-migration —"
|
||||
echo "if the bad release also ran a schema-changing migration, rolling the image back does NOT"
|
||||
echo "undo that migration, and old code may not run correctly against the new schema. That"
|
||||
echo "case requires restoring from the pre-migration backup (docs/RUNBOOK_POSTGRES_RESTORE.md),"
|
||||
echo "not a rollback, which is exactly why migrations in this project are check-before-alter"
|
||||
echo "and additive-only (docs/ENGINEERING_SOP.md §6) — to make image-only rollback usually safe."
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 4.1 §27 — the fake cohort dress rehearsal. One continuous narrative through the real
|
||||
// application (not synthetic unit assertions): instructor login, cohort + 4 students, assign
|
||||
// curriculum, Prepare Labs, students launch, work different scenarios, use hints, fail a
|
||||
// validation, complete scenarios, instructor checks analytics, an exam is assigned/taken,
|
||||
// a Lab is reset, a Lab is reaped (destroyed out-of-band, the same consequence the real idle
|
||||
// reaper produces — see the note below on why this doesn't wait out the real multi-minute
|
||||
// reaper timer), the student returns and continues, capstone state is checked, and the cohort's
|
||||
// Labs are destroyed at the end.
|
||||
//
|
||||
// Run via run-fake-cohort-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
// No framework, matching project convention. Problems found are printed, not silently passed —
|
||||
// see the Phase 4.1 report for anything this surfaced.
|
||||
|
||||
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); }
|
||||
|
||||
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++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 4.1 fake cohort dress rehearsal — slow, real k3s boots\n');
|
||||
|
||||
const instructor = makeSession(GW);
|
||||
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
const names = ['Rehearsal-Ada', 'Rehearsal-Bo', 'Rehearsal-Cy', 'Rehearsal-Di'];
|
||||
const students = {};
|
||||
const sessions = {};
|
||||
|
||||
await step('instructor: create a cohort and roster 4 students', async () => {
|
||||
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: 'rehearsal-cohort', name: 'Rehearsal Cohort' });
|
||||
for (const name of names) {
|
||||
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: 'rehearsal-cohort' });
|
||||
students[name] = body.student_id;
|
||||
sessions[name] = makeSession(GW);
|
||||
await sessions[name].request('POST', '/api/login', { student_id: body.student_id });
|
||||
}
|
||||
});
|
||||
|
||||
await step('instructor: assign curriculum to the cohort', async () => {
|
||||
const { status } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/curriculum', { curriculum_path: 'rootnode-path' });
|
||||
assert.ok([200, 201].includes(status));
|
||||
});
|
||||
|
||||
await step('instructor: Prepare Labs for the whole cohort (concurrent)', async () => {
|
||||
const { status, body } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/prepare-labs');
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.results.every((r) => r.status === 'running'), `expected every Lab running, got: ${JSON.stringify(body.results)}`);
|
||||
});
|
||||
|
||||
const launches = {};
|
||||
await step('students: everyone\'s prepared Lab is already running (no re-launch wait)', async () => {
|
||||
for (const name of names) {
|
||||
const { body } = await sessions[name].request('POST', '/api/environment/launch');
|
||||
assert.equal(body.status, 'running');
|
||||
launches[name] = body;
|
||||
}
|
||||
});
|
||||
|
||||
await step('students: Ada and Bo work DIFFERENT scenarios', async () => {
|
||||
await sessions['Rehearsal-Ada'].request('POST', `${launches['Rehearsal-Ada'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
||||
await sessions['Rehearsal-Bo'].request('POST', `${launches['Rehearsal-Bo'].launch_path}api/scenarios/pod-basics-mcq/start`);
|
||||
});
|
||||
|
||||
await step('student: Cy uses a hint on a scenario', async () => {
|
||||
const res = await sessions['Rehearsal-Cy'].request('POST', `${launches['Rehearsal-Cy'].launch_path}api/scenarios/pod-basics-mcq/hint`);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
await step('student: Di fails a validation on purpose', async () => {
|
||||
const res = await sessions['Rehearsal-Di'].request('POST', `${launches['Rehearsal-Di'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'a' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.correct, false, 'expected a deliberately wrong answer to be marked incorrect');
|
||||
});
|
||||
|
||||
await step('instructor: cohort learning summary reflects real activity', async () => {
|
||||
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/learning');
|
||||
assert.equal(body.student_count, 4);
|
||||
});
|
||||
|
||||
await step('instructor: needs-attention returns real signals for the struggling students, no punitive language', async () => {
|
||||
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/needs-attention');
|
||||
const text = JSON.stringify(body).toLowerCase();
|
||||
assert.ok(!text.includes('fail') || text.includes('failed validations'), 'the word "fail" should only appear in a factual "N failed validations" context, never as a standalone judgment');
|
||||
});
|
||||
|
||||
await step('instructor: reset Ada\'s Lab — academic progress is untouched', async () => {
|
||||
const before = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
||||
// Admin environment routes need the real environment_id, which publicEnvView doesn't echo
|
||||
// back to the student directly — resolved via the student environment lookup instead.
|
||||
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Ada']}/environment`);
|
||||
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
||||
await instructor.request('POST', `/api/admin/environments/${env.environment_id}/reset`);
|
||||
const after = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
||||
assert.deepEqual(before.body.details, after.body.details, 'progress must be identical before/after a Lab reset');
|
||||
});
|
||||
|
||||
await step('§27 — a Lab is reaped: same consequence as the real idle reaper (real multi-minute timer not waited out here — see reaper.js/resource-policy.yaml for the real thresholds; this exercises the destroy-and-preserve-progress consequence directly, already proven mechanistically by infra/scripts/drills/test-lab-host-loss.js)', async () => {
|
||||
const before = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
||||
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Bo']}/environment`);
|
||||
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
||||
await instructor.request('DELETE', `/api/admin/environments/${env.environment_id}`);
|
||||
const after = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
||||
assert.deepEqual(before.body.details, after.body.details, 'progress must survive a reaped Lab unchanged');
|
||||
});
|
||||
|
||||
await step('student: Bo returns later — a fresh Lab is created and curriculum continues', async () => {
|
||||
const { status, body } = await sessions['Rehearsal-Bo'].request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
const curriculum = await sessions['Rehearsal-Bo'].request('GET', '/api/curriculum/path');
|
||||
assert.ok(curriculum.body.stages.length > 0, 'curriculum must still be there for the returning student');
|
||||
});
|
||||
|
||||
await step('instructor: assign an exam to the cohort and Ada takes it', async () => {
|
||||
const create = await instructor.request('POST', '/api/admin/assessments', {
|
||||
id: 'rehearsal-exam', type: 'exam', title: 'Rehearsal Exam', duration_minutes: 30, hint_policy: 'disabled',
|
||||
pool_spec: { required: ['pod-basics-mcq'], random: [] },
|
||||
});
|
||||
assert.ok([200, 201, 409].includes(create.status)); // 409 if a prior run already created it — fine, idempotent enough for a rehearsal
|
||||
const assign = await instructor.request('POST', '/api/admin/assessments/rehearsal-exam/assign', { cohort_id: 'rehearsal-cohort' });
|
||||
assert.ok([200, 201].includes(assign.status));
|
||||
const start = await sessions['Rehearsal-Ada'].request('POST', '/api/assessments/rehearsal-exam/start');
|
||||
assert.equal(start.status, 201);
|
||||
assert.ok(start.body.scenarios.length > 0);
|
||||
});
|
||||
|
||||
await step('instructor: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
|
||||
const before = {};
|
||||
for (const name of names) before[name] = (await sessions[name].request('GET', '/api/progress')).body.details;
|
||||
await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/destroy-labs');
|
||||
for (const name of names) {
|
||||
const after = (await sessions[name].request('GET', '/api/progress')).body.details;
|
||||
assert.deepEqual(before[name], after, `${name}'s progress must survive end-of-class Lab destruction`);
|
||||
}
|
||||
});
|
||||
|
||||
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,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 4.1 §21 — Lab Host loss/rebuild drill. Proves disposable infrastructure is genuinely
|
||||
// disposable: a student's Lab container is destroyed OUT FROM UNDER the Provisioner (no clean
|
||||
// destroy call — simulating the whole rn-lab-host-01 VM disappearing), and the system must:
|
||||
// - keep the Platform (API/Gateway) up throughout — it never depended on that one container
|
||||
// - never present the now-gone environment as healthy/running once reconciliation runs
|
||||
// - leave academic data (progress/attempts) completely untouched
|
||||
// - let the student launch a brand-new Lab afterward and continue where they left off
|
||||
//
|
||||
// Run via run-lab-host-loss-drill.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
// No framework, matching project convention.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/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'); process.exit(1); }
|
||||
|
||||
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.stack || e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 4.1 Lab Host loss/rebuild drill — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession(GW);
|
||||
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(loginStatus, 200, 'owner login must succeed');
|
||||
|
||||
let studentId;
|
||||
await test('roster: add a student', async () => {
|
||||
const { body } = await admin.request('POST', '/api/admin/students', { display_name: 'Drill-LabHostLoss', cohort_id: null });
|
||||
studentId = body.student_id;
|
||||
});
|
||||
|
||||
const session = makeSession(GW);
|
||||
await session.request('POST', '/api/login', { student_id: studentId });
|
||||
|
||||
let launch;
|
||||
await test('launch: student gets a running Lab (real k3s boot)', async () => {
|
||||
const { status, body } = await session.request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
launch = body;
|
||||
});
|
||||
|
||||
await test('progress: student completes a real scenario before the Lab Host is lost', async () => {
|
||||
const res = await session.request('POST', `${launch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
||||
assert.equal(res.status, 200);
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must show completed before we destroy anything');
|
||||
});
|
||||
|
||||
const token = launch.launch_path.split('/')[2];
|
||||
|
||||
await test('§21 — simulate total Lab Host loss: kill the Lab container directly, bypassing the Provisioner entirely', async () => {
|
||||
const containers = execFileSync('docker', ['ps', '--filter', 'name=rootnode-lab-', '--format', '{{.Names}}']).toString().trim().split('\n').filter(Boolean);
|
||||
assert.ok(containers.length >= 1, 'expected at least one running Lab container to destroy');
|
||||
for (const c of containers) execFileSync('docker', ['rm', '-f', c]);
|
||||
console.log(` destroyed ${containers.length} Lab container(s) out from under the Provisioner`);
|
||||
});
|
||||
|
||||
await test('§21 — Platform stays available throughout (it never depended on that container)', async () => {
|
||||
const api = await fetch(`${API}/healthz`);
|
||||
const gw = await fetch(`${GW}/healthz`);
|
||||
assert.equal(api.status, 200);
|
||||
assert.equal(gw.status, 200);
|
||||
});
|
||||
|
||||
await test('§21 — academic progress is completely untouched by the infrastructure loss', async () => {
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must survive the Lab Host loss unchanged');
|
||||
});
|
||||
|
||||
await test('§21 — the now-gone environment is NEVER presented as healthy once reconciliation runs (Provisioner restart, its real startup path)', async () => {
|
||||
// cwd is already platform/ — run-lab-host-loss-drill.sh cd's there before invoking this script.
|
||||
execFileSync('docker', ['compose', '-f', 'docker-compose.dev.yml', 'restart', 'provisioner'], { stdio: 'inherit' });
|
||||
// Give the Provisioner a moment to finish its real reconcileOnStartup pass.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const res = await session.rawFetch(`/lab/${token}/`);
|
||||
assert.ok([503, 404].includes(res.status), `expected the stale environment to be rejected (503/404), got ${res.status} — a healthy-looking response here would mean a destroyed environment is being silently presented as available`);
|
||||
});
|
||||
|
||||
await test('§21 — a replacement Lab can be launched and boots successfully (the "replacement Lab Host" case, minus needing a literal second VM)', async () => {
|
||||
const { status, body } = await session.request('POST', '/api/environment/launch');
|
||||
assert.equal(status, 200); assert.equal(body.status, 'running');
|
||||
assert.notEqual(body.launch_path, launch.launch_path, 'the replacement environment must be a genuinely new one, not the destroyed one still being served');
|
||||
});
|
||||
|
||||
await test('§21 — previous academic progress is still there after the replacement Lab boots', async () => {
|
||||
const progress = await session.request('GET', '/api/progress');
|
||||
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+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); });
|
||||
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);
|
||||
});
|
||||
@@ -6,18 +6,26 @@ set -euo pipefail
|
||||
|
||||
PROD_URL="${PRODUCTION_URL:?set PRODUCTION_URL, e.g. https://academy.example.com}"
|
||||
|
||||
# Phase 4.1 §23: every `curl ... && echo "OK"` below used to be one statement — under `set -e`,
|
||||
# a failing command that isn't the LAST one in an && list does not trigger the shell to exit (a
|
||||
# well-known bash gotcha), so an unreachable/broken production endpoint used to fall through
|
||||
# silently instead of failing this script. Split into two statements each, so the real exit
|
||||
# status is what `set -e` sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
|
||||
echo "==> Gateway health"
|
||||
curl -sf "$PROD_URL/healthz" > /dev/null && echo "OK"
|
||||
curl -sf "$PROD_URL/healthz" > /dev/null
|
||||
echo "OK"
|
||||
|
||||
echo "==> TLS certificate is valid and not expiring soon"
|
||||
EXPIRY=$(echo | openssl s_client -connect "$(echo "$PROD_URL" | sed -e 's#https://##')":443 -servername "$(echo "$PROD_URL" | sed -e 's#https://##')" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
|
||||
echo "Certificate expires: $EXPIRY"
|
||||
|
||||
echo "==> Roster endpoint responds (public, unauthenticated, read-only)"
|
||||
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null && echo "OK"
|
||||
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null
|
||||
echo "OK"
|
||||
|
||||
echo "==> Admin login page loads (does not attempt to log in)"
|
||||
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200 && echo "OK"
|
||||
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200
|
||||
echo "OK"
|
||||
|
||||
echo
|
||||
echo "OK: production smoke test passed. For a full functional check (owner/instructor login,"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Two-Host Routing Simulation
|
||||
|
||||
Proves, with real Docker network isolation (not mocks), that the Gateway-to-Lab data path
|
||||
described in `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1 actually works before the real
|
||||
Hetzner/Proxmox hardware exists. See that document for the full design; this is the test harness.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd infra/scripts/simulate-two-host
|
||||
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh # builds, runs, tears down
|
||||
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh --keep-up # leaves the stack up for debugging
|
||||
```
|
||||
|
||||
Confirmed passing, 13/13, as of this phase's implementation — see the Phase 4.1 report for the
|
||||
full run transcript.
|
||||
|
||||
## What this genuinely proves (TESTED LOCALLY, with real Docker networking)
|
||||
|
||||
- The `gateway` container, attached ONLY to an isolated bridge network with no route to any
|
||||
Lab's per-environment bridge network, **cannot** open a TCP connection to a Lab container's
|
||||
Docker-internal IP — verified via a raw connect attempt run from inside the actual gateway
|
||||
container's own network namespace, not asserted from the test driver's (irrelevant) vantage
|
||||
point.
|
||||
- The Gateway **does** reach the Lab correctly through the Lab Host Router, over real mTLS,
|
||||
for both a plain HTTP request and a full WebSocket terminal session — including a genuine
|
||||
`kubectl get nodes` executed inside the Lab and its real output read back.
|
||||
- Alice/Bob isolation (own lab ALLOW, other's lab DENY, including over WebSocket), garbage
|
||||
tokens, anonymous requests, and destroyed-environment tokens are all rejected through the
|
||||
real code path — the same `checkOwnership`/`resolveTarget` logic production will run.
|
||||
- The Lab Host Router refuses a connection with no client certificate at all — mTLS is the
|
||||
actual authentication mechanism, not a documented intention.
|
||||
|
||||
## What this does NOT prove (SIMULATED PRODUCTION BOUNDARY, not identical to real hardware)
|
||||
|
||||
- Real cross-VM latency, MTU, or anything about two genuinely separate kernels — this is one
|
||||
Docker daemon with two isolated networks, a faithful stand-in for network *reachability*
|
||||
rules but not a physical network.
|
||||
- The actual Hetzner/Proxmox firewall enforcement (`infra/firewall/*.rules`) — this harness
|
||||
doesn't touch iptables at all; Docker's own network isolation is what's under test here.
|
||||
- Provisioner/API/Postgres reachability across the VM boundary — deliberately kept
|
||||
host-networked and unchanged from `docker-compose.dev.yml` in this harness, because that
|
||||
control-plane path was already proven separately (Phase 4's real mTLS handshake test against
|
||||
the Docker Engine TLS port) and isn't what changed this phase. Only the Gateway's isolation
|
||||
is new here.
|
||||
|
||||
## REQUIRES REAL HARDWARE (tracked in docs/PRODUCTION_VALIDATION_CHECKLIST.md, not here)
|
||||
|
||||
Running this exact flow against the real `rn-platform-01`/`rn-lab-host-01` VMs, through the
|
||||
real firewall, with real network latency between two physical/virtual hosts.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Phase 4.1 two-host routing simulation — docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
|
||||
#
|
||||
# Proves the ONE thing that matters using REAL Docker network isolation, not mocks: a Gateway
|
||||
# container with no route to a Lab container's bridge network genuinely cannot reach it, and
|
||||
# can only reach it through the Lab Host Router.
|
||||
#
|
||||
# What's genuinely simulated (real Docker networking facts, not faked):
|
||||
# - `gateway` is attached ONLY to `sim-platform-net`. It has no network_mode: host, no
|
||||
# attachment to any per-Lab bridge network (rootnode-lab-net-<id>, created dynamically by
|
||||
# the Provisioner) — its network namespace simply has no route to one. This is the same
|
||||
# kind of isolation two separate Proxmox VMs would have, achieved here with two Docker
|
||||
# networks on one daemon instead of two physical hosts.
|
||||
# - `lab-router` runs with network_mode: host — exactly like `egress-proxy` already does in
|
||||
# docker-compose.dev.yml (see that file's comment on why: a host-networked process can
|
||||
# reach every bridge network's containers via the host's own routing table, which is
|
||||
# exactly what a real Lab Host Router process running directly on rn-lab-host-01 needs).
|
||||
# It's also reachable FROM `sim-platform-net` via that network's own gateway IP — the same
|
||||
# mechanism the egress-proxy already relies on for the reverse direction.
|
||||
# - `provisioner`/`api`/`postgres` stay host-networked, unchanged from docker-compose.dev.yml
|
||||
# — this phase's new proof target is the Gateway-to-Lab DATA plane, not the CONTROL plane
|
||||
# (already proven with a real mTLS handshake in Phase 4). Keeping them as-is is a
|
||||
# deliberate scope choice, not an oversight — see the README in this directory.
|
||||
#
|
||||
# What this does NOT prove (needs the real hardware — docs/PRODUCTION_VALIDATION_CHECKLIST.md):
|
||||
# real cross-VM network latency/MTU, the actual Hetzner/Proxmox firewall enforcement, or
|
||||
# anything about two genuinely separate kernels.
|
||||
#
|
||||
# Usage: see README.md in this directory.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rootnode_platform_sim
|
||||
ports:
|
||||
- "5434:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
egress-proxy:
|
||||
build: ../../../platform/egress-proxy
|
||||
network_mode: host
|
||||
|
||||
provisioner:
|
||||
build: ../../../platform/provisioner
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# run-simulation.sh generates this with the real routing_mode: router / router_endpoint
|
||||
# once it knows sim-platform-net's actual gateway IP — overrides just this one file, the
|
||||
# image's own resource-policy.yaml underneath is untouched.
|
||||
- ${SIM_LAB_HOSTS_SEED:?set by run-simulation.sh}:/app/config/lab-hosts.seed.yaml:ro
|
||||
environment:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PROVISIONER_PORT: "4101"
|
||||
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
|
||||
EGRESS_PROXY_PORT: "3128"
|
||||
DNS_RESOLVER: "127.0.0.11"
|
||||
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: platform/api/Dockerfile
|
||||
network_mode: host
|
||||
environment:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
|
||||
PROVISIONER_URL: "http://localhost:4101"
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PLATFORM_API_PORT: "4301"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
provisioner:
|
||||
condition: service_started
|
||||
|
||||
# Simulates rn-platform-01's Gateway — deliberately NOT host-networked, deliberately not
|
||||
# attached to any Lab bridge network. This is the isolation under test.
|
||||
gateway:
|
||||
build: ../../../platform/gateway
|
||||
networks:
|
||||
- sim-platform-net
|
||||
environment:
|
||||
GATEWAY_PORT: "4200"
|
||||
# Reaches host-networked Provisioner/API via sim-platform-net's own gateway IP — the
|
||||
# same "reachable via the bridge's gateway interface" mechanism egress-proxy already
|
||||
# relies on (see docker-compose.dev.yml). run-simulation.sh fills these in.
|
||||
PROVISIONER_URL: ${SIM_PROVISIONER_URL:?set by run-simulation.sh}
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PLATFORM_API_URL: ${SIM_PLATFORM_API_URL:?set by run-simulation.sh}
|
||||
LAB_ROUTER_TLS_CA: /tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT: /tls/gateway-client-cert.pem
|
||||
LAB_ROUTER_TLS_KEY: /tls/gateway-client-key.pem
|
||||
volumes:
|
||||
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
|
||||
ports:
|
||||
- "4210:4200" # exposed to the test driver script only, not "public" in any real sense
|
||||
depends_on:
|
||||
- provisioner
|
||||
- api
|
||||
|
||||
# Simulates rn-lab-host-01's Lab Host Router — host-networked like egress-proxy, for the same
|
||||
# reason (needs to reach whatever per-Lab bridge networks the Provisioner creates, which are
|
||||
# not known/attachable ahead of time).
|
||||
lab-router:
|
||||
build: ../../../platform/lab-router
|
||||
network_mode: host
|
||||
environment:
|
||||
LAB_ROUTER_PORT: "8443"
|
||||
LAB_ROUTER_TLS_CA: /tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
|
||||
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
|
||||
DOCKER_SOCKET: /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
|
||||
|
||||
networks:
|
||||
# Pre-created by run-simulation.sh (so it can inspect the real gateway IP BEFORE any
|
||||
# container starts and hand it to provisioner/gateway/lab-router as config) — Compose is
|
||||
# told to use that existing network rather than create its own.
|
||||
sim-platform-net:
|
||||
external: true
|
||||
name: ${COMPOSE_NET_NAME:?set by run-simulation.sh}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 two-host routing simulation end-to-end: generates a real TLS
|
||||
# bundle, computes the sim-platform-net gateway IP, renders the lab_hosts seed with it, brings
|
||||
# the stack up, runs test-remote-routing.js, tears everything down. See README.md in this
|
||||
# directory and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
|
||||
#
|
||||
# Usage: ./run-simulation.sh [--keep-up] (--keep-up skips teardown, for debugging)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
KEEP_UP=false
|
||||
[ "${1:-}" = "--keep-up" ] && KEEP_UP=true
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-sim-shared-secret-not-for-real-use}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
export SIM_TLS_DIR="$WORK_DIR/tls"
|
||||
export SIM_LAB_HOSTS_SEED="$WORK_DIR/lab-hosts.seed.yaml"
|
||||
|
||||
echo "==> [1/6] Creating sim-platform-net so we know its real gateway IP before generating certs"
|
||||
NET_NAME="rootnode-sim-platform-net"
|
||||
docker network inspect "$NET_NAME" >/dev/null 2>&1 || docker network create "$NET_NAME" >/dev/null
|
||||
GW_IP="$(docker network inspect "$NET_NAME" --format '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
echo " sim-platform-net gateway IP: $GW_IP (lab-router and provisioner/api, all host-networked, are reachable from Gateway's isolated network through this address)"
|
||||
|
||||
echo "==> [2/6] Generating a real mTLS bundle for this simulation run (SAN covers the real gateway IP above)"
|
||||
../docker-tls/generate-docker-tls.sh --out-dir "$SIM_TLS_DIR" --lab-host-ip "$GW_IP" --lab-host-dns lab-router-sim >/dev/null
|
||||
|
||||
# Rewrite the compose file's network name to match what we just created/inspected, and point
|
||||
# the sim gateway/router services at the shared network by that exact name.
|
||||
export COMPOSE_NET_NAME="$NET_NAME"
|
||||
|
||||
echo "==> [3/6] Rendering lab-hosts.seed.yaml with routing_mode: router at the real gateway IP"
|
||||
cat > "$SIM_LAB_HOSTS_SEED" <<EOF
|
||||
lab_hosts:
|
||||
- lab_host_id: sim-lab-host-01
|
||||
display_name: "Simulated rn-lab-host-01"
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
enabled: true
|
||||
routing_mode: router
|
||||
router_endpoint: "https://${GW_IP}:8443"
|
||||
EOF
|
||||
|
||||
export SIM_PROVISIONER_URL="http://${GW_IP}:4101"
|
||||
export SIM_PLATFORM_API_URL="http://${GW_IP}:4301"
|
||||
|
||||
echo "==> [4/6] Building and starting the simulated two-host stack"
|
||||
docker compose -f docker-compose.simulate.yml -p rootnode-sim up -d --build
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_UP" = true ]; then
|
||||
echo "==> --keep-up set, leaving the stack running (docker compose -f docker-compose.simulate.yml -p rootnode-sim down to clean up)"
|
||||
return
|
||||
fi
|
||||
echo "==> [6/6] Tearing down the simulation stack"
|
||||
docker compose -f docker-compose.simulate.yml -p rootnode-sim down -v >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for services to become healthy"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:4301/healthz >/dev/null 2>&1 && curl -sf http://localhost:4210/healthz >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> [5/6] Running the routing test"
|
||||
GATEWAY_URL=http://localhost:4210 \
|
||||
PROVISIONER_URL=http://localhost:4101 \
|
||||
PROVISIONER_SHARED_SECRET="$PROVISIONER_SHARED_SECRET" \
|
||||
GATEWAY_CONTAINER=rootnode-sim-gateway-1 \
|
||||
node test-remote-routing.js
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 4.1 §6-9 — the real, unmocked proof that remote Lab routing works. Run via
|
||||
// run-simulation.sh, which brings up docker-compose.simulate.yml first. No framework, per
|
||||
// project convention (see platform/provisioner/test/run.js).
|
||||
//
|
||||
// Proves, against the real simulated stack:
|
||||
// §6/7 Gateway cannot reach a Lab's Docker-internal IP directly, but reaches it correctly
|
||||
// through the Lab Host Router — both HTTP and WebSocket terminal traffic, including a
|
||||
// real `kubectl get nodes` inside the Lab.
|
||||
// §8/9 Alice/Bob isolation, garbage tokens, destroyed environments, anonymous requests, and
|
||||
// that the Router itself is unreachable without the Gateway's mTLS client identity —
|
||||
// all through the real path, not asserted against mocks.
|
||||
|
||||
const assert = require('assert');
|
||||
const https = require('https');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4210';
|
||||
const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4101';
|
||||
const PROVISIONER_KEY = process.env.PROVISIONER_SHARED_SECRET;
|
||||
const GATEWAY_CONTAINER = process.env.GATEWAY_CONTAINER || 'rootnode-sim-gateway-1';
|
||||
const WS_URL = GW.replace(/^http/, 'ws');
|
||||
|
||||
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.stack || e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function provisionerCall(method, path) {
|
||||
const res = await fetch(`${PROVISIONER_URL}${path}`, { method, headers: { 'X-Provisioner-Key': PROVISIONER_KEY } });
|
||||
const body = await res.json().catch(() => null);
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
// The "browser never learns internal_ip" invariant holds everywhere else in the app — this
|
||||
// test is the one deliberate, documented exception: it needs the real Docker-internal address
|
||||
// as ground truth to prove the Gateway genuinely CANNOT reach it, which requires calling the
|
||||
// Provisioner's internal API directly (the same privilege level an operator/CI job has, never
|
||||
// a student's).
|
||||
async function internalAddressForToken(token) {
|
||||
const { body } = await provisionerCall('GET', `/environments/by-token/${token}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
// bootstrap-owner.js isn't copied into the api image (it's an operator-run one-off script, not
|
||||
// a runtime dependency — same reason platform/test regression runs hit this before, see
|
||||
// docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md's regression notes). Run it from the host against
|
||||
// the sim Postgres's published port instead of `docker exec`ing into the container.
|
||||
function bootstrapOwner(email, name, password) {
|
||||
execFileSync('node', ['bootstrap-owner.js', email, name, password], {
|
||||
cwd: require('path').join(__dirname, '../../../platform/api'),
|
||||
env: { ...process.env, PLATFORM_DB_URL: 'postgres://postgres:postgres@localhost:5434/rootnode_platform_sim' },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
// Raw TCP connect attempt from WITHIN the gateway container's own network namespace — proves
|
||||
// isolation from the gateway's actual point of view, not the test driver's (which runs on the
|
||||
// host and has a completely different, irrelevant reachability picture).
|
||||
function gatewayCanReach(ip, port, timeoutMs = 2500) {
|
||||
const script = `
|
||||
const net = require('net');
|
||||
const s = net.createConnection({ host: '${ip}', port: ${port}, timeout: ${timeoutMs} });
|
||||
s.on('connect', () => { console.log('REACHABLE'); s.destroy(); process.exit(0); });
|
||||
s.on('timeout', () => { console.log('UNREACHABLE(timeout)'); process.exit(0); });
|
||||
s.on('error', (e) => { console.log('UNREACHABLE(' + e.code + ')'); process.exit(0); });
|
||||
`;
|
||||
const out = execFileSync('docker', ['exec', GATEWAY_CONTAINER, 'node', '-e', script]).toString().trim();
|
||||
return out.startsWith('REACHABLE');
|
||||
}
|
||||
|
||||
function connectRouterWithoutClientCert(host, port, timeoutMs = 2500) {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.request({ host, port, method: 'GET', rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
|
||||
resolve({ ok: true, status: res.statusCode });
|
||||
});
|
||||
req.on('error', (e) => resolve({ ok: false, error: e.code || e.message }));
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function openTerminal(launchPath, cookieHeader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => { ws.close(); reject(new Error(`terminal timed out, buffer so far: ${buffer.slice(-400)}`)); }, 30000);
|
||||
ws.addEventListener('open', () => ws.send('kubectl get nodes\n'));
|
||||
ws.addEventListener('message', (ev) => {
|
||||
buffer += ev.data.toString();
|
||||
if (/Ready\s/.test(buffer) || /node\.?\d*\s+Ready/i.test(buffer)) {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`ws error: ${e.message || e}`)); });
|
||||
});
|
||||
}
|
||||
|
||||
function expectWsRejected(launchPath, cookieHeader, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
|
||||
const timer = setTimeout(() => { ws.close(); reject(new Error('expected the WS to be rejected, but it stayed open')); }, timeoutMs);
|
||||
ws.addEventListener('open', () => { clearTimeout(timer); ws.close(); reject(new Error('expected the WS upgrade to be rejected, but it succeeded')); });
|
||||
ws.addEventListener('close', () => { clearTimeout(timer); resolve(); });
|
||||
ws.addEventListener('error', () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 4.1 two-host remote-routing simulation — slow, real k3s boots\n');
|
||||
|
||||
bootstrapOwner('[email protected]', 'Sim Owner', 'SimOwnerPass!2026');
|
||||
const admin = makeSession(GW);
|
||||
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'SimOwnerPass!2026' });
|
||||
assert.equal(loginStatus, 200, 'owner login must succeed through the Gateway');
|
||||
|
||||
let alice, bob;
|
||||
await test('roster: add Alice and Bob via admin (through the Gateway)', async () => {
|
||||
const a = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Alice', cohort_id: null });
|
||||
const b = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Bob', cohort_id: null });
|
||||
alice = a.body.student_id; bob = b.body.student_id;
|
||||
assert.notEqual(alice, bob);
|
||||
});
|
||||
|
||||
const aliceSession = makeSession(GW);
|
||||
const bobSession = makeSession(GW);
|
||||
await aliceSession.request('POST', '/api/login', { student_id: alice });
|
||||
await bobSession.request('POST', '/api/login', { student_id: bob });
|
||||
|
||||
let aliceLaunch, bobLaunch, aliceEnv;
|
||||
await test('launch: Alice and Bob each get a running environment on the simulated remote lab host', async () => {
|
||||
const a = await aliceSession.request('POST', '/api/environment/launch');
|
||||
const b = await bobSession.request('POST', '/api/environment/launch');
|
||||
assert.equal(a.status, 200); assert.equal(a.body.status, 'running');
|
||||
assert.equal(b.status, 200); assert.equal(b.body.status, 'running');
|
||||
aliceLaunch = a.body; bobLaunch = b.body;
|
||||
const aliceToken = aliceLaunch.launch_path.split('/')[2];
|
||||
aliceEnv = await internalAddressForToken(aliceToken);
|
||||
assert.equal(aliceEnv.lab_host_routing_mode, 'router', 'sim-lab-host-01 must be configured as routing_mode: router for this test to mean anything');
|
||||
});
|
||||
|
||||
await test('§6/7 — the Gateway CANNOT reach the Lab\'s Docker-internal IP directly', async () => {
|
||||
const reachable = gatewayCanReach(aliceEnv.internal_ip, aliceEnv.internal_port);
|
||||
assert.equal(reachable, false, `gateway container reached ${aliceEnv.internal_ip}:${aliceEnv.internal_port} directly — the network isolation this whole phase depends on is not real`);
|
||||
});
|
||||
|
||||
await test('§6/7 — but the Gateway DOES reach the Lab through the Lab Host Router (HTTP)', async () => {
|
||||
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
|
||||
assert.equal(res.status, 200);
|
||||
const text = await res.text();
|
||||
assert.ok(text.length > 0, 'expected real HTML/asset content back from the Lab, routed through the Router');
|
||||
});
|
||||
|
||||
await test('§8 — Alice -> her own Lab: ALLOW', async () => {
|
||||
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
await test('§8 — Bob -> his own Lab: ALLOW', async () => {
|
||||
const res = await bobSession.rawFetch(bobLaunch.launch_path);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
await test('§8 — Alice -> Bob\'s Lab: DENY', async () => {
|
||||
const res = await aliceSession.rawFetch(bobLaunch.launch_path);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await test('§8 — garbage token: DENY', async () => {
|
||||
const res = await aliceSession.rawFetch('/lab/not-a-real-token-at-all/');
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
await test('§8 — anonymous Gateway request (no session at all): DENY', async () => {
|
||||
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
await test('§8/9 — direct public access to the Lab Host Router: NOT AVAILABLE (mTLS required, no client cert presented)', async () => {
|
||||
const routerUrl = new URL(aliceEnv.lab_host_router_endpoint);
|
||||
const result = await connectRouterWithoutClientCert(routerUrl.hostname, routerUrl.port);
|
||||
assert.equal(result.ok, false, 'the Router accepted a connection with no client certificate at all — mTLS is not actually being enforced');
|
||||
});
|
||||
|
||||
await test('§7 — WebSocket terminal connects and a real `kubectl get nodes` succeeds, through the Router', async () => {
|
||||
const output = await openTerminal(bobLaunch.launch_path, `rn_session=${bobSession.getCookie('rn_session')}`);
|
||||
assert.ok(/Ready/i.test(output), `expected a Ready node in kubectl output, got: ${output.slice(-300)}`);
|
||||
});
|
||||
|
||||
await test('§8 — Alice cannot open Bob\'s terminal WebSocket even with a valid session (her own)', async () => {
|
||||
await expectWsRejected(bobLaunch.launch_path, `rn_session=${aliceSession.getCookie('rn_session')}`);
|
||||
});
|
||||
|
||||
await test('§8/9 — destroyed environment: routing token stops working, both at the Gateway and at the Router directly', async () => {
|
||||
const aliceToken = aliceLaunch.launch_path.split('/')[2];
|
||||
await provisionerCall('DELETE', `/environments/${aliceEnv.environment_id}`);
|
||||
// Give the Gateway's short-TTL env-cache a moment to expire so this proves real
|
||||
// destroyed-environment handling, not just a stale cache hit.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
const res = await aliceSession.rawFetch(`/lab/${aliceToken}/`);
|
||||
assert.ok([404, 503].includes(res.status), `expected the destroyed environment to be rejected, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -9,7 +9,13 @@ set -euo pipefail
|
||||
STAGING_URL="${STAGING_GATEWAY_URL:?set STAGING_GATEWAY_URL, e.g. https://staging.academy.example.com}"
|
||||
|
||||
echo "==> Health checks"
|
||||
curl -sf "$STAGING_URL/healthz" > /dev/null && echo "OK: Gateway /healthz"
|
||||
# Phase 4.1 §23: this was `curl ... && echo ...` as one statement — under `set -e`, a failing
|
||||
# command that ISN'T the last one in an && list does not trigger the shell to exit (a
|
||||
# well-known bash gotcha), so a genuinely unreachable staging Gateway used to fall through
|
||||
# silently instead of failing this script. Split into two statements so curl's own exit status
|
||||
# is what `set -e` actually sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
|
||||
curl -sf "$STAGING_URL/healthz" > /dev/null
|
||||
echo "OK: Gateway /healthz"
|
||||
|
||||
if [ -n "${OWNER_EMAIL:-}" ] && [ -n "${OWNER_PASSWORD:-}" ]; then
|
||||
echo "==> Full acceptance run (platform/test/e2e-phase3b.js against staging)"
|
||||
|
||||
@@ -36,3 +36,17 @@ sed -e 's/{{ academy_domain }}/academy.example.com/g' -e 's/{{ management_cidr }
|
||||
chmod a+r "$TMPDIR/Caddyfile"
|
||||
docker run --rm -v "$TMPDIR:/etc/caddy:ro" caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile 2>&1 | grep -q "Valid configuration" \
|
||||
&& echo "OK: Caddyfile is valid" || { echo "FAIL: Caddyfile did not validate"; exit 1; }
|
||||
|
||||
# Phase 4.1: rn-lab-host-01's compose file (egress-proxy, lab-router, observability agents)
|
||||
# was never covered here before — added when the lab-router service landed.
|
||||
echo
|
||||
echo "--- lab-host production compose ---"
|
||||
LABHOST_TMPDIR="$(mktemp -d)"
|
||||
sed \
|
||||
-e 's/{{ registry }}/registry.example.com/g' \
|
||||
-e 's/{{ image_tag }}/test/g' \
|
||||
-e "s/{{ lab_router_port | default('8443') }}/8443/g" \
|
||||
../lab-host/docker-compose.production.yml > "$LABHOST_TMPDIR/docker-compose.yml"
|
||||
docker compose -f "$LABHOST_TMPDIR/docker-compose.yml" config --quiet
|
||||
echo "OK: lab-host production compose file is well-formed YAML and passes docker compose's own schema validation"
|
||||
rm -rf "$LABHOST_TMPDIR"
|
||||
|
||||
+25
-8
@@ -7,15 +7,21 @@ 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), 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
|
||||
`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). 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,
|
||||
@@ -23,6 +29,10 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
|
||||
(persistent sessions, CSRF), `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3 (hint gating).
|
||||
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
|
||||
- `lab-router/` — Phase 4.1 addition. Runs on the Lab Host VM (or `dev-local`'s stand-in in
|
||||
local dev, though `direct` mode there never exercises it); resolves an opaque routing token
|
||||
to a local Lab container purely from Docker state, over mTLS, so the Gateway never needs a
|
||||
Docker-internal address. Design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1-3.
|
||||
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
|
||||
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
|
||||
the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1.
|
||||
@@ -77,13 +87,20 @@ those need a browser-equivalent client and aren't automated here.
|
||||
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
|
||||
2A), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A identity limitations), and
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (Phase 3B learning/assessment limitations) for the
|
||||
full, honest lists.
|
||||
full, honest lists. Phase 3B's "no automatic abandoned-attempt detection" and "no per-cohort
|
||||
needs-attention threshold configuration" limitations were both closed in Phase 4.1 (§13/§17 of
|
||||
`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`); its raw-JSON pool_spec limitation was replaced with
|
||||
a structured editor (§18) with an owner-only raw-JSON fallback still available. Phase 4.1's own
|
||||
known limitation: real remote/self-paced delivery needs student email/password authentication,
|
||||
not built — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`.
|
||||
|
||||
## Running this in production
|
||||
|
||||
`../infra/` has the full production infrastructure package (Ansible, Terraform, firewall,
|
||||
monitoring, backup, CI/CD) for deploying the Platform API/Gateway/Provisioner/Postgres onto a
|
||||
dedicated `rn-platform-01` VM, with student Labs on a separate `rn-lab-host-01` VM. See
|
||||
dedicated `rn-platform-01` VM, with student Labs — and a new `lab-router` service
|
||||
(`../platform/lab-router/`) — on a separate `rn-lab-host-01` VM. See
|
||||
`../docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` for the overview and
|
||||
`../docs/PRODUCTION_NETWORK_SECURITY.md` for a known gap in Gateway-to-Lab reachability across
|
||||
two real VMs that must be closed before production go-live.
|
||||
`../docs/PHASE4_1_PREPRODUCTION_HARDENING.md` for how Gateway-to-Lab reachability across two
|
||||
real VMs was closed (a Lab Host Router + mTLS data plane, simulation-tested in
|
||||
`../infra/scripts/simulate-two-host/`) and for the rest of Phase 4.1's pre-production hardening.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Abandoned/interrupted attempt handling — Phase 4.1, docs/PHASE4_1_PREPRODUCTION_HARDENING.md
|
||||
-- §13. 'abandoned' was already a valid scenario_attempts.status value (migration 012's CHECK
|
||||
-- constraint) but nothing ever set it — an attempt left open when a Lab was reaped, an
|
||||
-- environment destroyed, a session ended, or a student moved on to a different scenario just
|
||||
-- stayed 'in_progress' forever, an honest but incomplete signal. This is NOT an academic
|
||||
-- failure marker — an abandoned attempt has no `result`, doesn't count against the student,
|
||||
-- and the row is never deleted (see src/attempts.js abandonAttempt for the enforcement of
|
||||
-- that). It exists so instructor views and needs-attention logic can tell "still working" from
|
||||
-- "stopped, for a reason we know."
|
||||
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS abandoned_at TIMESTAMPTZ;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS abandoned_reason TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_abandoned_reason_check
|
||||
CHECK (abandoned_reason IS NULL OR abandoned_reason IN
|
||||
('environment_destroyed', 'session_ended', 'scenario_switched'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Postgres-backed login rate limiting — Phase 4.1 §15. Replaces the in-memory sliding-window
|
||||
-- Map in security.js, which reset on every Platform API restart (an attacker could always just
|
||||
-- wait for a deploy) and would silently stop being shared the moment there's more than one
|
||||
-- Platform API instance. One small table, one cheap query per login attempt (this is a login
|
||||
-- form, not a hot path) — no Redis, no new infrastructure dependency.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rate_key TEXT NOT NULL, -- "ip:email", same composite key security.js always used
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The hot query is "count attempts for this key within the last N minutes" — this index makes
|
||||
-- that a range scan on one key instead of a table scan.
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_key_time ON login_attempts(rate_key, created_at);
|
||||
@@ -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,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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -7,9 +7,13 @@
|
||||
// can do, per the Phase 3B RBAC matrix.
|
||||
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
|
||||
// Phase 4.1 §17 — configurable via env (environment-backed policy, matching the pattern
|
||||
// sessions.js already uses for TTLs), not a database-backed settings table — that's more
|
||||
// machinery than two numbers need. Sensible defaults if unset.
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = Number(process.env.NEEDS_ATTENTION_FAIL_THRESHOLD || 3); // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = Number(process.env.NEEDS_ATTENTION_STALE_DAYS || 7); // no progress in this many days
|
||||
|
||||
module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
@@ -70,9 +74,13 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
|
||||
[req.params.id]
|
||||
);
|
||||
// Phase 4.1 §16 — this is a per-student summary embedded in a wider dashboard view, not a
|
||||
// standalone history list (that's /api/admin/assessments/:id/sessions, which got real
|
||||
// pagination above) — a bounded LIMIT is the appropriate fix here, not a second paginator
|
||||
// inside an already-composite response.
|
||||
const { rows: assessmentSessions } = await pool.query(
|
||||
`SELECT assessment_session_id, assessment_definition_id, type, title, status, started_at, submitted_at, result
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({
|
||||
@@ -152,6 +160,21 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
res.json(signals);
|
||||
});
|
||||
|
||||
// Phase 4.1 §18 — the scenario catalog (stage -> scenario id/title, no student-specific
|
||||
// status) the structured assessment editor picks from, instead of an instructor hand-typing
|
||||
// scenario ids into raw JSON. Same source data as the per-student progress view
|
||||
// (admin-routes.js's /api/admin/students/:id/progress), just without the progress overlay.
|
||||
admin.get('/api/admin/scenario-catalog', requireAnyInstructor, async (req, res) => {
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
if (!path) return res.json({ stages: [] });
|
||||
const stages = path.stages.map((stage) => ({
|
||||
id: stage.id, name: stage.name,
|
||||
scenarios: stage.scenario_ids.map((sid) => ({ id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid })),
|
||||
}));
|
||||
res.json({ stages });
|
||||
});
|
||||
|
||||
// ── Assessment definitions ───────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
|
||||
@@ -231,21 +254,32 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Phase 4.1 §16 — was fully unbounded (no LIMIT at all — the one endpoint here that could
|
||||
// genuinely grow into thousands of rows over a semester of exam sessions). Now real
|
||||
// pagination, same shape as audit-events/student-attempts.
|
||||
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
let scopeFilter = '';
|
||||
const params = [req.params.id];
|
||||
if (req.instructor.role === 'instructor') {
|
||||
scopeFilter = `AND s.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = $2)`;
|
||||
params.push(req.instructor.instructor_id);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
|
||||
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
|
||||
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC`,
|
||||
params
|
||||
);
|
||||
res.json(rows);
|
||||
const pageParams = [...params, pg.pageSize, pg.offset];
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
|
||||
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
|
||||
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
pageParams
|
||||
),
|
||||
pool.query(
|
||||
`SELECT count(*)::int AS n FROM assessment_sessions s WHERE s.assessment_definition_id = $1 ${scopeFilter}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -14,10 +14,18 @@
|
||||
// destroy, events) — the Admin UI never talks to the Provisioner directly.
|
||||
|
||||
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;
|
||||
const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
|
||||
const {
|
||||
requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
} = rbac;
|
||||
|
||||
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
|
||||
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
|
||||
@@ -71,6 +79,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
`SELECT student_id, lab_host_id, status, error_reason, started_at
|
||||
FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10`
|
||||
);
|
||||
// Phase 4.1 §26 — best-effort: a Provisioner hiccup here shouldn't break the whole
|
||||
// dashboard, capacity is just omitted (null) rather than the request failing.
|
||||
const capacity = await provisioner.capacity().then((r) => r.status === 200 ? r.body : null).catch(() => null);
|
||||
res.json({
|
||||
active_cohorts: cohorts.rows[0].n,
|
||||
total_students: students.rows[0].n,
|
||||
@@ -80,39 +91,198 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
error: envCounts.error || 0,
|
||||
idle_or_destroyed: envCounts.destroyed || 0,
|
||||
},
|
||||
capacity, // { current, max } — max is null when admission control is unconfigured (see resource-policy.yaml)
|
||||
recent_completions: recentCompletions.rows,
|
||||
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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) => {
|
||||
@@ -143,11 +313,15 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json({ stages });
|
||||
});
|
||||
|
||||
// Phase 4.1 §16 — was a flat LIMIT 100; now real pagination (see pagination.js).
|
||||
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [req.params.id, pg.pageSize, pg.offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM scenario_attempts WHERE student_id = $1`, [req.params.id]),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
@@ -156,7 +330,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortWriteAccess('id'), async (req, res) => {
|
||||
const { status, body } = await provisioner.provision(req.params.id);
|
||||
res.status(status === 409 ? 200 : status).json(body);
|
||||
});
|
||||
@@ -236,6 +410,12 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
||||
const { instructor_id, role_in_cohort } = req.body || {};
|
||||
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
|
||||
// Phase 4.1 §19 — role_in_cohort is now actually enforced (rbac.js's
|
||||
// requireCohortWriteAccess/requireStudentCohortWriteAccess), so it's validated here rather
|
||||
// than accepting any string that would silently never match either check.
|
||||
if (role_in_cohort && !['teacher', 'viewer'].includes(role_in_cohort)) {
|
||||
return res.status(400).json({ error: 'role_in_cohort must be teacher or viewer' });
|
||||
}
|
||||
await getPool().query(
|
||||
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
|
||||
@@ -286,27 +466,13 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('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 }),
|
||||
});
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
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, requireCohortAccess('id'), async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
||||
[req.params.id]
|
||||
@@ -315,6 +481,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { body } = await provisioner.listForStudent(s.student_id);
|
||||
const active = body?.find((e) => e.status !== 'destroyed');
|
||||
if (!active) return { student_id: s.student_id, status: 'none' };
|
||||
// Phase 4.1 §13 — before the destroy, so any still-open attempt is honestly abandoned
|
||||
// rather than left dangling once its environment no longer exists.
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), active.environment_id);
|
||||
await provisioner.destroy(active.environment_id);
|
||||
return { student_id: s.student_id, status: 'destroyed' };
|
||||
}));
|
||||
@@ -328,12 +497,14 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
// resolving a bare environment id back to a cohort would need an extra Provisioner round
|
||||
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
|
||||
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
||||
const { status, body } = await provisioner.reset(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
||||
const { status, body } = await provisioner.destroy(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
@@ -423,8 +594,16 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
|
||||
// Phase 4.1 §16 — was a flat LIMIT 200 with no way to see anything older; now real
|
||||
// server-side pagination (see pagination.js). Default page size (50) intentionally smaller
|
||||
// than the old flat cap since this is now genuinely browsable instead of "the last 200 ever."
|
||||
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`);
|
||||
res.json(rows);
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pg.pageSize, pg.offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM audit_events`),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,11 +2,55 @@
|
||||
// session on a scenario (not per validation click, see migrations/012). Every mutation here
|
||||
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
|
||||
//
|
||||
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
|
||||
// — that would need a reaper-style background job wired to Provisioner environment_events,
|
||||
// which is more machinery than the instructional-visibility goal here needs. An attempt just
|
||||
// stays 'in_progress' if the student never returns to finish it; instructors can already see
|
||||
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
|
||||
// Phase 4.1 (docs/PHASE4_1_PREPRODUCTION_HARDENING.md §13) added real abandonment handling —
|
||||
// see abandonAttempt/abandonOpenAttemptsFor* below and their call sites (environment
|
||||
// destroy/reset in server.js and admin-routes.js, logout in server.js, the session sweeper in
|
||||
// sessions.js, and the "different scenario" transition inline in getOrCreateOpenAttempt just
|
||||
// below). Still deliberately NOT a new standalone background job — every trigger either rides
|
||||
// an existing request handler or the session sweeper that already runs periodically.
|
||||
|
||||
// An attempt is never silently deleted or marked as a failure by abandonment — result stays
|
||||
// NULL, the row stays queryable, instructors see "abandoned (reason)" as a real, honest status
|
||||
// rather than a punitive one. No-op (and does not overwrite an existing abandoned_reason) if
|
||||
// the attempt is already terminal.
|
||||
async function abandonAttempt(pool, attemptId, reason) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = $2
|
||||
WHERE attempt_id = $1 AND status = 'in_progress' RETURNING *`,
|
||||
[attemptId, reason]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Trigger: environment destroyed (idle reaper, student reset, admin reset/destroy — see
|
||||
// server.js's student reset route and admin-routes.js's reset/destroy routes, all of which
|
||||
// call this right after telling the Provisioner to tear the environment down). A reset creates
|
||||
// a fresh environment_id in some flows and reuses the same one in others (see
|
||||
// provisioner lifecycle.js's reset — same environment_id, fresh container) — either way, any
|
||||
// attempt still open against the OLD container state is honestly "not going to be resumed as
|
||||
// the same working session," so it's abandoned rather than left open indefinitely.
|
||||
async function abandonOpenAttemptsForEnvironment(pool, environmentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'environment_destroyed'
|
||||
WHERE environment_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
|
||||
[environmentId]
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Trigger: logout (server.js POST /api/logout) and the session sweeper's periodic pass for
|
||||
// students whose sessions have all expired (sessions.js). Deliberately student-scoped, not
|
||||
// environment-scoped — a student can log out while their Lab keeps running (it isn't
|
||||
// destroyed until reaped or explicitly ended), but the WORKING SESSION on whatever scenario
|
||||
// they had open is over the moment they're no longer authenticated to record more progress on it.
|
||||
async function abandonOpenAttemptsForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'session_ended'
|
||||
WHERE student_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
|
||||
[studentId]
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
|
||||
const { rows: existing } = await pool.query(
|
||||
@@ -17,6 +61,17 @@ async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environment
|
||||
);
|
||||
if (existing[0]) return existing[0];
|
||||
|
||||
// Trigger: student starts a different scenario while a previous one is still open (started
|
||||
// via /start or implicitly via /validate, never finished). Only one scenario is "the one
|
||||
// currently being worked on" per student+environment at a time from the app's own UX, so an
|
||||
// open attempt on ANY OTHER scenario in this same environment is abandoned before the new
|
||||
// one is created — never silently left as a phantom "still in progress" row.
|
||||
await pool.query(
|
||||
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'scenario_switched'
|
||||
WHERE student_id = $1 AND environment_id = $2 AND scenario_id != $3 AND status = 'in_progress'`,
|
||||
[studentId, environmentId, scenarioId]
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO scenario_attempts
|
||||
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
|
||||
@@ -80,4 +135,7 @@ async function getAttemptSummary(pool, { studentId, scenarioId, environmentId })
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
|
||||
module.exports = {
|
||||
getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary,
|
||||
abandonAttempt, abandonOpenAttemptsForEnvironment, abandonOpenAttemptsForStudent,
|
||||
};
|
||||
|
||||
@@ -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,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 };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user