Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a950afb | ||
|
|
7baf7074e5 | ||
|
|
777efd168d | ||
|
|
b38c46aaec | ||
|
|
be92be81cd | ||
|
|
f25535027c | ||
|
|
3c7f0ca59c | ||
|
|
9d382c95c5 | ||
|
|
5183a6af09 | ||
|
|
1c08a30c7f | ||
|
|
23f881884d | ||
|
|
f89a769540 | ||
|
|
4bc202e5f3 | ||
|
|
1b6da0a966 | ||
|
|
17160da89e | ||
|
|
611e1ac4ba | ||
|
|
895e16fea4 |
@@ -163,6 +163,19 @@ its core:
|
||||
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
|
||||
guidance-only current-activity) — RootNode owns identity/coordination, an external service
|
||||
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
|
||||
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
|
||||
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
|
||||
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
|
||||
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
|
||||
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
|
||||
boundary — server-to-server only, no browser session involved — is what the future public
|
||||
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
- 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
|
||||
@@ -200,7 +213,8 @@ Key properties of the platform (not this image):
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.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
|
||||
|
||||
@@ -357,3 +357,65 @@ Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
|
||||
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
|
||||
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
|
||||
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
|
||||
|
||||
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
|
||||
|
||||
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
|
||||
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
|
||||
|
||||
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
|
||||
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
|
||||
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
|
||||
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
|
||||
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
|
||||
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
|
||||
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
|
||||
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
|
||||
just hidden in the UI. Completion is entirely server-derived from the existing capstone
|
||||
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
|
||||
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
|
||||
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
|
||||
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
|
||||
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
|
||||
concurrent duplicate requests, not just sequential retries.
|
||||
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
|
||||
async-error-handling convention — an unexpected internal error there (a malformed
|
||||
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
|
||||
(wrapped + input-validated) and regression-tested.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
|
||||
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
|
||||
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
|
||||
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
|
||||
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
|
||||
(see that doc §14).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -507,3 +507,15 @@ any of it — the Provisioner, the `environments` state machine, the Lab Host Ro
|
||||
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
|
||||
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
|
||||
the same state enum) rather than inventing a second one.
|
||||
|
||||
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
|
||||
|
||||
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
|
||||
of the exact same disposable Lab this document describes — again without changing the
|
||||
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
|
||||
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
|
||||
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
|
||||
Lab container out-of-band and restarting the Provisioner to trigger its actual
|
||||
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
|
||||
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
|
||||
is completely unaffected and a fresh Lab boots normally.
|
||||
|
||||
@@ -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.
|
||||
@@ -67,5 +67,40 @@ can prove in advance.
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
|
||||
still make sense walked against the real admin portal
|
||||
|
||||
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
|
||||
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
|
||||
value) and is stored only in the Platform API's own server-side environment — never
|
||||
committed, never in any browser-reachable config
|
||||
- [ ] The website team's production integration key was delivered out of band (not plaintext
|
||||
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
|
||||
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
|
||||
clean `401` against the real production endpoint, not a 500 or an information leak
|
||||
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
|
||||
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
|
||||
external_reference ever appears in a real production scrape
|
||||
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
|
||||
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
|
||||
actually integrated against (update it if reality drifted during that integration)
|
||||
|
||||
## Enrollment communications / Google Workspace email (Phase 5C — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md)
|
||||
- [ ] Every step in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` marked REQUIRES GOOGLE WORKSPACE
|
||||
ADMIN ACTION has actually been completed (sending mailbox, service account, domain-wide
|
||||
delegation scoped to `gmail.send` only, SPF/DKIM/DMARC)
|
||||
- [ ] `EMAIL_PROVIDER=gws` set in the real production environment — confirm it is NOT left at
|
||||
the dev-provider default, which would silently mean no real student ever receives email
|
||||
- [ ] `GOOGLE_SERVICE_ACCOUNT_JSON`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`,
|
||||
and `PUBLIC_APP_ORIGIN` are all set from the real production secret store, not left at any
|
||||
dev/example value
|
||||
- [ ] `infra/scripts/gws-email-test.js` (opt-in, `RUN_GWS_EMAIL_TEST=true`) run once against a
|
||||
real `TEST_EMAIL_RECIPIENT` and confirmed delivered — the one and only "does this actually
|
||||
work" proof this checklist can't fake
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms the dev-preview
|
||||
notification route (`GET /api/admin/notifications/:id/dev-preview`) is unreachable and no
|
||||
activation/reset token is ever returned by any API response
|
||||
- [ ] `GET /metrics`'s notification gauges/counters contain no student_id/email/token/
|
||||
notification_id/external_event_id in any label value on a real production scrape
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACTIVATION.md`'s steps still make sense walked against the real
|
||||
admin portal, including a real (not dev-provider) activation email actually arriving
|
||||
|
||||
Only once every box above is checked against the real server does the deployment graduate from
|
||||
"infrastructure package built" to "actually in production."
|
||||
|
||||
@@ -45,11 +45,12 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
|
||||
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
|
||||
|
||||
1. **No self-registration / public enrollment.** A student row is created only by an instructor
|
||||
(`POST /api/admin/students`, admin/owner RBAC-gated). There is no public "sign up" surface.
|
||||
This is almost certainly the right call to leave alone for now — it's explicitly a Phase 5+
|
||||
product/business decision (course commerce, is it free, who's allowed to enroll), not an
|
||||
infrastructure gap.
|
||||
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
|
||||
built the boundary a *separate* public website will call
|
||||
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
|
||||
row can now be created automatically by a trusted server-to-server enrollment event, but there
|
||||
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
|
||||
|
||||
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
|
||||
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
|
||||
@@ -59,25 +60,38 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
|
||||
an account has real credentials or a pending activation token.
|
||||
|
||||
3. **`curriculum_assignments.student_id` is schema-ready but unwired.** The table has supported
|
||||
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
|
||||
(`CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT
|
||||
NULL)`) — but nothing in the application ever reads or writes that column today; there's only
|
||||
one curriculum path, so nothing has needed to differentiate yet. Not a gap that blocks
|
||||
self-paced delivery now (every student already sees the one path with no gate), but the
|
||||
moment a second curriculum path exists, this is where per-individual assignment lands — the
|
||||
schema was designed for exactly this and doesn't need a migration to support it.
|
||||
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
|
||||
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
|
||||
the real problem it was meant to eventually solve (which curriculum a given student sees) is
|
||||
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
|
||||
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
|
||||
future direct per-student assignment need outside the program/enrollment model — it wasn't
|
||||
removed, just not the mechanism Phase 5B ended up using. See
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
|
||||
|
||||
4. **No delivery-mode concept anywhere.** In-person, remote, and self-paced sessions aren't
|
||||
distinguished in any data model today, and nothing currently needs them to be — capacity
|
||||
planning, the reaper, and academic records all behave identically regardless of how a student
|
||||
is attending. Worth being aware this is unmodeled, not worth building a taxonomy for before a
|
||||
real product reason exists (YAGNI).
|
||||
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
|
||||
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
|
||||
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
|
||||
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
|
||||
is modeled, not infrastructure.
|
||||
|
||||
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
|
||||
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
|
||||
self-paced student dashboard.
|
||||
|
||||
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 (item 2, above) was
|
||||
closed in Phase 5A; the rest remain open, unmodeled by design (YAGNI) until a real product
|
||||
reason exists.
|
||||
explicit non-goals per this phase's authorization. Student authentication (Phase 5A), 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,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.
|
||||
@@ -1,15 +1,20 @@
|
||||
# Runbook: Student Account Lifecycle
|
||||
|
||||
Covers creating, converting, resetting, and revoking student accounts. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design.
|
||||
`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>`. Copy it immediately (print it, read it
|
||||
aloud, hand it over) — it is never shown again and never stored in plaintext anywhere. The
|
||||
student opens the link and sets their own password; the instructor never sees it.
|
||||
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):
|
||||
|
||||
@@ -40,5 +45,8 @@ students list — deactivation takes effect on their very next request, not just
|
||||
login, because `studentSession` middleware re-checks `status='active'` every time.
|
||||
|
||||
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
|
||||
time-limited (activation 7 days, reset 1 hour). Just issue a new one via Convert or Reset
|
||||
password — there's no way to "resend" the same token, and that's intentional.
|
||||
time-limited (activation 7 days, reset 1 hour). Use Students → \[student\] → **Resend
|
||||
activation** (or Reset password, for an already-activated account) to issue a new one — there's
|
||||
no way to "resend" the same token, and that's intentional (only the most recent link is ever
|
||||
valid, so there's never ambiguity about which one to use). See `docs/RUNBOOK_STUDENT_
|
||||
ACTIVATION.md` for checking whether the automatic email actually delivered.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Runbook: Student Activation Pipeline
|
||||
|
||||
Covers the day-to-day operation of the enrollment → activation → login pipeline from `/admin`.
|
||||
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` for the underlying design and
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the email delivery mechanism itself.
|
||||
|
||||
## What happens automatically
|
||||
|
||||
Every time a student account gains a new enrollment — whether the website's checkout triggered
|
||||
it (`POST /api/integrations/enrollments`) or an instructor enrolled them manually from
|
||||
`/admin` → Programs — RootNode decides and sends the right email on its own:
|
||||
|
||||
- **Never activated before** → an activation email, with a one-time link to set their password.
|
||||
- **Already has real credentials** → an enrollment confirmation email pointing them back to
|
||||
sign in — no link/token involved.
|
||||
|
||||
Nothing needs to be triggered by hand for the normal case. The controls below are for when the
|
||||
normal case didn't work.
|
||||
|
||||
## Checking delivery status
|
||||
|
||||
**Per student**: Students → \[student\] → the "Notifications" card shows every notification ever
|
||||
sent to (or attempted for) that student, with type/status/attempt count. `sent` means it
|
||||
actually reached the provider successfully; `failed` means it gave up (see below); `queued`/
|
||||
`sending` means it's still in flight — check back in a few seconds, delivery happens through a
|
||||
background worker, not the same request that triggered it.
|
||||
|
||||
**Everywhere**: `/admin` → Notifications (owner/admin only) — the same information across every
|
||||
student, most recent first.
|
||||
|
||||
## If a student never got their activation email
|
||||
|
||||
1. Check Students → \[student\] → Notifications. If it shows `failed`, note the error — a
|
||||
`no_recipient_address` failure means the student truly has no email or contact address on
|
||||
file and can only be activated by an instructor manually relaying a link (see below); other
|
||||
error codes usually mean a delivery-infrastructure issue worth checking
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting table for.
|
||||
2. Students → \[student\] → Credentials → **Resend activation**. This invalidates whatever link
|
||||
was outstanding and sends a fresh one — safe to click as many times as needed; only the most
|
||||
recent link is ever valid, so there's never a "which link do I use" confusion for the student.
|
||||
3. If the student genuinely has no deliverable address at all (a younger student with a
|
||||
login-only account and no guardian contact email on file), convert/create them without email
|
||||
in the first place (see `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md`) and relay the one-time
|
||||
activation link shown in the admin UI directly (print it, read it aloud) — this manual path
|
||||
still works exactly as it did before Phase 5C added automatic email.
|
||||
|
||||
## If a student says they didn't get an enrollment confirmation
|
||||
|
||||
Check Students → \[student\] → Notifications for the `enrollment_confirmation` row. If it's
|
||||
`sent`, the email genuinely went out — check their spam folder, or confirm the address on file
|
||||
(Students → \[student\]) is actually correct. If none exists for a given enrollment,
|
||||
Programs → \[program\] → Enrollments → find the enrollment → (student detail page also works)
|
||||
and use **Send enrollment confirmation** to trigger one explicitly.
|
||||
|
||||
## Password reset
|
||||
|
||||
Students should use "Forgot password" on the login screen themselves wherever possible — it's
|
||||
delivered the same way as everything else here and the flow is self-service end to end.
|
||||
|
||||
If a student can't complete self-service reset (e.g. they no longer have access to the email a
|
||||
guardian originally provided and it needs an instructor to re-trigger with a corrected address),
|
||||
Students → \[student\] → Credentials → **Send password reset** issues a fresh reset link and
|
||||
revokes all of that student's existing sessions immediately. In production, the instructor
|
||||
never sees the token itself — the response is a confirmation that an email was queued, not the
|
||||
link. If automatic delivery still can't reach the student, use `docs/RUNBOOK_STUDENT_ACCOUNT_
|
||||
LIFECYCLE.md`'s manual-relay procedure, which is unaffected by any of this.
|
||||
|
||||
## What "resend" does NOT do
|
||||
|
||||
Resending activation does not touch the student's enrollment status, progress, achievements, or
|
||||
attempt history — it only affects the credential/token side of their account. It's always safe.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Website → RootNode Academy Enrollment Integration
|
||||
|
||||
Audience: whoever builds `rootnodeacademy.com` (marketing, pricing, checkout) and needs to turn
|
||||
a successful purchase/signup into a real RootNode Academy student enrollment. This document is
|
||||
meant to be enough on its own — you should not need to read the Platform API's source to
|
||||
integrate against it.
|
||||
|
||||
## Responsibility boundary
|
||||
|
||||
**The website is responsible for:** marketing, program selection, pricing, checkout/payment (now
|
||||
or in the future), collecting enrollment/customer information, and sending ONE verified
|
||||
enrollment event to the endpoint below once payment/signup is confirmed.
|
||||
|
||||
**RootNode's platform is responsible for everything after that:** student identity, program
|
||||
entitlement, account activation, the entire password lifecycle, all operational enrollment
|
||||
email (activation, enrollment confirmation, password reset — see
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`), learning, Labs, progress, assessment, and
|
||||
completion. **The website does not send email for platform activation or any other account
|
||||
lifecycle event — RootNode owns that entirely, as of Phase 5C.** Do not build a parallel
|
||||
"welcome" or "here's how to log in" email on the website side; it would either duplicate or
|
||||
conflict with what RootNode already sends automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
rootnodeacademy.com (marketing, pricing, checkout — built separately, not in this repo)
|
||||
|
|
||||
| your backend verifies payment/signup with your payment provider
|
||||
v
|
||||
your backend calls the RootNode Enrollment API (below) — server-to-server only
|
||||
v
|
||||
RootNode Academy Platform API
|
||||
creates/matches a student account
|
||||
creates or reuses (idempotently) an enrollment
|
||||
v
|
||||
the student logs in at the RootNode Academy platform and sees their program
|
||||
```
|
||||
|
||||
**RootNode never talks to your payment provider, and your payment provider never talks to
|
||||
RootNode.** Your backend is the only thing that calls this endpoint, after it has already
|
||||
confirmed payment (or whatever your enrollment trigger is) on its own. This endpoint knows
|
||||
nothing about Stripe, PayPal, or any other provider's object model — it only understands a
|
||||
small, generic enrollment event described below.
|
||||
|
||||
## The endpoint
|
||||
|
||||
```
|
||||
POST /api/integrations/enrollments
|
||||
```
|
||||
|
||||
Reachable at the RootNode Academy platform's public origin (the same host your students'
|
||||
browsers already use), via HTTPS in any real deployment. This is the **only** endpoint your
|
||||
backend needs.
|
||||
|
||||
## Authentication
|
||||
|
||||
A pre-shared secret, sent as a header:
|
||||
|
||||
```
|
||||
X-RootNode-Integration-Key: <the secret>
|
||||
```
|
||||
|
||||
- This is **not** a student or admin login. It has nothing to do with cookies, sessions, or
|
||||
browser auth of any kind.
|
||||
- **The secret must live only in your backend's server-side configuration.** It must never be
|
||||
embedded in any browser-served JavaScript, mobile app bundle, or client-side config — anyone
|
||||
who obtains it can create enrollments.
|
||||
- A request with a missing or wrong key gets `401 { "error": "unauthorized" }` and nothing else
|
||||
happens — no student is looked up, nothing is created.
|
||||
- RootNode will provide you with the production secret out of band (not over email/chat in
|
||||
plaintext — ask the RootNode team for the current secure-sharing process). Rotate it by
|
||||
asking RootNode to issue a new one; there is no self-service rotation yet.
|
||||
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "order_9f8e7d6c5b4a",
|
||||
"source": "website",
|
||||
"program_slug": "root-kubernetes-foundations",
|
||||
"status": "paid",
|
||||
"student": {
|
||||
"email": "[email protected]",
|
||||
"login_identifier": null,
|
||||
"display_name": "Jamie Rivera",
|
||||
"contact_email": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `event_id` | **yes** | A string, unique per real-world event, from *your* system (an order ID, a payment intent ID — anything stable). This is the idempotency key — see below. Not a RootNode-generated value. |
|
||||
| `source` | no | Free text, e.g. `"website"`. Recorded for audit only, defaults to `"integration"` if omitted. |
|
||||
| `program_slug` | **yes** | The RootNode program's id, e.g. `"root-kubernetes-foundations"`. Ask RootNode for the current list — there is no public program-catalog endpoint. |
|
||||
| `status` | **yes** | One of: `"paid"`, `"active"`, `"confirmed"` (all mean "grant access now") or `"pending"`, `"awaiting_payment"` (means "record the enrollment but don't grant access yet"). This is a small, fixed, provider-neutral vocabulary — translate whatever your payment provider says into one of these five values before calling. Anything else is rejected. |
|
||||
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
|
||||
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
|
||||
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
|
||||
| `student.contact_email` | no | **Only** for a student with no email of their own (§ below) — where to send THIS enrollment's activation/confirmation email (typically a guardian's address collected at checkout). Never treated as the student's own identity/login. |
|
||||
|
||||
### What you must NOT send
|
||||
|
||||
- No card numbers, CVVs, bank details, or any other payment-card data. This endpoint has no
|
||||
field for it and never will — RootNode never touches payment-card data at all.
|
||||
- No raw provider webhook payloads (Stripe event objects, PayPal IPN bodies, etc.) — translate
|
||||
to the shape above first. This endpoint is deliberately provider-neutral.
|
||||
- No password. RootNode issues its own credential/activation flow (see "What the student
|
||||
experiences" below) — you cannot set a student's password through this endpoint, and should
|
||||
never try to invent one.
|
||||
|
||||
## Response
|
||||
|
||||
**Success — a new enrollment was created** (`201`):
|
||||
```json
|
||||
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": true }
|
||||
```
|
||||
|
||||
**Success — this exact `event_id` was already processed** (`200`, idempotent replay — see below):
|
||||
```json
|
||||
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": false }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Body `error` | Meaning |
|
||||
|---|---|---|
|
||||
| `401` | `unauthorized` | Missing/wrong `X-RootNode-Integration-Key`. |
|
||||
| `400` | `event_id is required` | — |
|
||||
| `400` | `program_slug is required` | — |
|
||||
| `400` | `status must be one of: paid, active, confirmed, pending, awaiting_payment` | — |
|
||||
| `400` | `student.email or student.login_identifier is required` | — |
|
||||
| `404` | `PROGRAM_NOT_FOUND` | `program_slug` doesn't exist. |
|
||||
| `409` | `PROGRAM_INACTIVE` | The program exists but isn't currently open for enrollment. |
|
||||
| `500` | `internal_error` | Something unexpected broke server-side. Safe to retry (see below). |
|
||||
|
||||
## Idempotency and retries
|
||||
|
||||
**`event_id` is the whole mechanism.** Send the exact same `event_id` again (your payment
|
||||
provider retried a webhook, your own backend retried a timed-out request, anything) and you get
|
||||
back the **same enrollment**, `created: false`, `200` instead of `201` — never a duplicate. This
|
||||
is enforced at the database level (a unique constraint), not just "best effort," so it's safe
|
||||
under concurrent/parallel retries too, not just sequential ones.
|
||||
|
||||
**Retry policy:** on a `5xx` or a network error/timeout, retry with the *same* `event_id`
|
||||
(standard exponential backoff is fine — a few attempts over a couple of minutes is plenty). On a
|
||||
`4xx`, do not blindly retry — fix the request (see the error table above) or, for `401`, check
|
||||
your key.
|
||||
|
||||
**Never generate a new `event_id` for a retry of the same real-world event.** Doing so would
|
||||
create a second enrollment. Reserve a fresh `event_id` only for a genuinely new enrollment event
|
||||
(e.g. a renewal, a second purchase).
|
||||
|
||||
## Student matching rules
|
||||
|
||||
RootNode looks for an existing student by `email` (case-insensitive) or `login_identifier`
|
||||
(exact match), in that priority. If found, that student is enrolled. If not found, a new student
|
||||
account is created automatically — this is a trusted server-to-server call from your backend,
|
||||
equivalent to an instructor adding a student through the admin portal, not a public signup form.
|
||||
|
||||
## Minor / no-email students
|
||||
|
||||
RootNode explicitly supports students without their own email address (younger students,
|
||||
enrolled by a parent/guardian). Send `student.login_identifier` instead of (or in addition to)
|
||||
`email` — any stable string you control, such as an internal account/order reference. You must
|
||||
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
|
||||
|
||||
If the enrolled student has no email at all, send `student.contact_email` too (e.g. the
|
||||
guardian's address collected at checkout) so RootNode has somewhere to deliver the activation
|
||||
email — otherwise the enrollment is created but nobody is notified automatically, and an
|
||||
instructor has to relay the activation link manually. `contact_email` is stored only against
|
||||
that specific enrollment, never copied into the student's own identity record.
|
||||
|
||||
There is no guardian/parent account system yet — the enrollment is created directly against the
|
||||
student identity you provide. If you need to support "a parent manages multiple children's
|
||||
enrollments" as a first-class concept later, talk to the RootNode team before building around a
|
||||
workaround; the schema has room for it but it isn't implemented.
|
||||
|
||||
## What the student experiences after you call this (as of Phase 5C)
|
||||
|
||||
If `status` mapped to "active": RootNode sends the student (or their enrollment's
|
||||
`contact_email`) a real email automatically —an activation email with a one-time setup link if
|
||||
they're new, or an enrollment confirmation if they already have a RootNode account — and they
|
||||
can log in as soon as they act on it. **You do not need to send any email yourself for this** —
|
||||
see "Responsibility boundary" above. If `status` mapped to "pending": the enrollment exists but
|
||||
the student can't start until a RootNode instructor activates it — useful if your flow needs a
|
||||
manual review step before access is granted; no email goes out until activation happens.
|
||||
|
||||
If the student has neither an `email` nor a `contact_email` on file, RootNode still creates the
|
||||
account and enrollment, but nothing gets emailed automatically — an instructor has to relay the
|
||||
activation link by hand. Send whichever address you have whenever you have one.
|
||||
|
||||
## Local/staging testing
|
||||
|
||||
Set `INTEGRATION_SHARED_SECRET` in your test environment's config (in local dev,
|
||||
`platform/.env`; example dev value used by this repo's own test suite:
|
||||
`dev-integration-secret-not-for-production` — never use this value anywhere real). Then:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:4200/api/integrations/enrollments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
|
||||
-d '{
|
||||
"event_id": "test-order-001",
|
||||
"source": "website",
|
||||
"program_slug": "root-kubernetes-foundations",
|
||||
"status": "paid",
|
||||
"student": { "email": "[email protected]", "display_name": "Test Student" }
|
||||
}'
|
||||
```
|
||||
|
||||
All example data on this page is fake — do not reuse these values against a real deployment.
|
||||
|
||||
## Production secret handling
|
||||
|
||||
- The production `X-RootNode-Integration-Key` value must be requested from the RootNode team
|
||||
and stored in your backend's secret manager/environment configuration — never in source
|
||||
control, never in a browser-reachable config, never logged.
|
||||
- RootNode's own logs and audit trail never record the secret's value, only that an
|
||||
authenticated call happened (or a `401` if it didn't).
|
||||
@@ -9,6 +9,8 @@ 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,
|
||||
@@ -21,9 +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'] },
|
||||
]
|
||||
@@ -61,9 +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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,8 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
||||
const [env, setEnv] = useState(null)
|
||||
const [events, setEvents] = useState(null)
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [enrollments, setEnrollments] = useState(null)
|
||||
const [notifications, setNotifications] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -180,6 +182,11 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
||||
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
|
||||
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
||||
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
|
||||
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
|
||||
// instructor only sees enrollments they created or that share a cohort with them.
|
||||
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
|
||||
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
|
||||
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
|
||||
}, [studentId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
@@ -205,21 +212,47 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
||||
<h3>Credentials</h3>
|
||||
{student?.has_credentials ? (
|
||||
<>
|
||||
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}</p>
|
||||
<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): ${window.location.origin}/?reset=${reset_token}`)
|
||||
})}>Reset password</button>
|
||||
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>
|
||||
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
||||
@@ -248,6 +281,26 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrolled programs</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.program_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
|
||||
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
|
||||
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
|
||||
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum progress</h3>
|
||||
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
||||
|
||||
@@ -116,4 +116,22 @@ export const adminApi = {
|
||||
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
|
||||
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
|
||||
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
|
||||
|
||||
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
|
||||
programs: () => get('/api/admin/programs'),
|
||||
program: (id) => get(`/api/admin/programs/${id}`),
|
||||
createProgram: (fields) => post('/api/admin/programs', fields),
|
||||
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
|
||||
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
|
||||
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
|
||||
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
|
||||
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
|
||||
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
|
||||
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
|
||||
|
||||
// ── 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,140 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
const BLOCKED_MESSAGES = {
|
||||
pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
|
||||
not_yet_started: 'This program opens on the date below. Check back then.',
|
||||
access_expired: 'Access to this program has ended. Your progress is still here for reference.',
|
||||
cancelled: 'This enrollment was cancelled.',
|
||||
}
|
||||
|
||||
// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
|
||||
// already carries real access/progress/next-activity per enrollment — see program-status.js),
|
||||
// split into Active and Completed. Continue Learning delegates to the entitlement-gated
|
||||
// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
|
||||
// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
|
||||
export default function ProgramsTab() {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.programs().then(setPrograms).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
|
||||
const completed = programs.filter((p) => p.effective_status === 'completed')
|
||||
const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
|
||||
|
||||
if (!programs.length) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
<p className={styles.muted}>You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{active.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>My Programs</h2>
|
||||
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{completed.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Completed Programs</h2>
|
||||
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{cancelled.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<h2>Other</h2>
|
||||
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramCard({ program, onChange }) {
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const poll = useCallback(() => {
|
||||
api.environmentStatus().then((s) => {
|
||||
setEnvStatus(s)
|
||||
if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
|
||||
clearInterval(pollRef.current); pollRef.current = null
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleContinue = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchProgram(program.enrollment_id)
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
|
||||
if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
|
||||
pollRef.current = setInterval(poll, POLL_MS)
|
||||
}
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const p = program.progress
|
||||
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<strong>{program.program_name}</strong>
|
||||
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
|
||||
{program.effective_status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{p && (
|
||||
<div style={{ margin: '0.6rem 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{program.effective_status === 'completed' ? (
|
||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||
) : program.blocked_reason ? (
|
||||
<p className={styles.muted}>
|
||||
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
|
||||
{error && <p className={styles.errorBox}>{error}</p>}
|
||||
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
import ClassroomTab from './ClassroomTab'
|
||||
import ProfileTab from './ProfileTab'
|
||||
import ProgramsTab from './ProgramsTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'programs', label: 'My Programs' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
@@ -45,6 +47,7 @@ export default function StudentDashboard({ student, onLogout }) {
|
||||
</nav>
|
||||
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'programs' && <ProgramsTab />}
|
||||
{tab === 'classroom' && <ClassroomTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
|
||||
@@ -119,4 +119,9 @@ export const api = {
|
||||
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
|
||||
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
|
||||
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
|
||||
|
||||
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
|
||||
programs: () => fetch('/api/student/programs').then(json),
|
||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||
}
|
||||
|
||||
+33
@@ -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
|
||||
+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);
|
||||
});
|
||||
+11
-7
@@ -7,18 +7,22 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
|
||||
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
|
||||
`provisioner/README.md`.
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
|
||||
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
- `api/` — the lightweight Platform API: students, cohorts and cohort membership, curriculum,
|
||||
detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
capstone assessment engine, achievements, per-instructor accounts, persistent student/
|
||||
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
|
||||
(Phase 5A), and the instructor/admin control plane. No k3s, no `--privileged` — this replaced
|
||||
Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s cluster just
|
||||
to serve these APIs. Design:
|
||||
(Phase 5A), 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),
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom).
|
||||
Operational reference: `api/README.md`.
|
||||
`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,
|
||||
as of Phase 3B, server-side gating of progressive hint reveal). Design:
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
};
|
||||
@@ -153,6 +153,15 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
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 });
|
||||
@@ -179,20 +188,34 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
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.
|
||||
// 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, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Phase 5C §20 — the deterministic development/test email provider. NEVER makes a network
|
||||
// call, NEVER reaches a real inbox, no matter what recipient it's given — this is the fail-safe
|
||||
// default (see provider.js) precisely so a dev machine or a CI run can never accidentally email
|
||||
// a real student. Returns the fully rendered message back to the caller so
|
||||
// notifications.js can stash a preview (dev/test only — see its isProduction gate, mirroring
|
||||
// student-auth-routes.js's existing dev_reset_token pattern) for tests/admins to inspect the
|
||||
// activation/reset URL without a real mailbox.
|
||||
|
||||
async function send({ to, subject, text, html }) {
|
||||
if (!to) {
|
||||
const e = new Error('no recipient address');
|
||||
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
|
||||
throw e;
|
||||
}
|
||||
// Deterministic failure-injection for the permanent test suite (e2e-phase5c.js) — magic
|
||||
// recipient markers, the same pattern test payment sandboxes use for "this card always
|
||||
// declines." Only recognized by THIS provider; a real GWS send never looks at the address
|
||||
// this way, so there's no risk of this leaking into production behavior.
|
||||
if (to.includes('+forcetempfail')) {
|
||||
const e = new Error('simulated temporary provider failure'); e.errorClass = 'temporary'; e.errorCode = 'simulated_temporary_failure';
|
||||
throw e;
|
||||
}
|
||||
if (to.includes('+forcepermfail')) {
|
||||
const e = new Error('simulated permanent provider failure'); e.errorClass = 'permanent'; e.errorCode = 'simulated_permanent_failure';
|
||||
throw e;
|
||||
}
|
||||
console.log(`[dev-email] to=${to} subject="${subject}"`);
|
||||
return {
|
||||
ok: true,
|
||||
providerMessageId: `dev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
preview: { to, subject, text, html },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { send };
|
||||
@@ -0,0 +1,132 @@
|
||||
// Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API
|
||||
// (users.messages.send), authenticated as a Google Cloud service account using Workspace
|
||||
// domain-wide delegation to impersonate a Workspace user.
|
||||
//
|
||||
// GOOGLE_DELEGATED_USER vs. EMAIL_FROM_ADDRESS — two different things, deliberately kept
|
||||
// separate: domain-wide delegation authorizes the service account to act as one real Workspace
|
||||
// USER ACCOUNT (the "sub" claim below) — if the sending address is an ALIAS of a real account
|
||||
// rather than its own paid seat (a deliberate cost-saving choice, not a mistake), the delegated
|
||||
// user must be the ALIAS'S OWNER (the real account it belongs to), not the alias string itself.
|
||||
// EMAIL_FROM_ADDRESS is only ever the visible From: header — Gmail lets any account send as its
|
||||
// own verified aliases, so this can safely be the alias while GOOGLE_DELEGATED_USER is the real
|
||||
// account underneath it. If the sending address IS a primary account already,
|
||||
// GOOGLE_DELEGATED_USER can be left unset and this falls back to EMAIL_FROM_ADDRESS.
|
||||
//
|
||||
// WHY this mechanism, not SMTP or a human login: Google's own guidance for a backend
|
||||
// application sending mail as a specific Workspace address is a service account + domain-wide
|
||||
// delegation + the Gmail API — no human password is ever involved, nothing is stored that
|
||||
// could log in to the Workspace Admin Console or read the mailbox's other mail (the delegated
|
||||
// scope here is exactly `gmail.send`, nothing broader), and it never touches or weakens the
|
||||
// impersonated mailbox's own login credentials. Plain SMTP with a username/password (or an app
|
||||
// password) was explicitly ruled out — see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md §2 for the
|
||||
// full comparison and Google Admin Console setup steps this requires (REQUIRES GOOGLE
|
||||
// WORKSPACE ADMIN ACTION — cannot be completed from this repository).
|
||||
//
|
||||
// No googleapis/google-auth-library dependency — this is a small, auditable amount of code
|
||||
// (RS256 JWT assertion -> OAuth2 token exchange -> one REST call), consistent with this
|
||||
// codebase's existing "hand-roll before adding a dependency" convention (CSRF tokens,
|
||||
// Prometheus exposition, and CSV parsing all do the same — see their own header comments).
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function configError(message) {
|
||||
return Object.assign(new Error(message), { errorClass: 'permanent', errorCode: 'gws_not_configured' });
|
||||
}
|
||||
|
||||
// GOOGLE_SERVICE_ACCOUNT_JSON holds the standard JSON key Google issues for a service account —
|
||||
// an environment/secret-manager value, loaded fresh on every send (no key material cached to
|
||||
// disk by this code). Never a file committed to git, never a human password.
|
||||
function loadServiceAccount() {
|
||||
const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
||||
if (!raw) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not set');
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON'); }
|
||||
if (!parsed.client_email || !parsed.private_key) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is missing client_email/private_key');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// A short-lived (1 hour) OAuth2 access token via the JWT-bearer grant — minted fresh per send
|
||||
// rather than cached, since this codebase's send volume doesn't justify the added state a
|
||||
// cache would need to get right (token refresh races, expiry skew). See RFC 7523 / Google's
|
||||
// "Using OAuth 2.0 for Server to Server Applications" documentation for the exact flow this
|
||||
// implements.
|
||||
async function getAccessToken() {
|
||||
const sa = loadServiceAccount();
|
||||
const impersonate = process.env.GOOGLE_DELEGATED_USER || process.env.EMAIL_FROM_ADDRESS;
|
||||
if (!impersonate) throw configError('Set GOOGLE_DELEGATED_USER (or EMAIL_FROM_ADDRESS if the sending address is itself a primary account) — required as the delegated-user "sub" claim');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
iss: sa.client_email, sub: impersonate, scope: 'https://www.googleapis.com/auth/gmail.send',
|
||||
aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600,
|
||||
})).toString('base64url');
|
||||
const signature = crypto.createSign('RSA-SHA256').update(`${header}.${payload}`).sign(sa.private_key).toString('base64url');
|
||||
const assertion = `${header}.${payload}.${signature}`;
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`);
|
||||
// 'unauthorized_client' is retried, not treated as permanent — real-world observation
|
||||
// during Phase 5C setup: freshly-authorized domain-wide delegation can take a few minutes
|
||||
// to propagate across Google's auth infrastructure, producing this EXACT error
|
||||
// intermittently (the identical request succeeded, then failed, then failed again, over a
|
||||
// span of about a minute) even though the delegation is correctly configured. A config
|
||||
// that's genuinely wrong still ends up 'failed' once MAX_ATTEMPTS is exhausted — this only
|
||||
// buys the propagation-delay case a real chance to self-resolve instead of giving up on
|
||||
// the very first attempt.
|
||||
e.errorClass = (res.status >= 500 || body.error === 'unauthorized_client') ? 'temporary' : 'permanent';
|
||||
e.errorCode = 'gws_auth_failed';
|
||||
throw e;
|
||||
}
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
// A minimal, correct-enough RFC 2822 multipart/alternative message (text + HTML) — no MIME
|
||||
// library dependency for the same "small and auditable" reason as the rest of this file.
|
||||
function buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html }) {
|
||||
const boundary = `rootnode-${crypto.randomBytes(12).toString('hex')}`;
|
||||
const headers = [
|
||||
`From: ${fromName} <${fromAddress}>`,
|
||||
`To: ${to}`,
|
||||
replyTo ? `Reply-To: ${replyTo}` : null,
|
||||
`Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
].filter(Boolean).join('\r\n');
|
||||
const body = [
|
||||
`--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', text, '',
|
||||
`--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', html, '',
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
return `${headers}\r\n\r\n${body}`;
|
||||
}
|
||||
|
||||
async function send({ to, fromName, fromAddress, replyTo, subject, text, html }) {
|
||||
if (!to) {
|
||||
const e = new Error('no recipient address');
|
||||
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
|
||||
throw e;
|
||||
}
|
||||
const accessToken = await getAccessToken();
|
||||
const raw = Buffer.from(buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html })).toString('base64url');
|
||||
|
||||
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ raw }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const e = new Error(`Gmail API send failed: ${body.error?.message || res.status}`);
|
||||
e.errorClass = res.status >= 500 || res.status === 429 ? 'temporary' : 'permanent';
|
||||
e.errorCode = res.status === 429 ? 'gws_rate_limited' : `gws_http_${res.status}`;
|
||||
throw e;
|
||||
}
|
||||
return { ok: true, providerMessageId: body.id };
|
||||
}
|
||||
|
||||
module.exports = { send };
|
||||
@@ -0,0 +1,20 @@
|
||||
// Phase 5C §1 — the provider-neutral seam. Business logic (notifications.js, the worker) never
|
||||
// imports a concrete provider directly; it asks getEmailProvider() for whichever one
|
||||
// EMAIL_PROVIDER selects. Swapping Google Workspace for a different provider later never
|
||||
// touches enrollment/auth/notification code — only this file's require() target changes.
|
||||
//
|
||||
// Fail-safe default: EMAIL_PROVIDER unset -> 'dev', REGARDLESS of any other environment
|
||||
// setting. Misconfiguring this in a real deployment means email silently doesn't send for
|
||||
// real, never that a dev/test run accidentally reaches a real student's inbox (§20's explicit
|
||||
// safety requirement) — the failure direction is deliberately the safe one.
|
||||
const EMAIL_PROVIDER = (process.env.EMAIL_PROVIDER || 'dev').toLowerCase();
|
||||
|
||||
function getEmailProvider() {
|
||||
if (EMAIL_PROVIDER === 'gws') return require('./gws-provider');
|
||||
if (EMAIL_PROVIDER !== 'dev') {
|
||||
console.error(`[email] Unknown EMAIL_PROVIDER "${EMAIL_PROVIDER}" — falling back to the dev provider (no real mail will send).`);
|
||||
}
|
||||
return require('./dev-provider');
|
||||
}
|
||||
|
||||
module.exports = { getEmailProvider, EMAIL_PROVIDER };
|
||||
@@ -0,0 +1,134 @@
|
||||
// Phase 5C §9 — RootNode-branded transactional email templates. Every template returns
|
||||
// { subject, text, html }: plain-text is never a second-class fallback, both are written
|
||||
// deliberately. No external images, no tracking pixels, no JavaScript, no marketing content —
|
||||
// this module is transactional/operational only (§5's explicit boundary).
|
||||
//
|
||||
// escapeHtml matters here more than in most of this codebase's server-rendered HTML: displayName
|
||||
// can come straight from a website integration event's `student.display_name` (§21 "no
|
||||
// arbitrary email template injection" — this is genuinely external, caller-controlled text, not
|
||||
// admin-typed), and programName from an admin-created program name. Every value interpolated
|
||||
// into an HTML template goes through this first; the plain-text bodies deliberately do NOT
|
||||
// escape (there's no markup to break there — escaping would just show a student literal "&").
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
const BRAND = 'RootNode Academy';
|
||||
const GREEN = '#2ee6a6';
|
||||
const BG = '#0b0f0e';
|
||||
const TEXT = '#e8f5ef';
|
||||
const MUTED = '#8fa89c';
|
||||
|
||||
function wrap(bodyHtml) {
|
||||
return `<!doctype html><html><body style="margin:0;padding:0;background:${BG};font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${BG};padding:2rem 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#121815;border:1px solid #1e2b26;border-radius:12px;padding:2rem;">
|
||||
<tr><td style="font-family:monospace;color:${GREEN};font-size:1.1rem;font-weight:600;padding-bottom:1.25rem;">⎈ ${BRAND}</td></tr>
|
||||
<tr><td style="color:${TEXT};font-size:0.95rem;line-height:1.6;">${bodyHtml}</td></tr>
|
||||
<tr><td style="color:${MUTED};font-size:0.75rem;padding-top:2rem;border-top:1px solid #1e2b26;margin-top:1.5rem;">
|
||||
This is an automated message from ${BRAND}. If you weren't expecting it, you can safely ignore it — no action is required.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// href is always a URL this module itself built from a token/origin, never caller-supplied text
|
||||
// — still escaped for attribute-safety hygiene, not because it's expected to contain markup.
|
||||
function button(url, label) {
|
||||
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${GREEN};color:#05130a;font-weight:600;padding:0.65rem 1.25rem;border-radius:6px;text-decoration:none;margin:1rem 0;">${label}</a>`;
|
||||
}
|
||||
|
||||
// A. Account activation / welcome — §9A. Fires for a brand-new credentialed account OR an
|
||||
// existing pilot/unconverted student gaining their first real credential, always via the same
|
||||
// existing Phase 5A activation-token mechanism.
|
||||
function activation({ displayName, programName, activationUrl, expiresInDays }) {
|
||||
const subject = `Welcome to ${BRAND} — Activate Your Account`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const safeProgram = escapeHtml(programName);
|
||||
const programLine = programName ? `You've been enrolled in <strong>${safeProgram}</strong>. ` : '';
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
You've been given access to ${BRAND}. ${programName ? `You've been enrolled in ${programName}. ` : ''}To get started, set your password using the link below:
|
||||
|
||||
${activationUrl}
|
||||
|
||||
This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.
|
||||
|
||||
If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>You've been given access to ${BRAND}. ${programLine}To get started, set your password:</p>
|
||||
<p>${button(activationUrl, 'Activate Your Account')}</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// B. Existing student, new program enrollment — §9B. No token, no link needed beyond "go sign in."
|
||||
function enrollmentConfirmation({ displayName, programName, signInUrl }) {
|
||||
const subject = `You're Enrolled — ${programName}`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const safeProgram = escapeHtml(programName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
You've been enrolled in ${programName} at ${BRAND}. Sign in to get started:
|
||||
|
||||
${signInUrl}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>You've been enrolled in <strong>${safeProgram}</strong> at ${BRAND}.</p>
|
||||
<p>${button(signInUrl, 'Sign In and Start Learning')}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// C. Password reset — §9C. Same anti-enumeration principle as the API itself: this template
|
||||
// only ever renders for an account that actually exists and requested it — the enumeration
|
||||
// protection lives in whether this notification gets queued at all (student-auth-routes.js),
|
||||
// not in the template.
|
||||
function passwordReset({ displayName, resetUrl, expiresInMinutes }) {
|
||||
const subject = `Reset Your ${BRAND} Password`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
We received a request to reset your ${BRAND} password. Use the link below to set a new one:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>We received a request to reset your ${BRAND} password.</p>
|
||||
<p>${button(resetUrl, 'Reset Your Password')}</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// D. Password changed / security notice — §9D. No link at all, purely informational.
|
||||
function passwordChanged({ displayName }) {
|
||||
const subject = `Your ${BRAND} Password Was Changed`;
|
||||
const safeName = escapeHtml(displayName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your ${BRAND} password was just changed. If this was you, no action is needed.
|
||||
|
||||
If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`
|
||||
<p>Hi ${safeName},</p>
|
||||
<p>Your ${BRAND} password was just changed. If this was you, no action is needed.</p>
|
||||
<p style="color:${MUTED};font-size:0.85rem;">If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
|
||||
@@ -0,0 +1,244 @@
|
||||
// Phase 5B §3/§10/§14 — enrollment lifecycle. A student's academic access to a program,
|
||||
// separate from cohort membership (cohort_enrollments, untouched). All status transitions are
|
||||
// server-side only — nothing here accepts a client-supplied status.
|
||||
//
|
||||
// Errors are thrown as `Error` with a `.code` the route layer maps to an HTTP status, the same
|
||||
// convention classroom.js established in Phase 5A (NOT_FOUND/INVALID_STATE/ALREADY_LIVE).
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message || code), { code });
|
||||
}
|
||||
|
||||
// Pure and derived-at-read-time, matching classroom.js's derivePresence — 'expired' is never
|
||||
// written to the database by a background sweep; it's computed here from access_ends_at
|
||||
// whenever an enrollment is read, so there's no stale/out-of-sync value to worry about. The
|
||||
// one status this function can "see" that the stored column doesn't is exactly that: whether a
|
||||
// still-`active` row has actually run out its access window.
|
||||
function deriveEnrollmentAccess(enrollment, now = new Date()) {
|
||||
const { status, starts_at: startsAt, access_ends_at: accessEndsAt } = enrollment;
|
||||
if (status === 'cancelled') return { effective_status: 'cancelled', can_launch: false, reason: 'cancelled' };
|
||||
if (status === 'completed') return { effective_status: 'completed', can_launch: false, reason: 'completed' };
|
||||
if (status === 'pending') return { effective_status: 'pending', can_launch: false, reason: 'pending_activation' };
|
||||
// status === 'active' below.
|
||||
if (accessEndsAt && new Date(accessEndsAt) <= now) {
|
||||
return { effective_status: 'expired', can_launch: false, reason: 'access_expired' };
|
||||
}
|
||||
if (startsAt && new Date(startsAt) > now) {
|
||||
return { effective_status: 'active', can_launch: false, reason: 'not_yet_started' };
|
||||
}
|
||||
return { effective_status: 'active', can_launch: true, reason: null };
|
||||
}
|
||||
|
||||
async function getEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listEnrollmentsForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, p.name AS program_name, p.family AS program_family
|
||||
FROM enrollments e JOIN programs p ON p.program_id = e.program_id
|
||||
WHERE e.student_id = $1 ORDER BY e.enrolled_at DESC`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listEnrollmentsForProgram(pool, programId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, s.display_name AS student_display_name FROM enrollments e
|
||||
JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`,
|
||||
[programId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Admin manual enrollment (§11) — the program is snapshotted into the new row exactly the way
|
||||
// createFromIntegrationEvent below does, so both creation paths produce identically-shaped
|
||||
// enrollments. Throws PROGRAM_NOT_FOUND / PROGRAM_INACTIVE / ALREADY_ENROLLED (the DB's
|
||||
// one-open-enrollment-per-program constraint, translated to a clean error instead of a raw
|
||||
// 23505).
|
||||
async function createManualEnrollment(pool, { studentId, programId, status, startsAt, accessEndsAt, deliveryMode, externalReference, metadata, createdBy }) {
|
||||
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
|
||||
const program = programRows[0];
|
||||
if (!program) throw err('PROGRAM_NOT_FOUND', 'No such program.');
|
||||
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
|
||||
const initialStatus = status === 'active' ? 'active' : 'pending';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_reference, starts_at, access_ends_at, activated_at, metadata, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'admin', $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||
[studentId, programId, deliveryMode || program.delivery_mode, program.version, program.curriculum_path, initialStatus,
|
||||
externalReference || null, startsAt || null, accessEndsAt || null, initialStatus === 'active' ? new Date() : null,
|
||||
metadata ? JSON.stringify(metadata) : null, createdBy || null]
|
||||
);
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') throw err('ALREADY_ENROLLED', 'This student already has an open enrollment in this program.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// §12/§14 — the website/integration boundary's enrollment creation. Idempotent by construction:
|
||||
// the DB-level unique index on external_event_id (not a SELECT-then-INSERT check) is what
|
||||
// actually makes a concurrent duplicate submission safe, not just a sequential retry. Student
|
||||
// resolution is create-or-match (§13): an existing student (matched by email OR
|
||||
// login_identifier) is reused; no match creates a new one via the exact same credential/token
|
||||
// machinery admin-created students already use (students-auth.js) — this endpoint is a
|
||||
// trusted, authenticated, server-to-server caller, the same trust level an instructor's
|
||||
// "add student" admin action already has, not a public signup form.
|
||||
async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, contactEmail, programId, status }) {
|
||||
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent enrollment events.');
|
||||
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
|
||||
const program = programRows[0];
|
||||
if (!program) throw err('PROGRAM_NOT_FOUND', 'Unknown program_slug.');
|
||||
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
|
||||
|
||||
// Replay of an already-processed event — return the original result, create nothing.
|
||||
if (eventId) {
|
||||
const { rows: existing } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
|
||||
if (existing[0]) return { enrollment: existing[0], created: false };
|
||||
}
|
||||
|
||||
const student = await resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName });
|
||||
const initialStatus = status === 'active' ? 'active' : 'pending';
|
||||
|
||||
// §10 — contactEmail (an integration-event-supplied "who to notify about THIS enrollment,"
|
||||
// e.g. a guardian's address for a minor with no email of their own) is stored on the
|
||||
// enrollment's existing metadata JSONB, never written to students.email — see
|
||||
// enrollmentContactEmail() below and notifications.js's resolveRecipient, the only two places
|
||||
// this value is ever read back out. Not a guardian account, not a linked identity — just a
|
||||
// delivery address for this one enrollment's notifications.
|
||||
const metadata = contactEmail ? { contact_email: String(contactEmail).trim() } : null;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (external_event_id) WHERE external_event_id IS NOT NULL DO NOTHING
|
||||
RETURNING *`,
|
||||
[student.student_id, programId, program.delivery_mode, program.version, program.curriculum_path, initialStatus,
|
||||
source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
if (rows[0]) return { enrollment: rows[0], created: true, student };
|
||||
// Lost the ON CONFLICT race (concurrent identical replay) — the winner's row is now there.
|
||||
const { rows: won } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
|
||||
return { enrollment: won[0], created: false, student };
|
||||
} catch (e) {
|
||||
// The one-open-enrollment-per-program index fired instead — a genuinely new event for a
|
||||
// student who already has an open enrollment in this program (e.g. a retried purchase with
|
||||
// a different event id). Idempotent in spirit even though it's not a literal event replay:
|
||||
// return the existing open enrollment rather than erroring or creating a second one.
|
||||
if (e.code === '23505') {
|
||||
const { rows: existingOpen } = await pool.query(
|
||||
`SELECT * FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status IN ('pending','active')`,
|
||||
[student.student_id, programId]
|
||||
);
|
||||
if (existingOpen[0]) return { enrollment: existingOpen[0], created: false, student };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Matches by email OR login_identifier; creates via students-auth's own credential-issuing path
|
||||
// on no match (an activation token is generated exactly like an admin-created student, and
|
||||
// handed back so a future email/notification step — not built in this phase — could relay it).
|
||||
// A second concurrent call resolving the "same new student" case falls back to re-matching
|
||||
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) {
|
||||
const identifier = studentEmail || studentLoginIdentifier;
|
||||
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
|
||||
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
if (existing) return existing;
|
||||
|
||||
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
|
||||
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
|
||||
const displayName = studentDisplayName || studentEmail || studentLoginIdentifier;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
|
||||
);
|
||||
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') {
|
||||
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
if (reMatched) return reMatched;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'active', activated_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status = 'pending' RETURNING *`,
|
||||
[enrollmentId]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
const existing = await getEnrollment(pool, enrollmentId);
|
||||
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending enrollment can be activated.' : 'No such enrollment.');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function cancelEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'cancelled', cancelled_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status IN ('pending', 'active') RETURNING *`,
|
||||
[enrollmentId]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
const existing = await getEnrollment(pool, enrollmentId);
|
||||
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending or active enrollment can be cancelled.' : 'No such enrollment.');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// §10 — completion is entirely server-derived: called opportunistically (right after a
|
||||
// capstone/exam finalizes, and again as a safety-net on every program-status read) rather than
|
||||
// via a client "mark complete" action. Idempotent — the `WHERE status = 'active'` guard means
|
||||
// calling this again after an enrollment is already completed is a no-op, not an error.
|
||||
async function maybeCompleteEnrollment(pool, studentId, programId = null) {
|
||||
const { rows: candidates } = await pool.query(
|
||||
`SELECT e.enrollment_id, e.program_id, p.completion_assessment_id FROM enrollments e
|
||||
JOIN programs p ON p.program_id = e.program_id
|
||||
WHERE e.student_id = $1 AND e.status = 'active' AND p.completion_assessment_id IS NOT NULL
|
||||
${programId ? 'AND e.program_id = $2' : ''}`,
|
||||
programId ? [studentId, programId] : [studentId]
|
||||
);
|
||||
const completed = [];
|
||||
for (const candidate of candidates) {
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM achievements
|
||||
WHERE student_id = $1 AND achievement_type = 'capstone_completed' AND metadata->>'assessment_definition_id' = $2
|
||||
LIMIT 1`,
|
||||
[studentId, candidate.completion_assessment_id]
|
||||
);
|
||||
if (!rowCount) continue;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'completed', completed_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status = 'active' RETURNING *`,
|
||||
[candidate.enrollment_id]
|
||||
);
|
||||
if (rows[0]) completed.push(rows[0]);
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
// Phase 5C §10 — the one place `enrollments.metadata.contact_email` is read back out (its only
|
||||
// writer is createFromIntegrationEvent/createManualEnrollment above). Kept as a named accessor
|
||||
// rather than inline `metadata->>'contact_email'` scattered across notifications.js so this
|
||||
// transitional, non-identity field has exactly one documented meaning and one call site per
|
||||
// direction (write here, read here).
|
||||
function enrollmentContactEmail(enrollment) {
|
||||
return enrollment?.metadata?.contact_email || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
|
||||
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
|
||||
maybeCompleteEnrollment, enrollmentContactEmail,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// Shared "launch/view a student's disposable Lab" logic. Phase 5B extracted this out of
|
||||
// server.js's original POST /api/environment/launch route body so the new entitlement-gated
|
||||
// self-paced launch route (selfpaced-routes.js) can reuse the EXACT same Provisioner call and
|
||||
// response shape instead of a second copy — the underlying disposable-Lab mechanism (Phase
|
||||
// 2A-4.1) is completely unchanged either way, only who's allowed to reach it differs.
|
||||
|
||||
async function publicEnvView(provisioner, env) {
|
||||
if (!env) return { status: 'none' };
|
||||
const view = { status: env.status };
|
||||
if (env.status === 'running') {
|
||||
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
|
||||
if (!token) {
|
||||
const { status, body } = await provisioner.getToken(env.environment_id);
|
||||
if (status === 200) token = body.routing_token;
|
||||
}
|
||||
if (token) view.launch_path = `/lab/${token}/`;
|
||||
}
|
||||
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
|
||||
return view;
|
||||
}
|
||||
|
||||
async function launchEnvironmentForStudent(provisioner, studentId) {
|
||||
const existing = await provisioner.listForStudent(studentId);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { status: 200, body: await publicEnvView(provisioner, active) };
|
||||
|
||||
const { status, body } = await provisioner.provision(studentId);
|
||||
if (status === 409) {
|
||||
const retry = await provisioner.listForStudent(studentId);
|
||||
return { status: 200, body: await publicEnvView(provisioner, retry.body?.[0]) };
|
||||
}
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return { status: 503, body: { error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' } };
|
||||
}
|
||||
if (status !== 201) return { status: 502, body: { error: 'provision_failed', message: body?.error } };
|
||||
return { status: 200, body: await publicEnvView(provisioner, { environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }) };
|
||||
}
|
||||
|
||||
module.exports = { publicEnvView, launchEnvironmentForStudent };
|
||||
@@ -0,0 +1,117 @@
|
||||
// Phase 5B §12 — the website/commerce integration boundary. POST /api/integrations/enrollments
|
||||
// is the ONLY thing an external system (the public website, a future payment provider) ever
|
||||
// calls. It is completely separate from both student and instructor session auth — no
|
||||
// rn_session/rn_instructor cookie is ever read here, and this route accepts none — so a
|
||||
// browser cannot reach it just by having a logged-in student or admin tab open. See
|
||||
// docs/WEBSITE_ENROLLMENT_INTEGRATION.md for the full contract the website team should build
|
||||
// against.
|
||||
//
|
||||
// Auth follows the exact same pattern as platform/provisioner/src/server.js's
|
||||
// X-Provisioner-Key (a pre-shared secret in an env var, checked via a custom header, fatal at
|
||||
// startup if unset) — the same internal server-to-server trust boundary this codebase already
|
||||
// established for Gateway<->Provisioner, not a new scheme. The header value must be held by
|
||||
// the website's OWN backend only; it is never sent to or readable by browser JS.
|
||||
|
||||
const enrollment = require('./enrollment');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const notifications = require('./notifications');
|
||||
const programsLib = require('./programs');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
||||
|
||||
if (!INTEGRATION_SHARED_SECRET) {
|
||||
console.error('WARNING: INTEGRATION_SHARED_SECRET is not set — POST /api/integrations/enrollments will refuse every request until it is. This is fine for a dev stack that never exercises the integration boundary, fatal for anything that needs to.');
|
||||
}
|
||||
|
||||
// Small, deliberately provider-neutral vocabulary — NOT Stripe's (or anyone else's) object
|
||||
// model. The website's backend translates whatever its payment provider says into one of these
|
||||
// two values before calling this endpoint.
|
||||
const STATUS_MAP = { paid: 'active', active: 'active', confirmed: 'active', pending: 'pending', awaiting_payment: 'pending' };
|
||||
|
||||
function enrollmentErrorStatus(code) {
|
||||
if (code === 'PROGRAM_NOT_FOUND') return 404;
|
||||
if (code === 'PROGRAM_INACTIVE') return 409;
|
||||
if (code === 'EVENT_ID_REQUIRED' || code === 'STUDENT_IDENTIFIER_REQUIRED') return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
const { getPool, recordAudit, wrap } = deps;
|
||||
|
||||
app.post('/api/integrations/enrollments', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
const { event_id, source, student, program_slug, status } = req.body || {};
|
||||
const email = student?.email ? String(student.email).trim() : null;
|
||||
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
|
||||
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
|
||||
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
|
||||
// identity — see enrollment.js's enrollmentContactEmail.
|
||||
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
|
||||
const mappedStatus = STATUS_MAP[status];
|
||||
|
||||
if (!event_id || typeof event_id !== 'string') {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
return res.status(400).json({ error: 'event_id is required' });
|
||||
}
|
||||
if (!program_slug) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
return res.status(400).json({ error: 'program_slug is required' });
|
||||
}
|
||||
if (!mappedStatus) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
return res.status(400).json({ error: 'status must be one of: ' + Object.keys(STATUS_MAP).join(', ') });
|
||||
}
|
||||
if (!email && !loginIdentifier) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
return res.status(400).json({ error: 'student.email or student.login_identifier is required' });
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
|
||||
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
|
||||
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
|
||||
});
|
||||
} catch (e) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
|
||||
metrics.recordEnrollmentEvent(result.created ? 'created' : 'duplicate');
|
||||
// Audited regardless of created/duplicate — a replay is still a real, worth-recording
|
||||
// event, just not a new enrollment. No payment/card data ever reaches this far (the
|
||||
// payload never carries it — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md "what NOT to send").
|
||||
await recordAudit(getPool(), {
|
||||
actorType: 'integration', actorId: source || 'integration', action: result.created ? 'enrollment_integration_created' : 'enrollment_integration_duplicate',
|
||||
resourceType: 'enrollment', resourceId: result.enrollment.enrollment_id, result: 'success', sourceIp: req.ip,
|
||||
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
|
||||
});
|
||||
|
||||
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
|
||||
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
|
||||
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
|
||||
// guarantees at most one caller ever sees created:true for a given event/race, so at most
|
||||
// one notification is ever queued, with no separate notification-level dedup needed.
|
||||
if (result.created) {
|
||||
const program = await programsLib.getProgram(getPool(), program_slug);
|
||||
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
|
||||
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
enrollment_id: result.enrollment.enrollment_id,
|
||||
student_id: result.enrollment.student_id,
|
||||
program_id: result.enrollment.program_id,
|
||||
status: result.enrollment.status,
|
||||
created: result.created,
|
||||
});
|
||||
}));
|
||||
};
|
||||
@@ -17,6 +17,30 @@ let studentLoginFailureTotal = 0;
|
||||
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
|
||||
function recordStudentLoginFailure() { studentLoginFailureTotal++; }
|
||||
|
||||
// Phase 5B §16 — the integration endpoint's result counter. `result` is a fixed 3-value enum
|
||||
// ('created' | 'duplicate' | 'rejected'), never anything from the request body, so it can never
|
||||
// become high-cardinality no matter what a caller sends.
|
||||
const enrollmentEventTotals = { created: 0, duplicate: 0, rejected: 0 };
|
||||
function recordEnrollmentEvent(result) {
|
||||
if (result in enrollmentEventTotals) enrollmentEventTotals[result]++;
|
||||
}
|
||||
|
||||
// Phase 5C §18 — per-attempt delivery outcomes (a real counter, since "how many send attempts
|
||||
// happened" is an ephemeral event count, not something a DB GROUP BY can reconstruct after the
|
||||
// fact the way notifications_total below can). `provider` is 'dev'|'gws' (bounded), `result` is
|
||||
// 'sent'|'retry'|'failed' (bounded) — never a notification_id/student_id/provider message id.
|
||||
const notificationAttemptTotals = {};
|
||||
function recordNotificationAttempt(provider, result) {
|
||||
const key = `${provider}|${result}`;
|
||||
notificationAttemptTotals[key] = (notificationAttemptTotals[key] || 0) + 1;
|
||||
}
|
||||
|
||||
// student.activate outcomes — 'success' | 'invalid_or_expired_token'. Bounded, no student id.
|
||||
const studentActivationTotals = {};
|
||||
function recordStudentActivation(result) {
|
||||
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
|
||||
}
|
||||
|
||||
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
|
||||
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
|
||||
// keep in sync with reality).
|
||||
@@ -39,6 +63,26 @@ async function collectGauges(pool, provisioner) {
|
||||
}
|
||||
|
||||
const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n]));
|
||||
|
||||
// Phase 5B — enrollments by status/delivery_mode (both fixed enums, safe labels) and
|
||||
// completions by program (program_id is an admin-curated slug, small and bounded by design —
|
||||
// the same trust level assessment_definition_id already has, unlike student_id/email/
|
||||
// external_reference, which must NEVER appear as a label — see the file header).
|
||||
const [{ rows: enrollmentBreakdown }, { rows: selfPacedActive }, { rows: completionsByProgram }] = await Promise.all([
|
||||
pool.query(`SELECT status, delivery_mode, count(*)::int AS n FROM enrollments GROUP BY status, delivery_mode`),
|
||||
pool.query(`SELECT count(*)::int AS n FROM enrollments WHERE status = 'active' AND delivery_mode = 'self_paced'`),
|
||||
pool.query(`SELECT program_id, count(*)::int AS n FROM enrollments WHERE status = 'completed' GROUP BY program_id`),
|
||||
]);
|
||||
|
||||
// Phase 5C — notification lifecycle, computed fresh from the DB (like enrollment_breakdown
|
||||
// above) rather than an in-process counter, so "how many notifications are in each
|
||||
// type/status right now" can never drift from what's actually stored.
|
||||
const [{ rows: notificationBreakdown }, { rows: queueStats }] = await Promise.all([
|
||||
pool.query(`SELECT notification_type, status, count(*)::int AS n FROM notification_events GROUP BY notification_type, status`),
|
||||
pool.query(`SELECT count(*)::int AS depth, COALESCE(extract(epoch FROM now() - min(created_at)), 0)::int AS oldest_seconds
|
||||
FROM notification_events WHERE status = 'queued'`),
|
||||
]);
|
||||
|
||||
return {
|
||||
classroom_sessions_live: liveClassrooms.length,
|
||||
classroom_participants_online: onlineParticipants,
|
||||
@@ -46,6 +90,12 @@ async function collectGauges(pool, provisioner) {
|
||||
labs_running: envCounts.running || 0,
|
||||
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
||||
labs_error: envCounts.error || 0,
|
||||
enrollment_breakdown: enrollmentBreakdown,
|
||||
selfpaced_active_enrollments: selfPacedActive[0].n,
|
||||
completions_by_program: completionsByProgram,
|
||||
notification_breakdown: notificationBreakdown,
|
||||
notification_queue_depth: queueStats[0].depth,
|
||||
notification_oldest_queued_seconds: queueStats[0].oldest_seconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +103,30 @@ function line(name, help, type, value) {
|
||||
return `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${name} ${value}\n`;
|
||||
}
|
||||
|
||||
function labeledGauge(name, help, rows, labelCols, valueCol) {
|
||||
const header = `# HELP ${name} ${help}\n# TYPE ${name} gauge\n`;
|
||||
if (!rows.length) return header;
|
||||
const body = rows.map((r) => {
|
||||
const labels = labelCols.map((c) => `${c}="${String(r[c]).replace(/"/g, '\\"')}"`).join(',');
|
||||
return `${name}{${labels}} ${r[valueCol]}`;
|
||||
}).join('\n');
|
||||
return header + body + '\n';
|
||||
}
|
||||
|
||||
// Same idea as labeledGauge, for the flat "label-key -> count" dicts the in-process counters
|
||||
// above use (e.g. notificationAttemptTotals's `${provider}|${result}` keys).
|
||||
function labeledCounter(name, help, totals, labelNames) {
|
||||
const header = `# HELP ${name} ${help}\n# TYPE ${name} counter\n`;
|
||||
const keys = Object.keys(totals);
|
||||
if (!keys.length) return header;
|
||||
const body = keys.map((key) => {
|
||||
const values = key.split('|');
|
||||
const labels = labelNames.map((n, i) => `${n}="${values[i].replace(/"/g, '\\"')}"`).join(',');
|
||||
return `${name}{${labels}} ${totals[key]}`;
|
||||
}).join('\n');
|
||||
return header + body + '\n';
|
||||
}
|
||||
|
||||
async function renderMetrics(pool, provisioner) {
|
||||
const gauges = await collectGauges(pool, provisioner);
|
||||
return [
|
||||
@@ -64,7 +138,22 @@ async function renderMetrics(pool, provisioner) {
|
||||
line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error),
|
||||
line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal),
|
||||
line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal),
|
||||
labeledGauge('rootnode_enrollments_total', 'Number of enrollments by lifecycle status and delivery mode.', gauges.enrollment_breakdown, ['status', 'delivery_mode'], 'n'),
|
||||
line('rootnode_selfpaced_active_enrollments', 'Number of currently active self-paced enrollments.', 'gauge', gauges.selfpaced_active_enrollments),
|
||||
labeledGauge('rootnode_program_completions_total', 'Number of completed enrollments by program.', gauges.completions_by_program, ['program_id'], 'n'),
|
||||
line('rootnode_enrollment_events_created_total', 'Integration enrollment events that created a new enrollment.', 'counter', enrollmentEventTotals.created),
|
||||
line('rootnode_enrollment_events_duplicate_total', 'Integration enrollment events that were idempotent replays of an already-processed event.', 'counter', enrollmentEventTotals.duplicate),
|
||||
line('rootnode_enrollment_events_rejected_total', 'Integration enrollment events rejected (bad auth, unknown program, invalid payload).', 'counter', enrollmentEventTotals.rejected),
|
||||
labeledGauge('rootnode_notifications_total', 'Number of notification rows by type and lifecycle status.', gauges.notification_breakdown, ['notification_type', 'status'], 'n'),
|
||||
line('rootnode_notification_queue_depth', 'Number of notifications currently queued for delivery.', 'gauge', gauges.notification_queue_depth),
|
||||
line('rootnode_notification_oldest_queued_seconds', 'Age in seconds of the oldest still-queued notification (0 if none queued).', 'gauge', gauges.notification_oldest_queued_seconds),
|
||||
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']),
|
||||
line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0),
|
||||
line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, renderMetrics };
|
||||
module.exports = {
|
||||
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
|
||||
recordNotificationAttempt, recordStudentActivation, renderMetrics,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Phase 5C §15/§16 — instructor/admin communication operations. Global notification visibility
|
||||
// is owner/admin only (§16: "Do not grant global notification visibility to every instructor");
|
||||
// a plain instructor can still see/act on notifications for a SPECIFIC student they're
|
||||
// authorized to manage via rbac.js's requireStudentRelationshipAccess (cohort-shared or an
|
||||
// enrollment they created — the same rule admin-programs-routes.js already established for
|
||||
// enrollment visibility). Every response here goes through notifications.js's toAdminView,
|
||||
// which masks the recipient address and omits `metadata` entirely — no token, no full email,
|
||||
// ever, through any route in this file.
|
||||
|
||||
const notificationsLib = require('./notifications');
|
||||
const enrollmentLib = require('./enrollment');
|
||||
const programsLib = require('./programs');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const { parsePagination, paginatedResponse } = require('./pagination');
|
||||
|
||||
module.exports = function registerNotificationRoutes(app, deps) {
|
||||
const { instructorSession, rbac, getPool, recordAudit } = deps;
|
||||
const { requireAdminOrOwner, requireStudentRelationshipAccess } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// Global operational view — owner/admin only.
|
||||
admin.get('/api/admin/notifications', requireAdminOrOwner, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { page, pageSize, offset } = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM notification_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM notification_events`),
|
||||
]);
|
||||
res.json(paginatedResponse(rows.map(notificationsLib.toAdminView), countRows[0].n, { page, pageSize }));
|
||||
});
|
||||
|
||||
// Per-student view — the scoped surface a plain instructor can actually reach.
|
||||
admin.get('/api/admin/students/:id/notifications', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const rows = await notificationsLib.listForStudent(getPool(), req.params.id);
|
||||
res.json(rows.map(notificationsLib.toAdminView));
|
||||
});
|
||||
|
||||
// §20 — dev/test ONLY: lets a test script (or a developer) see exactly what the dev provider
|
||||
// would have sent, including the activation/reset URL, without a real inbox. Unconditionally
|
||||
// refuses in production regardless of what's stored (mirrors student-auth-routes.js's
|
||||
// dev_reset_token gate on the same COOKIE_SECURE-derived signal) — this is never how an
|
||||
// admin inspects a real delivery, only how a dev/CI run verifies one.
|
||||
admin.get('/api/admin/notifications/:id/dev-preview', requireAdminOrOwner, async (req, res) => {
|
||||
if (process.env.COOKIE_SECURE === '1') return res.status(404).json({ error: 'not_found' });
|
||||
const preview = await notificationsLib.getDevPreview(getPool(), req.params.id);
|
||||
if (!preview) return res.status(404).json({ error: 'no_preview_available' });
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
// §15 — one action covers both "resend" and "regenerate": Phase 5A's issueCredentialToken
|
||||
// already unconditionally invalidates any previous outstanding token on mint, so there is
|
||||
// never a moment with two simultaneously valid activation links — a second button that did
|
||||
// something different would have nothing different to do. See
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6 for the full reasoning.
|
||||
admin.post('/api/admin/students/:id/resend-activation', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (rows[0].password_hash) return res.status(409).json({ error: 'already_activated', message: 'This student already has real credentials — use Send Password Reset instead.' });
|
||||
|
||||
const { rows: enrollmentRows } = await pool.query(
|
||||
`SELECT enrollment_id, program_id FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
|
||||
);
|
||||
const enrollmentId = enrollmentRows[0]?.enrollment_id || null;
|
||||
const program = enrollmentRows[0] ? await programsLib.getProgram(pool, enrollmentRows[0].program_id) : null;
|
||||
|
||||
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
|
||||
studentId: req.params.id, enrollmentId, programName: program?.name, isResend: true,
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'activation_resent', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(notificationsLib.toAdminView(notification));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/send-enrollment-confirmation', requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { enrollment_id } = req.body || {};
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_activated', message: 'This student has no credentials yet — use Resend Activation instead.' });
|
||||
|
||||
let targetEnrollmentId = enrollment_id;
|
||||
if (!targetEnrollmentId) {
|
||||
const { rows: latest } = await pool.query(
|
||||
`SELECT enrollment_id FROM enrollments WHERE student_id = $1 AND status = 'active' ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
|
||||
);
|
||||
targetEnrollmentId = latest[0]?.enrollment_id;
|
||||
}
|
||||
if (!targetEnrollmentId) return res.status(400).json({ error: 'no_active_enrollment', message: 'This student has no active enrollment to confirm.' });
|
||||
const enrollmentRow = await enrollmentLib.getEnrollment(pool, targetEnrollmentId);
|
||||
const program = await programsLib.getProgram(pool, enrollmentRow.program_id);
|
||||
|
||||
const notification = await notificationsLib.enqueue(pool, {
|
||||
studentId: req.params.id, enrollmentId: targetEnrollmentId, notificationType: 'enrollment_confirmation',
|
||||
metadata: { display_name: rows[0].display_name, program_name: program?.name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_confirmation_sent', resourceType: 'enrollment', resourceId: targetEnrollmentId, result: 'success', sourceIp: req.ip });
|
||||
res.json(notificationsLib.toAdminView(notification));
|
||||
});
|
||||
|
||||
// §8/§15's "admin may need a safe SEND PASSWORD RESET operation" is the EXISTING
|
||||
// POST /api/admin/students/:id/reset-password (admin-routes.js, Phase 5A) — Phase 5C extended
|
||||
// it to also enqueue a real email (see that route) rather than duplicating a second endpoint
|
||||
// here. It still returns `reset_token` in its response, unchanged from Phase 5A: for a
|
||||
// student with no deliverable address at all (no email, no enrollment contact), manually
|
||||
// relaying that token is the ONLY way they can ever get in, so removing it would leave that
|
||||
// population with no recovery path. §21's "admin cannot view token" is enforced for
|
||||
// resend-activation/send-enrollment-confirmation above, which have no legitimate reason to
|
||||
// ever expose one — reset-password's token-in-response is a deliberate, documented,
|
||||
// unchanged exception for exactly the case automatic delivery can't cover. See
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6.
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Phase 5C §14 — a simple durable worker, same pattern this codebase already uses for
|
||||
// sessions.js's session sweeper and security.js's login-attempt sweeper: a plain setInterval
|
||||
// poll against Postgres, no Kafka/RabbitMQ/Redis. notifications.js's claimNext() does the
|
||||
// actual concurrency-safety work (FOR UPDATE SKIP LOCKED) — this file just calls it on a timer
|
||||
// and hands the claimed row to the configured email provider.
|
||||
|
||||
const notifications = require('./notifications');
|
||||
const templates = require('./email/templates');
|
||||
const { getEmailProvider } = require('./email/provider');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const POLL_MS = Number(process.env.NOTIFICATION_WORKER_POLL_MS) || 3000;
|
||||
|
||||
function buildAppUrl(path) {
|
||||
const origin = process.env.PUBLIC_APP_ORIGIN;
|
||||
if (!origin) return null;
|
||||
try {
|
||||
const u = new URL(origin);
|
||||
return `${u.origin}${path}`;
|
||||
} catch {
|
||||
return null; // malformed origin — treated as unconfigured, not a crash
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the right template for a queued row. Returns null (a permanent, non-retryable
|
||||
// failure) if required config (PUBLIC_APP_ORIGIN for a token-bearing type) is missing — §7's
|
||||
// "production should fail safely if required public-origin configuration is missing."
|
||||
function renderFor(row) {
|
||||
const displayName = row.metadata?.display_name || 'there';
|
||||
if (row.notification_type === 'activation') {
|
||||
const url = buildAppUrl(`/?activate=${encodeURIComponent(row.metadata?.token || '')}`);
|
||||
if (!url || !row.metadata?.token) return null;
|
||||
return templates.activation({ displayName, programName: row.metadata?.program_name, activationUrl: url, expiresInDays: 7 });
|
||||
}
|
||||
if (row.notification_type === 'password_reset') {
|
||||
const url = buildAppUrl(`/?reset=${encodeURIComponent(row.metadata?.token || '')}`);
|
||||
if (!url || !row.metadata?.token) return null;
|
||||
return templates.passwordReset({ displayName, resetUrl: url, expiresInMinutes: 60 });
|
||||
}
|
||||
if (row.notification_type === 'enrollment_confirmation') {
|
||||
const url = buildAppUrl('/') || '/';
|
||||
return templates.enrollmentConfirmation({ displayName, programName: row.metadata?.program_name || 'your program', signInUrl: url });
|
||||
}
|
||||
if (row.notification_type === 'password_changed') {
|
||||
return templates.passwordChanged({ displayName });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processOne(pool) {
|
||||
const row = await notifications.claimNext(pool);
|
||||
if (!row) return false;
|
||||
|
||||
const { EMAIL_PROVIDER } = require('./email/provider');
|
||||
const rendered = renderFor(row);
|
||||
if (!rendered) {
|
||||
await notifications.markFailedOrRetry(pool, row, { errorCode: 'template_or_config_error', retryable: false });
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'failed');
|
||||
return true;
|
||||
}
|
||||
|
||||
const provider = getEmailProvider();
|
||||
try {
|
||||
const result = await provider.send({
|
||||
to: row.recipient_email,
|
||||
fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy',
|
||||
fromAddress: process.env.EMAIL_FROM_ADDRESS,
|
||||
replyTo: process.env.EMAIL_REPLY_TO,
|
||||
subject: rendered.subject, text: rendered.text, html: rendered.html,
|
||||
});
|
||||
// §20 — a dev-only preview (never in production, regardless of provider) so a test/admin
|
||||
// can inspect exactly what would have been sent, including the activation/reset URL,
|
||||
// through the dedicated dev-only route — never through the normal admin notification view.
|
||||
const isProduction = process.env.COOKIE_SECURE === '1';
|
||||
const devPreview = (!isProduction && result.preview) ? result.preview : null;
|
||||
await notifications.markSent(pool, row.notification_id, result.providerMessageId, EMAIL_PROVIDER, devPreview);
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'sent');
|
||||
} catch (e) {
|
||||
const retryable = e.errorClass !== 'permanent';
|
||||
const outcome = await notifications.markFailedOrRetry(pool, row, { errorCode: e.errorCode || 'unknown_error', retryable });
|
||||
metrics.recordNotificationAttempt(EMAIL_PROVIDER, outcome === 'retrying' ? 'retry' : 'failed');
|
||||
console.error(`[notification-worker] send failed (${row.notification_type}, ${outcome}): ${e.errorCode || e.message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function startNotificationWorker(pool) {
|
||||
const tick = async () => {
|
||||
try {
|
||||
// Drain everything currently claimable before waiting out the next poll interval —
|
||||
// keeps a burst (e.g. a CSV import's worth of activations) from trickling out one per
|
||||
// POLL_MS when they could all go immediately.
|
||||
while (await processOne(pool)) { /* keep draining */ }
|
||||
} catch (e) {
|
||||
console.error('[notification-worker] tick failed:', e.message);
|
||||
}
|
||||
};
|
||||
const handle = setInterval(tick, POLL_MS);
|
||||
handle.unref(); // never keeps the process alive on its own, matching sessions.js's sweeper
|
||||
tick(); // don't wait a full interval for the first pass
|
||||
return handle;
|
||||
}
|
||||
|
||||
module.exports = { startNotificationWorker, processOne, renderFor, buildAppUrl };
|
||||
@@ -0,0 +1,189 @@
|
||||
// Phase 5C — durable notification lifecycle: enqueue (fast, synchronous, no network I/O),
|
||||
// claim (worker-side, concurrency-safe), and terminal-state transitions.
|
||||
//
|
||||
// Transient plaintext token handling: activation and password-reset emails need the ACTUAL
|
||||
// token value to build a working link, but Phase 5A's invariant is that only the HASH is ever
|
||||
// persisted long-term (students.credential_token_hash). The token is minted synchronously by
|
||||
// whichever request triggers it (admin create/convert/reset-password, self-service
|
||||
// forgot-password) — exactly as Phase 5A already does — and its plaintext is handed to
|
||||
// enqueueWithToken() here, which stores it in this row's `metadata` ONLY until the worker
|
||||
// reaches a terminal state (sent/failed), then scrubs it (see markSent/markFailedOrRetry).
|
||||
// This is a deliberate, narrow exception to "hashed at rest" for the row that exists
|
||||
// specifically to deliver the plaintext once — not the canonical credential store, which
|
||||
// remains hash-only and untouched by any of this. It is never returned by the admin
|
||||
// notification API regardless of whether it's still present (see toAdminView).
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRYABLE_BACKOFF_BASE_SEC = 30;
|
||||
const RETRYABLE_BACKOFF_MAX_SEC = 3600;
|
||||
|
||||
function backoffSeconds(attemptCount) {
|
||||
return Math.min(RETRYABLE_BACKOFF_BASE_SEC * 2 ** attemptCount, RETRYABLE_BACKOFF_MAX_SEC);
|
||||
}
|
||||
|
||||
// §10 — minor/no-email recipient resolution. A student's OWN email always wins when present.
|
||||
// Otherwise, for an enrollment-scoped notification, fall back to that ENROLLMENT's contact
|
||||
// email (enrollments.metadata.contact_email — see enrollment.js's enrollmentContactEmail) —
|
||||
// deliberately never written to students.email, which stays reserved for the student's own
|
||||
// identity. A password-reset/password-changed notification has no enrollment to fall back to
|
||||
// and, correctly, no fallback recipient for a no-email student — those actions require the
|
||||
// student to already be able to log in, which requires an email or a login_identifier they use
|
||||
// directly, not a guardian contact.
|
||||
async function resolveRecipient(pool, studentId, enrollmentId) {
|
||||
const { rows: studentRows } = await pool.query(`SELECT email FROM students WHERE student_id = $1`, [studentId]);
|
||||
if (studentRows[0]?.email) return studentRows[0].email;
|
||||
if (enrollmentId) {
|
||||
const { enrollmentContactEmail } = require('./enrollment');
|
||||
const { rows: enrollmentRows } = await pool.query(`SELECT metadata FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
|
||||
const contact = enrollmentContactEmail(enrollmentRows[0]);
|
||||
if (contact) return contact;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
|
||||
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
|
||||
// state. A notification with no resolvable recipient is still recorded (never silently
|
||||
// dropped, §13) but created already-failed, so the worker never wastes an attempt on it.
|
||||
async function enqueue(pool, { studentId, enrollmentId, notificationType, plaintextToken, metadata }) {
|
||||
const recipient = await resolveRecipient(pool, studentId, enrollmentId);
|
||||
const meta = { ...(metadata || {}) };
|
||||
if (plaintextToken) meta.token = plaintextToken;
|
||||
|
||||
if (!recipient) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
|
||||
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
|
||||
[studentId, enrollmentId || null, notificationType, JSON.stringify({})] // no token stored — nothing to deliver, nothing to leak
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[studentId, enrollmentId || null, notificationType, recipient, JSON.stringify(meta)]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Atomic claim: one UPDATE, no separate transaction/client management needed. FOR UPDATE SKIP
|
||||
// LOCKED inside the subquery is what makes this safe if two worker processes ever poll
|
||||
// concurrently — each gets a disjoint set of rows, never the same one twice.
|
||||
async function claimNext(pool) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE notification_events SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
||||
WHERE notification_id = (
|
||||
SELECT notification_id FROM notification_events
|
||||
WHERE status = 'queued' AND next_attempt_at <= now()
|
||||
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
) RETURNING *`
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// devPreview is ONLY ever populated by the caller when both the dev provider was used AND
|
||||
// production is off (see notification-worker.js) — it replaces the row's metadata entirely
|
||||
// (the token is gone either way), so a dev/test run can recover exactly what would have been
|
||||
// sent without a real inbox. Never populated, never present, in a production deployment — see
|
||||
// the dedicated dev-only GET route this exists for (notification-routes.js), which itself
|
||||
// refuses to run in production regardless of what's actually stored.
|
||||
async function markSent(pool, notificationId, providerMessageId, provider, devPreview) {
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'sent', sent_at = now(), updated_at = now(),
|
||||
provider = $2, provider_message_id = $3, metadata = $4
|
||||
WHERE notification_id = $1`,
|
||||
[notificationId, provider, providerMessageId || null, devPreview ? JSON.stringify({ dev_preview: devPreview }) : '{}']
|
||||
);
|
||||
}
|
||||
|
||||
// Permanent failures (bad recipient, misconfiguration) skip straight to 'failed' on the first
|
||||
// attempt — retrying can't fix them and would just burn the attempt budget. Temporary failures
|
||||
// (network/provider hiccup) go back to 'queued' with bounded exponential backoff until
|
||||
// MAX_ATTEMPTS is exhausted, then 'failed' too — never an infinite loop.
|
||||
async function markFailedOrRetry(pool, row, { errorCode, retryable }) {
|
||||
const exhausted = row.attempt_count >= MAX_ATTEMPTS;
|
||||
if (retryable && !exhausted) {
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'queued', next_attempt_at = now() + ($2 || ' seconds')::interval,
|
||||
last_error_code = $3, updated_at = now() WHERE notification_id = $1`,
|
||||
[row.notification_id, backoffSeconds(row.attempt_count), errorCode]
|
||||
);
|
||||
return 'retrying';
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE notification_events SET status = 'failed', failed_at = now(), last_error_code = $2,
|
||||
updated_at = now(), metadata = metadata - 'token' WHERE notification_id = $1`,
|
||||
[row.notification_id, errorCode]
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// §10/§5 — the shared decision point every enrollment-creation path (integration + admin
|
||||
// manual enroll) calls right after a NEW enrollment is actually created (never on an idempotent
|
||||
// replay — see integration-routes.js/admin-programs-routes.js, which only call this when their
|
||||
// own creation call reported created:true). Reused for the admin "Resend Activation"/"Send
|
||||
// Enrollment Confirmation" actions too, parameterized by isResend for audit-action naming only.
|
||||
async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId, programName, isResend }) {
|
||||
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [studentId]);
|
||||
const student = rows[0];
|
||||
if (!student) return null;
|
||||
|
||||
if (student.password_hash) {
|
||||
return enqueue(pool, {
|
||||
studentId, enrollmentId, notificationType: 'enrollment_confirmation',
|
||||
metadata: { display_name: student.display_name, program_name: programName },
|
||||
});
|
||||
}
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
|
||||
return enqueue(pool, {
|
||||
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
|
||||
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
|
||||
});
|
||||
}
|
||||
|
||||
async function listForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
async function getById(pool, notificationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Phase 5C §20 — the ONLY function that ever returns a dev preview (recipient/subject/text/
|
||||
// activation-or-reset URL), and only the caller (notification-routes.js's dedicated dev-only
|
||||
// route) decides whether to expose it at all — this function itself does no production check,
|
||||
// it just returns whatever's there, which is nothing at all once a real (non-dev) provider was
|
||||
// used or the row has moved past 'sent' via markFailedOrRetry (which never writes dev_preview).
|
||||
async function getDevPreview(pool, notificationId) {
|
||||
const row = await getById(pool, notificationId);
|
||||
return row?.metadata?.dev_preview || null;
|
||||
}
|
||||
|
||||
// §16 — safe display: the local part is truncated, never the full address, and this is the
|
||||
// ONLY shape any admin API ever returns for recipient_email. Nothing this module exports ever
|
||||
// hands back an unmasked address or the metadata column at all.
|
||||
function maskEmail(email) {
|
||||
if (!email) return null;
|
||||
const [local, domain] = email.split('@');
|
||||
if (!domain) return '***';
|
||||
const visible = local.slice(0, 1);
|
||||
return `${visible}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`;
|
||||
}
|
||||
|
||||
// The one and only shape returned by any notification-related API — deliberately excludes
|
||||
// `metadata` entirely (which is where a still-in-flight token transiently lives) and masks the
|
||||
// recipient address.
|
||||
function toAdminView(row) {
|
||||
return {
|
||||
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
|
||||
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
|
||||
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
|
||||
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
|
||||
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Phase 5B — combines an enrollment row with real curriculum/progress/achievement state into
|
||||
// one "program status" view: access/launch eligibility, stage/percent progress, and a
|
||||
// deterministic next-activity (resume) target. Shared by the student self-paced routes and the
|
||||
// admin program/enrollment routes so both surfaces show identical information computed one way,
|
||||
// not two independently-maintained views that could drift.
|
||||
|
||||
const { deriveEnrollmentAccess, maybeCompleteEnrollment } = require('./enrollment');
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
|
||||
async function buildProgramStatus(deps, enrollmentIn) {
|
||||
const { pool, curriculum, loadProgress } = deps;
|
||||
let enrollment = enrollmentIn;
|
||||
|
||||
// Opportunistic, idempotent completion check (§10) — safe to run on every read; the
|
||||
// underlying UPDATE's `WHERE status = 'active'` guard makes a repeat call a no-op once an
|
||||
// enrollment is already completed, so this never double-fires or races itself.
|
||||
if (enrollment.status === 'active') {
|
||||
const completed = await maybeCompleteEnrollment(pool, enrollment.student_id, enrollment.program_id);
|
||||
if (completed.length) enrollment = { ...enrollment, ...completed[0] };
|
||||
}
|
||||
|
||||
const access = deriveEnrollmentAccess(enrollment);
|
||||
// Only one curriculum path exists today (curriculum.js) — enrollment.curriculum_path is the
|
||||
// snapshot that would let a second path be resolved here later without touching this
|
||||
// function's shape; see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §5.
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const progress = await loadProgress(pool, enrollment.student_id);
|
||||
|
||||
const capstoneStatus = enrollment.status === 'completed' ? 'completed' : undefined;
|
||||
const state = path ? computeCurriculumState(path, progress, capstoneStatus) : null;
|
||||
|
||||
let nextActivity = null;
|
||||
if (state) {
|
||||
const currentStage = state.stages.find((s) => s.id === state.current_stage);
|
||||
if (currentStage?.next_scenario) {
|
||||
nextActivity = { type: 'scenario', scenario_id: currentStage.next_scenario, stage_id: currentStage.id };
|
||||
} else if (state.capstone && state.capstone.status === 'unlocked') {
|
||||
nextActivity = { type: 'capstone', assessment_definition_id: state.capstone.assessment_definition_id };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enrollment_id: enrollment.enrollment_id,
|
||||
program_id: enrollment.program_id,
|
||||
program_name: enrollment.program_name || null,
|
||||
program_family: enrollment.program_family || null,
|
||||
status: enrollment.status,
|
||||
effective_status: access.effective_status,
|
||||
can_launch: access.can_launch,
|
||||
blocked_reason: access.reason,
|
||||
delivery_mode: enrollment.delivery_mode,
|
||||
starts_at: enrollment.starts_at,
|
||||
access_ends_at: enrollment.access_ends_at,
|
||||
enrolled_at: enrollment.enrolled_at,
|
||||
activated_at: enrollment.activated_at,
|
||||
completed_at: enrollment.completed_at,
|
||||
cancelled_at: enrollment.cancelled_at,
|
||||
progress: state ? { percent_complete: state.percent_complete, current_stage: state.current_stage, stages: state.stages, capstone: state.capstone } : null,
|
||||
next_activity: nextActivity,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildProgramStatus };
|
||||
@@ -0,0 +1,51 @@
|
||||
// Phase 5B §2 — programs: first-class learning products (distinct from curriculum JSON, which
|
||||
// stays just the content a program points at). CRUD here mirrors assessments.js's definition
|
||||
// pattern (a slug primary key, a plain incrementing version, active/inactive status) rather
|
||||
// than inventing a new convention.
|
||||
|
||||
async function listPrograms(pool, { activeOnly } = {}) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.*, count(e.enrollment_id)::int AS enrollment_count
|
||||
FROM programs p LEFT JOIN enrollments e ON e.program_id = p.program_id
|
||||
${activeOnly ? "WHERE p.status = 'active'" : ''}
|
||||
GROUP BY p.program_id ORDER BY p.family, p.name`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getProgram(pool, programId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createProgram(pool, { programId, name, description, family, deliveryMode, curriculumPath, completionAssessmentId, estimatedDurationHours, createdBy }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[programId, name, description || null, family, deliveryMode || 'self_paced', curriculumPath || 'rootnode-path', completionAssessmentId || null, estimatedDurationHours || null, createdBy || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Bumps version whenever a field an enrollment snapshots (curriculum_path, delivery_mode)
|
||||
// actually changes — matches assessment_definitions' "edit later never changes what an
|
||||
// already-drawn session is graded against" principle. Editing only cosmetic fields (name,
|
||||
// description, estimated_duration_hours) does not bump the version.
|
||||
async function updateProgram(pool, programId, fields) {
|
||||
const current = await getProgram(pool, programId);
|
||||
if (!current) return null;
|
||||
const snapshotChanged = (fields.curriculum_path && fields.curriculum_path !== current.curriculum_path)
|
||||
|| (fields.delivery_mode && fields.delivery_mode !== current.delivery_mode);
|
||||
const map = { ...fields };
|
||||
if (snapshotChanged) map.version = current.version + 1;
|
||||
const cols = Object.keys(map);
|
||||
if (!cols.length) return current;
|
||||
const setClause = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE programs SET ${setClause}, updated_at = now() WHERE program_id = $1 RETURNING *`,
|
||||
[programId, ...cols.map((c) => map[c])]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
module.exports = { listPrograms, getProgram, createProgram, updateProgram };
|
||||
@@ -179,10 +179,75 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id
|
||||
// directly instead of resolving through one enrollment first: an instructor may see/manage a
|
||||
// student's notifications if they share a cohort with that student OR they created at least
|
||||
// one of that student's enrollments — otherwise 403, never blanket visibility into every
|
||||
// self-paced student's notifications ("Do not grant global notification visibility to every
|
||||
// instructor," §16). Used by the per-student notification list and the resend-activation/
|
||||
// send-enrollment-confirmation/send-password-reset admin actions.
|
||||
async function checkStudentRelationshipAccess(req, res, studentId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2
|
||||
UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $2`,
|
||||
[studentId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function requireStudentRelationshipAccess(studentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
|
||||
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
|
||||
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
|
||||
// they already have cohort-based access to that student (the existing relationship, reused
|
||||
// as-is) OR they personally created the enrollment (enrollments.created_by) — never blanket
|
||||
// global visibility into every self-paced student. owner/admin bypass, same as everywhere
|
||||
// else. Resolves the enrollment first (404 if it doesn't exist) and stashes the owning
|
||||
// student_id on the request.
|
||||
function requireEnrollmentAccess(enrollmentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT student_id, created_by FROM enrollments WHERE enrollment_id = $1`, [req.params[enrollmentIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.enrollmentStudentId = rows[0].student_id;
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
if (rows[0].created_by === instructor.instructor_id) return next();
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`,
|
||||
[rows[0].student_id, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'enrollment', resourceId: req.params[enrollmentIdParam], result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||
requireEnrollmentAccess, requireStudentRelationshipAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Phase 5B §6/§7/§8/§9 — student-facing self-paced program API. Only ever shows a student
|
||||
// programs THEY hold an enrollment for (never a public catalog — see §22, no public course
|
||||
// catalog this phase) and enforces access/launch entitlement server-side, not just by hiding a
|
||||
// button (§9: "Hiding a button in React is not access control").
|
||||
|
||||
const enrollment = require('./enrollment');
|
||||
const { buildProgramStatus } = require('./program-status');
|
||||
const { launchEnvironmentForStudent } = require('./environment-view');
|
||||
|
||||
module.exports = function registerSelfPacedRoutes(app, deps) {
|
||||
const { studentSession, getPool, curriculum, loadProgress, provisioner, wrap } = deps;
|
||||
const statusDeps = { pool: getPool(), curriculum, loadProgress };
|
||||
|
||||
// §6 — "My Programs": every enrollment this student holds, active and historical alike,
|
||||
// annotated with real progress/access/next-activity. The frontend splits active vs completed
|
||||
// client-side from the `status`/`effective_status` fields — no separate endpoint needed.
|
||||
app.get('/api/student/programs', studentSession, wrap(async (req, res) => {
|
||||
const rows = await enrollment.listEnrollmentsForStudent(getPool(), req.studentId);
|
||||
const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e)));
|
||||
res.json(views);
|
||||
}));
|
||||
|
||||
app.get('/api/student/programs/:enrollmentId', studentSession, wrap(async (req, res) => {
|
||||
const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId);
|
||||
if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(await buildProgramStatus(statusDeps, row));
|
||||
}));
|
||||
|
||||
// §8/§9 — the entitlement-gated launch. Delegates to the EXACT same disposable-Lab sequence
|
||||
// /api/environment/launch uses (environment-view.js) — a self-paced Lab is not a different
|
||||
// kind of Lab, just a gated entry point into the same one. The gate itself (deriveEnrollment
|
||||
// Access) is what makes this route different from calling /api/environment/launch directly;
|
||||
// that route is unchanged and still works exactly as before for cohort/pilot students.
|
||||
app.post('/api/student/programs/:enrollmentId/launch', studentSession, wrap(async (req, res) => {
|
||||
const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId);
|
||||
if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' });
|
||||
const access = enrollment.deriveEnrollmentAccess(row);
|
||||
if (!access.can_launch) {
|
||||
return res.status(403).json({ error: 'not_entitled', reason: access.reason });
|
||||
}
|
||||
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
|
||||
res.status(status).json(body);
|
||||
}));
|
||||
};
|
||||
+43
-33
@@ -245,11 +245,22 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
|
||||
res.json({ total, completed, details: progress });
|
||||
});
|
||||
|
||||
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
// Phase 5B — found by testing: this route's handler was never passed through wrap() (a
|
||||
// leftover from Phase 1/2A, before that convention existed), so any unexpected internal error
|
||||
// here — including Postgres rejecting a malformed environment_id, a 22P02 "invalid input
|
||||
// syntax for type uuid" — became an unhandled promise rejection that CRASHED THE ENTIRE
|
||||
// PLATFORM API PROCESS for every student, not just a 500 for this one request. Now wrapped
|
||||
// like every route added since Phase 3B, plus explicit UUID-shape validation so a bad
|
||||
// environment_id is a clean 400 rather than ever reaching Postgres as a type error at all.
|
||||
const ENV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
app.post('/api/progress/record', wrap(studentSession), wrap(async (req, res) => {
|
||||
const { scenario_id, status, environment_id } = req.body || {};
|
||||
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
|
||||
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
|
||||
}
|
||||
if (environment_id && !ENV_ID_RE.test(environment_id)) {
|
||||
return res.status(400).json({ error: 'environment_id must be a valid UUID' });
|
||||
}
|
||||
const pool = getPool();
|
||||
const passed = status === 'completed';
|
||||
|
||||
@@ -293,26 +304,17 @@ app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
// STUDENT-FACING: environment lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function publicEnvView(env) {
|
||||
if (!env) return { status: 'none' };
|
||||
const view = { status: env.status };
|
||||
if (env.status === 'running') {
|
||||
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
|
||||
if (!token) {
|
||||
const { status, body } = await provisioner.getToken(env.environment_id);
|
||||
if (status === 200) token = body.routing_token;
|
||||
}
|
||||
if (token) view.launch_path = `/lab/${token}/`;
|
||||
}
|
||||
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
|
||||
return view;
|
||||
}
|
||||
// Phase 5B — publicEnvView/the launch sequence now live in environment-view.js so
|
||||
// selfpaced-routes.js's entitlement-gated launch route can call the identical logic instead of
|
||||
// a second copy. Behavior here is unchanged from before the extraction.
|
||||
const { publicEnvView: publicEnvViewFor, launchEnvironmentForStudent } = require('./environment-view');
|
||||
const publicEnvView = (env) => publicEnvViewFor(provisioner, env);
|
||||
|
||||
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
|
||||
const { status, body } = await provisioner.listForStudent(req.studentId);
|
||||
@@ -322,23 +324,8 @@ app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
|
||||
const existing = await provisioner.listForStudent(req.studentId);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return res.json(await publicEnvView(active));
|
||||
|
||||
const { status, body } = await provisioner.provision(req.studentId);
|
||||
if (status === 409) {
|
||||
const retry = await provisioner.listForStudent(req.studentId);
|
||||
return res.json(await publicEnvView(retry.body?.[0]));
|
||||
}
|
||||
// Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic
|
||||
// provision_failed 502 below. The student sees "temporarily full, try again shortly," never
|
||||
// a raw 500/502 — this is expected operational backpressure, not an application error.
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' });
|
||||
}
|
||||
if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error });
|
||||
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
|
||||
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
|
||||
@@ -397,6 +384,25 @@ require('./admin-classroom-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 5B — programs/enrollment. selfpaced-routes.js is the student-facing half (My
|
||||
// Programs, program detail, entitlement-gated launch); admin-programs-routes.js is the
|
||||
// instructor/admin half (program authoring, manual enrollment, activate/cancel);
|
||||
// integration-routes.js is the separate, non-session website/commerce boundary — see
|
||||
// docs/PHASE5B_SELF_PACED_ENROLLMENT.md and docs/WEBSITE_ENROLLMENT_INTEGRATION.md.
|
||||
require('./selfpaced-routes')(app, { studentSession, getPool, curriculum, loadProgress, provisioner, wrap });
|
||||
require('./admin-programs-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
require('./integration-routes')(app, { getPool, recordAudit, wrap });
|
||||
|
||||
// Phase 5C — enrollment operations & communications. notification-routes.js is the
|
||||
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
|
||||
// confirmation); the actual delivery worker starts in main() below, alongside the existing
|
||||
// session/login-attempt sweepers — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md.
|
||||
require('./notification-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||
@@ -416,6 +422,10 @@ async function main() {
|
||||
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
||||
sessions.startSessionSweeper(getPool());
|
||||
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
|
||||
const { startNotificationWorker } = require('./notification-worker');
|
||||
const { EMAIL_PROVIDER } = require('./email/provider');
|
||||
console.log(`[notification-worker] starting (EMAIL_PROVIDER=${EMAIL_PROVIDER})`);
|
||||
startNotificationWorker(getPool()); // Phase 5C §14
|
||||
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ const {
|
||||
const { recordAudit } = require('./audit');
|
||||
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
|
||||
const { recordStudentLoginSuccess, recordStudentLoginFailure, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C
|
||||
const notifications = require('./notifications');
|
||||
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||
@@ -69,7 +70,11 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||
}
|
||||
const student = await consumeCredentialToken(pool, token, 'activation', password);
|
||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
||||
if (!student) {
|
||||
recordStudentActivation('invalid_or_expired_token');
|
||||
return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
||||
}
|
||||
recordStudentActivation('success');
|
||||
|
||||
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
@@ -105,27 +110,30 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
||||
const newHash = hashPassword(new_password);
|
||||
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
|
||||
// Phase 5C §5D — a security-notice email, no token, purely informational.
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
|
||||
// (explicitly not required this phase), so this deliberately does NOT invent fake email
|
||||
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
|
||||
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
|
||||
// Always returns the same generic response regardless of whether the identifier exists
|
||||
// (anti-enumeration). In non-production, the real token is included so local/test flows can
|
||||
// complete the loop without pretending email exists; in production it is never returned by
|
||||
// this endpoint — completing a real self-service reset needs email delivery, a documented,
|
||||
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
|
||||
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
|
||||
// §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the
|
||||
// SAME generic response returns regardless of whether the identifier exists, is inactive, or
|
||||
// is a genuine hit — the only difference a caller can ever observe is whether an email
|
||||
// eventually arrives, never anything in this response. Production NEVER returns the token
|
||||
// (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see
|
||||
// server.js); the dev-only echo exists purely so a local/CI run can complete the loop without
|
||||
// a real inbox, exactly as before. What Phase 5C actually closes: the token is now also
|
||||
// handed to notifications.enqueue() so a real email goes out — see
|
||||
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here
|
||||
// (synchronously, as before) rather than by the worker.
|
||||
app.post('/api/student/request-password-reset', async (req, res) => {
|
||||
const { identifier } = req.body || {};
|
||||
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
|
||||
if (!identifier) return res.json(generic);
|
||||
const student = await getStudentByIdentifier(pool, identifier);
|
||||
if (!student || student.status !== 'active') return res.json(generic);
|
||||
if (!student || student.status !== 'active' || !student.password_hash) return res.json(generic);
|
||||
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
|
||||
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
|
||||
res.json(generic);
|
||||
@@ -139,6 +147,7 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
||||
const student = await consumeCredentialToken(pool, token, 'reset', password);
|
||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
|
||||
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -68,10 +68,33 @@ services:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
|
||||
PROVISIONER_URL: "http://localhost:4100"
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
# Phase 5B — the website/commerce integration boundary's shared secret (see
|
||||
# docs/WEBSITE_ENROLLMENT_INTEGRATION.md). Optional in dev: unset means
|
||||
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
|
||||
# for anyone not exercising that boundary locally.
|
||||
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
|
||||
# Phase 5C — activation/reset email links are built from this origin (see
|
||||
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
|
||||
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
|
||||
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
|
||||
# network call no matter what recipient it's given.
|
||||
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
|
||||
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
|
||||
# Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js).
|
||||
# Set all four in platform/.env only for a deliberate one-off live-delivery test; unset
|
||||
# again afterward so routine dev/test runs never risk a real send.
|
||||
EMAIL_PROVIDER: ${EMAIL_PROVIDER:-}
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-}
|
||||
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
|
||||
GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-}
|
||||
PLATFORM_API_PORT: "4300"
|
||||
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
|
||||
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
|
||||
# COOKIE_SECURE: "1" # set once served over real HTTPS
|
||||
# Empty by default — unset/empty means the exact same dev behavior as before this was
|
||||
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
|
||||
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
|
||||
# real, then unsets it again — see that test's own comment.
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5B — self-paced learning & enrollment foundation acceptance test. Covers the program/
|
||||
// enrollment model, entitlement gating (pending/future/expired/cancelled), curriculum
|
||||
// assignment, resume/Continue Learning, disposable-Lab persistence across destroy/reaper/
|
||||
// recreate, server-derived idempotent completion, the website/commerce integration boundary
|
||||
// (auth, idempotency, concurrency, minor/no-email path), RBAC scoping, and a real restart. Same
|
||||
// conventions as e2e-phase5a.js/e2e-phase3b.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack, real k3s boots for the Lab-persistence section.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_KEY=... node test/e2e-phase5b.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
// Same curriculum snapshot e2e-phase3b.js drives full completion against — see that file's
|
||||
// header for why this is a real (not synthetic) content list.
|
||||
const FOUNDATION = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics',
|
||||
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
|
||||
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
|
||||
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
||||
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
|
||||
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
|
||||
const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
async function complete(session, scenarioId, envId) {
|
||||
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed', environment_id: envId });
|
||||
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
||||
}
|
||||
// The routing token in launch_path is NOT the environment_id scenario_attempts/progress
|
||||
// records expect (a UUID) — resolve the real one via the admin view, same technique
|
||||
// e2e-phase3b.js uses.
|
||||
async function resolveEnvId(owner, studentId) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/environment`);
|
||||
return body.find((e) => e.status === 'running')?.environment_id;
|
||||
}
|
||||
function fireIntegrationEvent(payload) {
|
||||
return fetch(`${GW}/api/integrations/enrollments`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5B self-paced learning / enrollment foundation acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── Regression: a malformed environment_id must never crash the whole Platform API ──────
|
||||
// Found by this test suite — POST /api/progress/record's handler predated the wrap()
|
||||
// convention and an unexpected internal error (Postgres rejecting a non-UUID environment_id)
|
||||
// became an unhandled promise rejection that killed the entire process. Fixed in server.js
|
||||
// (wrap() + explicit UUID-shape validation); this proves it stays fixed.
|
||||
await test('malformed environment_id is a clean 400, not a process crash', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { body: probe } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-CrashProbe-${RUN_ID}` });
|
||||
await s.request('POST', '/api/login', { student_id: probe.student_id });
|
||||
const bad = await s.request('POST', '/api/progress/record', { scenario_id: 'pod-basics-mcq', status: 'completed', environment_id: 'not-a-uuid' });
|
||||
assert.equal(bad.status, 400);
|
||||
const health = await fetch(`${API}/healthz`);
|
||||
assert.equal(health.status, 200, 'the API process must still be alive');
|
||||
});
|
||||
|
||||
// ── §1: program seed ─────────────────────────────────────────────────────────────────────
|
||||
await test('program seed exists (root-kubernetes-foundations)', async () => {
|
||||
const { status, body } = await owner.request('GET', '/api/admin/programs');
|
||||
assert.equal(status, 200);
|
||||
const program = body.find((p) => p.program_id === PROGRAM);
|
||||
assert.ok(program);
|
||||
assert.equal(program.family, 'root');
|
||||
assert.equal(program.curriculum_path, 'rootnode-path');
|
||||
});
|
||||
|
||||
const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-Alice-${RUN_ID}`, login_identifier: `e2e5b-alice-${RUN_ID}` });
|
||||
const { body: bobCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-5B-Bob-${RUN_ID}`, login_identifier: `e2e5b-bob-${RUN_ID}` });
|
||||
const alice = makeSession(GW);
|
||||
const bob = makeSession(GW);
|
||||
await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'AlicePassword123!' });
|
||||
await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: 'BobPassword123!' });
|
||||
|
||||
// ── §2: no entitlement ───────────────────────────────────────────────────────────────────
|
||||
await test('authenticated student with no enrollment sees an empty programs list', async () => {
|
||||
const { status, body } = await alice.request('GET', '/api/student/programs');
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.length, 0);
|
||||
});
|
||||
|
||||
// ── §3-4: admin enrolls, activation, entitlement gating ─────────────────────────────────
|
||||
let enrollmentId;
|
||||
await test('admin manually enrolls student', async () => {
|
||||
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: aliceCreate.student_id, program_id: PROGRAM });
|
||||
assert.equal(status, 201);
|
||||
assert.equal(body.status, 'pending');
|
||||
enrollmentId = body.enrollment_id;
|
||||
});
|
||||
|
||||
await test('pending enrollment is visible but launch is blocked server-side, not just hidden', async () => {
|
||||
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(body.can_launch, false);
|
||||
assert.equal(body.blocked_reason, 'pending_activation');
|
||||
const { status } = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
await test('activated enrollment appears active on the student dashboard/API', async () => {
|
||||
const activated = await owner.request('POST', `/api/admin/enrollments/${enrollmentId}/activate`);
|
||||
assert.equal(activated.status, 200);
|
||||
const { body } = await alice.request('GET', '/api/student/programs');
|
||||
const view = body.find((p) => p.enrollment_id === enrollmentId);
|
||||
assert.equal(view.can_launch, true);
|
||||
assert.ok(view.progress); // §8: curriculum assignment resolved — real progress state, not a stub
|
||||
});
|
||||
|
||||
// ── §5-7: future / expired / cancelled ──────────────────────────────────────────────────
|
||||
await test('future-dated enrollment is visible but blocked before starts_at', async () => {
|
||||
const created = await owner.request('POST', '/api/admin/enrollments', {
|
||||
student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active', starts_at: '2099-01-01',
|
||||
});
|
||||
assert.equal(created.status, 201);
|
||||
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
|
||||
assert.equal(body.effective_status, 'active');
|
||||
assert.equal(body.can_launch, false);
|
||||
assert.equal(body.blocked_reason, 'not_yet_started');
|
||||
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
|
||||
assert.equal(launch.status, 403);
|
||||
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`); // cleanup for later RBAC tests
|
||||
});
|
||||
|
||||
await test('expired enrollment (access_ends_at passed) is visible historically but blocked from new Lab work', async () => {
|
||||
const created = await owner.request('POST', '/api/admin/enrollments', {
|
||||
student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active', access_ends_at: '2020-01-01',
|
||||
});
|
||||
assert.equal(created.status, 201);
|
||||
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
|
||||
assert.equal(body.effective_status, 'expired');
|
||||
assert.equal(body.can_launch, false);
|
||||
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
|
||||
assert.equal(launch.status, 403);
|
||||
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`);
|
||||
});
|
||||
|
||||
await test('cancelled enrollment is blocked from launch', async () => {
|
||||
const created = await owner.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
await owner.request('POST', `/api/admin/enrollments/${created.body.enrollment_id}/cancel`);
|
||||
const { body } = await bob.request('GET', `/api/student/programs/${created.body.enrollment_id}`);
|
||||
assert.equal(body.effective_status, 'cancelled');
|
||||
const launch = await bob.request('POST', `/api/student/programs/${created.body.enrollment_id}/launch`);
|
||||
assert.equal(launch.status, 403);
|
||||
});
|
||||
|
||||
// ── §16-17: cross-student isolation ─────────────────────────────────────────────────────
|
||||
await test('student B cannot read student A\'s enrollment or launch A\'s program entitlement', async () => {
|
||||
const read = await bob.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(read.status, 404);
|
||||
const launch = await bob.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(launch.status, 404);
|
||||
});
|
||||
|
||||
// ── §9/§10/§11/§12: resume, real Lab launch, destroy/reaper/recreate persistence ────────
|
||||
let aliceEnvId;
|
||||
await test('entitled launch provisions the real disposable Lab (same mechanism as /api/environment/launch)', async () => {
|
||||
const { status, body } = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.status, 'running');
|
||||
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id);
|
||||
assert.ok(aliceEnvId);
|
||||
});
|
||||
|
||||
await test('resume/Continue Learning points at the first real activity', async () => {
|
||||
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(body.next_activity.type, 'scenario');
|
||||
assert.equal(body.next_activity.scenario_id, FOUNDATION[0]);
|
||||
});
|
||||
|
||||
await test('completing a scenario advances resume state deterministically', async () => {
|
||||
await complete(alice, FOUNDATION[0], aliceEnvId);
|
||||
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(body.next_activity.scenario_id, FOUNDATION[1]);
|
||||
assert.ok(body.progress.percent_complete > 0);
|
||||
});
|
||||
|
||||
await test('academic progress survives a Lab destroy + a fresh Lab recreation', async () => {
|
||||
const before = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
await owner.request('DELETE', `/api/admin/environments/${aliceEnvId}`);
|
||||
const relaunch = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(relaunch.body.status, 'running');
|
||||
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id); // a NEW environment_id — same student, fresh container
|
||||
assert.ok(aliceEnvId);
|
||||
const after = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.deepEqual(after.body.progress.percent_complete, before.body.progress.percent_complete);
|
||||
assert.equal(after.body.next_activity.scenario_id, before.body.next_activity.scenario_id);
|
||||
});
|
||||
|
||||
await test('academic progress survives the reaper\'s exact consequence (destroy out-of-band + Provisioner reconciliation, same as test-lab-host-loss.js)', async () => {
|
||||
// Mirrors infra/scripts/drills/test-lab-host-loss.js's technique exactly: kill the
|
||||
// container directly (bypassing the Provisioner entirely, exactly what the real idle
|
||||
// reaper's failure mode looks like from the Provisioner's point of view), then restart the
|
||||
// Provisioner to run its real reconcileOnStartup pass — a plain `docker rm` alone leaves
|
||||
// the DB row still saying 'running' and launch would just hand back the now-dead
|
||||
// environment instead of a fresh one, which wouldn't prove anything.
|
||||
const before = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
const containers = execSync(`docker ps --filter "name=rootnode-lab-" --format "{{.Names}}"`).toString().trim().split('\n').filter(Boolean);
|
||||
for (const c of containers) execSync(`docker rm -f ${c}`, { stdio: 'pipe' });
|
||||
execSync('docker compose -f docker-compose.dev.yml restart provisioner', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
const relaunch = await alice.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(relaunch.body.status, 'running');
|
||||
aliceEnvId = await resolveEnvId(owner, aliceCreate.student_id);
|
||||
const after = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.deepEqual(after.body.progress.percent_complete, before.body.progress.percent_complete);
|
||||
});
|
||||
|
||||
// ── §13-15: completion is server-derived, idempotent, and correctly categorized ─────────
|
||||
await test('completing every remaining stage scenario + the capstone transitions the enrollment to completed (server-derived, not a client button)', async () => {
|
||||
for (const sid of ALL_NON_CAPSTONE.filter((s) => s !== FOUNDATION[0])) await complete(alice, sid, aliceEnvId);
|
||||
const { status: startStatus, body: capstone } = await alice.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(startStatus, 201);
|
||||
for (const s of capstone.scenarios) await complete(alice, s.scenario_id, aliceEnvId);
|
||||
|
||||
// No client ever calls anything like "mark enrollment complete" — reading the program
|
||||
// status is what triggers the (idempotent) server-side check, via program-status.js's
|
||||
// maybeCompleteEnrollment.
|
||||
const { body } = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(body.status, 'completed');
|
||||
assert.ok(body.completed_at);
|
||||
});
|
||||
|
||||
await test('completion is idempotent — reading again does not change completed_at or error', async () => {
|
||||
const first = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
const second = await alice.request('GET', `/api/student/programs/${enrollmentId}`);
|
||||
assert.equal(first.body.completed_at, second.body.completed_at);
|
||||
assert.equal(second.status, 200);
|
||||
});
|
||||
|
||||
await test('completed program is categorized correctly in the programs list', async () => {
|
||||
const { body } = await alice.request('GET', '/api/student/programs');
|
||||
const view = body.find((p) => p.enrollment_id === enrollmentId);
|
||||
assert.equal(view.effective_status, 'completed');
|
||||
});
|
||||
|
||||
// ── §18-23: website/commerce integration boundary ───────────────────────────────────────
|
||||
await test('integration endpoint rejects a request carrying an ordinary browser/student session instead of the shared key', async () => {
|
||||
const res = await fetch(`${GW}/api/integrations/enrollments`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${alice.getCookie('rn_session')}` },
|
||||
body: JSON.stringify({ event_id: 'evt-session-auth', program_slug: PROGRAM, status: 'paid', student: { email: '[email protected]' } }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
let integrationEnrollmentId, integrationStudentId;
|
||||
await test('a valid integration event creates the correct enrollment', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `evt-${RUN_ID}-1`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `newkid-${RUN_ID}@example.com`, display_name: 'New Kid' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
assert.equal(body.status, 'active');
|
||||
assert.equal(body.program_id, PROGRAM);
|
||||
integrationEnrollmentId = body.enrollment_id;
|
||||
integrationStudentId = body.student_id;
|
||||
});
|
||||
|
||||
await test('the exact same external event_id replayed creates no duplicate enrollment', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `evt-${RUN_ID}-1`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `newkid-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.created, false);
|
||||
assert.equal(body.enrollment_id, integrationEnrollmentId);
|
||||
});
|
||||
|
||||
await test('concurrent duplicate integration events create exactly one logical enrollment', async () => {
|
||||
const eventId = `evt-${RUN_ID}-race`;
|
||||
const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `racer-${RUN_ID}@example.com` } });
|
||||
const [a, b, c] = await Promise.all([fire(), fire(), fire()]);
|
||||
const ids = new Set([a.body.enrollment_id, b.body.enrollment_id, c.body.enrollment_id]);
|
||||
assert.equal(ids.size, 1);
|
||||
assert.equal([a, b, c].filter((r) => r.body.created).length, 1);
|
||||
});
|
||||
|
||||
await test('unknown program_slug rejected cleanly (404), not a raw FK error', async () => {
|
||||
const { status } = await fireIntegrationEvent({ event_id: `evt-${RUN_ID}-badprog`, program_slug: 'not-a-real-program', status: 'paid', student: { email: `x-${RUN_ID}@example.com` } });
|
||||
assert.equal(status, 404);
|
||||
});
|
||||
|
||||
await test('inactive program rejected cleanly (409)', async () => {
|
||||
await owner.request('POST', '/api/admin/programs', { program_id: `e2e-5b-inactive-${RUN_ID}`, name: 'Inactive Test Program', family: 'root' });
|
||||
await owner.request('PATCH', `/api/admin/programs/e2e-5b-inactive-${RUN_ID}`, { status: 'inactive' });
|
||||
const { status } = await fireIntegrationEvent({ event_id: `evt-${RUN_ID}-inactive`, program_slug: `e2e-5b-inactive-${RUN_ID}`, status: 'paid', student: { email: `x2-${RUN_ID}@example.com` } });
|
||||
assert.equal(status, 409);
|
||||
});
|
||||
|
||||
await test('the integration payload/response never carries payment-card data (no such field exists to leak)', async () => {
|
||||
const { body } = await owner.request('GET', `/api/admin/enrollments/${integrationEnrollmentId}`);
|
||||
const serialized = JSON.stringify(body).toLowerCase();
|
||||
assert.ok(!serialized.includes('card') && !serialized.includes('cvv') && !serialized.includes('pan'));
|
||||
});
|
||||
|
||||
await test('minor/no-email enrollment works via login_identifier alone (§13)', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `evt-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { login_identifier: `guardian-ref-${RUN_ID}`, display_name: 'Guardian-Enrolled Kid' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
assert.ok(body.student_id);
|
||||
});
|
||||
|
||||
// ── §25-26: RBAC ─────────────────────────────────────────────────────────────────────────
|
||||
const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned', role: 'instructor', password: 'AssignedPass123!',
|
||||
});
|
||||
const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `e2e5b-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!',
|
||||
});
|
||||
await test('admin RBAC: creating a program/enrollment is admin/owner only', async () => {
|
||||
const assigned = makeSession(GW);
|
||||
await assigned.request('POST', '/api/auth/instructor/login', { email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
|
||||
const { status } = await assigned.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM });
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
await test('instructor scope: an instructor sharing a cohort with the student can manage the enrollment; an unrelated instructor cannot', async () => {
|
||||
const assigned = makeSession(GW);
|
||||
await assigned.request('POST', '/api/auth/instructor/login', { email: `e2e5b-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
|
||||
const unrelated = makeSession(GW);
|
||||
await unrelated.request('POST', '/api/auth/instructor/login', { email: `e2e5b-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' });
|
||||
|
||||
// Neither plain instructor can create (admin/owner only, proven above) — so exercise scope
|
||||
// on an owner-created enrollment via cohort-sharing instead: assign `assigned` to a cohort
|
||||
// Bob is in, leave `unrelated` with no relationship at all.
|
||||
const cohortId = `e2e5b-scope-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'E2E 5B scope' });
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: assignedInstr.instructor_id });
|
||||
await owner.request('POST', `/api/admin/students/${bobCreate.student_id}/cohort`, { cohort_id: cohortId });
|
||||
const enr = await owner.request('POST', '/api/admin/enrollments', { student_id: bobCreate.student_id, program_id: PROGRAM, external_reference: 'scope-test' });
|
||||
|
||||
const assignedRead = await assigned.request('GET', `/api/admin/enrollments/${enr.body.enrollment_id}`);
|
||||
assert.equal(assignedRead.status, 200);
|
||||
const unrelatedRead = await unrelated.request('GET', `/api/admin/enrollments/${enr.body.enrollment_id}`);
|
||||
assert.equal(unrelatedRead.status, 403);
|
||||
});
|
||||
|
||||
// ── §30: no secrets in new APIs ──────────────────────────────────────────────────────────
|
||||
await test('no password/credential-token hashes are ever returned by the new program/enrollment APIs', async () => {
|
||||
const list = await owner.request('GET', '/api/admin/programs');
|
||||
const detail = await owner.request('GET', `/api/admin/enrollments/${enrollmentId}`);
|
||||
const serialized = JSON.stringify(list.body) + JSON.stringify(detail.body);
|
||||
assert.ok(!/password_hash|credential_token_hash/i.test(serialized));
|
||||
});
|
||||
|
||||
// ── §28-29: Phase 5A still works ─────────────────────────────────────────────────────────
|
||||
await test('Phase 5A student authentication still works unmodified', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `e2e5b-alice-${RUN_ID}`, password: 'AlicePassword123!' });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('Phase 5A remote classroom still works unmodified', async () => {
|
||||
const cohortId = `e2e5b-classroom-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'E2E 5B classroom check' });
|
||||
const created = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Still works' });
|
||||
const started = await owner.request('POST', `/api/admin/classroom-sessions/${created.body.classroom_session_id}/start`);
|
||||
assert.equal(started.status, 200);
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${created.body.classroom_session_id}/end`);
|
||||
});
|
||||
|
||||
// ── §27: restart persistence ─────────────────────────────────────────────────────────────
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('enrollment and resume state survive a real Platform API restart', async () => {
|
||||
const before = await alice.request('GET', '/api/student/programs');
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const after = await alice.request('GET', '/api/student/programs');
|
||||
assert.equal(after.status, 200);
|
||||
assert.deepEqual(after.body.map((p) => p.enrollment_id).sort(), before.body.map((p) => p.enrollment_id).sort());
|
||||
const completedView = after.body.find((p) => p.enrollment_id === enrollmentId);
|
||||
assert.equal(completedView.status, 'completed');
|
||||
});
|
||||
} else {
|
||||
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5C — enrollment operations & communications acceptance test. Covers the notification
|
||||
// lifecycle (queue/claim/sent/failed/retry), activation/enrollment-confirmation/password-reset/
|
||||
// password-changed delivery, duplicate-webhook idempotency, retry/failure classification and
|
||||
// exhaustion, minor/no-email contact-email resolution, admin operations (resend, RBAC scoping),
|
||||
// security (token secrecy, production gating), and a real Platform API restart. Uses the
|
||||
// deterministic dev email provider throughout — no real network call is ever made by this
|
||||
// suite. Same conventions as e2e-phase5b.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_KEY=... node test/e2e-phase5c.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
function fireIntegrationEvent(payload) {
|
||||
return fetch(`${GW}/api/integrations/enrollments`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
}
|
||||
async function waitForStatus(owner, studentId, wantStatus, timeoutMs = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const match = body.find((n) => n.status === wantStatus);
|
||||
if (match) return match;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`timed out waiting for a notification with status=${wantStatus}`);
|
||||
}
|
||||
// Same idea, but for one SPECIFIC notification_id — waitForStatus alone isn't enough once a
|
||||
// student has more than one notification (e.g. right after a resend), since it would happily
|
||||
// match an EARLIER notification that already reached the wanted status instead of the new one.
|
||||
async function waitForNotificationStatus(owner, studentId, notificationId, wantStatus, timeoutMs = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
const row = body.find((n) => n.notification_id === notificationId);
|
||||
if (row?.status === wantStatus) return row;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`timed out waiting for notification ${notificationId} to reach status=${wantStatus}`);
|
||||
}
|
||||
// Nudges a still-queued row's next_attempt_at to now, so a test doesn't have to sit through
|
||||
// real exponential backoff (up to an hour) to observe retry/exhaustion behavior.
|
||||
function nudgeRetry() {
|
||||
execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`,
|
||||
{ cwd: `${__dirname}/..`, stdio: 'pipe' }
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5C enrollment operations & communications acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── §1-2: activation vs enrollment-confirmation ─────────────────────────────────────────
|
||||
let newStudentId, newEnrollmentId;
|
||||
await test('new student integration enrollment queues an activation notification', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-new-${RUN_ID}@example.com`, display_name: 'New Student' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
newStudentId = body.student_id; newEnrollmentId = body.enrollment_id;
|
||||
const sent = await waitForStatus(owner, newStudentId, 'sent');
|
||||
assert.equal(sent.notification_type, 'activation');
|
||||
});
|
||||
|
||||
await test('existing active student gets enrollment_confirmation instead of activation on a second program', async () => {
|
||||
// Activate the student from the previous test first (credentials required for this branch).
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
const activationNotif = preview.find((n) => n.notification_type === 'activation');
|
||||
const { body: devPreview } = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
const token = devPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
const student = makeSession(GW);
|
||||
await student.request('POST', '/api/student/activate', { token, password: 'NewStudentPass123!' });
|
||||
|
||||
await owner.request('POST', '/api/admin/programs', { program_id: `p5c-second-${RUN_ID}`, name: 'Second Program', family: 'root' });
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-second`, source: 'website', program_slug: `p5c-second-${RUN_ID}`, status: 'paid',
|
||||
student: { email: `p5c-new-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, newStudentId, 'sent', 8000);
|
||||
// waitForStatus returns the FIRST sent row it finds — there are now two (activation +
|
||||
// confirmation); assert the confirmation specifically exists among this student's notifications.
|
||||
const { body: all } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
const confirmation = all.find((n) => n.notification_type === 'enrollment_confirmation' && n.enrollment_id === body.enrollment_id);
|
||||
assert.ok(confirmation, 'expected an enrollment_confirmation notification for the already-activated student');
|
||||
});
|
||||
|
||||
// ── §3-4: duplicate/concurrent webhook -> no notification storm ────────────────────────
|
||||
await test('duplicate integration event sends no duplicate notification', async () => {
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com` } });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const { body: after } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
assert.equal(after.length, before.length);
|
||||
});
|
||||
|
||||
await test('concurrent duplicate integration events send exactly one logical initial notification', async () => {
|
||||
const eventId = `p5c-${RUN_ID}-race`;
|
||||
const email = `p5c-racer-${RUN_ID}@example.com`;
|
||||
const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email } });
|
||||
const [a, b, c] = await Promise.all([fire(), fire(), fire()]);
|
||||
const studentId = a.body.student_id;
|
||||
await waitForStatus(owner, studentId, 'sent');
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
|
||||
assert.equal(notifs.length, 1, `expected exactly one notification, got ${notifs.length}`);
|
||||
});
|
||||
|
||||
// ── §5-10: worker delivery, retry, permanent failure, exhaustion ────────────────────────
|
||||
await test('temporary provider failure retries (does not immediately fail)', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-temp`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-temp-${RUN_ID}[email protected]` },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3500)); // let the first (failing) attempt run
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
|
||||
const notif = notifs[0];
|
||||
assert.ok(['queued', 'sending'].includes(notif.status), `expected still-retrying status, got ${notif.status}`);
|
||||
assert.ok(notif.attempt_count >= 1);
|
||||
assert.equal(notif.last_error_code, 'simulated_temporary_failure');
|
||||
});
|
||||
|
||||
await test('permanent failure becomes failed on the first attempt (no wasted retries)', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-perm`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-perm-${RUN_ID}[email protected]` },
|
||||
});
|
||||
const failed = await waitForStatus(owner, body.student_id, 'failed');
|
||||
assert.equal(failed.attempt_count, 1);
|
||||
assert.equal(failed.last_error_code, 'simulated_permanent_failure');
|
||||
});
|
||||
|
||||
await test('retry exhaustion: a persistently-failing temporary error eventually becomes failed, bounded', async () => {
|
||||
const { body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-exhaust`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-exhaust-${RUN_ID}[email protected]` },
|
||||
});
|
||||
let finalStatus = null, finalAttempts = null;
|
||||
// MAX_ATTEMPTS is 5 (notifications.js) and the worker polls every NOTIFICATION_WORKER_POLL_MS
|
||||
// (default 3000ms) — each loop pass must outlast one poll cycle for the nudge to actually be
|
||||
// picked up before the next nudge, or this just re-nudges a row the worker hasn't touched yet.
|
||||
for (let i = 0; i < 10 && finalStatus !== 'failed'; i++) {
|
||||
nudgeRetry();
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
|
||||
finalStatus = notifs[0]?.status;
|
||||
finalAttempts = notifs[0]?.attempt_count;
|
||||
}
|
||||
assert.equal(finalStatus, 'failed', `expected exhaustion to reach failed within the nudge loop (last status: ${finalStatus}, attempts: ${finalAttempts})`);
|
||||
assert.ok(finalAttempts <= 5, `expected a bounded attempt count, got ${finalAttempts}`);
|
||||
});
|
||||
|
||||
// ── §11-15: admin visibility, resend/regenerate ─────────────────────────────────────────
|
||||
await test('admin sees delivery status via both the global and per-student views', async () => {
|
||||
const { body: global_ } = await owner.request('GET', '/api/admin/notifications');
|
||||
assert.ok(global_.items.length > 0);
|
||||
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
|
||||
assert.ok(perStudent.length > 0);
|
||||
assert.ok(!('metadata' in perStudent[0]));
|
||||
assert.ok(!('recipient_email' in perStudent[0])); // only the masked field is ever present
|
||||
});
|
||||
|
||||
let pendingStudentId, firstActivationNotifId, resentNotifId;
|
||||
await test('resend creates an intentional NEW notification attempt (not a mutation of the old row)', async () => {
|
||||
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Resend Test', email: `p5c-resend-${RUN_ID}@example.com` });
|
||||
pendingStudentId = created.student_id;
|
||||
await waitForStatus(owner, pendingStudentId, 'sent');
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
firstActivationNotifId = before[0].notification_id;
|
||||
const { status, body: resent } = await owner.request('POST', `/api/admin/students/${pendingStudentId}/resend-activation`);
|
||||
assert.equal(status, 200);
|
||||
assert.notEqual(resent.notification_id, firstActivationNotifId);
|
||||
resentNotifId = resent.notification_id;
|
||||
});
|
||||
|
||||
let oldToken, newToken;
|
||||
await test('regenerate (resend) invalidates the previous activation token; old token now fails, new one works', async () => {
|
||||
const { status: previewStatus, body: firstPreview } = await owner.request('GET', `/api/admin/notifications/${firstActivationNotifId}/dev-preview`);
|
||||
if (previewStatus !== 200) throw new Error(`dev-preview for ${firstActivationNotifId} returned ${previewStatus}: ${JSON.stringify(firstPreview)}`);
|
||||
oldToken = firstPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
await waitForNotificationStatus(owner, pendingStudentId, resentNotifId, 'sent'); // the resend's OWN delivery, not just "any" sent notification
|
||||
const { status: secondPreviewStatus, body: secondPreview } = await owner.request('GET', `/api/admin/notifications/${resentNotifId}/dev-preview`);
|
||||
if (secondPreviewStatus !== 200) throw new Error(`dev-preview for ${resentNotifId} returned ${secondPreviewStatus}: ${JSON.stringify(secondPreview)}`);
|
||||
newToken = secondPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
assert.notEqual(oldToken, newToken);
|
||||
|
||||
const failWithOld = await makeSession(GW).request('POST', '/api/student/activate', { token: oldToken, password: 'ShouldNotWork123!' });
|
||||
assert.equal(failWithOld.status, 400);
|
||||
const okWithNew = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'ResendPass123!' });
|
||||
assert.equal(okWithNew.status, 200);
|
||||
});
|
||||
|
||||
await test('activation token is single-use (the new token, already consumed above, fails a second time)', async () => {
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'AnotherPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
await test('activation token expiration is enforced server-side', async () => {
|
||||
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Expiry Test', email: `p5c-expiry-${RUN_ID}@example.com` });
|
||||
execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE students SET credential_token_expires_at = now() - interval '1 hour' WHERE student_id = '${created.student_id}'"`,
|
||||
{ cwd: `${__dirname}/..` }
|
||||
);
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: created.activation_token, password: 'ExpiredPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
// ── §18/§20/§21: token secrecy ───────────────────────────────────────────────────────────
|
||||
await test('no activation/reset plaintext ever appears in any admin notification API response', async () => {
|
||||
const { body: list } = await owner.request('GET', '/api/admin/notifications');
|
||||
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
const serialized = JSON.stringify(list) + JSON.stringify(perStudent);
|
||||
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken));
|
||||
});
|
||||
|
||||
// ── §19-24: password reset ───────────────────────────────────────────────────────────────
|
||||
await test('reset anti-enumeration preserved (identical response for real vs unknown identifier)', async () => {
|
||||
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
|
||||
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}@example.com` });
|
||||
assert.equal(real.body.message, fake.body.message);
|
||||
});
|
||||
|
||||
let resetToken;
|
||||
await test('reset email is queued and delivered for a valid account', async () => {
|
||||
const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
|
||||
resetToken = res.body.dev_reset_token;
|
||||
assert.ok(resetToken);
|
||||
const sent = await waitForStatus(owner, pendingStudentId, 'sent', 8000);
|
||||
});
|
||||
|
||||
await test('reset token is single-use and expiration is enforced', async () => {
|
||||
const good = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'ResetPassNew123!' });
|
||||
assert.equal(good.status, 200);
|
||||
const reuse = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'AnotherOne123!' });
|
||||
assert.equal(reuse.status, 400);
|
||||
});
|
||||
|
||||
await test('password reset revokes prior sessions and the new password works', async () => {
|
||||
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
assert.equal(login.status, 200);
|
||||
});
|
||||
|
||||
// ── §25-26: minor/no-email contact-email path ───────────────────────────────────────────
|
||||
await test('minor/no-email recipient flow: contact_email delivers the activation notification', async () => {
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { login_identifier: `p5c-minor-${RUN_ID}`, display_name: 'Minor Student', contact_email: `p5c-guardian-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, body.student_id, 'sent');
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.equal(preview.to, `p5c-guardian-${RUN_ID}@example.com`);
|
||||
});
|
||||
|
||||
await test('the guardian contact email is NEVER written to students.email', async () => {
|
||||
const out = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -tA -c "SELECT email FROM students WHERE login_identifier = 'p5c-minor-${RUN_ID}'"`,
|
||||
{ cwd: `${__dirname}/..` }
|
||||
).toString().trim();
|
||||
assert.equal(out, '');
|
||||
});
|
||||
|
||||
// ── §27-30: integration auth, cross-student isolation, RBAC scoping ────────────────────
|
||||
await test('integration auth remains separate from student/admin session auth', async () => {
|
||||
const res = await fetch(`${GW}/api/integrations/enrollments`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
|
||||
body: JSON.stringify({ event_id: 'x', program_slug: PROGRAM, status: 'paid', student: { email: '[email protected]' } }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
await test('a student session cannot invoke any admin notification route', async () => {
|
||||
const student = makeSession(GW);
|
||||
await student.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
const { status } = await student.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.ok([401, 403].includes(status));
|
||||
});
|
||||
|
||||
// ── Regression: no arbitrary email template injection (§21) ────────────────────────────
|
||||
// Found and fixed during this phase — display_name flows straight from a website
|
||||
// integration event (genuinely external, caller-controlled text) into the activation
|
||||
// email's HTML. templates.js now escapes every interpolated value in the HTML body.
|
||||
await test('a display_name containing HTML markup is escaped in the rendered email, not injected', async () => {
|
||||
const maliciousName = '<img src=x onerror=alert(1)>Injected';
|
||||
const { status, body } = await fireIntegrationEvent({
|
||||
event_id: `p5c-${RUN_ID}-injection`, source: 'website', program_slug: PROGRAM, status: 'paid',
|
||||
student: { email: `p5c-injection-${RUN_ID}@example.com`, display_name: maliciousName },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const sent = await waitForStatus(owner, body.student_id, 'sent');
|
||||
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
|
||||
assert.ok(!preview.html.includes('<img src=x onerror=alert(1)>'), 'raw markup must never appear unescaped in the HTML body');
|
||||
assert.ok(preview.html.includes('<img'), 'the escaped form must be present instead');
|
||||
});
|
||||
|
||||
const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5c-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!',
|
||||
});
|
||||
await test('instructor RBAC/scoping preserved for notification visibility', async () => {
|
||||
const unrelated = makeSession(GW);
|
||||
await unrelated.request('POST', '/api/auth/instructor/login', { email: `p5c-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' });
|
||||
const scopedList = await unrelated.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.equal(scopedList.status, 403);
|
||||
const globalList = await unrelated.request('GET', '/api/admin/notifications');
|
||||
assert.equal(globalList.status, 403); // global view is owner/admin only
|
||||
});
|
||||
|
||||
// ── §33-34: metrics / audit hygiene ──────────────────────────────────────────────────────
|
||||
await test('metrics contain notification gauges/counters with no PII', async () => {
|
||||
const res = await fetch(`${API}/metrics`);
|
||||
const text = await res.text();
|
||||
assert.ok(text.includes('rootnode_notifications_total'));
|
||||
assert.ok(text.includes('rootnode_notification_delivery_attempts_total'));
|
||||
assert.ok(!text.includes(`p5c-resend-${RUN_ID}@example.com`));
|
||||
assert.ok(!text.includes(pendingStudentId));
|
||||
assert.ok(!text.includes(newToken || ''));
|
||||
});
|
||||
|
||||
await test('audit events contain no tokens/secrets', async () => {
|
||||
const { body } = await owner.request('GET', '/api/admin/audit-events?page=1');
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken) && !serialized.includes(resetToken));
|
||||
assert.ok(!/password_hash|credential_token_hash/i.test(serialized));
|
||||
});
|
||||
|
||||
// ── production gating (§8/§21) — recreate the SAME api service with COOKIE_SECURE=1 via the
|
||||
// standard compose env-file mechanism (no ad hoc container/port juggling under
|
||||
// network_mode: host, which doesn't tolerate a second container easily) ─────────────────
|
||||
await test('production mode never returns dev_reset_token or exposes the dev-preview route', async () => {
|
||||
const cwd = `${__dirname}/..`;
|
||||
execSync(`bash -c "echo COOKIE_SECURE=1 >> .env"`, { cwd, stdio: 'pipe' });
|
||||
try {
|
||||
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const reset = await fetch(`${API}/api/student/request-password-reset`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: `p5c-resend-${RUN_ID}@example.com` }),
|
||||
}).then((r) => r.json());
|
||||
assert.ok(!('dev_reset_token' in reset), 'production must never echo the reset token');
|
||||
// The dev-preview route also refuses outright once COOKIE_SECURE=1, regardless of what a
|
||||
// notification row happens to hold.
|
||||
const preview = await fetch(`${API}/api/admin/notifications/${firstActivationNotifId}/dev-preview`, { headers: { Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` } });
|
||||
assert.equal(preview.status, 404);
|
||||
} finally {
|
||||
execSync(`bash -c "grep -v '^COOKIE_SECURE=' .env > .env.tmp && mv .env.tmp .env"`, { cwd, stdio: 'pipe' });
|
||||
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
});
|
||||
|
||||
// ── §31-32/§35-36: restart persistence + regression smoke ───────────────────────────────
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('a real Platform API restart preserves the notification queue and does not duplicate an already-sent notification', async () => {
|
||||
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const reLogin = makeSession(GW);
|
||||
await reLogin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
const { body: after } = await reLogin.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
|
||||
assert.equal(after.length, before.length, 'restart must not duplicate any notification row');
|
||||
const sentCount = after.filter((n) => n.status === 'sent').length;
|
||||
const sentCountBefore = before.filter((n) => n.status === 'sent').length;
|
||||
assert.equal(sentCount, sentCountBefore);
|
||||
});
|
||||
} else {
|
||||
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
|
||||
}
|
||||
|
||||
await test('Phase 5B enrollment/resume still works unmodified', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
|
||||
const { status, body } = await s.request('GET', '/api/student/programs');
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('Phase 5A instructor authentication still works unmodified', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user