Author SHA1 Message Date
ced4568 4c0a950afb fix: retry Google's transient domain-wide-delegation propagation-delay error
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
Live Google Workspace delivery validation surfaced a real, self-resolving
Google-side condition: freshly-authorized domain-wide delegation can take
up to about a minute to propagate across Google's auth infrastructure,
producing intermittent unauthorized_client on the OAuth token exchange
even with correct configuration. gws-provider.js now classifies
unauthorized_client as a temporary (retryable) error instead of permanent,
so the existing bounded-backoff worker absorbs it automatically.

Also documents the alias-vs-delegated-user env passthrough in
docker-compose.dev.yml, and records both real findings (this one and the
GOOGLE_DELEGATED_USER alias distinction) plus the full live-delivery
validation outcome in the runbook and Phase 5C doc.

Validated live: isolated gws-email-test.js send (DKIM/SPF passing,
delivered) and a full real lifecycle test (create student, enrollment,
real activation email, activate, login, see program, real password-reset
email, reset, login with new password) via [email protected].
2026-08-25 21:36:12 -05:00
ced4568 7baf7074e5 fix(email): support a delegated-mailbox alias distinct from the delegated Workspace account
Real-world case found during setup: the sending address ([email protected]) is an
alias of an existing Workspace user, not its own paid seat (a deliberate cost-saving choice).
Domain-wide delegation authorizes a real Workspace ACCOUNT, not an arbitrary alias string, so
using the alias directly as the delegated 'sub' claim would have been wrong. Added
GOOGLE_DELEGATED_USER (the account that actually owns the alias and was granted delegation) as
an optional override, falling back to EMAIL_FROM_ADDRESS unchanged when the sending address is
itself a primary account. EMAIL_FROM_ADDRESS remains the visible From: header in both cases.
Documented in docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md with a concrete example.
2026-08-25 17:38:59 -05:00
ced4568 777efd168d docs(phase5c): enrollment communications documentation + Google Workspace runbook
- PHASE5C_ENROLLMENT_COMMUNICATIONS.md: the full design doc.
- RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md: every REQUIRES GOOGLE WORKSPACE ADMIN ACTION step
  (mailbox, service account, domain-wide delegation scoped to gmail.send only, SPF/DKIM/DMARC),
  environment variables, testing procedure, production secret handling, rotation, revocation,
  troubleshooting, quotas.
- RUNBOOK_STUDENT_ACTIVATION.md: day-to-day operation of the pipeline from /admin.
- WEBSITE_ENROLLMENT_INTEGRATION.md: explicit website-vs-RootNode responsibility boundary,
  the new contact_email field, and closes the 'RootNode doesn't send activation email yet'
  known limitation this doc previously documented.
- RUNBOOK_SELF_PACED_ENROLLMENT.md, RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md: point at the new
  automatic-email behavior and the Resend activation control.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md, ENGINEERING_SOP.md: close the enrollment-
  communication gap; new ENGINEERING_SOP §16.
- PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md: cross-references and a
  Phase 5C production-validation section.
2026-08-25 14:45:18 -05:00
ced4568 b38c46aaec test(phase5c): permanent regression suite + enrollment/activation dress rehearsal
- platform/test/e2e-phase5c.js (29 checks): activation-vs-confirmation routing,
  duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry
  classification, resend/regenerate token invalidation, activation/reset single-use and
  expiration, HTML-injection regression, production token-secrecy gating via 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 through a real rendered/extracted activation email, a real Lab
  launch and scenario completion, a second enrollment's confirmation email, a full
  password-reset round trip, and the minor/no-email contact-email path.
- infra/scripts/gws-email-test.js: the opt-in (RUN_GWS_EMAIL_TEST=true) live Google Workspace
  delivery test — does nothing without the explicit flag, sends exactly one message to one
  operator-configured recipient when enabled.
2026-08-25 14:44:25 -05:00
ced4568 be92be81cd feat(observability): notification delivery metrics, no PII/high-cardinality labels
- rootnode_notifications_total{notification_type,status} (computed fresh from the DB, like
  the existing enrollment_breakdown gauge — never drifts from what's actually stored),
  rootnode_notification_queue_depth, rootnode_notification_oldest_queued_seconds,
  rootnode_notification_delivery_attempts_total{provider,result} (a real in-process counter —
  per-attempt outcomes aren't reconstructable from the DB after the fact),
  rootnode_student_activations_{success,invalid}_total. Every label is a bounded enum —
  never student_id/email/token/notification_id/external_event_id.
2026-08-25 14:43:48 -05:00
ced4568 f25535027c feat(admin): notification operations — resend activation, delivery status, RBAC-scoped views
- notification-routes.js: global list (owner/admin only), per-student list (scoped via
  requireStudentRelationshipAccess), resend-activation (mechanically the same as
  regenerate — Phase 5A's token model already guarantees only the latest is ever valid, so one
  action covers both), a dev-only preview route (unconditionally refuses when
  COOKIE_SECURE=1, mirroring student-auth-routes.js's existing dev_reset_token gate).
- NotificationsView.jsx: owner/admin global notification list, paginated, matching
  AuditView.jsx's exact convention.
- StudentsView.jsx: student detail gained a Resend activation control and a Notifications
  history card; Credentials card now explains the automatic-email behavior.
- AdminPortal.jsx: new Notifications nav entry (owner/admin only).
2026-08-25 14:43:10 -05:00
ced4568 3c7f0ca59c feat(activation): wire notifications into enrollment/auth, real password-reset delivery
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a
  genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven
  enrollment-level idempotency, no separate notification dedup needed).
- admin-routes.js: create-student/convert/reset-password now enqueue the corresponding
  notification using the SAME token already minted synchronously (never mints twice, which
  would silently invalidate the token just handed back in the response). Fixed a real bug:
  request-password-reset would mint a reset token for a never-activated student, which makes
  no sense and would confuse them — now falls through to the generic anti-enumeration response.
- admin-programs-routes.js: manual enrollment triggers the same notification decision as the
  website path.
- student-auth-routes.js: /activate records a metric; /change-password and /reset-password
  both queue a password_changed security notice; /request-password-reset now actually delivers
  email (production still never returns the token — unchanged anti-enumeration/isProduction
  gating, just now with a real send behind it).
- rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification
  visibility rule (cohort-shared or an enrollment they created), reusable beyond just
  enrollments.
- server.js: wires notification-routes.js and starts the notification worker in main().
- docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE
  (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
2026-08-25 14:42:34 -05:00
ced4568 9d382c95c5 feat(email): provider-neutral email service — Google Workspace (Gmail API) + dev provider
- email/provider.js: the seam. EMAIL_PROVIDER unset always defaults to the dev provider,
  regardless of any other setting — misconfiguration means email silently doesn't send for
  real, never that a dev/test run accidentally reaches a real student.
- email/gws-provider.js: Gmail API users.messages.send via a service account with Workspace
  domain-wide delegation, impersonating one dedicated mailbox. No human password, no SMTP
  basic auth, no googleapis/google-auth-library dependency — a hand-rolled RS256 JWT-bearer
  OAuth exchange + one REST call (~120 lines), matching this codebase's established
  hand-roll-before-adding-a-dependency convention.
- email/dev-provider.js: never makes a network call, returns the rendered message back to the
  caller for dev/test inspection. Supports magic +forcetempfail/+forcepermfail recipient
  markers so the permanent test suite can deterministically exercise retry/failure
  classification without needing a real flaky provider.
- email/templates.js: activation/enrollment-confirmation/password-reset/password-changed,
  HTML + plain-text, no external images/tracking/JS. Every interpolated value is HTML-escaped
  in the HTML body (a real injection finding — display_name can come straight from a website
  integration event).
- notification-worker.js: a setInterval poller (same pattern as sessions.js's sweeper), claims
  via notifications.js, renders via templates.js, builds activation/reset URLs from
  PUBLIC_APP_ORIGIN, stores a dev-only preview (never in production) so tests can extract the
  real URL without a mailbox.
2026-08-25 14:42:01 -05:00
ced4568 5183a6af09 feat(notifications): durable notification lifecycle + minor-safe recipient resolution
- migrations/021: notification_events (queued/sending/sent/failed/cancelled, bounded
  exponential backoff via attempt_count/next_attempt_at, a classified last_error_code, never a
  raw stack trace or provider credential).
- notifications.js: enqueue (records a no-recipient case as already-failed rather than
  silently dropping it), claimNext (FOR UPDATE SKIP LOCKED — safe if two worker processes ever
  run), markSent/markFailedOrRetry (bounded retry, terminal-state token scrubbing),
  notifyForEnrollment (the shared activation-vs-confirmation decision every enrollment-creation
  path calls), maskEmail/toAdminView (the only shapes any admin API ever returns).
- enrollment.js: enrollmentContactEmail() — reads back the minor/no-email transitional
  contact-email convention (stored in the existing enrollments.metadata JSONB, never written to
  students.email) that createFromIntegrationEvent/createManualEnrollment now accept.
2026-08-25 14:41:29 -05:00
ced4568 1c08a30c7f docs(phase5b): self-paced enrollment documentation + website integration contract
- PHASE5B_SELF_PACED_ENROLLMENT.md: the full design doc.
- RUNBOOK_SELF_PACED_ENROLLMENT.md: operational runbook for /admin -> Programs.
- WEBSITE_ENROLLMENT_INTEGRATION.md: the complete contract for the separate website team —
  endpoint, auth, request/response schema, idempotency/retry rules, student matching, minor/
  no-email behavior, error responses, what not to send, local testing, production-secret
  handling. Written to be usable without reading the Platform API source.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the self-registration/curriculum-assignment/
  delivery-mode/enrollment-model gaps this doc previously flagged as Phase 5+ scope.
- ENGINEERING_SOP.md: new §15. LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md,
  README.md, platform/README.md: cross-references and a Phase 5B production-validation section.
2026-08-25 12:10:13 -05:00
ced4568 23f881884d test(phase5b): permanent regression suite + self-paced dress rehearsal
- platform/test/e2e-phase5b.js (32 checks): program seed, entitlement gating
  (pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/
  reaper-reconciliation/recreate persistence, server-derived idempotent completion via a full
  real capstone completion, the integration boundary (auth, idempotency, real concurrency,
  unknown/inactive program rejection, minor/no-email path, no card data), RBAC scoping, a
  regression test for the process-crash bug this suite found, Phase 5A smoke, and a real
  Platform API restart.
- infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js (15 checks): one continuous story —
  activation, a simulated website enrollment event, real Lab work, Lab destroy, a new session,
  Continue Learning, a fresh Lab, full real capstone completion, Completed transition, and a
  second uninvolved student proven unable to reach any of it.
2026-08-25 12:09:34 -05:00
ced4568 f89a769540 feat(observability): enrollment/completion metrics, no PII/high-cardinality labels
- metrics.js: rootnode_enrollments_total{status,delivery_mode}, rootnode_selfpaced_active_
  enrollments, rootnode_program_completions_total{program_id}, rootnode_enrollment_events_
  {created,duplicate,rejected}_total. Every label is a bounded enum or an admin-curated slug —
  never student_id/email/enrollment_id/external_reference.
2026-08-25 12:09:17 -05:00
ced4568 4bc202e5f3 feat(integration): provider-neutral website enrollment boundary
- integration-routes.js: POST /api/integrations/enrollments — separate from student/instructor
  session auth entirely (no cookie read), pre-shared-secret header auth matching the existing
  Gateway<->Provisioner trust-boundary pattern. Idempotent via a DB unique constraint on the
  caller's own event_id (not app-level check-then-insert) — proven safe under real concurrent
  duplicate requests. Student resolution is create-or-match by email OR login_identifier
  (minor/no-email support), reusing Phase 5A's exact credential-issuing path. A small,
  provider-neutral status vocabulary (paid/active/confirmed/pending/awaiting_payment) — never
  a raw payment-provider object model.
- docker-compose.dev.yml: INTEGRATION_SHARED_SECRET, optional in dev (fails closed if unset).
2026-08-25 12:09:02 -05:00
ced4568 1b6da0a966 feat(selfpaced): student-facing My Programs dashboard
- ProgramsTab.jsx: Active/Completed programs, real progress, deterministic Continue Learning
  (reuses the same launch/poll pattern MyLearningTab already established), entitlement-blocked
  states shown with a real reason, never a silent dead end.
- StudentDashboard.jsx: new My Programs tab. api.js: programs/program/launchProgram.
2026-08-25 12:08:44 -05:00
ced4568 17160da89e feat(admin): Programs area, enrollment management, student enrolled-programs view
- admin-programs-routes.js: program CRUD (admin/owner authoring, all roles can view — matches
  assessment_definitions' convention), scoped enrollment listing, manual enroll, activate/
  cancel (via rbac.js's requireEnrollmentAccess).
- ProgramsView.jsx: cohort-picker-free program list/create, per-program detail with
  enrollment table and a manual-enroll form.
- StudentsView.jsx: student detail gained an 'Enrolled programs' card.
- AdminPortal.jsx: new Programs nav entry.
2026-08-25 12:08:29 -05:00
ced4568 611e1ac4ba feat(enrollment): enrollment lifecycle, entitlement gating, self-paced student API
- enrollment.js: server-only status transitions (pending->active->completed, cancel),
  deriveEnrollmentAccess (pure, derived-at-read-time like classroom.js's derivePresence —
  'expired' is never stored, computed from access_ends_at), maybeCompleteEnrollment
  (idempotent, server-derived from the existing capstone achievement).
- environment-view.js: publicEnvView/launchEnvironmentForStudent extracted out of server.js's
  original /api/environment/launch route body so the new entitlement-gated self-paced launch
  reuses the identical disposable-Lab sequence instead of a second copy — behavior for the
  existing cohort/pilot route is completely unchanged.
- program-status.js: combines an enrollment with real curriculum/progress state into one view
  (access, percent-complete, current stage, deterministic next-activity/resume target) — shared
  by the student and admin surfaces so both show identical information.
- selfpaced-routes.js: GET /api/student/programs(/:id), POST .../launch — entitlement enforced
  server-side before ever touching the Provisioner, never just hidden in the UI.
- rbac.js: requireEnrollmentAccess — a plain instructor may manage an enrollment they created
  or that belongs to a student sharing one of their cohorts; a self-paced enrollment can have
  no cohort at all, so the existing cohort-only checks can't cover it.
- server.js: found and fixed a real bug — POST /api/progress/record's handler predated the
  wrap() convention, so an unexpected internal error (a malformed environment_id) crashed the
  entire process instead of just 500ing that request. Now wrapped + input-validated.
2026-08-25 12:08:12 -05:00
ced4568 895e16fea4 feat(programs): first-class program model — init_/compile_/root_ ladder, seeded root-kubernetes-foundations
- migrations/019: programs table (slug PK, matching assessment_definitions/cohorts
  convention), family (init/compile/root) and delivery_mode (self_paced/cohort/hybrid) as real
  CHECK-constrained enums, versioned like assessment_definitions so an edit to
  curriculum_path/delivery_mode never silently changes what an existing enrollment means. Seeds
  exactly one program wrapping the existing RootNode/Kubernetes curriculum and capstone — no
  new curriculum content invented.
- programs.js: list/get/create/update, with automatic version bump only when a
  snapshot-relevant field actually changes.
2026-08-25 12:07:34 -05:00
ced4568 896c230142 docs(phase5a): student identity and remote classroom documentation
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
- PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md: the full design doc.
- RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md, RUNBOOK_REMOTE_CLASSROOM.md: operational runbooks.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the 'no real student authentication' gap this
  doc previously flagged as the one item blocking self-paced delivery.
- ENGINEERING_SOP.md §5 rewritten (was describing a pre-Phase-2A state no longer true of any
  deployed version of this codebase) plus a new §14; roadmap item 3 updated.
- LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md:
  cross-references and a Phase 5A production-validation section.
2026-08-25 01:20:13 -05:00
ced4568 bfa16d9162 test(phase5a): permanent regression suite + dress rehearsal drill
- platform/test/e2e-phase5a.js (21 checks): the two-student RBAC matrix (student can't reach
  any /api/admin/* route, assigned/unassigned/viewer instructor access) and the full password
  security checklist (correct/wrong/unknown-identifier/deactivated/immediate-deactivation/
  rate-limit/rate-limit-survives-a-real-restart/change-password/session-revocation/
  forgot-password anti-enumeration/admin-issued reset/CSRF), plus a classroom lifecycle smoke
  pass. Replaces the throwaway smoke5a/smoke5b scripts used during development.
- infra/scripts/drills/test-phase5a-rehearsal.js (15 checks): 5 pilot students with real
  seeded pre-existing progress/attempt/achievement history, a live classroom, help requests,
  announcements, a REAL Platform API restart mid-rehearsal proving classroom/session/presence
  recovery, then converting two students mid-rehearsal and confirming zero data was orphaned.
- Both run against a live docker-compose.dev.yml stack over real HTTP, not mocks.
2026-08-25 01:20:02 -05:00
ced4568 7557fdc753 feat(admin): student credential management, CSV import UI, remote classroom control center
- StudentsView: credential status in the roster table, a create-student form
  (email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
  bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
  re-fetchable.
- ClassroomView: cohort picker -> schedule/list classes -> a live dashboard (participants with
  presence/Lab-status/current-work, help queue with acknowledge/resolve, current-activity
  setter, announcement poster, start/end/cancel, Prepare Labs). Polls the dashboard endpoint
  rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
  for createStudent's new object-argument signature.
2026-08-25 01:19:54 -05:00
ced4568 5c7e49c399 feat(student): live student experience — real login, classroom, profile
- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry
  and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by
  ?activate=/?reset= query params — no router library, matching this app's existing
  state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only
  reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
  guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
  exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
  change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
  tabs; api.js/Platform.module.css extended to match.
2026-08-25 01:19:44 -05:00
ced4568 ee8ccb2ab6 feat(classroom): remote classroom backend — lifecycle, presence, help queue, dashboard
- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one
  live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/
  leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored
  status to go stale), help-request create/acknowledge/resolve, announcements, current-activity
  (guidance only, never redirects a browser or marks work complete), append-only event log.
- admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's
  requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves
  through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere
  else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines
  real Provisioner Lab state and real academic records with classroom.js's own data — no
  second Lab-state or academic-state system.
- classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request,
  announcements), requireEnrolledClassroom middleware.
- prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new
  classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission
  control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
2026-08-25 01:19:33 -05:00
ced4568 d34ddae458 feat(student): admin student-account management + CSV import, session hardening
- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions,
  /sessions, /import (transactional CSV import — whole file validated, one Postgres
  transaction, never accepts a password/hash column from input). Fixed GET
  /api/admin/students to select an explicit safe column list instead of SELECT * — the new
  password_hash/credential_token_hash columns must never be exposed through this route.
- server.js: /api/login now also blocks on credential_token_hash (not just password_hash) —
  closes a real window where a student mid-conversion stayed reachable via the old
  bare-student_id path. studentSession middleware now re-verifies status='active' on every
  request, not just at session creation, matching instructorSession's existing behavior.
- sessions.js: revokeAllStudentSessions, listStudentSessions.
- rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the
  new classroom/help-request RBAC (next commit) reuses the exact same authorization rule
  instead of a parallel copy.
- student-csv.js + test: hand-rolled parser (no new dependency), unit tested.
- prometheus.yml.j2: activated the platform-api scrape job.
2026-08-25 01:19:19 -05:00
ced4568 2f09b1a498 feat(student-auth): real student credentials — email/login-ID + password, token-based activation and reset
- migrations/017: email, login_identifier, password_hash, last_login_at, credential_token_*
  on students. Fully additive, both nullable/independent, no destructive change.
- students-auth.js: shared credential/token lifecycle (issue/consume/find), reusing
  instructors' exact scrypt password.js verbatim.
- student-auth-routes.js: /api/student/login, /activate, /change-password,
  /request-password-reset (anti-enumeration, dev-only token echo), /reset-password, /profile.
- metrics.js: hand-rolled /metrics Prometheus exporter, login success/failure counters, no
  student/instructor ID ever used as a label value.
- migrations/018: classroom_sessions/participants/events/announcements/help_requests, with a
  partial unique index enforcing at most one live classroom per cohort at the DB level.
2026-08-25 01:18:59 -05:00
ced4568 5bc0743b08 docs(phase4.1): pre-production hardening documentation
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
Master overview (PHASE4_1_PREPRODUCTION_HARDENING.md) covering all 27
substantive items, plus REMOTE_AND_SELF_PACED_ARCHITECTURE.md (reviews
what already works for remote/self-paced delivery - student-driven
provisioning, cohort-less students, idle-reaper-destroy-then-continue -
and the one real gap: student authentication, explicitly Phase 5 scope,
not built here).

Updated PRODUCTION_NETWORK_SECURITY.md (the Gateway-to-Lab gap is now
RESOLVED, with the full design writeup and a new control-plane-vs-data-plane
section), PRODUCTION_DEPLOYMENT.md (four-cert TLS bundle),
PRODUCTION_VALIDATION_CHECKLIST.md (Router mTLS check, admission control
checks), PHASE4_PRODUCTION_INFRASTRUCTURE.md and ENGINEERING_SOP.md and
LAB_ARCHITECTURE.md (stale 'known gap' pointers updated to point at the
resolution), BACKUP_DR.md (destructive drill), README.md and
platform/README.md (Phase 4.1 pointers, lab-router component, updated
known-limitations).

Also fixes a real pre-existing bug found while auditing what gets
committed: infra/.gitignore's patterns were written repo-root-relative
(e.g. "infra/terraform/...") but the file lives in infra/, so git resolved
them relative to THAT directory and none of them ever matched anything -
a real .terraform/ provider cache showed up as untracked when it should
have been ignored. Fixed by dropping the redundant leading "infra/" from
every pattern.

Status: PHASE 4.1 COMPLETE / PRE-PRODUCTION HARDENING COMPLETE / READY FOR
HARDWARE DEPLOYMENT.
2026-08-24 20:16:20 -05:00
ced4568 f3c356951c test(preprod): two-host routing simulation, drill suite, CI security scanning
- infra/scripts/simulate-two-host/: real two-Docker-network simulation
  (Gateway genuinely isolated from any Lab bridge network) proving the
  Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the
  Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router
  access).
- infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback,
  CI-fails-closed (6 required failure modes), fake cohort dress rehearsal
  (14/14, one continuous narrative through the real app).
- infra/ci/gitea-workflows/build-and-test.yml: new security-scan job
  (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source),
  severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks,
  MEDIUM/LOW reported only). Gates build-images alongside regression.

Found and fixed two real pre-existing bugs via the CI-fails-closed drill:
staging-smoke-test.sh and production-smoke-test.sh both had a classic
set -e gotcha (curl ... && echo "OK" as one statement - a failing command
that isn't last in an && chain doesn't trigger -e) that let an
unreachable/broken endpoint fall through silently instead of failing the
smoke test.

platform/test/e2e-phase3a.js updated for the new paginated
/api/admin/audit-events response shape (Phase 4.1 pagination work).
2026-08-24 20:16:00 -05:00
ced4568 f5110d3c1b test(dr): destructive Postgres restore drill
infra/backup/test-destructive-restore-drill.sh - realistic academy data
(owner, admin instructor, cohort, 3 students, progress, attempts, an
assessment definition, achievements) seeded through the REAL Platform API,
encrypted-backed-up, the source Postgres container genuinely destroyed
(docker rm, not just left running alongside a second one), a fresh
Postgres created from nothing, restored, and the real Platform API booted
again against the restored database and used to log in as the restored
admin instructor over HTTP. Never touches the developer's real database.
Confirmed passing.
2026-08-24 20:15:45 -05:00
ced4568 a8e543234c feat(platform): pagination, admission control, cohort roles, version endpoint, structured assessment editor
- Real server-side pagination (platform/api/src/pagination.js) on the audit
  log (was a flat LIMIT 200), student attempt history (was LIMIT 100), and
  assessment session history (was fully unbounded - the one genuine growth
  risk found). Frontend updated (AuditView, AssessmentsView, StudentsView).
- Admission control: resource-policy.yaml's new (disabled-by-default)
  admission_control.max_concurrent_labs, checked before touching Docker at
  all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision.
  Relayed as a clean student-facing message, never a raw 500. New
  GET /capacity endpoint feeds the instructor dashboard.
- cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is
  now enforced: teacher vs viewer, rbac.js's
  requireCohortWriteAccess/requireStudentCohortWriteAccess.
- GET /api/version - app version, deployed Git SHA, latest migration as a
  schema-version stand-in.
- Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor)
  replaces the raw-JSON pool_spec textarea, backed by a new
  GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback
  remains for anything the structured editor doesn't cover.
- NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration.

Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after
these changes; frontend production build confirmed clean.
2026-08-24 20:15:39 -05:00
ced4568 addf474b70 feat(security): Postgres-backed login rate limiting
Replaces the in-memory sliding-window Map (reset on every restart, not
shareable across instances) with a login_attempts table (migration 016).
No Redis added - login is not a hot path, and this is a single indexed
range-scan query. Restart behavior confirmed for real:
platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine
Platform API restart in the same run.
2026-08-24 20:15:03 -05:00
ced4568 6dda13b837 feat(learning): abandoned attempt handling
scenario_attempts.status = 'abandoned' (a valid value since Phase 3B,
never previously set by anything) now gets set on: environment
destroy/reset, logout, session expiry (piggybacked on the existing hourly
session sweeper - no new background job), and switching to a different
scenario while one is still open. Never an academic failure marker -
result stays NULL, history is preserved. Call-site wiring in
server.js/admin-routes.js lands in the next commit alongside other
platform hardening in those same files.
2026-08-24 20:14:52 -05:00
ced4568 eae8e48d43 feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap
Adds platform/lab-router/ — a narrow service that runs on the Lab Host,
authenticates the Gateway via mTLS, and resolves an opaque routing token to
a local container purely from Docker label state (no Postgres access, no
callback to the Provisioner). Replaces the Gateway's env.internal_ip
assumption (only valid when Gateway and Lab share a Docker daemon) with a
data-driven direct/router decision per lab_host
(lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one
place (platform/gateway/src/lab-endpoint.js's labTarget()) that every
proxy/fetch call site in server.js now goes through.

Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh)
with a second mTLS leaf pair (router-server/gateway-client) for this new
data-plane connection, deployed by the docker-tls Ansible role into a
separate directory from the control-plane pair. Firewall
(rn-lab-host-01.rules) replaces the old speculative wide port-range
allowance with one narrow rule: platform_ip -> the Router's port only.

dev-local stays routing_mode: direct — zero behavior change for local dev,
confirmed by the full existing regression suite passing unmodified.
2026-08-24 20:14:39 -05:00
139 changed files with 11787 additions and 381 deletions
+34 -9
View File
@@ -150,12 +150,32 @@ its core:
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
Lab UI, not a rewrite of it.
- The student flow (real login (email or instructor-assigned login ID) or legacy pilot roster
entry → curriculum dashboard → Launch Lab → practice/assessments/capstone → achievements →
live classroom → profile) lives in `frontend/src/platform/`; the instructor/admin portal
(per-instructor login, cohorts, students with credential/CSV-import management, a remote
classroom control center, instructor accounts, audit log, environment controls, learning
analytics, assessment/capstone management) lives in `frontend/src/admin/` at the `/admin`
route — both additions alongside the existing Lab UI, not a rewrite of it.
- Students authenticate with real credentials as of Phase 5A (email+password or
login-identifier+password, first-login/reset via a single-use token an instructor relays) —
see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. Instructors run live, remote-capable
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
guidance-only current-activity) — RootNode owns identity/coordination, an external service
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
boundary — server-to-server only, no browser session involved — is what the future public
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
- As of Phase 5C, RootNode actually tells students what's happening — real activation,
enrollment-confirmation, and password-reset email, delivered asynchronously through a
provider-neutral notification worker (Google Workspace via the Gmail API in production, a
deterministic dev provider everywhere else — no test/dev run ever emails a real person). See
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -192,12 +212,17 @@ Key properties of the platform (not this image):
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`,
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
that runs all of this on real hardware lives in `infra/` — see
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`. Built and locally validated now; deployment is
pending purchase of the target Hetzner dedicated server.
`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` and `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` (the
Gateway-to-Lab cross-VM routing fix, plus application hardening — real pagination, abandonment
handling, a Postgres-backed rate limiter, a structured assessment editor, admission control).
Built and locally validated now; deployment is pending purchase of the target Hetzner dedicated
server.
---
+10 -1
View File
@@ -37,10 +37,19 @@ requires typing `RESTORE` to confirm, then decrypts and pipes into `psql`.
**Genuinely tested**, not just written: `infra/backup/test-backup-restore.sh` runs the full
cycle against two disposable local Postgres containers, with the actual production migrations
applied (all 17 — 3 Provisioner + 14 Platform API, in the correct cross-service order), a
applied (3 Provisioner + Platform API, in the correct cross-service order), a
sample row inserted, backed up, restored into the second container, and verified byte-for-byte.
Confirmed passing, exit 0.
**Phase 4.1's destructive drill goes one step further**
(`infra/backup/test-destructive-restore-drill.sh`): realistic data (owner, admin instructor,
cohort, 3 students, progress, attempts, an assessment definition, achievements) seeded through
the REAL Platform API, backed up, the source Postgres **container genuinely destroyed**
(`docker rm`, not just left running alongside a second one), a fresh Postgres created from
nothing, restored, and the real Platform API booted again against the restored database and
used to log in as the restored admin instructor over HTTP — proving the whole stack recovers,
not just the SQL. Never touches the developer's real persistent database. Confirmed passing.
## Disaster recovery scenarios
| Scenario | RPO | RTO (estimate) | What happens |
+149 -35
View File
@@ -90,35 +90,34 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
## 5. Student identity — what's built, what isn't
**Built (backend only, as of this doc):**
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
`created_at`, `last_active_at`.
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
a student (instructor-only action, currently unauthenticated at the API level — see
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
answering the same scenario don't affect each other's state, and pre-migration data was
preserved under `student_id = 'unassigned'`.
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
whoever's browser hits the API next.
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
full design):**
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
of and never changes across an account's lifecycle, including conversion.
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
{ student_id }`, no password, instructor-roster-only.
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
(`students-auth.js`) — instructors never see or set a student's actual password.
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
instructor login (Phase 4.1), now also applied to students.
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
already in place well before this — Phase 5A only changed how a student proves who they are,
not how their data or Labs are isolated.
**Not built yet — do not assume these exist:**
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
nothing calls it yet. This is the next slice.
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
Fine for a dev instance, not fine once this is reachable by actual students — needs an
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
item 3).
- **No concurrency / isolation.** Exam sessions are still global-single-active
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
cannot yet use this platform at the same time without stepping on each other.** That's
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
out — this SOP is not claiming that gate is cleared.
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
- No public self-registration — accounts are still instructor-created/converted/imported only.
- No email delivery infrastructure — password reset works via an instructor-issued token today;
self-service reset returns a real token only in non-production, by design.
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
reflects any deployed version of this codebase; that content has been superseded by the above.
## 6. Schema migration pattern
@@ -146,9 +145,11 @@ Order agreed 2026-08-17/18. Status as of this doc:
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
`frontend/src/platform/`.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
name" gap as of Phase 5A — see §5 above and
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
unchanged for anyone not yet converted.
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
owner/admin/instructor roles, audit log.
@@ -296,12 +297,125 @@ Compose, firewall, monitoring, backup, CI, load-test tooling).
- **Where the boundary held:** the Platform VM / Lab Host VM security split from
`docs/LAB_ARCHITECTURE.md` is codified in firewall rulesets and mutual-TLS Docker config, not
weakened anywhere.
- **Known gap surfaced, not silently fixed:** the Gateway's Lab-reachability mechanism doesn't
survive the move to two real separate VMs — see `docs/PRODUCTION_NETWORK_SECURITY.md`'s
"Known gap" section for what's built (firewall policy) vs. what's an explicit, reviewed,
not-yet-built application follow-up (Provisioner/Gateway address handling).
- **Known gap surfaced, then resolved in Phase 4.1:** the Gateway's Lab-reachability mechanism
didn't survive the move to two real separate VMs — flagged explicitly rather than silently
fixed, then actually built and simulation-tested (a Lab Host Router,
`platform/lab-router/`) in Phase 4.1. See §13 below.
- **Validated without the hardware:** real `tofu validate`, `ansible-playbook --syntax-check`,
`docker compose config`, `caddy validate`, `promtool`/`amtool`, and a genuine end-to-end
backup→encrypt→restore→verify cycle against disposable local Postgres with the real
production migrations applied. None of this proves the real Hetzner network boundary — that
needs `docs/PRODUCTION_VALIDATION_CHECKLIST.md` walked against the real server.
## 13. Pre-Production Hardening (Phase 4.1)
Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
`docs/PRODUCTION_NETWORK_SECURITY.md` (routing resolution), `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
`docs/SECURITY_SCANNING.md`.
- **What it is:** closes the Gateway-to-Lab cross-VM gap Phase 4 flagged (a Lab Host Router,
`platform/lab-router/`, plus a routing abstraction in the Gateway,
`platform/gateway/src/lab-endpoint.js`), then hardens the application layer found along the
way: abandoned-attempt handling (`scenario_attempts.status = 'abandoned'`, never a punitive
failure), a Postgres-backed login rate limiter (survives a restart, ready to be
shared/multi-instance), real pagination on audit/attempt/assessment-session history, a
structured assessment authoring UI (owner-only raw JSON remains as a fallback), a minimal
`role_in_cohort` enforcement (`teacher`/`viewer`), a `/api/version` endpoint, and admission
control (a clean 503 "capacity full" instead of a 500 once a configured cap is reached).
- **Tested, not just designed:** a real two-Docker-network routing simulation
(`infra/scripts/simulate-two-host/`, 13/13), a destructive Postgres restore drill
(`infra/backup/test-destructive-restore-drill.sh`), a Lab Host loss/rebuild drill
(`infra/scripts/drills/test-lab-host-loss.js`), a deployment rollback drill, a CI-fails-closed
drill (which found and fixed a real pre-existing `set -e` bug in both smoke-test scripts —
see that drill's own output), and a full fake-cohort dress rehearsal
(`infra/scripts/drills/test-fake-cohort-rehearsal.js`) — all passing, all in
`infra/scripts/drills/` or `infra/backup/`.
- **Known limitation carried forward:** real remote/self-paced delivery needs actual student
authentication (today's `student_id`-only login relies on instructors controlling who gets a
valid id) — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` for the full accounting of what
already works vs. what's genuinely Phase 5 scope. **Closed in Phase 5A, below.**
## 14. Student Identity & Remote Classroom (Phase 5A)
Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
`docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`.
- **What it is:** two deliverables built on the Phase 4.1 milestone. (A) Real student accounts —
email-or-login-identifier + password, reusing `instructors`' exact credential machinery, a
single first-login/reset token mechanism, deactivation enforced per-request — replacing pilot
"pick your name" identification without touching a single existing pilot student's progress.
(B) A remote classroom control center — scheduled/live/ended sessions, derived (not stored)
presence via heartbeat, a live instructor dashboard combining real Provisioner Lab state and
real academic records, a help-request queue, guidance-only announcements/current-activity, and
a generic external meeting-URL field. Explicitly **not** video/voice/chat/shell-takeover —
RootNode owns identity and coordination, an external service owns the call.
- **Tested, not just designed:** `platform/test/e2e-phase5a.js` (21 checks: the two-student RBAC
matrix and full password-security checklist, including a real Platform API restart proving
rate-limit state survives it) and `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks:
5 pilot students with seeded real history, a live classroom, help requests, **a real
mid-rehearsal API restart** proving classroom/session/presence recovery, then converting two
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
just hidden in the UI. Completion is entirely server-derived from the existing capstone
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
concurrent duplicate requests, not just sequential retries.
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
async-error-handling convention — an unexpected internal error there (a malformed
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
(wrapped + input-validated) and regression-tested.
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
(see that doc §14).
## 16. Enrollment Operations & Communications (Phase 5C)
Full design: `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`, plus
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` and `docs/RUNBOOK_STUDENT_ACTIVATION.md`.
- **What it is:** a durable, provider-neutral notification pipeline (`notification_events`, a
`FOR UPDATE SKIP LOCKED` worker polling on a plain `setInterval` — same pattern as the
existing session/login-attempt sweepers, no new infrastructure) that finally sends real email
for activation, enrollment confirmation, password reset, and password-changed — closing the
gap Phase 5A explicitly left open. Google Workspace adapter: Gmail API via a service account
with domain-wide delegation (no human password, no SMTP basic auth — see that doc §2 for why),
behind the same provider interface a deterministic dev provider implements, so no test or dev
run ever reaches a real inbox. Reuses Phase 5A's activation/reset token mechanism entirely — no
competing invitation-token system.
- **A real bug found and fixed:** `request-password-reset` would mint a reset token for a
never-activated student, which doesn't make sense (there's no password yet to reset) and would
have sent a confusing email. Fixed to fall through to the same anti-enumeration response as a
nonexistent account.
- **Tested, not just designed:** `platform/test/e2e-phase5c.js` (28 checks: activation vs.
confirmation routing, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted
retry classification, resend/regenerate token invalidation, production token-secrecy gating
via a real container recreate with `COOKIE_SECURE=1`, minor/contact-email resolution, RBAC
scoping, metrics/audit hygiene, a real restart) and
`infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks: one continuous story
from website enrollment through a real rendered/extracted activation email, a real Lab launch
and scenario completion, a second enrollment's confirmation email, and a full password-reset
round trip) — both against a live stack, using only the dev provider (no real network calls).
Live Google Workspace delivery itself is implemented and unit-level ready
(`infra/scripts/gws-email-test.js`, opt-in) but not yet validated against a real Workspace
domain — see that doc's final section.
+28 -5
View File
@@ -491,8 +491,31 @@ networking, egress, resource policy, reaper) against these decisions.
Phase 4 (`docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md`) codifies the Platform/Lab-Host split this
document designed as two real, separately-firewalled Proxmox VMs (`rn-platform-01`,
`rn-lab-host-01`) connected only by mutual-TLS Docker and a tightly scoped firewall allowance —
see `docs/PRODUCTION_NETWORK_SECURITY.md`. That document also records a gap Phase 4 surfaced but
did not resolve: the Gateway's `env.internal_ip` mechanism for reaching a Lab container assumes
Gateway and Lab share a Docker daemon, which breaks once they're genuinely on separate VMs. Read
that document's "Known gap" section before treating two-VM production as fully wired.
`rn-lab-host-01`) connected by two separate mTLS channels (Provisioner↔dockerd CONTROL plane,
Gateway↔Lab Host Router DATA plane) and a tightly scoped firewall allowance — see
`docs/PRODUCTION_NETWORK_SECURITY.md`. Phase 4 surfaced a gap in the Gateway's `env.internal_ip`
mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docker daemon, which
breaks once they're genuinely on separate VMs); Phase 4.1
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
Router (`platform/lab-router/`).
## Phase 5A: reused, not modified
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
remote classroom control center on top of everything this document describes, without changing
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
the same state enum) rather than inventing a second one.
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
of the exact same disposable Lab this document describes — again without changing the
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
Lab container out-of-band and restarting the Provisioner to trigger its actual
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
is completely unaffected and a fresh Lab boots normally.
+254
View File
@@ -0,0 +1,254 @@
# Phase 4.1 — Pre-Production Hardening
**Status: PHASE 4.1 COMPLETE. PRE-PRODUCTION HARDENING COMPLETE. READY FOR HARDWARE DEPLOYMENT.**
Phase 4 built the production infrastructure package with the real Hetzner/Proxmox hardware not
yet purchased, and flagged one genuine architectural gap: the Gateway's mechanism for reaching
a student Lab assumed Gateway and Lab shared a Docker daemon, which is false the moment Labs run
on a genuinely separate `rn-lab-host-01` VM. Phase 4.1's job was to close that gap and harden
everything else discovered along the way, still without the real hardware.
## 1. The core fix: a Lab Host Router
**Before:** Gateway → `http://${env.internal_ip}:${env.internal_port}` directly — a Docker
bridge-local address, unreachable across a real VM boundary.
**After:** Gateway → (mode-aware) → either `direct` (unchanged, dev/single-host) or `router`
(production): a small new service, `platform/lab-router/`, runs on the Lab Host itself,
authenticates the Gateway via mTLS (the same private CA the Docker TLS control plane already
used, extended with two new leaf certs), resolves an opaque routing token to a local container
purely from `docker ps` label state (no Postgres access, no call back to the Provisioner), and
reverse-proxies HTTP and WebSocket traffic to it.
The decision of which mode a given `lab_host` uses is data-driven
(`lab_hosts.routing_mode`/`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`),
resolved once per request in `platform/gateway/src/lab-endpoint.js`'s `labTarget()` — the only
place in the Gateway that knows this distinction exists. Every proxy/fetch call site in
`platform/gateway/src/server.js` goes through it.
**Exact `env.internal_ip` replacement:** `labTarget(env, token)` returns `{ url, headers, agent,
mode }`. In `direct` mode, `url` is exactly the old `http://internal_ip:internal_port`. In
`router` mode, `url` is the lab_host's `router_endpoint`, `headers` carries
`X-RootNode-Route-Token: <token>` (computed server-side from the Provisioner-verified
environment row — never trusted from the browser), and `agent` is an `https.Agent` configured
with the Gateway's mTLS client identity.
## 2. No per-student host port
Deliberately not solved by publishing a host port per Lab (the originally-flagged, rejected
approach). The Router resolves entirely from Docker's own label state on its own host — no
port-per-student anything, no dynamic firewall rule per Lab.
## 3. Lab Host Router — design
See `platform/lab-router/src/server.js`'s header comment for the full "narrow by design" list:
one route (proxy-by-token), requires mTLS (no bearer-token fallback), never exposes Docker API/
container IDs/network info to its caller, never provides student-to-student routing (each
request resolves to exactly the one container matching its own token).
## 4. Multi-Lab-Host ready
Routing is resolved per `lab_host_id` — adding `rn-lab-host-02` later is one new `lab_hosts` row
(its own `routing_mode`/`router_endpoint`/TLS material), no Gateway code change. No scheduler
was built (explicitly out of scope) — the Provisioner still picks "the one enabled lab_host"
(`selectLabHost`), unchanged from Phase 1.
## 5. Local development compatibility
`dev-local` (the Unix-socket lab_host used by `docker-compose.dev.yml`) stays `routing_mode:
direct` — zero behavior change for the T480/any local dev setup, confirmed by the full existing
regression suite passing unmodified (§29 below) and by every new drill in this phase running
against the same dev stack. The standalone single-container Lab image build/boot is unaffected
(it never touches the Gateway or Provisioner at all).
## 6-9. Simulation, HTTP/WS validation, security tests, routing token security
`infra/scripts/simulate-two-host/` — a real two-Docker-network simulation (not mocks): Gateway
attached ONLY to an isolated bridge with no route to any Lab's bridge network, Lab Host Router
host-networked (mirrors `egress-proxy`'s existing pattern) so it can reach whatever per-Lab
bridge the Provisioner creates. 13/13 real assertions pass, including:
- Gateway genuinely cannot open a TCP connection to a Lab's Docker-internal IP (proven from
inside the Gateway container's own network namespace via a raw connect attempt).
- Gateway reaches the Lab correctly through the Router — HTTP AND a real WebSocket terminal
session running a genuine `kubectl get nodes` inside the Lab.
- Alice/Bob isolation (own lab ALLOW, other's DENY — HTTP and WebSocket both), garbage tokens,
anonymous requests, and destroyed-environment tokens are all rejected through the real code
path.
- The Router refuses any connection with no client certificate — mTLS is the real
authentication mechanism.
Routing token security reviewed: already CSPRNG since Phase 1 (`generateRoutingToken`), never
logged in plaintext (the Router's `tokenFingerprint` truncates), redacted in every API response
except the two narrow internal reads that legitimately need it, and — critically — the header
the Router trusts is computed server-side from the Provisioner-verified environment row, never
read from the browser's own request, so a browser cannot forge routing by supplying its own
`X-RootNode-Route-Token`.
## 10. Control plane vs data plane
Kept strictly separate — see `docs/PRODUCTION_NETWORK_SECURITY.md`'s dedicated section. Student
browser traffic is never proxied through the Docker API at any point.
## 11. Firewall update
`infra/firewall/rn-lab-host-01.rules`'s old speculative wide port-range allowance replaced with
one narrow rule: `platform_ip` → the Router's port only (`8443` default, configurable).
## 12. Self-paced / remote delivery architecture check
`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`. Short version: student-driven provisioning,
cohort-less students, and "idle reaper destroys → progress survives → student returns → fresh
Lab → curriculum continues" all already worked before this phase and are now proven end-to-end
by two new drills. The one real gap for actual remote/self-paced delivery is student
authentication (today's bare `student_id` login assumes an instructor-controlled roster) — not
built here, explicitly Phase 5 scope.
## 13. Abandoned attempt cleanup
`scenario_attempts.status = 'abandoned'` (already a valid value since Phase 3B, never
previously set) now gets set on: environment destroy/reset (student- or instructor-triggered),
logout, session expiry (piggybacked on the existing hourly session sweeper — no new background
job), and switching to a different scenario while one is still open. Never a punitive marker —
`result` stays NULL, history is preserved, instructors see an honest "abandoned (reason)"
instead of a phantom "still in progress."
## 14. Gateway session cache
Reviewed — already a tight 2-second TTL since Phase 3A, specifically to bound the
revocation-propagation window. Decision: **keep it, don't add push-based invalidation** — the
added coupling (Platform API would need to know how to reach the Gateway) isn't justified by
shrinking an already-2-second window further. Documented reasoning, not a silently-accepted gap.
## 15. Rate limiting
Login rate limiting moved from an in-memory `Map` (reset on every restart, not shareable across
instances) to Postgres (`login_attempts` table, `migrations/016_login_attempts.sql`) —
`platform/api/src/security.js`'s `tooManyAttempts()`. No Redis added. Restart behavior
confirmed for real: `platform/test/e2e-phase3a.js`'s rate-limiting test passes after a genuine
Platform API restart in the same run.
## 16. Pagination
Real server-side pagination (`platform/api/src/pagination.js`) on: the audit log (was a flat
`LIMIT 200`), student attempt history (was `LIMIT 100`), and assessment session history (was
**fully unbounded** — the one genuine growth risk found). Scenario analytics was deliberately
left as-is — grouped by scenario id, naturally bounded around ~100 rows, no real growth risk to
paginate against. Frontend (`AuditView.jsx`, `AssessmentsView.jsx`, `StudentsView.jsx`) updated
to match; `frontend` build confirmed clean.
## 17. Needs-attention configuration
`NEEDS_ATTENTION_FAIL_THRESHOLD`/`NEEDS_ATTENTION_STALE_DAYS` moved from hardcoded constants to
env-var-configurable (matching the existing session-TTL pattern), sensible defaults unchanged.
## 18. Assessment authoring UX
Raw-JSON `pool_spec` textarea replaced with a structured editor
(`frontend/src/admin/AssessmentsView.jsx`'s `PoolSpecEditor`): required scenarios and random
pools picked from a real scenario catalog (`GET /api/admin/scenario-catalog`, new), grouped by
curriculum stage, via native multi-select — no new UI framework. An owner-only "advanced" raw
JSON view remains for anything the structured editor doesn't cover.
## 19. Cohort role-in-cohort
`cohort_instructors.role_in_cohort` existed since Phase 2B but was never read. Implemented
minimally: `teacher` (unchanged full capability) vs `viewer` (read-only — dashboards/analytics
still visible, but Prepare/Destroy Labs and provisioning a student's environment are now 403'd
server-side, `rbac.js`'s `requireCohortWriteAccess`/`requireStudentCohortWriteAccess`).
## 20. Backup / restore destructive drill
`infra/backup/test-destructive-restore-drill.sh` — real owner/admin/cohort/3-students/progress/
attempts/assessment/achievements seeded through the real Platform API, encrypted-backed-up, the
disposable Postgres container **genuinely destroyed** (not just restored into a second
container), a fresh Postgres created from nothing, restored, and the real Platform API booted
again against the restored database and used to log in as the restored admin over HTTP. All
data verified present; never touches the developer's real database.
## 21. Lab Host rebuild drill
`infra/scripts/drills/test-lab-host-loss.js` — a student's Lab container is destroyed out from
under the Provisioner (no clean destroy call, simulating total Lab Host loss). Proves: Platform
stays up throughout, the stale environment is never presented as healthy once the Provisioner's
real `reconcileOnStartup` reconciliation runs, academic progress is untouched, and the student
can launch a genuinely new Lab and continue.
## 22. Deployment rollback test
`infra/scripts/drills/test-deployment-rollback.sh` — builds a known-good and a deliberately
broken gateway image, "deploys" each via the real deterministic-tag mechanism, proves the smoke
test catches the regression, computes the rollback target with the exact same
`deploy-history.log` logic `rollback-production.sh` uses, and proves the rollback recovers.
Documents explicitly that image rollback never implies a DB migration rollback.
## 23. CI/CD failure tests
`infra/scripts/drills/test-ci-fails-closed.sh` — proves all six required failure modes (test/
syntax failure, frontend build failure, image build failure, config validation failure,
migration preflight failure, staging smoke test failure) genuinely exit nonzero. **Found and
fixed two real pre-existing bugs**: `staging-smoke-test.sh` and `production-smoke-test.sh` both
had a classic `set -e` gotcha (`curl ... && echo "OK"` as one statement — a failing command
that isn't the last in an `&&` chain doesn't trigger `-e`) that let an unreachable/broken
endpoint fall through silently instead of failing the smoke test. Fixed in both files.
## 24. Security scanning
`infra/ci/gitea-workflows/build-and-test.yml`'s new `security-scan` job — npm audit, gitleaks
(secrets), hadolint (Dockerfiles), shellcheck (shell scripts), trivy (container image CVEs).
All free/open-source. Severity policy documented in `docs/SECURITY_SCANNING.md`: CRITICAL/HIGH
blocks the build, MEDIUM/LOW is reported only. `security-scan` gates `build-images` the same
way `regression` does.
## 25. Version / release visibility
`GET /api/version` (Platform API) — app version (`package.json`), deployed Git SHA
(`GIT_SHA` env var, set to the same value as the image tag in the production compose file), and
the most recently applied migration as a schema-version stand-in. Unauthenticated, minimal, no
infrastructure detail.
## 26. Admission control failure mode
`resource-policy.yaml`'s new (disabled-by-default) `admission_control.max_concurrent_labs` —
when set, the Provisioner checks it BEFORE touching Docker at all, so a full host fails cleanly
(503, `CAPACITY_EXCEEDED`) instead of failing mid-provision. Relayed by the Platform API as a
clean "Lab capacity is temporarily full. Try again shortly." — never a raw 500. A new
`GET /capacity` Provisioner endpoint feeds the instructor dashboard's capacity display.
## 27. Fake cohort dress rehearsal
`infra/scripts/drills/test-fake-cohort-rehearsal.js` — one continuous narrative: instructor
login, cohort + 4 students, curriculum assignment, Prepare Labs, students working different
scenarios, hints, a deliberate failed validation, instructor analytics, a Lab reset, a Lab
reaped (same consequence as the real idle reaper — see the drill's own comment on why the real
multi-minute timer isn't waited out), the student returning and continuing, an exam
assigned/taken, and end-of-class Lab destruction — progress verified intact after every
destructive step. 14/14 passing.
## 28. Documentation
This file, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`, `docs/SECURITY_SCANNING.md` (new).
Updated: `docs/LAB_ARCHITECTURE.md`, `docs/PRODUCTION_NETWORK_SECURITY.md`,
`docs/PRODUCTION_DEPLOYMENT.md`, `docs/PRODUCTION_VALIDATION_CHECKLIST.md`, `docs/BACKUP_DR.md`,
`docs/ENGINEERING_SOP.md`, `README.md`, `platform/README.md`.
## 29. Regression
Re-ran the full existing suite after every application-code change in this phase:
`e2e-phase2a.js` (10/10), `e2e-phase2b.js` (12/12), `e2e-phase3a.js` (21/21 — including a real
audit-log pagination-shape fix and confirming rate-limit persistence across a genuine restart),
`e2e-phase3b.js` (24/24). Frontend production build clean. Real Lab image builds/boots (both
dev-compose and standalone single-container). `tofu validate`, `ansible-playbook
--syntax-check`, production Compose config (both platform and lab-host), `caddy validate`,
`promtool`/`amtool`, and the backup/restore mechanism all re-validated clean after the
infrastructure changes in this phase (new `lab-router` service, new firewall rule, new Ansible
tasks). No knowingly-unexplained regression was left in this codebase at the end of this phase.
## What Phase 4.1 deliberately did NOT build
Production deployment itself, Hetzner/Proxmox installation, public course signup, payments,
subscriptions, parent portal, marketing site, full LMS, video hosting, email automation,
certificates, course marketplace, mobile app, a multi-host scheduler, an AI tutor, invasive
proctoring, held scenario content, student email/password authentication (documented as the one
real gap for self-paced delivery, not built). All explicit non-goals per this phase's
authorization.
+11 -8
View File
@@ -170,15 +170,18 @@ unchanged. No parent portal, billing, held scenarios, or production OIDC provide
RootNode production infrastructure is dedicated to RootNode Academy — never folded into
existing Synthos production servers, and canonical source stays Gitea, never GitHub `kubekosh`.
## Known gap surfaced during this phase (not silently papered over)
## Known gap surfaced during this phase (not silently papered over) — RESOLVED in Phase 4.1
Building the real production Compose/network config surfaced that the Gateway's existing
mechanism for reaching a Lab container (`env.internal_ip`, a Docker-bridge-local address) only
works when Gateway and Lab share a Docker daemon — true in every dev/single-host setup so far,
**not** true once Labs run on a genuinely separate `rn-lab-host-01`. The network-policy half is
built (a firewall allowance for Gateway→Lab-Host on a published port range,
`infra/firewall/rn-lab-host-01.rules`); the application half (the Provisioner recording a
reachable external address for each Lab, and the Gateway using it) is an explicit, reviewed,
NOT-YET-BUILT follow-up — see `docs/PRODUCTION_NETWORK_SECURITY.md` for the two candidate
designs and why neither was silently chosen here. This must land before real two-VM go-live;
flagging it clearly was judged better than guessing at an architecture change mid-infra-phase.
**not** true once Labs run on a genuinely separate `rn-lab-host-01`. At the time this phase
closed, the network-policy half was built but the application half was flagged as an explicit,
not-yet-built follow-up rather than guessed at mid-infra-phase.
**Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) built and proved the application
half** — a Lab Host Router (`platform/lab-router/`) plus a `labTarget()` routing abstraction in
the Gateway, real-tested against a genuine two-network Docker simulation
(`infra/scripts/simulate-two-host/`), not just designed. See
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "Gateway-to-Lab reachability" section for the full
resolution. This item no longer blocks real two-VM go-live.
@@ -0,0 +1,195 @@
# Phase 5A — Student Identity & Remote Classroom
**Status: PHASE 5A COMPLETE. STUDENT IDENTITY COMPLETE. REMOTE CLASSROOM FOUNDATION COMPLETE.**
Phase 4.1 closed the last pre-production infrastructure gap without real hardware. Phase 5A's
job — still without real hardware — was the last real *product* gap flagged along the way: pilot
students identify themselves by picking a name from a list, and there was no way for an
instructor to run a live, remote-capable class through the platform itself. This phase replaces
pilot identification with real accounts and adds a remote classroom control center. It does
**not** add video, voice, chat, or any A/V — those stay Meet/Zoom/Teams's job; RootNode owns
identity, presence, curriculum guidance, Lab status, help requests, and instructor control.
## 1. Student identity model
`students` (migration `017_student_credentials.sql`) gained `email`, `login_identifier`,
`password_hash`, `last_login_at`, and a single credential-token triple
(`credential_token_hash`/`_purpose`/`_expires_at`) — all nullable, all additive. `email` and
`login_identifier` are independently unique (partial unique indexes, `WHERE ... IS NOT NULL`)
and both optional: an older/self-paced student gets an email, a younger instructor-managed
student gets an instructor-assigned login ID, neither is required, and nothing here blocks a
future `guardian_id` FK for a Parent Portal — deliberately not built this phase (§37).
A student with **neither** field set is unchanged from before Phase 5A: a plain pilot row,
identified with `POST /api/login { student_id }`. Setting either field (via account creation or
`/convert`) is what makes an account "real." `password_hash` reuses `platform/api/src/auth/
password.js` (scrypt, `salt:hash`) verbatim — the same function instructors use, no second
hashing scheme.
## 2. Credential lifecycle — one token mechanism, not two
Activation ("first login") and password reset are the same problem: a time-limited, single-use
secret that lets someone set a new password. `students-auth.js`'s `issueCredentialToken`/
`consumeCredentialToken` implement it once (SHA-256-hashed at rest, plaintext returned exactly
once in the API response, never logged), with `purpose` (`activation` | `reset`) distinguishing
the two callers. Activation tokens live 7 days, reset tokens 1 hour. `consumeCredentialToken` is
atomic: token verification, password hashing, `password_hash` write, and token-clearing happen
in one statement — there is no window where a token is "spent" but the password isn't set.
Instructors never see or choose a student's password (§5's "instructor creates account →
temporary credential" flow): `POST /api/admin/students`, `/convert`, and `/reset-password` all
return a token (activation or reset), which the instructor relays out of band (printed, read
aloud, handed over). The admin UI surfaces the resulting `?activate=<token>`/`?reset=<token>`
link once and never again — there is no "view credentials" screen anywhere.
## 3. Password reset without email delivery
No email-sending infrastructure exists yet, and this phase deliberately doesn't fake one.
`POST /api/student/request-password-reset` always returns an identical generic response
regardless of whether the identifier exists (anti-enumeration) and never echoes the token in
production. In non-production (`isProduction` false, i.e. `COOKIE_SECURE !== '1'`) it also
returns `dev_reset_token` so local/CI flows can complete the loop without a real inbox — a
documented dev/test path, not a production feature. The realistic production path today is
instructor-triggered: `POST /api/admin/students/:id/reset-password`. Real email delivery is a
named, not-yet-built follow-up, not something silently promised.
## 4. Session and login security — reused, not duplicated
Students and instructors share the exact same `sessions` table and mechanism
(`sessions.js`: opaque tokens, HttpOnly, SameSite, hashed at rest, revocable), differentiated
only by `session_type`/`student_id` vs `instructor_id`. `studentSession` middleware now
re-verifies `status = 'active'` on **every** request (previously only checked session
existence) — deactivating a student takes effect immediately, matching `instructorSession`'s
existing behavior, not just at next login (verified in `e2e-phase5a.js`).
Rate limiting reuses Phase 4.1's Postgres-backed `tooManyAttempts` (`security.js`) with a
`student:` key prefix — 8 attempts / 5 minutes, survives a Platform API restart because it's a
table, not an in-memory Map (verified with a real restart in both `e2e-phase5a.js` and the
rehearsal drill). CSRF reuses the existing double-submit cookie pattern (`rn_csrf_s`).
**A real bug this closed:** the legacy `POST /api/login` (bare `student_id`, no password) only
checked `password_hash` to decide whether an account had "moved on" from pilot mode. An account
mid-conversion — instructor has issued an activation token, student hasn't set a password yet —
has `credential_token_hash` set but `password_hash` still `NULL`, and was reachable through the
old insecure path for that whole window. Found via testing, fixed by checking
`student.password_hash || student.credential_token_hash` instead (`server.js`).
## 5. Pilot migration
No destructive migration. `017_student_credentials.sql` only adds nullable columns — every
existing pilot row, its progress, attempts, achievements, and cohort membership are completely
untouched at migration time. `POST /api/admin/students/:id/convert` assigns email/login_
identifier and issues an activation token without touching `student_id` (the FK every other
table hangs off of) or anything downstream of it. Verified directly, not just asserted: the
Phase 5A rehearsal drill (`infra/scripts/drills/test-phase5a-rehearsal.js`) seeds real pre-
existing progress/attempt/achievement rows for a pilot student, converts them, activates them,
logs in the new way, and confirms `/api/progress` shows the exact same completed scenario —
nothing orphaned, nothing re-created.
## 6. Remote classroom — a fourth session concept
Distinct from cohort membership, assessment sessions, and Lab environments: a scheduled,
bounded teaching event. `018_classroom_sessions.sql` adds `classroom_sessions` (lifecycle
`scheduled → live → ended`, or `cancelled`), `classroom_participants`, `classroom_events`
(append-only audit), `classroom_announcements`, and `help_requests`. A partial unique index
(`WHERE status = 'live'`) enforces at most one live classroom per cohort at the database level —
the app layer catches the resulting `23505` and returns a clean `ALREADY_LIVE` (409), not a
raw constraint error (verified in `e2e-phase5a.js`).
## 7. Presence — derived, not stored
`classroom.derivePresence(lastSeenAt, leftAt)` is a pure function computing `online` /
`recently_active` / `offline` from a timestamp at read time — there's no status column that can
go stale or need a background job to sweep. Heartbeats (`POST /api/classroom/:id/heartbeat`,
client fires every 20-30s while the Classroom tab is open and joined) are throttled server-side
(`WHERE last_seen_at IS NULL OR last_seen_at < now() - interval`), never depend on Lab terminal
activity, and stop the moment the tab is closed or the student leaves — no invasive tracking.
## 8. The classroom dashboard — real data, not a second state system
`GET /api/admin/classroom-sessions/:id` is the one place that reaches beyond `classroom.js`: it
joins participant/presence data with the Provisioner's actual `environments` state (same enum
every other Lab-status view uses — `none`/`requested`/`provisioning`/`running`/`resetting`/
`error`/`destroying`/`destroyed`) and a lightweight academic snapshot from the existing
`progress`/`scenario_attempts` tables (current stage, most recent scenario, validation-fail
count, hints used). Never inspects terminal command history. Nothing here is a second Lab-state
or academic-state system — it's the existing ones, read the same way every other admin view
already reads them.
## 9. Help requests — a queue, not a chat
`POST /api/classroom/:id/help-request { category, message }` → `open` → (instructor)
`acknowledge` → `resolve`. That's the entire surface — explicitly no chat, no video, no remote
shell takeover (§37). The instructor dashboard's help queue polls the same dashboard endpoint
everything else does.
## 10. Guidance, never control
`setCurrentActivity` (current scenario) and announcements are exactly that — a link the student
can choose to open, a message they can read. Nothing server-side redirects a student's browser
or marks work complete on their behalf. The external meeting URL (`classroom_sessions.
meeting_url`) is a generic field the student sees as a link — RootNode never proxies or embeds
Meet/Zoom/Teams/anything else, so it isn't coupled to a single vendor.
## 11. CSV roster import — whole-file, transactional
`student-csv.js` hand-parses (no CSV dependency — the file format here is small and simple
enough that pulling in a library would just be one more thing to audit) and validates the
**entire** file before any database write. `POST /api/admin/students/import` then does every
insert inside one Postgres transaction — one bad row rolls back the whole import rather than
leaving a half-imported roster, which is the more dangerous failure mode of the two. Never
accepts a `password`/`password_hash`/`credential_token_hash` column — activation tokens are
always generated server-side, never taken from input.
## 12. Observability
`GET /metrics` is a hand-rolled Prometheus text-exposition endpoint (no `prom-client`
dependency — a handful of counters/gauges don't need one) with real in-process counters for
login success/failure and on-demand gauges for live-classroom/help-request/Lab-state counts.
No student or instructor ID ever appears as a label value — every label is a bounded enum
(status, presence, result), per the explicit no-PII/no-high-cardinality-labels instruction. The
Prometheus scrape job for the Platform API (`infra/monitoring/prometheus/prometheus.yml.j2`),
previously commented out, is now active.
## 13. Frontend
**Student side** (`frontend/src/platform/`): `LoginScreen` (credentialed, now the default entry
point) with links to the legacy pilot entry and forgot-password; `SetPasswordScreen` (shared
activate/reset UI, driven by `?activate=`/`?reset=` query params — no router library, matching
this app's existing state-machine-per-shell convention); `ForgotPasswordScreen`; a `Classroom`
tab (live-class banner, join, guidance-only current-activity link, own Lab status/launch reusing
the My Learning tab's exact API, announcements, Need Help form); a `Profile` tab (identity,
cohort, progress, achievements, active-session count, change-password).
**Admin side** (`frontend/src/admin/`): `StudentsView` gained credential status in the roster
table, a create-student form (email/login-ID/cohort), convert/reset-password/revoke-sessions
controls per student, and a bulk CSV import form. A new `ClassroomView` is the control center:
cohort picker → schedule/list classes → a live dashboard (participants with presence/Lab-status/
current-work, help queue with acknowledge/resolve, current-activity setter, announcement
poster, start/end/cancel, Prepare Labs).
## 14. Testing — real, not asserted
- `platform/test/e2e-phase5a.js` (21 checks): activation single-use, password security (§32 —
correct/wrong/unknown-identifier/deactivated/immediate-deactivation/rate-limit/rate-limit-
survives-restart/change-password/session-revocation/forgot-password anti-enumeration/admin
reset/CSRF), and the two-student RBAC matrix (§31 — student can't reach `/api/admin/*`,
assigned/unassigned/viewer instructor access), plus a classroom lifecycle smoke pass.
- `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks): 5 pilot students, real seeded
progress/attempt/achievement history, a live classroom, help requests, announcements, **a real
Platform API restart mid-rehearsal** proving classroom/session/presence recovery, then
converting two students mid-rehearsal and confirming every bit of their prior history survived
under the new login.
- Both run against a live `docker-compose.dev.yml` stack over real HTTP, not mocks.
- The full pre-existing regression suite (Phase 1/2A/2B/3A/3B/4.1) was re-run unmodified given
how much shared code (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`) this phase
touched — see the Phase 5A completion report for results.
## 15. What was deliberately not built
Per §37: video/voice conferencing, student-to-student chat, remote instructor shell takeover,
screen recording or proctoring, public registration or course catalog, payments/Stripe, a Parent
Portal, school/SIS integrations, real email delivery infrastructure, certificates, an AI tutor,
a mobile app, a multi-Lab-host scheduler, and production deployment itself. Each is a plausible
"while we're in here" addition and each was left alone on purpose — the schema and API surface
built this phase don't block any of them later.
+195
View File
@@ -0,0 +1,195 @@
# Phase 5B — Self-Paced Learning & Enrollment Foundation
**Status: PHASE 5B COMPLETE. SELF-PACED LEARNING FOUNDATION COMPLETE. WEBSITE ENROLLMENT BOUNDARY READY.**
Phase 5A gave students real identity and instructors a remote classroom. Phase 5B's job was
the last piece needed for an authenticated student to be enrolled in a RootNode Academy program
and complete it entirely on their own — plus the clean, provider-neutral boundary a *separate*
public website/commerce system will call to create those enrollments later. This phase does
**not** build that website or any payment processing — see §12.
## 1. The RootNode program ladder
`init_` (ages ~5-10, robotics/block coding), `compile_` (ages ~11-14, ESP32/MicroPython/IoT),
`root_` (ages ~15-18+, Linux/containers/Kubernetes/DevOps). The existing KubeKosh-derived
curriculum belongs to `root_`. This phase does not write init_/compile_ curriculum content — it
makes the platform capable of holding those programs later without another schema change (the
`programs.family` enum already accepts `init`/`compile`/`root`).
## 2. Programs — a first-class object, not curriculum JSON
`programs` (migration `019_programs.sql`) is the commercial/academic unit a student enrolls in.
It is deliberately **not** the curriculum file itself — `programs.curriculum_path` just points at
one (today, always `rootnode-path`, the only curriculum that exists). `program_id` is the slug
primary key, matching `assessment_definitions`/`cohorts`' existing convention rather than a
separate id+slug pair. `family` (`init`/`compile`/`root`) and `delivery_mode`
(`self_paced`/`cohort`/`hybrid`) are both real CHECK-constrained enums, not free text — small,
stable vocabularies, matching how this codebase already treats `assessment_definitions.type` and
`hint_policy`. The phase seeds exactly one program: `root-kubernetes-foundations`, wrapping the
existing RootNode/Kubernetes curriculum and the existing `rootnode-capstone` as its completion
criterion — no new curriculum content invented.
`programs.version` bumps automatically whenever an edit changes a field an enrollment snapshots
(`curriculum_path`, `delivery_mode`) — same versioning principle `assessment_definitions`
already established, so editing a program later never silently changes what an already-enrolled
student is being held to.
## 3. Enrollments — real academic access, separate from cohorts
`enrollments` (migration `020_enrollments.sql`) represents a student's access to a program:
`pending → active → completed`, or `pending`/`active → cancelled`. All transitions are
server-side only (`enrollment.js`) — nothing accepts a client-supplied status. `expired` is
**derived**, not stored: `deriveEnrollmentAccess(enrollment, now)` is a pure function (matching
Phase 5A's `classroom.js` `derivePresence` pattern) that computes it from `access_ends_at` at
read time, so there's no background sweep and nothing to go stale.
A self-paced enrollment has **no cohort at all** — `cohort_enrollments` (Phase 1) is completely
untouched. A student can hold a cohort enrollment, a self-paced enrollment, and multiple
historical enrollments simultaneously without any of those concepts colliding; re-enrollment
after completion/cancellation always creates a new row rather than reviving a historical one (a
partial unique index enforces at most one `pending`/`active` enrollment per student+program at a
time — DB-level, not app-level "check then insert").
## 4. Curriculum assignment — the schema Phase 4.1 flagged, now wired
Phase 4.1's `curriculum_assignments` table (schema-ready since Phase 2B, never read by
anything) is **not** reused as-is — it models cohort/student curriculum assignment
independent of any commercial concept, which is a different problem than "this enrollment in
this program uses this curriculum." Instead, `enrollments.curriculum_path` is itself the
snapshot: set from `programs.curriculum_path` at enrollment time, so a later edit to the
program never changes what an existing enrollment means. `curriculum_assignments` remains
exactly as Phase 4.1 left it — still available for a future direct per-student assignment need
outside the program/enrollment model, not duplicated or removed.
The resolution chain is real: `enrollment.student_id → enrollment.program_id →
enrollment.curriculum_path → curriculum.loadCurriculumPath() → stages → scenarios`
(`program-status.js`'s `buildProgramStatus`). Only one curriculum path exists today, so this
always resolves to `rootnode-path` — the extension point for a second path is
`enrollments.curriculum_path` itself, already snapshotted per-enrollment.
## 5. Self-paced dashboard and resume — reused, not reinvented
`GET /api/student/programs` returns every enrollment a student holds (never a public catalog —
no public course browsing exists), each annotated with real access state, progress, and a
deterministic next activity. The heavy lifting is **entirely existing Phase 3B machinery**:
`curriculum-rules.js`'s `computeCurriculumState` already returns `current_stage` and each
stage's `next_scenario` — `program-status.js` just wraps that per-enrollment. "Continue
Learning" resume is not a guess based on the last page rendered; it's the same rule-driven
current-stage/next-scenario computation the My Learning tab has used since Phase 3B, now scoped
per program. Destroying a Lab never touches this — `progress`/`scenario_attempts` live in
Postgres, completely independent of any container's lifecycle (verified for real, not just
asserted — see §9).
## 6. Entitlement — enforced server-side, not by hiding a button
`POST /api/student/programs/:enrollmentId/launch` is the only self-paced entry point into a
Lab. It resolves the enrollment (404 if it isn't the caller's own), calls
`deriveEnrollmentAccess`, and 403s with a `reason` (`pending_activation` / `not_yet_started` /
`access_expired` / `cancelled`) if `can_launch` is false — **before** ever touching the
Provisioner. A pending, future-dated, expired, or cancelled enrollment cannot provision a Lab no
matter what the browser sends. The existing `POST /api/environment/launch` (cohort/pilot path)
is completely unchanged — this is a new, additive gate in front of the exact same disposable-Lab
sequence (`environment-view.js`, extracted from `server.js` so both routes share one
implementation), not a second kind of Lab.
## 7. Completion — server-derived and idempotent
`enrollment.maybeCompleteEnrollment` checks, for every `active` enrollment whose program has a
`completion_assessment_id`, whether a matching `capstone_completed` achievement already exists
(the achievement `recordAssessmentAchievement` already writes when a capstone session finalizes
— Phase 3B, unmodified). If so, it transitions the enrollment to `completed` — an `UPDATE ...
WHERE status = 'active'`, so calling it again once already completed is a guaranteed no-op, not
a duplicate transition or an error. This runs opportunistically on every program-status read
(`program-status.js`), which means completion is detected on the next read after the qualifying
event, not pushed instantly — acceptable latency for a status field, and it means there is no
"mark complete" button anywhere in the frontend to accidentally trust.
## 8. Pacing and availability
`starts_at`/`access_ends_at` are the entire pacing model this phase builds — no drip scheduling,
no per-lesson unlock dates. `starts_at` in the future: the enrollment is visible
(`effective_status: 'active'`) but `can_launch: false`, reason `not_yet_started`. `access_ends_at`
in the past: `effective_status: 'expired'`, prior progress/achievements remain fully visible,
`can_launch: false`. Both are enforced in `deriveEnrollmentAccess`, called from the same
server-side gate every launch goes through — never a frontend-only check.
## 9. Real Lab-persistence proof
`platform/test/e2e-phase5b.js` and `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js`
both drive this against a real stack: launch a Lab, complete a scenario, destroy the environment
via the admin API, relaunch, confirm identical progress and resume target; separately, kill the
Lab container directly (bypassing the Provisioner, exactly the idle reaper's failure mode from
the Provisioner's point of view) and restart the Provisioner to run its real
`reconcileOnStartup` reconciliation (the same technique `infra/scripts/drills/
test-lab-host-loss.js` established in Phase 4.1) before relaunching — progress is identical
both times.
## 10. Instructor/admin management
`/admin` → Programs: list/create/edit/activate-deactivate (admin/owner only, matching
`assessment_definitions`' authoring convention — every role can view), per-program enrollment
list and manual-enroll form, per-enrollment activate/cancel. The existing student detail view
gained an "Enrolled programs" card (status, delivery mode, progress, last activity).
**RBAC** (`rbac.js`'s new `requireEnrollmentAccess`): owner/admin have global management, as
everywhere else. A plain instructor may view/manage an enrollment if they created it
(`enrollments.created_by`) **or** the student shares one of their cohorts — never blanket
visibility into every self-paced student, since a self-paced enrollment can have no cohort
relationship at all. Enrollment *creation* stays admin/owner only, matching the existing
roster-creation precedent (`POST /api/admin/students` is the same restriction).
## 11. The website/commerce integration boundary
`POST /api/integrations/enrollments` — completely separate from student/instructor session
auth (no cookie is ever read), authenticated by a pre-shared secret header
(`X-RootNode-Integration-Key`), the exact same trust-boundary pattern this codebase already
uses for Gateway↔Provisioner (`X-Provisioner-Key`/`PROVISIONER_SHARED_SECRET`), not a new
scheme. Full contract for the separate website team: `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
Idempotent by construction: a unique index on `enrollments.external_event_id`, not an
app-level "check then insert" — proven under real concurrency (three simultaneous identical
requests produce exactly one enrollment, one `created: true`, two `created: false`) in both the
permanent suite and manually during development. Student matching is create-or-match by email
**or** login_identifier (§13's minor/no-email requirement) — reuses Phase 5A's exact
`students-auth.js` credential-issuing path, not a second account-creation mechanism, and this is
a trusted server-to-server caller (the website's own verified-payment backend), not a public
signup form.
## 12. What was deliberately not built (§22)
Stripe/payment processing, checkout UI, the public website itself, public self-registration, a
Parent Portal or full guardian accounts, email delivery infrastructure, certificates, complex
drip scheduling, and full init_/compile_ curriculum content. Each is a plausible "while we're in
here" addition; each was left alone on purpose. Nothing built this phase blocks any of them —
`enrollments.external_reference`/`metadata` and the create-or-match student resolution are the
deliberate extension points for a future guardian-purchases-for-minor flow, without inventing a
fake guardian record now.
## 13. A real bug found and fixed
`POST /api/progress/record` predated the `wrap()` async-error-handling convention (it's a
Phase 1/2A route, never updated). An unexpected internal error there — including Postgres
rejecting a malformed `environment_id` — became an unhandled promise rejection that **crashed
the entire Platform API process**, not just that one request. Found by this phase's own test
suite. Fixed: the handler is now wrapped like every route added since Phase 3B, plus explicit
UUID-shape validation so a bad `environment_id` is a clean 400 and never reaches Postgres as a
type error at all. Regression-tested in `e2e-phase5b.js`.
## 14. Testing
- `platform/test/e2e-phase5b.js` (32 checks): program seed, entitlement gating
(pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/reaper/
recreate persistence, server-derived idempotent completion, the full integration boundary
(auth, idempotency, concurrency, unknown/inactive program rejection, minor/no-email path, no
card data), RBAC scoping, Phase 5A regression smoke, and a real Platform API restart.
- `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks): one continuous
realistic story — activation, a simulated website enrollment event, real Lab work, Lab
destroy, a new session later, Continue Learning, a fresh Lab, full capstone completion,
program moves to Completed, and a second uninvolved student proven unable to reach any of it.
- Both against a live `docker-compose.dev.yml` stack, real HTTP, real k3s Lab boots.
- Migration safety proven explicitly: a fresh Postgres taken through Phase 1-5A's migrations
only, seeded with realistic pre-5B data (credentialed student, cohort membership, progress,
scenario attempts, achievements, classroom history), then 5B's migrations applied — every
prior record intact and correctly associated with the same `student_id`; a new enrollment
correctly links to that same pre-existing student; running the migration set a second time
applies nothing.
+248
View File
@@ -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.
+9 -3
View File
@@ -31,7 +31,7 @@ tofu apply
Creates `rn-platform-01` and `rn-lab-host-01` from the cloud-init template. Confirm both boot
and are SSH-reachable from the management network before continuing.
## 2. Generate Docker TLS certificates (offline, once)
## 2. Generate TLS certificates (offline, once)
```bash
export ROOTNODE_TLS_BUNDLE_DIR=/secure/offline/path # never inside the repo
@@ -39,8 +39,14 @@ export ROOTNODE_TLS_BUNDLE_DIR=/secure/offline/path # never inside the repo
./infra/scripts/docker-tls/verify-docker-tls.sh
```
The CA private key stays in `ROOTNODE_TLS_BUNDLE_DIR` and never touches either VM — only the CA
cert (public) and the relevant leaf cert+key go to each host.
Generates FOUR leaf certs under one CA (Phase 4.1 extended this from two): the CONTROL-plane
pair (`server-*`/`provisioner-client-*`, for Provisioner↔dockerd) and the DATA-plane pair
(`router-server-*`/`gateway-client-*`, for Gateway↔Lab Host Router — see
`docs/PRODUCTION_NETWORK_SECURITY.md`'s "control plane vs data plane" section). The CA private
key stays in `ROOTNODE_TLS_BUNDLE_DIR` and never touches either VM — only the CA cert (public)
and each host's own leaf cert+key go to each host, both pairs deployed by the same `docker-tls`
Ansible role into two separate directories (`/etc/rootnode/docker-tls/` and
`/etc/rootnode/lab-router-tls/`).
## 3. Configure both hosts
+62 -25
View File
@@ -12,7 +12,8 @@ management_cidr ──SSH──▶ rn-platform-01 │
management_cidr ──SSH──▶ rn-lab-host-01 │
proxmox_mgmt_cidr ──22/8006──▶ Proxmox host │
│
rn-platform-01 (Provisioner) ──TLS:2376──▶ rn-lab-host-01 (dockerd)
rn-platform-01 (Provisioner) ──TLS:2376──▶ rn-lab-host-01 (dockerd) [CONTROL plane]
rn-platform-01 (Gateway) ──mTLS:8443──▶ rn-lab-host-01 (Lab Host Router) [DATA plane]
rn-lab-host-01 (promtail) ──3100──▶ rn-platform-01 (Loki)
rn-lab-host-01 (node-exporter/cadvisor) ◀──scrape── rn-platform-01 (Prometheus)
```
@@ -37,6 +38,25 @@ Firewall source of truth: `infra/firewall/{proxmox-host,rn-platform-01,rn-lab-ho
(Grafana is reachable only via Caddy's `/grafana/*` path, itself restricted to
`management_cidr` by a `caddy` matcher — see `infra/docker/platform/Caddyfile.j2`).
## Control plane vs data plane (Phase 4.1 §10)
Two separate, never-conflated mTLS connections between the two VMs, deliberately kept apart
both conceptually and operationally:
| | Control plane | Data plane |
|---|---|---|
| What | Provisioner manages Lab containers | Gateway serves student browser traffic |
| Path | Provisioner → dockerd (port 2376) | Gateway → Lab Host Router (port 8443) |
| Who initiates | Provisioner only | Gateway only |
| What it can do | Create/start/stop/remove containers, full Docker API | Reverse-proxy one HTTP/WS request to one already-running container by opaque token — nothing else |
| Certs | `provisioner-client-*` / `server-*` | `gateway-client-*` / `router-server-*` |
| Exposes Docker API to caller? | Yes (that's its job — Provisioner IS a Docker client) | **Never** — the Router never exposes Docker API, container IDs, or network info to its caller |
The student browser's traffic is proxied at the Gateway/Router layer and never touches the
Docker API at any point — the Router deliberately has no route that echoes back anything
Docker-internal (see `platform/lab-router/src/server.js`'s header comment for the full "narrow
by design" list).
## Docker TLS (Provisioner ↔ Lab Host)
Mutual TLS on port 2376, restricted at the firewall to `platform_ip` only. Application support
@@ -57,34 +77,51 @@ front this with Cloudflare or expose the origin directly is left as a deployment
being able to see plaintext unless "Full (strict)" mode is used) — not decided here since it
doesn't affect anything else in this document.
## Known gap: Gateway-to-Lab reachability across two real VMs
## Gateway-to-Lab reachability across two real VMs — RESOLVED in Phase 4.1
**This is the most important item in this document.**
Phase 4 flagged this as the single most important open item; Phase 4.1
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1) closed it. Kept here as the historical record of
what the gap was and how it was closed, since the reasoning matters for anyone extending this
later (e.g. adding a second Lab Host).
The Gateway currently reaches a running Lab container via `env.internal_ip` — a Docker
bridge-local address recorded by the Provisioner when it creates the container
(`platform/gateway/src/server.js`, `platform/provisioner`, built in Phase 2A). This works when
Gateway and the Lab share a Docker daemon, which has been true in every setup so far, including
dev and any single-host deployment. **It stops being true once Labs run on a genuinely separate
`rn-lab-host-01`** — a Docker bridge-internal IP on one host is not routable from a different
host by default.
**The gap, as originally found:** the Gateway used to reach a running Lab container via
`env.internal_ip` — a Docker bridge-local address recorded by the Provisioner when it creates
the container. That only works when Gateway and the Lab share a Docker daemon, true in dev and
any single-host setup, false the moment Labs run on a genuinely separate `rn-lab-host-01`.
What's built now (network-policy half only): a firewall allowance in
`infra/firewall/rn-lab-host-01.rules` for `platform_ip` → `lab_gateway_port_range`
(`30000:32767` by default, configurable), so that once Lab containers publish ports on the Lab
Host's own interface, the Gateway VM is permitted to reach them.
**The fix — a Lab Host Router (`platform/lab-router/`):** a small, narrow service that runs on
rn-lab-host-01 (host-networked, like `egress-proxy`, so it can reach whatever per-Lab bridge
network the Provisioner creates) and does exactly one thing: given an opaque routing token over
mTLS, find the ONE local container labeled with that token (`docker ps --filter
label=rootnode.routing-token=<token>` — resolved purely from local Docker state, no Postgres,
no call back to the Provisioner) and reverse-proxy the request to it, HTTP and WebSocket both.
What's **not yet built** (application half — explicitly flagged, not silently decided here):
the Provisioner needs to either (a) publish each Lab container's relevant port(s) on the Lab
Host's interface and record a reachable `external_ip`/`external_port` alongside (or instead of)
`internal_ip`/`internal_port`, or (b) route Gateway→Lab traffic through a proxy/tunnel that
lives on the Lab Host and forwards to the container's internal address. Option (a) is simpler
and consistent with how the egress proxy and cadvisor are already handled in this phase's
compose files; option (b) avoids publishing a wide port range but adds a moving part. This is a
small, reviewable, application-level change — not a network-policy change — and should land as
its own reviewed commit before real two-VM production traffic depends on it. Until it lands,
Gateway↔Lab terminal/scenario traffic will not work correctly across two separate VMs, even
though every other production networking, firewall, and TLS boundary is in place and correct.
The Gateway now decides `direct` vs `router` per `lab_host` (`lab_hosts.routing_mode` +
`router_endpoint`, `platform/provisioner/migrations/004_lab_hosts_routing.sql`) via
`platform/gateway/src/lab-endpoint.js` — a single function, `labTarget(env, token)`, that every
proxy/fetch call site in `server.js` goes through instead of building
`http://${env.internal_ip}:${env.internal_port}` itself. `dev-local` stays `direct` (T480/local
dev unaffected, zero behavior change there); a real `rn-lab-host-01` is configured `router`.
**Firewall (`infra/firewall/rn-lab-host-01.rules`):** the old speculative
`lab_gateway_port_range` allowance (which assumed option (a), publishing per-Lab ports) was
replaced with a single, narrow rule: `platform_ip` → port `{{ lab_router_port | default('8443')
}}` only. No wide port range, no per-Lab port publishing at all — the Router is the only thing
the Gateway ever talks to on rn-lab-host-01 for Lab traffic.
**mTLS:** the Gateway↔Router pair uses the SAME private CA as the Provisioner↔dockerd pair
(`infra/scripts/docker-tls/generate-docker-tls.sh`, extended in Phase 4.1 to also issue
`router-server`/`gateway-client` leaf certs), deployed to a separate directory
(`/etc/rootnode/lab-router-tls/`) by the same `docker-tls` Ansible role.
**Proven, not just designed:** `infra/scripts/simulate-two-host/` builds a real two-network
Docker simulation (Gateway genuinely isolated on its own bridge, no route to any Lab's bridge)
and runs 13 real assertions against it — Gateway cannot reach a Lab's Docker-internal IP
directly, but reaches it correctly through the Router (HTTP and a real WebSocket
`kubectl get nodes`), Alice/Bob isolation holds, garbage/destroyed tokens are rejected, and the
Router itself refuses any connection without the Gateway's client certificate. See that
directory's README for exactly what is and isn't proven by a same-daemon simulation versus real
separate hardware.
## Cookie / auth production settings
+58 -2
View File
@@ -19,8 +19,9 @@ can prove in advance.
## Lab Host VM
- [ ] Docker daemon TLS verified: `docker --tlsverify --tlscacert=... -H tcp://<lab_host_ip>:2376 info` succeeds from the Platform VM, fails from anywhere else
- [ ] Lab Host Router mTLS verified: a request to `https://<lab_host_ip>:8443` with no client cert is rejected; with the Gateway's real client cert it isn't (mirrors `infra/scripts/simulate-two-host/test-remote-routing.js`'s "direct public access: NOT AVAILABLE" case, now against the real host)
- [ ] Provisioner successfully creates a real Lab against the real Lab Host
- [ ] Gateway can actually reach that Lab's terminal — **this depends on the not-yet-built application change documented in `docs/PRODUCTION_NETWORK_SECURITY.md`'s "Known gap" section; do not mark this item done until that change has landed and been verified for real**
- [ ] Gateway can actually reach that Lab's terminal (HTTP UI load + WebSocket `kubectl` command) — application mechanism built and simulation-tested in Phase 4.1 (`docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1/§6/§7); this line is the real-hardware confirmation that the same result holds across the actual two VMs, not two Docker networks on one daemon
- [ ] Egress proxy correctly restricts a Lab's outbound traffic (Provisioner's per-Lab `DOCKER-USER` chain)
## Security
@@ -38,7 +39,7 @@ can prove in advance.
## Backup
- [ ] Manual `backup-postgres.sh` run succeeds against real Postgres, real off-host target reachable
- [ ] A real restore (`restore-postgres.sh` + `verify-restore.sh`) has been rehearsed at least once against a throwaway target, not just the local test harness
- [ ] A real restore (`restore-postgres.sh` + `verify-restore.sh`) has been rehearsed at least once against a throwaway target, not just the local test harness — Phase 4.1's `infra/backup/test-destructive-restore-drill.sh` proved the full mechanism (realistic data, real storage destruction, restore, real Platform API boot) locally; this line is the real-hardware confirmation
- [ ] Cron job confirmed scheduled (`crontab -l`)
## Observability
@@ -46,5 +47,60 @@ can prove in advance.
- [ ] Trigger one alert deliberately (e.g. stop a service) and confirm it fires and reaches the webhook
- [ ] Loki has logs flowing from both VMs
## Pre-production hardening (Phase 4.1 — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md)
- [ ] `resource-policy.yaml`'s `admission_control.max_concurrent_labs` set from a real measured
`infra/scripts/capacity-model.js` run against the real Lab Host, not left disabled
- [ ] A deliberate over-capacity attempt returns the clean 503 "Lab capacity is temporarily
full" message, not a 500 — confirms the real host's admission control matches the local test
- [ ] `docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md`'s steps still make sense once capacity numbers are real
## Student identity & remote classroom (Phase 5A — see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md)
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms `/api/student/
request-password-reset` never returns `dev_reset_token` outside of dev/CI
- [ ] A real activation/reset link (`?activate=`/`?reset=`) round-trips against the production
domain, not just `localhost` — query-param parsing, no router library in play
- [ ] Student login rate limiting still 429s after a restart against the real production
Postgres, same as the local `e2e-phase5a.js` check
- [ ] `GET /metrics` is reachable by the real Prometheus instance (`infra/monitoring/
prometheus/prometheus.yml.j2`'s `rootnode-platform-api` job) and no student/instructor ID
appears in any label value
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
still make sense walked against the real admin portal
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
value) and is stored only in the Platform API's own server-side environment — never
committed, never in any browser-reachable config
- [ ] The website team's production integration key was delivered out of band (not plaintext
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
clean `401` against the real production endpoint, not a 500 or an information leak
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
external_reference ever appears in a real production scrape
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
actually integrated against (update it if reality drifted during that integration)
## Enrollment communications / Google Workspace email (Phase 5C — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md)
- [ ] Every step in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` marked REQUIRES GOOGLE WORKSPACE
ADMIN ACTION has actually been completed (sending mailbox, service account, domain-wide
delegation scoped to `gmail.send` only, SPF/DKIM/DMARC)
- [ ] `EMAIL_PROVIDER=gws` set in the real production environment — confirm it is NOT left at
the dev-provider default, which would silently mean no real student ever receives email
- [ ] `GOOGLE_SERVICE_ACCOUNT_JSON`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`,
and `PUBLIC_APP_ORIGIN` are all set from the real production secret store, not left at any
dev/example value
- [ ] `infra/scripts/gws-email-test.js` (opt-in, `RUN_GWS_EMAIL_TEST=true`) run once against a
real `TEST_EMAIL_RECIPIENT` and confirmed delivered — the one and only "does this actually
work" proof this checklist can't fake
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms the dev-preview
notification route (`GET /api/admin/notifications/:id/dev-preview`) is unreachable and no
activation/reset token is ever returned by any API response
- [ ] `GET /metrics`'s notification gauges/counters contain no student_id/email/token/
notification_id/external_event_id in any label value on a real production scrape
- [ ] `docs/RUNBOOK_STUDENT_ACTIVATION.md`'s steps still make sense walked against the real
admin portal, including a real (not dev-provider) activation email actually arriving
Only once every box above is checked against the real server does the deployment graduate from
"infrastructure package built" to "actually in production."
@@ -0,0 +1,97 @@
# Remote & Self-Paced Delivery Architecture
Phase 4.1 §12. This is an architecture review, not a new product — no self-registration,
payments, or public enrollment is built here (explicit non-goal). The question this document
answers: does the infrastructure and application assume "an instructor always provisions the
Lab," and if so, where, and what would need to change for self-paced delivery to work.
## The short answer: less of that assumption exists than expected
RootNode Academy's Lab lifecycle was built student-driven from Phase 1 — `POST
/api/environment/launch` is called by the STUDENT'S OWN session, not by an instructor action.
"Prepare Labs" (`POST /api/admin/cohorts/:id/prepare-labs`) is a classroom **convenience** that
front-loads several students' launches at once before a synchronous class session, not the only
path to a running Lab. This was true before Phase 4.1 and confirmed still true by this phase's
own regression suites (`platform/test/e2e-phase2a.js` roster's two students with `cohort_id:
null` and takes them through login → curriculum → launch → scenario work with zero cohort
involvement) and by two new Phase 4.1 drills built specifically to exercise the remote/returning
-student narrative:
- `infra/scripts/drills/test-lab-host-loss.js` — student launches, makes progress, their Lab is
destroyed out from under them (standing in for an idle-reaper destroy), Platform stays up,
progress is untouched, they launch again unprompted and continue.
- `infra/scripts/drills/test-fake-cohort-rehearsal.js` — the same "returns later, fresh Lab,
curriculum continues" narrative as part of a fuller classroom rehearsal.
Both pass today, with zero application code changes needed for the self-paced-relevant parts of
this flow specifically — see the Phase 4.1 report for the full run results.
## What already works, unmodified
- **Student-initiated provisioning** — no instructor step is required between "student wants to
work" and "Lab exists." (`platform/api/src/server.js`'s `/api/environment/launch`.)
- **Cohort-less students** — `cohort_id` is nullable on student creation
(`POST /api/admin/students`); a student with no cohort logs in, sees the curriculum, launches
Labs, and makes progress identically to a cohorted one. Confirmed by inspection AND by the
existing regression suite, not just by schema reading.
- **Idle reaper → academic state survives → return → continue** — the exact lifecycle this
section describes as the target self-paced experience is what Phase 1's reaper +
Phase 2B's Postgres-backed academic data (separate from the disposable Lab) were already
designed to produce. Phase 4.1 proved it end-to-end, twice (see above).
- **Curriculum content itself is not cohort-gated** — `GET /api/curriculum/path` reads the one
curriculum path file directly (`curriculum.loadCurriculumPath()`); it never checks
`curriculum_assignments` or cohort membership at all. A self-paced student sees the full
curriculum with no instructor action required to "unlock" it.
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
built the boundary a *separate* public website will call
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
row can now be created automatically by a trusted server-to-server enrollment event, but there
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
`instructors`' exact `src/auth/password.js`), a first-login/reset token flow, and Postgres-
backed rate-limited/CSRF-protected sessions on the same `sessions` table instructors use — see
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. The old bare-`student_id` `POST
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
an account has real credentials or a pending activation token.
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
the real problem it was meant to eventually solve (which curriculum a given student sees) is
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
future direct per-student assignment need outside the program/enrollment model — it wasn't
removed, just not the mechanism Phase 5B ended up using. See
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
is modeled, not infrastructure.
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
self-paced student dashboard.
6. ~~**No enrollment communication.**~~ **Closed in Phase 5C.** An enrolled student now actually
gets told — real activation/enrollment-confirmation/password-reset email, delivered
asynchronously through a provider-neutral notification worker (Google Workspace via the
Gmail API in production, a deterministic dev provider everywhere else). See
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
## What Phase 4.1 deliberately did NOT do here
No signup flow, no student password/email auth implementation, no per-student curriculum
assignment route, no payments, no public course catalog, no held/premium scenario content. All
explicit non-goals per this phase's authorization. Student authentication (Phase 5A), the
self-paced enrollment/entitlement model (Phase 5B), and enrollment/account-lifecycle
communication (Phase 5C) have all since been built; a public self-registration *page*, real
payment processing, and a Parent/Guardian portal remain deliberately out of scope — see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12 and `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
+186
View File
@@ -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.
+53
View File
@@ -0,0 +1,53 @@
# Runbook: Running a Remote Classroom
Covers scheduling, running, and recovering a live class through `/admin` → Classroom. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design. RootNode does
**not** provide video/voice — the meeting URL field just links out to Meet/Zoom/Teams/whatever
you already use for that.
**Before class: schedule it**
`/admin` → Classroom → pick the cohort → Schedule a class. Title is required; scheduled start,
description, and a meeting URL are optional. Only one class can be `live` per cohort at a time —
scheduling a second one is fine, starting it while another is live is rejected.
**Right before class: Prepare Labs**
Open the classroom → Prepare Labs. This provisions/warms every enrolled student's Lab ahead of
time (same underlying flow as the cohort-level Prepare Labs button) so nobody is waiting on a
cold start once class begins. If a Lab host is at capacity, that student shows as
`capacity_waiting` in the results rather than failing silently — check back or ease off other
concurrent load.
**Start class**
Open the classroom → Start class. Status moves to `live`; students now see a "LIVE NOW" banner
on their Classroom tab and can join.
**During class**
- **Participants table**: presence (`online`/`recently_active`/`offline`, derived from each
student's last heartbeat — not a hard real-time signal, allow ~30s of lag), Lab status, and a
one-line current-work snapshot (stage, scenario, validation failures, hints used).
- **Set current activity**: enter a scenario ID and Set. This only shows students a link — it
never redirects their browser or marks anything complete. Use it to say "everyone open this
next," not to force navigation.
- **Post an announcement**: short messages, shown newest-first on the student side.
- **Help queue**: students flag `lab_problem` / `dont_understand` / `validation_failing` /
`other` with an optional note. Acknowledge when you've seen it, Resolve when it's handled.
There is no chat here — if it needs back-and-forth, use your actual video call.
**End of class**
Open the classroom → End class. Status moves to `ended`; this does not touch Labs or student
progress — destroy Labs separately from the cohort view if you're done with them for the day.
**If you need to cancel a scheduled (not yet started) class:** Cancel, from the classroom
detail view. A `live` class can only be ended, not cancelled.
**If the Platform API restarts mid-class** (deploy, crash, host reboot): nothing to do. Sessions,
the live classroom row, and participant presence all live in Postgres, not in the API process's
memory — students' browsers keep their existing session and heartbeat right through it, and the
dashboard shows the same state a few seconds after the API comes back. This is exercised for
real in `infra/scripts/drills/test-phase5a-rehearsal.js`, which restarts the API mid-rehearsal
and asserts on exactly this.
+67
View File
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# Runbook: Student Account Lifecycle
Covers creating, converting, resetting, and revoking student accounts. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design and
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`/`docs/RUNBOOK_STUDENT_ACTIVATION.md` for the
automatic-email side of this, added in Phase 5C.
**Create a new credentialed student** (`/admin` → Students → Add student):
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
link **once**: `https://<domain>/?activate=<token>`. As of Phase 5C, if the student has an
email, RootNode also sends it to them automatically — the on-screen link remains a manual-relay
fallback, useful when there's no deliverable address or the automatic email needs backup. Copy
it if you might need it (print it, read it aloud, hand it over) — it is never shown again and
never stored in plaintext anywhere. The student opens the link and sets their own password; the
instructor never sees it.
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
Same as creation, but for a student who already exists with progress/attempts/achievements —
none of that is touched. Give an email or login ID, get a one-time activation link the same way.
**Bulk import via CSV** (Students → Bulk import):
Columns: `display_name, email, login_identifier, cohort_id` (only `display_name` is required per
row). The whole file is validated before anything is written — one bad row rejects the entire
file with a list of row errors, rather than importing some rows and skipping others. Never
include a password column; it will always be rejected if present. Activation links for every
created row are shown once after a successful import.
**Reset a student's password** (Students → \[student\] → Reset password):
Only for already-credentialed accounts. Issues a fresh reset link (`?reset=<token>`, valid 1
hour) and immediately revokes every existing session for that student — the instructor still
never sees or sets the actual password.
**Revoke a student's sessions** (Students → \[student\] → Revoke all sessions):
Immediately invalidates every active login for that student without touching their password.
Use this if a device is lost/shared or a session looks compromised.
**Deactivate a student** (Students → \[student\] → PATCH status: inactive, via the existing
students list — deactivation takes effect on their very next request, not just their next
login, because `studentSession` middleware re-checks `status='active'` every time.
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
time-limited (activation 7 days, reset 1 hour). Use Students → \[student\] → **Resend
activation** (or Reset password, for an already-activated account) to issue a new one — there's
no way to "resend" the same token, and that's intentional (only the most recent link is ever
valid, so there's never ambiguity about which one to use). See `docs/RUNBOOK_STUDENT_
ACTIVATION.md` for checking whether the automatic email actually delivered.
+71
View File
@@ -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.
+47
View File
@@ -0,0 +1,47 @@
# Security Scanning (Phase 4.1 §24)
`infra/ci/gitea-workflows/build-and-test.yml`'s `security-scan` job. All free, open-source,
no account/signup required for any of them — no paid scanner was added.
## What's scanned
| Tool | Checks | Scope |
|---|---|---|
| `npm audit --audit-level=high` | Known-vulnerable dependencies | Every first-party Node service |
| [gitleaks](https://github.com/gitleaks/gitleaks) | Committed secrets (keys, tokens, credentials) | Whole repo |
| [hadolint](https://github.com/hadolint/hadolint) | Dockerfile best-practice/security lint | Every first-party Dockerfile |
| [shellcheck](https://www.shellcheck.net/) | Shell script correctness/security | `infra/**/*.sh` |
| [trivy](https://github.com/aquasecurity/trivy) | OS/library CVEs in a built container image | `platform-api` image (representative — same base/layers as the other Node services) |
## Severity policy
- **CRITICAL / HIGH** — fails the build (`security-scan` blocks `build-images`, which blocks
`deploy-staging`, which blocks production promotion — see the job dependency chain in the
workflow file).
- **MEDIUM / LOW** — reported in the job output, never blocks. A build that's permanently red
over a low-severity transitive dependency finding nobody can immediately fix trains everyone
to ignore red builds, which is worse than not scanning at all.
`npm audit --audit-level=high` and trivy's `--severity HIGH,CRITICAL` both implement this
directly at the tool level, not via manual triage.
## What's NOT in the automated job (and why)
- **Dedicated IaC security linting** (checkov/tflint) — not added. `infra/scripts/validate-compose.sh`,
`infra/firewall/validate-firewall.sh`, `ansible-playbook --syntax-check`, and `tofu validate`
(all in the `regression` job) already catch structural/syntax problems in the infra code;
a dedicated security-policy linter for Terraform/Ansible is a reasonable Phase 5 addition once
there's a real cloud footprint to misconfigure, not before.
- **DAST/penetration testing** — out of scope for CI; this is a build-time gate, not a running-system
test. Covered separately by `docs/PRODUCTION_VALIDATION_CHECKLIST.md`'s security section once
real hardware exists.
- **SAST beyond hadolint/shellcheck** — the codebase is small enough (a handful of first-party
Node services) that `npm audit` + secret scanning + Dockerfile/shell lint cover the realistic
risk surface; a dedicated SAST tool (Semgrep, CodeQL) is a reasonable addition once the
codebase or team grows enough to justify the added CI time.
## Local reproduction
Every check above can be run locally with the exact same free tools — no CI-only dependency.
See the `security-scan` job's steps in `infra/ci/gitea-workflows/build-and-test.yml` for the
exact commands (they're copy-pasteable).
+220
View File
@@ -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).
+3
View File
@@ -57,3 +57,6 @@ tr:hover td { background: var(--surface2); }
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
.muted { color: var(--text-2); font-size: 0.85rem; }
/* Phase 4.1 §16 — shared pagination control (AuditView, StudentsView attempts). */
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
+9
View File
@@ -8,6 +8,9 @@ import AuditView from './AuditView'
import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import NotificationsView from './NotificationsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -20,8 +23,11 @@ const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
@@ -59,8 +65,11 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && <NotificationsView />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
+107 -13
View File
@@ -29,7 +29,7 @@ export default function AssessmentsView({ role }) {
{canAuthor && (
<div className={styles.card}>
<h2>Create assessment</h2>
<CreateAssessmentForm onCreated={reload} />
<CreateAssessmentForm onCreated={reload} role={role} />
</div>
)}
<div className={styles.card}>
@@ -56,20 +56,86 @@ export default function AssessmentsView({ role }) {
)
}
function CreateAssessmentForm({ onCreated }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
// Phase 4.1 §18 — pool_spec is built from structured picks (required scenarios + named random
// pools), not hand-typed JSON. `catalog` ({ stages: [{ id, name, scenarios: [{id,title}] }] })
// comes from GET /api/admin/scenario-catalog. An owner can still drop into a raw-JSON view for
// anything the structured editor doesn't cover — see the toggle below.
function PoolSpecEditor({ catalog, required, setRequired, pools, setPools }) {
const allScenarios = (catalog?.stages || []).flatMap((s) => s.scenarios.map((sc) => ({ ...sc, stageName: s.name })))
const titleFor = (id) => allScenarios.find((s) => s.id === id)?.title || id
const addPool = () => setPools([...pools, { count: 1, from: [] }])
const removePool = (i) => setPools(pools.filter((_, idx) => idx !== i))
const updatePool = (i, patch) => setPools(pools.map((p, idx) => (idx === i ? { ...p, ...patch } : p)))
return (
<div>
<label className={styles.label}>Required scenarios (always included)</label>
<select className={styles.select} multiple size={6} style={{ width: '100%' }}
value={required} onChange={(e) => setRequired([...e.target.selectedOptions].map((o) => o.value))}>
{catalog?.stages.map((stage) => (
<optgroup key={stage.id} label={stage.name}>
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
</optgroup>
))}
</select>
<p className={styles.muted}>{required.length ? `${required.length} selected: ${required.map(titleFor).join(', ')}` : 'None selected — cmd/ctrl-click to pick multiple.'}</p>
<label className={styles.label} style={{ marginTop: '1rem', display: 'block' }}>Random pools (draw N at random from a set, every attempt)</label>
{pools.map((pool, i) => (
<div key={i} className={styles.card} style={{ marginBottom: '0.5rem' }}>
<div className={styles.formRow}>
<div style={{ maxWidth: '100px' }}><label className={styles.label}>Draw count</label>
<input className={styles.input} type="number" min="1" value={pool.count}
onChange={(e) => updatePool(i, { count: Number(e.target.value) })} /></div>
<div><button type="button" className={styles.linkBtn} onClick={() => removePool(i)}>Remove pool</button></div>
</div>
<label className={styles.label}>Draw from</label>
<select className={styles.select} multiple size={5} style={{ width: '100%' }}
value={pool.from} onChange={(e) => updatePool(i, { from: [...e.target.selectedOptions].map((o) => o.value) })}>
{catalog?.stages.map((stage) => (
<optgroup key={stage.id} label={stage.name}>
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
</optgroup>
))}
</select>
{pool.count > pool.from.length && <p className={styles.muted} style={{ color: 'var(--amber, #b58900)' }}>Draw count ({pool.count}) exceeds the pool size ({pool.from.length}) — every draw will use every scenario, not a random subset.</p>}
</div>
))}
<button type="button" className={styles.linkBtn} onClick={addPool}>+ Add random pool</button>
</div>
)
}
function CreateAssessmentForm({ onCreated, role }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
const [catalog, setCatalog] = useState(null)
const [required, setRequired] = useState([])
const [pools, setPools] = useState([])
const [advanced, setAdvanced] = useState(false)
const [rawPoolSpec, setRawPoolSpec] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => { adminApi.scenarioCatalog().then(setCatalog).catch((e) => setError(e.message)) }, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
// Owner's raw-JSON view, when open, is the source of truth — otherwise built from the
// structured pickers above.
const pool_spec = advanced
? JSON.parse(rawPoolSpec || '{"required":[],"random":[]}')
: { required, random: pools.filter((p) => p.count > 0 && p.from.length > 0) };
if (!advanced && !required.length && !pool_spec.random.length) {
throw new Error('Pick at least one required scenario or one random pool.')
}
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
setRequired([]); setPools([]); setRawPoolSpec('')
onCreated()
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
} catch (e) { setError(e.message?.includes('JSON') ? 'Raw pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
}
return (
@@ -91,26 +157,47 @@ function CreateAssessmentForm({ onCreated }) {
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
</select></div>
</div>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
{role === 'owner' && (
<p className={styles.muted}>
<button type="button" className={styles.linkBtn} onClick={() => setAdvanced(!advanced)}>
{advanced ? '← Back to the structured editor' : 'Advanced: edit pool_spec as raw JSON →'}
</button>
</p>
)}
{advanced ? (
<>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={rawPoolSpec} onChange={(e) => setRawPoolSpec(e.target.value)} required />
</>
) : !catalog ? <p className={styles.muted}>Loading scenario catalog…</p> : (
<PoolSpecEditor catalog={catalog} required={required} setRequired={setRequired} pools={pools} setPools={setPools} />
)}
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function AssessmentDetail({ definition, cohorts, onBack }) {
const [sessions, setSessions] = useState(null)
// Phase 4.1 §16 — assessmentSessions is now paginated (was unbounded); `result` holds the
// full { items, page, total, total_pages } envelope, `sessions` stays the plain array for
// the table below so its render logic doesn't need to change.
const [result, setResult] = useState(null)
const [page, setPage] = useState(1)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
}, [definition])
adminApi.assessmentSessions(definition.assessment_definition_id, page).then(setResult).catch((e) => setError(e.message))
}, [definition, page])
useEffect(reload, [reload])
const sessions = result?.items || null
const assign = async () => {
setBusy(true); setError(null); setNotice(null)
@@ -158,6 +245,13 @@ function AssessmentDetail({ definition, cohorts, onBack }) {
</tbody>
</table>
)}
{result && result.total_pages > 1 && (
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages} ({result.total} total)</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
)}
</div>
</>
)
+32 -21
View File
@@ -2,36 +2,47 @@ import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
// admin/owner only (enforced server-side too). Phase 4.1 §16 — real server-side pagination
// (platform/api/src/pagination.js) replaces the old flat "latest 200, nothing older" cap.
export default function AuditView() {
const [events, setEvents] = useState(null)
const [page, setPage] = useState(1)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
}, [])
setResult(null)
adminApi.auditEvents(page).then(setResult).catch((e) => setError(e.message))
}, [page])
const events = result?.items || []
return (
<div className={styles.card}>
<h2>Audit log</h2>
<p className={styles.muted}>Most recent 200 security/admin events.</p>
<p className={styles.muted}>{result ? `${result.total} total events` : 'Security/admin events, most recent first.'}</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!events ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
{!result ? <p className={styles.muted}>Loading…</p> : (
<>
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
</div>
)
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
const DASHBOARD_POLL_MS = 5000
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
export default function ClassroomView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState('')
const [classrooms, setClassrooms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Classroom</h2>
<div className={styles.formRow}>
<div><label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
</div>
{cohortId && (
<div className={styles.card}>
<h3>Schedule a class</h3>
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h3>Classes</h3>
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
<tbody>
{classrooms.map((c) => (
<tr key={c.classroom_session_id}>
<td>{c.title}</td>
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateClassroomForm({ cohortId, onCreated }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [scheduledStart, setScheduledStart] = useState('')
const [meetingUrl, setMeetingUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createClassroom(cohortId, {
title: title.trim(), description: description.trim() || undefined,
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
})
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Title</label>
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
<div><label className={styles.label}>Scheduled start</label>
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
</div>
<label className={styles.label}>Description (optional)</label>
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
</form>
)
}
function ClassroomDashboard({ classroomId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [activityScenario, setActivityScenario] = useState('')
const reload = useCallback(() => {
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
}, [classroomId])
useEffect(() => {
reload()
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
return () => clearInterval(t)
}, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
const { classroom, students, announcements, help_requests: helpRequests } = data
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to classes</button>
<div className={styles.card} style={{ marginTop: '1rem' }}>
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.formRow}>
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { results } = await adminApi.prepareClassroomLabs(classroomId)
setNotice(`Labs prepared: ${results.length} student(s).`)
})}>Prepare Labs</button>
</div>
</div>
<div className={styles.card}>
<h3>Participants ({students.length})</h3>
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
<td className={styles.muted}>{s.hints_used}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
<table>
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
<tbody>
{helpRequests.map((h) => (
<tr key={h.help_request_id}>
<td>{h.display_name || h.student_id}</td>
<td className={styles.muted}>{h.category}</td>
<td className={styles.muted}>{h.message || '—'}</td>
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
<td>
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Current activity</h3>
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
<div className={styles.formRow}>
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
)}
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
</div>
</div>
</>
)
}
+1 -1
View File
@@ -101,7 +101,7 @@ function CohortDetail({ cohortId, canManage, onBack }) {
if (!newStudentName.trim()) return
setBusy(true); setError(null)
try {
await adminApi.createStudent(newStudentName.trim(), cohortId)
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
setNewStudentName('')
reload()
} catch (e) { setError(e.message) } finally { setBusy(false) }
+52
View File
@@ -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>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 5B §11 — Programs area. Authoring (create/edit/activate-deactivate) is admin/owner
// only, matching AssessmentsView's "viewed by all roles, only authoring restricted" pattern —
// canAuthor gates the create form and the activate/deactivate toggle only, not the list itself.
export default function ProgramsView({ role }) {
const [programs, setPrograms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.programs().then(setPrograms).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <ProgramDetail programId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create program</h2>
<CreateProgramForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Programs</h2>
{!programs ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Name</th><th>Family</th><th>Delivery</th><th>Enrollments</th><th>Status</th><th /></tr></thead>
<tbody>
{programs.map((p) => (
<tr key={p.program_id}>
<td>{p.name}</td>
<td><span className={styles.pill}>{p.family}</span></td>
<td className={styles.muted}>{p.delivery_mode.replace('_', ' ')}</td>
<td>{p.enrollment_count}</td>
<td><span className={`${styles.pill} ${p.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{p.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(p.program_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateProgramForm({ onCreated }) {
const [form, setForm] = useState({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createProgram(form)
setForm({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Program ID (slug)</label>
<input className={styles.input} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
<div><label className={styles.label}>Family</label>
<select className={styles.select} value={form.family} onChange={(e) => setForm({ ...form, family: e.target.value })}>
<option value="init">init_ (ages ~5-10)</option>
<option value="compile">compile_ (ages ~11-14)</option>
<option value="root">root_ (ages ~15-18+)</option>
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
<option value="self_paced">self-paced</option>
<option value="cohort">cohort</option>
<option value="hybrid">hybrid</option>
</select></div>
</div>
<label className={styles.label}>Description</label>
<input className={styles.input} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<p className={styles.muted} style={{ marginTop: '0.5rem' }}>
New programs default to curriculum_path 'rootnode-path' — the only curriculum content that exists today. A program for init_/compile_ can be created now and wired to real content later without a schema change.
</p>
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function ProgramDetail({ programId, canAuthor, onBack }) {
const [program, setProgram] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.program(programId).then(setProgram).catch((e) => setError(e.message))
adminApi.programEnrollments(programId).then(setEnrollments).catch(() => {})
}, [programId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to programs</button>
{!program ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{program.name} <span className={`${styles.pill} ${program.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{program.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{program.description || 'No description.'}</p>
<p>Family: <span className={styles.pill}>{program.family}</span> — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})</p>
<p className={styles.muted}>
Enrollments — pending: {program.enrollment_counts.pending || 0}, active: {program.enrollment_counts.active || 0},
{' '}completed: {program.enrollment_counts.completed || 0}, cancelled: {program.enrollment_counts.cancelled || 0}, expired: {program.enrollment_counts.expired || 0}
</p>
{canAuthor && (
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.updateProgram(programId, { status: program.status === 'active' ? 'inactive' : 'active' }))}>
{program.status === 'active' ? 'Deactivate' : 'Activate'} program
</button>
)}
</div>
{canAuthor && (
<div className={styles.card}>
<h3>Enroll a student</h3>
<EnrollForm programId={programId} onEnrolled={() => { setNotice('Enrolled.'); reload() }} />
</div>
)}
<div className={styles.card}>
<h3>Enrollments</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Status</th><th>Source</th><th>Enrolled</th><th /></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.student_display_name}</td>
<td><span className={`${styles.pill} ${e.status === 'active' ? styles.pillOk : e.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{e.status}</span></td>
<td className={styles.muted}>{e.source}</td>
<td className={styles.muted}>{new Date(e.enrolled_at).toLocaleDateString()}</td>
<td>
{e.status === 'pending' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.activateEnrollment(e.enrollment_id))}>Activate</button>}
{['pending', 'active'].includes(e.status) && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.cancelEnrollment(e.enrollment_id))}>Cancel</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function EnrollForm({ programId, onEnrolled }) {
const [studentId, setStudentId] = useState('')
const [status, setStatus] = useState('pending')
const [startsAt, setStartsAt] = useState('')
const [accessEndsAt, setAccessEndsAt] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createEnrollment({
student_id: studentId.trim(), program_id: programId, status,
starts_at: startsAt || undefined, access_ends_at: accessEndsAt || undefined,
})
setStudentId(''); setStartsAt(''); setAccessEndsAt('')
onEnrolled()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Student ID</label>
<input className={styles.input} value={studentId} onChange={(e) => setStudentId(e.target.value)} placeholder="student-001" required /></div>
<div><label className={styles.label}>Initial status</label>
<select className={styles.select} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">pending</option>
<option value="active">active (skip activation step)</option>
</select></div>
<div><label className={styles.label}>Starts (optional)</label>
<input className={styles.input} type="date" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} /></div>
<div><label className={styles.label}>Access ends (optional)</label>
<input className={styles.input} type="date" value={accessEndsAt} onChange={(e) => setAccessEndsAt(e.target.value)} /></div>
</div>
<button className={styles.btn} disabled={busy || !studentId.trim()}>{busy ? 'Enrolling…' : 'Enroll'}</button>
</form>
)
}
+257 -22
View File
@@ -3,54 +3,190 @@ import { adminApi } from './api'
import { StatusPill } from './CohortsView'
import styles from './Admin.module.css'
export default function StudentsView() {
// Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that
// were already here. canManage gates create/convert/reset/revoke/import the same way
// CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary,
// this just avoids showing controls a plain instructor would get a 403 from.
export default function StudentsView({ role }) {
const [students, setStudents] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canManage = role !== 'instructor'
const reload = useCallback(() => {
adminApi.students().then(setStudents).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
if (selected) return <StudentDetail studentId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
return (
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
<>
{error && <div className={styles.errorBox}>{error}</div>}
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td className={styles.muted}>{s.student_id}</td>
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
{canManage && (
<div className={styles.card}>
<h2>Add student</h2>
<CreateStudentForm onCreated={reload} />
</div>
)}
</div>
{canManage && (
<div className={styles.card}>
<h2>Bulk import (CSV)</h2>
<ImportForm onImported={reload} />
</div>
)}
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Login</th><th>Status</th><th>Last active</th><th /></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td className={styles.muted}>{s.student_id}</td>
<td>
{s.has_credentials
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
</td>
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function StudentDetail({ studentId, onBack }) {
function CreateStudentForm({ onCreated }) {
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [loginIdentifier, setLoginIdentifier] = useState('')
const [cohortId, setCohortId] = useState('')
const [cohorts, setCohorts] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once
useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setCreated(null)
try {
const result = await adminApi.createStudent({
display_name: displayName.trim(), cohort_id: cohortId || undefined,
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
})
setCreated(result)
setDisplayName(''); setEmail(''); setLoginIdentifier('')
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
{created?.activation_token && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
</div>
)}
<div className={styles.formRow}>
<div><label className={styles.label}>Display name</label>
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
<div><label className={styles.label}>Email (older/self-paced)</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID (younger/managed)</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><label className={styles.label}>Cohort (optional)</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
<option value="">— none —</option>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
</form>
)
}
function ImportForm({ onImported }) {
const [csv, setCsv] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [rowErrors, setRowErrors] = useState(null)
const [created, setCreated] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setRowErrors(null); setCreated(null)
try {
const { created: rows } = await adminApi.importStudents(csv)
setCreated(rows); setCsv('')
onImported()
} catch (e) {
if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below
setError(e.message)
} finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
{error && <div className={styles.errorBox}>{error}</div>}
{rowErrors && (
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
)}
{created && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
{created.filter((c) => c.activation_token).map((c) => (
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
))}
</div>
)}
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
value={csv} onChange={(e) => setCsv(e.target.value)} required />
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
</form>
)
}
function StudentDetail({ studentId, canManage, onBack }) {
const [student, setStudent] = useState(null)
const [progress, setProgress] = useState(null)
const [attempts, setAttempts] = useState(null)
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [notifications, setNotifications] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
adminApi.studentAttempts(studentId).then(setAttempts).catch(() => {})
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
// recent page (labeled "Recent attempts," not a full history browser — see
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
// instructor only sees enrollments they created or that share a cohort with them.
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -69,6 +205,53 @@ function StudentDetail({ studentId, onBack }) {
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to students</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
{canManage && (
<div className={styles.card}>
<h3>Credentials</h3>
{student?.has_credentials ? (
<>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.</p>
<div className={styles.formRow}>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { reset_token } = await adminApi.resetStudentPassword(studentId)
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
})}>Send password reset</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : student?.email || student?.login_identifier ? (
<>
<p className={styles.muted}>Not yet activated. An activation link was queued — use this if the student never got it or it expired.</p>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resendActivation(studentId))}>Resend activation</button>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</div>
)}
{canManage && (
<div className={styles.card}>
<h3>Notifications</h3>
{!notifications ? <p className={styles.muted}>Loading…</p> : notifications.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>When</th><th>Type</th><th>Status</th><th>Attempts</th></tr></thead>
<tbody>
{notifications.map((n) => (
<tr key={n.notification_id}>
<td>{new Date(n.created_at).toLocaleString()}</td>
<td>{n.notification_type.replace(/_/g, ' ')}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.attempt_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className={styles.card}>
<h3>Environment</h3>
@@ -98,6 +281,26 @@ function StudentDetail({ studentId, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Enrolled programs</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
<table>
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.program_name}</td>
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Curriculum progress</h3>
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
@@ -131,3 +334,35 @@ function StudentDetail({ studentId, onBack }) {
</>
)
}
function ConvertForm({ studentId, onDone }) {
const [email, setEmail] = useState('')
const [loginIdentifier, setLoginIdentifier] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const { activation_token } = await adminApi.convertStudent(studentId, {
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
})
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
</div>
</form>
)
}
+45 -5
View File
@@ -16,7 +16,9 @@ function getCookie(name) {
async function json(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || `Request failed (${res.status})`)
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
throw err
}
return res.json()
}
@@ -49,10 +51,16 @@ export const adminApi = {
dashboard: () => get('/api/admin/dashboard'),
students: () => get('/api/admin/students'),
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
createStudent: (fields) => post('/api/admin/students', fields),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
importStudents: (csv) => post('/api/admin/students/import', { csv }),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id) => get(`/api/admin/students/${id}/attempts`),
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
@@ -79,19 +87,51 @@ export const adminApi = {
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
auditEvents: () => get('/api/admin/audit-events'),
auditEvents: (page = 1) => get(`/api/admin/audit-events?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
scenarioCatalog: () => get('/api/admin/scenario-catalog'), // Phase 4.1 §18 — structured assessment editor
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
assessments: () => get('/api/admin/assessments'),
createAssessment: (fields) => post('/api/admin/assessments', fields),
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
programs: () => get('/api/admin/programs'),
program: (id) => get(`/api/admin/programs/${id}`),
createProgram: (fields) => post('/api/admin/programs', fields),
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
// ── Phase 5C §15/§16: enrollment operations & communications ────────────────────────────
notifications: (page = 1) => get(`/api/admin/notifications?page=${page}`),
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
}
+146
View File
@@ -0,0 +1,146 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
const POLL_MS = 5000
const HELP_CATEGORIES = [
{ value: 'lab_problem', label: 'Lab problem' },
{ value: 'dont_understand', label: "Don't understand the task" },
{ value: 'validation_failing', label: 'Validation failing' },
{ value: 'other', label: 'Other' },
]
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
// right now" state. Live -> title, current activity (guidance only — a link into the student's
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
export default function ClassroomTab() {
const [live, setLive] = useState(null)
const [detail, setDetail] = useState(null)
const [envStatus, setEnvStatus] = useState(null)
const [joined, setJoined] = useState(false)
const [helpCategory, setHelpCategory] = useState('lab_problem')
const [helpMessage, setHelpMessage] = useState('')
const [helpSent, setHelpSent] = useState(false)
const [error, setError] = useState(null)
const heartbeatRef = useRef(null)
const pollRef = useRef(null)
const loadLive = useCallback(() => {
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadLive() }, [loadLive])
const loadDetail = useCallback((id) => {
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
api.environmentStatus().then(setEnvStatus).catch(() => {})
}, [])
useEffect(() => {
if (!live) return
loadDetail(live.classroom_session_id)
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
return () => clearInterval(pollRef.current)
}, [live, loadDetail])
useEffect(() => {
if (!joined || !live) return
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
return () => clearInterval(heartbeatRef.current)
}, [joined, live])
const join = async () => {
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
catch (e) { setError(e.message) }
}
const sendHelp = async (e) => {
e.preventDefault()
try {
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
setHelpSent(true); setHelpMessage('')
} catch (e) { setError(e.message) }
}
const launchLab = async () => {
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
}
if (!live) {
return (
<div className={styles.card}>
<h2>Classroom</h2>
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
</div>
)
}
return (
<div>
<div className={styles.card}>
<p className={styles.livePill}>LIVE NOW</p>
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
{live.description && <p className={styles.muted}>{live.description}</p>}
{live.meeting_url && (
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
)}
{!joined ? (
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
) : (
<p className={styles.muted}>You're in this classroom.</p>
)}
</div>
{joined && detail && (
<>
{detail.classroom.current_scenario_id && (
<div className={styles.card}>
<h3>Current class activity</h3>
<p>{detail.classroom.current_scenario_id}</p>
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
</div>
)}
<div className={styles.card}>
<h3>Your Lab</h3>
{envStatus?.status === 'running'
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
</button>}
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
<ul>
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
</ul>
)}
</div>
<div className={styles.card}>
<h3>Need help?</h3>
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
<form onSubmit={sendHelp}>
<label className={styles.label}>What's going on?</label>
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<label className={styles.label}>Details (optional)</label>
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
<button className={styles.primaryBtn} type="submit">Need Help</button>
</form>
)}
</div>
</>
)}
{error && <div className={styles.errorBox}>{error}</div>}
</div>
)
}
@@ -0,0 +1,59 @@
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic
// message regardless of whether the identifier exists (anti-enumeration, matches the server's
// own response shape exactly). In dev/test, the server includes dev_reset_token in its
// response so this screen can offer a direct link — a real deployment omits it, and completing
// the flow needs an instructor to run the admin-side reset instead (see
// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md).
export default function ForgotPasswordScreen({ onCancel, onHaveToken }) {
const [identifier, setIdentifier] = useState('')
const [sent, setSent] = useState(null)
const [loading, setLoading] = useState(false)
const submit = async (e) => {
e.preventDefault()
setLoading(true)
try {
const result = await api.requestPasswordReset(identifier)
setSent(result)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Reset your password</p>
{!sent ? (
<form onSubmit={submit}>
<label className={styles.label}>Email or login</label>
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
</form>
) : (
<>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
{sent.dev_reset_token && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
Dev/test only (never shown in production):{' '}
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
</p>
)}
</>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §6 — the real, credentialed student login. This is now the default entry point
// (see PlatformShell.jsx); EntryScreen (pilot, no password) is still reachable for
// not-yet-converted accounts via the link at the bottom, not removed — see
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7.
export default function LoginScreen({ onLogin, onShowPilotEntry, onShowForgotPassword }) {
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleLogin = async (e) => {
e.preventDefault()
if (!identifier || !password) return
setLoading(true)
setError(null)
try {
const student = await api.studentLogin(identifier, password)
onLogin(student)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Sign in to continue your learning.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<form onSubmit={handleLogin}>
<label className={styles.label}>Email or login</label>
<input className={styles.input} type="text" autoComplete="username" value={identifier}
onChange={(e) => setIdentifier(e.target.value)} required />
<label className={styles.label}>Password</label>
<input className={styles.input} type="password" autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} required />
<button className={styles.primaryBtn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onShowForgotPassword}>Forgot password?</button>
</p>
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onShowPilotEntry}>Pick your name instead (pilot cohorts)</button>
</p>
</div>
</div>
)
}
+9 -1
View File
@@ -36,7 +36,7 @@
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
.select {
.select, .input {
width: 100%;
padding: 0.55rem 0.6rem;
background: var(--surface2);
@@ -45,6 +45,7 @@
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
box-sizing: border-box;
}
.primaryBtn {
@@ -179,5 +180,12 @@ table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pillErr { background: var(--red-dim); color: var(--red); }
.pillMuted { background: var(--surface3); color: var(--text-2); }
/* Phase 5A — classroom/profile screens */
.livePill {
display: inline-block; background: var(--red-dim); color: var(--red); font-family: var(--mono);
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; padding: 0.2rem 0.6rem; border-radius: 999px;
}
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
+38 -7
View File
@@ -1,17 +1,28 @@
import { useState, useEffect } from 'react'
import { api, getCachedDisplay } from './api'
import LoginScreen from './LoginScreen'
import EntryScreen from './EntryScreen'
import SetPasswordScreen from './SetPasswordScreen'
import ForgotPasswordScreen from './ForgotPasswordScreen'
import StudentDashboard from './StudentDashboard'
// Top-level Platform Shell: entry screen if no valid session, dashboard otherwise. Session
// validity is decided by the server (GET /api/session/whoami against the rn_session cookie),
// not by anything read from localStorage/sessionStorage — those only cache the display name
// for a snappier first paint, per api.js.
// Top-level Platform Shell. Phase 5A: the credentialed LoginScreen is now the default entry
// point (was EntryScreen's pilot name-picker); an activation/reset link
// (?activate=<token> or ?reset=<token>) short-circuits straight to SetPasswordScreen before
// any session check, since neither flow requires an existing session. Session validity is
// still decided by the server (GET /api/session/whoami against the rn_session cookie), not by
// anything read from localStorage/sessionStorage.
export default function PlatformShell() {
const [student, setStudent] = useState(null)
const [checking, setChecking] = useState(true)
const params = new URLSearchParams(window.location.search)
const [view, setView] = useState(
params.get('activate') ? 'activate' : params.get('reset') ? 'reset' : 'login'
)
const [resetToken, setResetToken] = useState(params.get('reset') || null)
useEffect(() => {
if (view === 'activate' || view === 'reset') { setChecking(false); return }
const cached = getCachedDisplay()
if (cached) setStudent(cached) // optimistic paint while we confirm with the server
api.whoami()
@@ -20,9 +31,29 @@ export default function PlatformShell() {
})
.catch(() => setStudent(null))
.finally(() => setChecking(false))
}, [])
}, [view])
if (checking) return null
if (!student) return <EntryScreen onLogin={setStudent} />
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
if (view === 'activate') {
return <SetPasswordScreen token={params.get('activate')} purpose="activation"
onDone={(s) => { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }}
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
}
if (view === 'reset') {
return <SetPasswordScreen token={resetToken} purpose="reset"
onDone={() => { setView('login'); window.history.replaceState({}, '', '/') }}
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
}
if (view === 'forgot') {
return <ForgotPasswordScreen onCancel={() => setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} />
}
if (view === 'pilot') {
return <EntryScreen onLogin={(s) => { setStudent(s); setView('dashboard') }} />
}
if (!student) {
return <LoginScreen onLogin={(s) => { setStudent(s); setView('dashboard') }}
onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} />
}
return <StudentDashboard student={student} onLogout={() => { setStudent(null); setView('login') }} />
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §10 — minimal account/profile page. Deliberately narrow: identity, cohort, progress
// summary, achievements, active session count, and change-password. No social/profile features.
export default function ProfileTab() {
const [profile, setProfile] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { api.profile().then(setProfile).catch((e) => setError(e.message)) }, [])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!profile) return <p className={styles.muted}>Loading…</p>
return (
<div>
<div className={styles.card}>
<h2>Account</h2>
<p><strong>{profile.student.display_name}</strong></p>
<p className={styles.muted}>{profile.student.email || profile.student.login_identifier || 'No login identifier set'}</p>
<p className={styles.muted}>
{profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'}
</p>
<p className={styles.muted}>{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}</p>
</div>
<div className={styles.card}>
<h2>Progress</h2>
<p>{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed</p>
<div className={styles.progressBarTrack}>
<div className={styles.progressBarFill} style={{ width: `${profile.progress_summary.total ? Math.round(100 * profile.progress_summary.completed / profile.progress_summary.total) : 0}%` }} />
</div>
</div>
<div className={styles.card}>
<h2>Achievements</h2>
{!profile.achievements.length ? <p className={styles.muted}>None yet — keep going!</p> : (
<ul>
{profile.achievements.map((a, i) => (
<li key={i}>{a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}</li>
))}
</ul>
)}
</div>
<ChangePasswordCard hasCredentials={profile.student.has_credentials} />
</div>
)
}
function ChangePasswordCard({ hasCredentials }) {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
if (!hasCredentials) {
return (
<div className={styles.card}>
<h2>Change password</h2>
<p className={styles.muted}>This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.</p>
</div>
)
}
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try {
await api.changePassword(oldPassword, newPassword)
setNotice('Password updated.'); setOldPassword(''); setNewPassword('')
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
return (
<div className={styles.card}>
<h2>Change password</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<form onSubmit={submit}>
<label className={styles.label}>Current password</label>
<input className={styles.input} type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
<label className={styles.label}>New password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={10} />
<button className={styles.primaryBtn} type="submit" disabled={busy}>{busy ? 'Saving…' : 'Update password'}</button>
</form>
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
const BLOCKED_MESSAGES = {
pending_activation: 'Your enrollment is pending — an instructor still needs to activate it.',
not_yet_started: 'This program opens on the date below. Check back then.',
access_expired: 'Access to this program has ended. Your progress is still here for reference.',
cancelled: 'This enrollment was cancelled.',
}
// Phase 5B §6 — "My Programs." Every enrollment the student holds (GET /api/student/programs
// already carries real access/progress/next-activity per enrollment — see program-status.js),
// split into Active and Completed. Continue Learning delegates to the entitlement-gated
// POST /api/student/programs/:id/launch, which reuses the exact same disposable-Lab sequence
// the My Learning tab's Launch Lab button does — this is not a second kind of Lab.
export default function ProgramsTab() {
const [programs, setPrograms] = useState(null)
const [error, setError] = useState(null)
const load = useCallback(() => {
api.programs().then(setPrograms).catch((e) => setError(e.message))
}, [])
useEffect(load, [load])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!programs) return <p className={styles.muted}>Loading…</p>
const active = programs.filter((p) => ['pending', 'active', 'expired'].includes(p.effective_status))
const completed = programs.filter((p) => p.effective_status === 'completed')
const cancelled = programs.filter((p) => p.effective_status === 'cancelled')
if (!programs.length) {
return (
<div className={styles.card}>
<h2>My Programs</h2>
<p className={styles.muted}>You're not enrolled in any program yet. Talk to your instructor, or check for an enrollment email if you signed up through the website.</p>
</div>
)
}
return (
<div>
{active.length > 0 && (
<div className={styles.card}>
<h2>My Programs</h2>
{active.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
{completed.length > 0 && (
<div className={styles.card}>
<h2>Completed Programs</h2>
{completed.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
{cancelled.length > 0 && (
<div className={styles.card}>
<h2>Other</h2>
{cancelled.map((p) => <ProgramCard key={p.enrollment_id} program={p} onChange={load} />)}
</div>
)}
</div>
)
}
function ProgramCard({ program, onChange }) {
const [envStatus, setEnvStatus] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const pollRef = useRef(null)
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const poll = useCallback(() => {
api.environmentStatus().then((s) => {
setEnvStatus(s)
if (!['requested', 'provisioning', 'resetting'].includes(s.status) && pollRef.current) {
clearInterval(pollRef.current); pollRef.current = null
}
}).catch(() => {})
}, [])
const handleContinue = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchProgram(program.enrollment_id)
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) { window.location.href = result.launch_path; return }
if (['requested', 'provisioning'].includes(result.status) && !pollRef.current) {
pollRef.current = setInterval(poll, POLL_MS)
}
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const p = program.progress
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
return (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong>{program.program_name}</strong>
<span className={`${styles.pill} ${program.effective_status === 'completed' ? styles.pillOk : program.effective_status === 'expired' || program.effective_status === 'cancelled' ? styles.pillMuted : styles.pillWarn}`}>
{program.effective_status.replace(/_/g, ' ')}
</span>
</div>
{p && (
<div style={{ margin: '0.6rem 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
<span>Progress</span><span className={styles.muted}>{p.percent_complete}%</span>
</div>
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${p.percent_complete}%` }} /></div>
</div>
)}
{program.effective_status === 'completed' ? (
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
) : program.blocked_reason ? (
<p className={styles.muted}>
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
{program.blocked_reason === 'not_yet_started' && program.starts_at && ` (${new Date(program.starts_at).toLocaleDateString()})`}
</p>
) : (
<>
{nextLabel && <p className={styles.nextUp}>Next: {nextLabel}</p>}
{error && <p className={styles.errorBox}>{error}</p>}
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
) : (
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
{busy ? 'Starting…' : 'Continue Learning'}
</button>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,76 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves:
// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same
// UI, different API call and success copy.
export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) {
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message))
}, [token, purpose])
const submit = async (e) => {
e.preventDefault()
if (password.length < 10) { setError('Password must be at least 10 characters.'); return }
if (password !== confirm) { setError('Passwords do not match.'); return }
setLoading(true)
setError(null)
try {
if (purpose === 'activation') {
const student = await api.activate(token, password)
onDone(student)
} else {
await api.resetPassword(token, password)
onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
{previewError && (
<div className={styles.errorBox}>
This link is invalid or has expired. Ask your instructor for a new one.
</div>
)}
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
{error && <div className={styles.errorBox}>{error}</div>}
{!previewError && (
<form onSubmit={submit}>
<label className={styles.label}>New password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
<label className={styles.label}>Confirm password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
<button className={styles.primaryBtn} type="submit" disabled={loading}>
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
</button>
</form>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}
@@ -6,13 +6,19 @@ import PracticeTab from './PracticeTab'
import AssessmentsTab from './AssessmentsTab'
import CapstoneTab from './CapstoneTab'
import AchievementsTab from './AchievementsTab'
import ClassroomTab from './ClassroomTab'
import ProfileTab from './ProfileTab'
import ProgramsTab from './ProgramsTab'
const TABS = [
{ key: 'learning', label: 'My Learning' },
{ key: 'programs', label: 'My Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'practice', label: 'Practice' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'capstone', label: 'Capstone' },
{ key: 'achievements', label: 'Achievements' },
{ key: 'profile', label: 'Profile' },
]
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
@@ -41,10 +47,13 @@ export default function StudentDashboard({ student, onLogout }) {
</nav>
{tab === 'learning' && <MyLearningTab />}
{tab === 'programs' && <ProgramsTab />}
{tab === 'classroom' && <ClassroomTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
{tab === 'capstone' && <CapstoneTab />}
{tab === 'achievements' && <AchievementsTab />}
{tab === 'profile' && <ProfileTab />}
</div>
)
}
+41
View File
@@ -83,4 +83,45 @@ export const api = {
capstone: () => fetch('/api/capstone').then(json),
achievements: () => fetch('/api/achievements').then(json),
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
// above: nothing authenticated exists yet to forge into.
studentLogin: async (identifier, password) => {
const { student } = await fetch('/api/student/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
}).then(json)
cacheDisplay(student)
return student
},
activate: async (token, password) => {
const { student } = await fetch('/api/student/activate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json)
cacheDisplay(student)
return student
},
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
}).then(json),
resetPassword: (token, password) => fetch('/api/student/reset-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json),
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
profile: () => fetch('/api/student/profile').then(json),
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
liveClassroom: () => fetch('/api/classroom/live').then(json),
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
programs: () => fetch('/api/student/programs').then(json),
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
}
+20 -15
View File
@@ -4,26 +4,31 @@
# Docker TLS material — CA/server/client certs and keys, generated by
# infra/scripts/docker-tls/generate-ca.sh and friends. Templates/READMEs stay tracked.
infra/docker/tls/*.pem
infra/docker/tls/*.key
infra/docker/tls/*.crt
infra/docker/tls/*.srl
!infra/docker/tls/**/README.md
docker/tls/*.pem
docker/tls/*.key
docker/tls/*.crt
docker/tls/*.srl
!docker/tls/**/README.md
# Ansible Vault password files and any decrypted vars
infra/ansible/.vault_pass
infra/ansible/**/vault_plain.yml
ansible/.vault_pass
ansible/**/vault_plain.yml
# Terraform/OpenTofu state and generated vars (may contain resource IDs/IPs)
infra/terraform/**/*.tfstate
infra/terraform/**/*.tfstate.*
infra/terraform/**/.terraform/
infra/terraform/**/terraform.tfvars
!infra/terraform/**/terraform.tfvars.example
# Phase 4.1: these patterns were previously written repo-root-relative (e.g.
# "infra/terraform/...") inside THIS file, which git resolves relative to the .gitignore's own
# directory (infra/) — so they silently matched nothing at all. Found via `git status` showing
# a real .terraform/ provider cache as untracked when it should have been ignored. Fixed by
# dropping the redundant leading "infra/" from every pattern below.
terraform/**/*.tfstate
terraform/**/*.tfstate.*
terraform/**/.terraform/
terraform/**/terraform.tfvars
!terraform/**/terraform.tfvars.example
# Backup output and restore scratch space
infra/backup/output/
infra/backup/restore-test/
backup/output/
backup/restore-test/
# Load test results (local artifacts, not source)
infra/scripts/loadtest/results/
scripts/loadtest/results/
+1 -1
View File
@@ -13,7 +13,7 @@ lab_host_ip: __LAB_HOST_VM_IP__
management_cidr: __YOUR_ADMIN_ACCESS_CIDR__ # e.g. a VPN/WireGuard subnet, never 0.0.0.0/0
proxmox_mgmt_cidr: __PROXMOX_HOST_MGMT_CIDR__
lab_subnet_range: "10.42.0.0/16" # matches NO_PROXY_CIDRS already used by the app
lab_gateway_port_range: "30000:32767" # see docs/PRODUCTION_NETWORK_SECURITY.md — Gateway-to-Lab reachability follow-up
lab_router_port: "8443" # Gateway -> Lab Host Router (data plane) — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3
# ── Storage (Phase 4 §4 — a decision framework, not a final layout; see docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §4)
rootnode_data_root: /srv/rootnode-data # expected to be a ZFS dataset mountpoint on the real host
@@ -4,3 +4,19 @@
name: docker
state: restarted
daemon_reload: true
# Phase 4.1 — these restart the whole per-host compose wrapper unit (no per-container systemd
# units exist in this project's convention, see the `platform`/`lab-host` roles), which is
# heavier than a surgical single-container restart but consistent with how every other
# config-changed-so-restart-the-stack case is already handled here.
- name: restart lab-router
ansible.builtin.systemd:
name: rootnode-lab-host.service
state: restarted
when: inventory_hostname in groups['lab_host']
- name: restart gateway
ansible.builtin.systemd:
name: rootnode-platform.service
state: restarted
when: inventory_hostname in groups['platform']
@@ -87,3 +87,50 @@
mode: "0644"
when: inventory_hostname in groups['lab_host']
notify: restart docker with tls
# ── Phase 4.1: the DATA-plane mTLS pair (Gateway <-> Lab Host Router). Same CA as the
# CONTROL-plane pair above, separate directory (/etc/rootnode/lab-router-tls) so the two
# concerns stay bind-mount-separable — the `lab-router`/`gateway` containers only ever need
# their own pair mounted in, never the Docker TLS one. See
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3.
- name: Create /etc/rootnode/lab-router-tls
ansible.builtin.file:
path: /etc/rootnode/lab-router-tls
state: directory
owner: root
group: root
mode: "0700"
- name: Deploy CA cert for the data-plane pair (public, both hosts)
ansible.builtin.copy:
src: "{{ docker_tls_local_bundle_dir }}/ca.pem"
dest: /etc/rootnode/lab-router-tls/ca.pem
owner: root
group: root
mode: "0444"
- name: Deploy Lab Host Router server cert/key (rn-lab-host-01 only)
ansible.builtin.copy:
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { src: router-server-cert.pem, dest: router-server-cert.pem, mode: "0444" }
- { src: router-server-key.pem, dest: router-server-key.pem, mode: "0400" }
when: inventory_hostname in groups['lab_host']
notify: restart lab-router
- name: Deploy Gateway client cert/key (rn-platform-01 only)
ansible.builtin.copy:
src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}"
dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}"
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { src: gateway-client-cert.pem, dest: gateway-client-cert.pem, mode: "0444" }
- { src: gateway-client-key.pem, dest: gateway-client-key.pem, mode: "0400" }
when: inventory_hostname in groups['platform']
notify: restart gateway
@@ -3,3 +3,11 @@ PLATFORM_API_URL=http://api:4300
PROVISIONER_URL=http://provisioner:4100
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
GATEWAY_PORT=4200
# Phase 4.1 — mTLS client identity for the Lab Host Router (data plane). Only used when a
# lab_host's routing_mode is 'router' (see platform/gateway/src/lab-endpoint.js); harmless if
# every configured lab_host is still 'direct'. Paths are inside the container — mounted from
# /etc/rootnode/lab-router-tls on the host by the `docker-tls` role, same pattern as the
# Provisioner's Docker TLS client cert.
LAB_ROUTER_TLS_CA=/tls/ca.pem
LAB_ROUTER_TLS_CERT=/tls/gateway-client-cert.pem
LAB_ROUTER_TLS_KEY=/tls/gateway-client-key.pem
@@ -16,3 +16,9 @@ INSTRUCTOR_SESSION_IDLE_HOURS=2
COOKIE_SECURE=1
TRUST_PROXY=1
# Phase 4.1 §17 — instructor "needs attention" thresholds. Commented out (code defaults apply:
# 3 failures, 7 stale days) — uncomment and override in group_vars/{{ env }}.yml only if a
# cohort's real usage shows the defaults are wrong for it.
# NEEDS_ATTENTION_FAIL_THRESHOLD=3
# NEEDS_ATTENTION_STALE_DAYS=7
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// Seeds realistic academy data for the Phase 4.1 §20 destructive restore drill — an owner, an
// admin instructor, a cohort, 3 students, progress, attempts, an assessment definition +
// session, and an achievement. Uses the REAL Platform API over HTTP for everything that
// doesn't need a live Provisioner/k3s Lab (owner login, instructor/cohort/student/assessment
// creation — genuinely exercises the real RBAC/creation code paths), and direct SQL inserts
// only for progress/attempts/achievements (those need real scenario activity in the full app;
// re-running the whole learning engine here would just re-prove what e2e-phase3b.js already
// proves — this drill is about DATA DURABILITY, not re-testing scenario validation).
//
// Usage: PLATFORM_API_URL=http://localhost:PORT PLATFORM_DB_URL=postgres://... node seed-realistic-data.js
const { Pool } = require('../../platform/api/node_modules/pg');
const API = process.env.PLATFORM_API_URL || 'http://localhost:4390';
const DB_URL = process.env.PLATFORM_DB_URL;
if (!DB_URL) { console.error('Set PLATFORM_DB_URL'); process.exit(1); }
function makeSession() {
const jar = new Map();
function cookieHeader() { return [...jar].map(([k, v]) => `${k}=${v}`).join('; '); }
function absorb(res) {
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
for (const c of cookies) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq === -1) continue;
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
return {
async req(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (cookieHeader()) headers.Cookie = cookieHeader();
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = jar.get('rn_csrf_i') || '';
const res = await fetch(`${API}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
absorb(res);
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json)}`);
return json;
},
};
}
async function main() {
const owner = makeSession();
await owner.req('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'DrillOwnerPass!2026' });
console.log(' owner login ok');
const admin = await owner.req('POST', '/api/admin/instructors', {
email: '[email protected]', display_name: 'Drill Admin', password: 'DrillAdminPass!2026', role: 'admin',
});
console.log(' admin instructor created:', admin.instructor_id);
const cohort = await owner.req('POST', '/api/admin/cohorts', { cohort_id: 'drill-cohort-01', name: 'Restore Drill Cohort' });
console.log(' cohort created');
const students = [];
for (const name of ['Drill Alice', 'Drill Bob', 'Drill Carlos']) {
const s = await owner.req('POST', '/api/admin/students', { display_name: name, cohort_id: 'drill-cohort-01' });
students.push(s.student_id);
}
console.log(' 3 students rostered and enrolled:', students.join(', '));
const assessment = await owner.req('POST', '/api/admin/assessments', {
id: 'drill-exam-01', type: 'exam', title: 'Restore Drill Exam', duration_minutes: 30, hint_policy: 'disabled',
pool_spec: { required: ['pod-basics-mcq'], random: [] },
});
console.log(' assessment definition created:', assessment.assessment_definition_id || 'drill-exam-01');
// Direct SQL for progress/attempts/achievements — see file header for why.
const pool = new Pool({ connectionString: DB_URL });
for (const studentId of students) {
// environment_id is a real FK to the Provisioner's environments table (nullable) — left
// NULL here rather than faking a row that doesn't exist, same as how a genuinely old
// attempt whose environment has long since been cleaned up would look in real data.
await pool.query(
`INSERT INTO scenario_attempts (student_id, scenario_id, status, result, started_at, completed_at, validation_attempt_count, validation_pass_count)
VALUES ($1, 'pod-basics-mcq', 'completed', 'passed', now() - interval '1 hour', now(), 1, 1)`,
[studentId]
);
await pool.query(
`INSERT INTO achievements (student_id, achievement_type, stage_id, achieved_at) VALUES ($1, 'stage_completed', 'foundation', now())`,
[studentId]
);
}
await pool.end();
console.log(' progress/attempts/achievements seeded for all 3 students');
console.log('\nSeed complete.');
}
main().catch((e) => { console.error('SEED FAILED:', e.message); process.exit(1); });
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Phase 4.1 §20 — the DESTRUCTIVE disaster-recovery drill, one level up from
# test-backup-restore.sh's mechanism-only test: seeds realistic academy data through the REAL
# Platform API (owner, admin instructor, cohort, 3 students, progress, attempts, an assessment
# definition, achievements), backs it up, DESTROYS the disposable Postgres storage entirely
# (docker rm — not just "restore into a second container," the actual source is gone),
# recreates Postgres from nothing, restores, and boots the REAL Platform API again against the
# restored database to prove the whole stack — not just the SQL — comes back.
#
# Never touches the developer's real persistent database — every Postgres here is a disposable
# container on a throwaway port, created and destroyed entirely within this script.
set -euo pipefail
cd "$(dirname "$0")/../.." # repo root
CONTAINER=rootnode-drill-postgres
PORT=55434
API_PORT=4390
TESTDIR="$(mktemp -d)"
# curriculum.js defaults to platform/api/scenarios/{data,bundles} — only present inside the
# Docker image build (COPY scenarios/ ./scenarios/). Running src/server.js directly from
# source (as this drill does, to avoid needing a full container build) needs these pointed at
# the repo's real scenario content instead.
export SCENARIOS_DIR="$PWD/scenarios/data"
export BUNDLES_DIR="$PWD/scenarios/bundles"
export CURRICULUM_PATH_FILE="$PWD/scenarios/rootnode-path.json"
export BACKUP_ENCRYPTION_PASSPHRASE="drill-passphrase-not-for-production"
export PROVISIONER_SHARED_SECRET="drill-shared-secret-not-for-production"
API_PID=""
cleanup() {
[ -n "$API_PID" ] && kill "$API_PID" 2>/dev/null || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
rm -rf "$TESTDIR"
}
trap cleanup EXIT
if ! command -v psql >/dev/null 2>&1 || ! command -v pg_dump >/dev/null 2>&1; then
echo "==> psql/pg_dump not found locally — reusing the same docker-exec shim as test-backup-restore.sh"
SHIM_BIN="$TESTDIR/bin"; mkdir -p "$SHIM_BIN"
# Same shim as test-backup-restore.sh (see that file's comment for the full "why") — the URL
# is DROPPED, not kept, since the host-side port it names doesn't exist inside the
# container's own network namespace; -tAc counts as a self-contained call (needs no stdin)
# the same way -c does, or the admin-verification queries below would hang waiting on stdin.
cat > "$SHIM_BIN/psql" <<'EOF'
#!/usr/bin/env bash
args=(); skip_next=0
for a in "$@"; do
if [ "$skip_next" = "1" ]; then skip_next=0; continue; fi
if [ "$a" = "-p" ] || [ "$a" = "-h" ]; then skip_next=1; continue; fi
if [[ "$a" =~ ^postgres://[^:]+:[^@]+@[^:]+:([0-9]+)/ ]]; then continue; fi # drop the URL — reconnect locally inside the container instead
args+=("$a")
done
container="rootnode-drill-postgres"
needs_stdin=1
for a in "${args[@]}"; do { [ "$a" = "-c" ] || [ "$a" = "-tAc" ]; } && needs_stdin=0; done
if [ "$needs_stdin" = "1" ]; then
exec docker exec -e PGPASSWORD=postgres -i "$container" psql -h localhost -U postgres "${args[@]}"
else
exec docker exec -e PGPASSWORD=postgres "$container" psql -h localhost -U postgres "${args[@]}"
fi
EOF
cat > "$SHIM_BIN/pg_dump" <<'EOF'
#!/usr/bin/env bash
args=()
for a in "$@"; do
if [[ "$a" =~ ^postgres://[^:]+:[^@]+@[^:]+:([0-9]+)/(.*)$ ]]; then args+=("postgres://postgres:postgres@localhost:5432/${BASH_REMATCH[2]}"); continue; fi
args+=("$a")
done
exec docker exec -e PGPASSWORD=postgres rootnode-drill-postgres pg_dump "${args[@]}"
EOF
chmod +x "$SHIM_BIN/psql" "$SHIM_BIN/pg_dump"
export PATH="$SHIM_BIN:$PATH"
fi
DB_URL="postgres://postgres:postgres@localhost:$PORT/postgres"
echo "==> [1/9] Starting disposable Postgres"
docker run -d --name "$CONTAINER" -e POSTGRES_PASSWORD=postgres -p "$PORT:5432" postgres:16-alpine >/dev/null
for i in $(seq 1 30); do PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -c "SELECT 1" >/dev/null 2>&1 && break; sleep 1; done
echo "==> [2/9] Applying real Provisioner + Platform API migrations"
( cd platform/provisioner && [ -d node_modules ] || npm install --no-audit --no-fund >/dev/null
PLATFORM_DB_URL="$DB_URL" node -e "require('./src/db').runMigrations().then((m)=>console.log(' Provisioner:', m.join(', ')))" )
( cd platform/api && [ -d node_modules ] || npm install --no-audit --no-fund >/dev/null
PLATFORM_DB_URL="$DB_URL" node -e "require('./src/db').runMigrations().then((m)=>console.log(' Platform API:', m.join(', ')))" )
echo "==> [3/9] Bootstrapping the drill owner and starting a REAL Platform API"
( cd platform/api && PLATFORM_DB_URL="$DB_URL" node bootstrap-owner.js [email protected] "Drill Owner" 'DrillOwnerPass!2026' )
( cd platform/api && PLATFORM_DB_URL="$DB_URL" PROVISIONER_URL="http://localhost:1" PLATFORM_API_PORT="$API_PORT" node src/server.js > "$TESTDIR/api.log" 2>&1 & echo $! > "$TESTDIR/api.pid" )
API_PID=$(cat "$TESTDIR/api.pid")
for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/healthz" >/dev/null 2>&1 && break; sleep 1; done
curl -sf "http://localhost:$API_PORT/healthz" >/dev/null || { echo "FAIL: Platform API never came up"; cat "$TESTDIR/api.log"; exit 1; }
echo " Platform API up on :$API_PORT"
echo "==> [4/9] Seeding realistic academy data through the real API (owner/admin/cohort/3 students/progress/attempts/assessment/achievements)"
( cd infra/backup && PLATFORM_API_URL="http://localhost:$API_PORT" PLATFORM_DB_URL="$DB_URL" node seed-realistic-data.js )
echo "==> [5/9] Backing up (real backup-postgres.sh)"
BACKUP_DIR="$TESTDIR/backups" PLATFORM_DB_URL="$DB_URL" ./infra/backup/backup-postgres.sh
ENC_FILE=$(ls "$TESTDIR"/backups/*.sql.gz.gpg | head -1)
[ -f "$ENC_FILE" ] || { echo "FAIL: no encrypted backup produced"; exit 1; }
echo "==> [6/9] DESTROYING the Postgres storage entirely (this is the destructive part — not restoring into a second container, the original is gone)"
kill "$API_PID" 2>/dev/null || true; API_PID=""
docker rm -f "$CONTAINER" >/dev/null
echo " source container + its storage are gone"
echo "==> [7/9] Recreating Postgres from nothing"
docker run -d --name "$CONTAINER" -e POSTGRES_PASSWORD=postgres -p "$PORT:5432" postgres:16-alpine >/dev/null
for i in $(seq 1 30); do PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -c "SELECT 1" >/dev/null 2>&1 && break; sleep 1; done
echo "==> [8/9] Restoring from the encrypted backup (schema + data both come from the dump — no separate migration step)"
echo "RESTORE" | BACKUP_ENCRYPTION_PASSPHRASE="$BACKUP_ENCRYPTION_PASSPHRASE" ./infra/backup/restore-postgres.sh "$ENC_FILE" "$DB_URL"
./infra/backup/verify-restore.sh "$DB_URL"
echo "==> [9/9] Booting the REAL Platform API again against the RESTORED database"
( cd platform/api && PLATFORM_DB_URL="$DB_URL" PROVISIONER_URL="http://localhost:1" PLATFORM_API_PORT="$API_PORT" node src/server.js > "$TESTDIR/api2.log" 2>&1 & echo $! > "$TESTDIR/api2.pid" )
API_PID=$(cat "$TESTDIR/api2.pid")
for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/healthz" >/dev/null 2>&1 && break; sleep 1; done
curl -sf "http://localhost:$API_PORT/healthz" >/dev/null || { echo "FAIL: Platform API did not come back up against the restored DB"; cat "$TESTDIR/api2.log"; exit 1; }
echo " Platform API is back up, against the RESTORED database — no fresh migration run needed"
echo "==> Verifying accounts/RBAC/cohorts/students/progress/attempts/achievements survived"
FAIL=0
check() { local desc="$1" want="$2" got="$3"; if [ "$want" = "$got" ]; then echo " ok — $desc"; else echo " FAIL — $desc (want $want, got $got)"; FAIL=1; fi; }
check "owner account (role) restored" "owner" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT role FROM instructors WHERE email='[email protected]'" | tr -d '[:space:]')"
check "admin instructor restored" "admin" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT role FROM instructors WHERE email='[email protected]'" | tr -d '[:space:]')"
check "cohort restored" "1" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM cohorts WHERE cohort_id='drill-cohort-01'" | tr -d '[:space:]')"
check "3 students restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM cohort_enrollments WHERE cohort_id='drill-cohort-01'" | tr -d '[:space:]')"
check "3 completed attempts restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM scenario_attempts WHERE result='passed' AND scenario_id='pod-basics-mcq'" | tr -d '[:space:]')"
check "3 achievements restored" "3" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM achievements WHERE stage_id='foundation'" | tr -d '[:space:]')"
check "assessment definition restored" "1" "$(PGPASSWORD=postgres psql -h localhost -p "$PORT" -U postgres -tAc "SELECT count(*) FROM assessment_definitions WHERE assessment_definition_id='drill-exam-01'" | tr -d '[:space:]')"
echo "==> Verifying RBAC still works against the restored DB (real login through the real API)"
LOGIN_STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://localhost:$API_PORT/api/auth/instructor/login" \
-H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"DrillAdminPass!2026"}')
check "restored admin instructor can log in" "200" "$LOGIN_STATUS"
if [ "$FAIL" = "1" ]; then
echo; echo "DRILL FAILED — see above."; exit 1
fi
echo
echo "OK: full destructive restore drill passed — owner/admin/cohort/3 students/progress/attempts/"
echo "assessment/achievements all survived a real Postgres storage loss + recreation + restore, and"
echo "the real Platform API booted and authenticated against the restored database with no fresh"
echo "migration run required."
echo "NOT TESTED HERE (needs real credentials/hardware): the actual off-host Storage Box/S3 copy."
+54 -3
View File
@@ -40,16 +40,19 @@ jobs:
- name: Install dependencies (all first-party services)
run: |
for d in backend frontend platform/api platform/gateway platform/provisioner; do
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
(cd "$d" && npm ci)
done
- name: Syntax-check every service
run: |
for d in backend frontend/src platform/api platform/gateway platform/provisioner; do
for d in backend frontend/src platform/api platform/gateway platform/provisioner platform/lab-router; do
find "$d" -name '*.js' -not -path '*/node_modules/*' -exec node --check {} \;
done
- name: Lab Host Router unit tests (Phase 4.1 — fast, no Docker daemon needed)
run: (cd platform/lab-router && npm test)
- name: Build frontend
run: (cd frontend && npm run build)
@@ -75,8 +78,56 @@ jobs:
# can't grant safely — they stay a LOCAL/staging pre-promotion step
# (infra/scripts/staging-smoke-test.sh), not this job. See docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §18.
# Phase 4.1 §24 — free/open-source security scanning, no paid tooling, no signup required for
# any of these. Severity policy (docs/SECURITY_SCANNING.md): CRITICAL/HIGH findings fail the
# build; MEDIUM/LOW are reported but never block a merge — a build that's permanently red over
# an unrelated low-severity transitive dependency finding trains everyone to ignore red builds.
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: npm audit (dependency vulnerabilities) — fails only on high/critical
run: |
FAIL=0
for d in backend frontend platform/api platform/gateway platform/provisioner platform/lab-router; do
echo "== $d =="
(cd "$d" && npm audit --audit-level=high) || FAIL=1
done
exit $FAIL
- name: Secret scanning (gitleaks — free, no account needed)
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v
- name: Dockerfile linting (hadolint — free)
run: |
FAIL=0
for f in Dockerfile platform/api/Dockerfile platform/gateway/Dockerfile platform/provisioner/Dockerfile platform/egress-proxy/Dockerfile platform/lab-router/Dockerfile; do
echo "== $f =="
docker run --rm -i hadolint/hadolint < "$f" || FAIL=1
done
exit $FAIL
# -S error, not warning — matches docs/SECURITY_SCANNING.md's severity policy. Real run
# against this tree at -S warning surfaces only style nits (unused loop vars, a missing
# `cd ... || exit` guard) on scripts that are otherwise correct; failing the build on
# those trains everyone to ignore the check.
- name: Shell linting (shellcheck — free, standard)
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
find infra -name '*.sh' -exec shellcheck -S error {} \+
- name: Container image scanning (trivy — free, no account needed)
run: |
docker build -q -t rootnode-scan/platform-api -f platform/api/Dockerfile .
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image \
--severity HIGH,CRITICAL --exit-code 1 rootnode-scan/platform-api
build-images:
needs: regression
needs: [regression, security-scan]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
+1
View File
@@ -8,6 +8,7 @@
| `rootnode/gateway` | `platform/gateway/Dockerfile` | `{{ registry }}/rootnode/gateway` |
| `rootnode/provisioner` | `platform/provisioner/Dockerfile` | `{{ registry }}/rootnode/provisioner` |
| `rootnode/egress-proxy` | `platform/egress-proxy/Dockerfile` | `{{ registry }}/rootnode/egress-proxy` |
| `rootnode/lab-router` | `platform/lab-router/Dockerfile` (Phase 4.1) | `{{ registry }}/rootnode/lab-router` |
| `rootnode/lab` | root `Dockerfile` (the student Lab image) | `{{ registry }}/rootnode/lab` |
## Tagging
@@ -1,10 +1,13 @@
# Production compose for rn-lab-host-01. Deployed by infra/ansible/roles/lab-host/ to
# /opt/rootnode/lab-host/docker-compose.yml, run as a systemd unit
# (rootnode-lab-host.service.j2). Runs exactly one long-lived service: the shared egress proxy
# every student Lab's outbound traffic passes through (platform/egress-proxy/, unchanged since
# Phase 1). Student Lab containers themselves are NOT part of this compose file — they're
# created/destroyed individually by the Provisioner over the remote Docker TLS endpoint
# (dockerd itself, not compose), same mechanism as dev, just remote.
# (rootnode-lab-host.service.j2). Runs the shared egress proxy every student Lab's outbound
# traffic passes through (platform/egress-proxy/, unchanged since Phase 1) and, as of Phase
# 4.1, the Lab Host Router (platform/lab-router/) that gives the Gateway on rn-platform-01 a
# way to reach a running Lab without ever touching a Docker-internal address — see
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3. Student Lab containers themselves are NOT
# part of this compose file — they're created/destroyed individually by the Provisioner over
# the remote Docker TLS endpoint (dockerd itself, not compose), same mechanism as dev, just
# remote; the Router finds them by label, purely from local Docker state (see its own README).
networks:
rn-egress:
@@ -22,6 +25,26 @@ services:
# is the actual control, see platform/egress-proxy/allowlist.txt)
networks: [rn-egress]
lab-router:
image: "{{ registry }}/rootnode/lab-router:{{ image_tag }}"
restart: unless-stopped
# Needs the host's own routing table to reach whichever per-Lab bridge network
# (rootnode-lab-net-<id>) the Provisioner created for a given container — those networks
# aren't known ahead of time and aren't attachable one-by-one, the same reason egress-proxy
# would need this too if it initiated connections outward instead of just accepting them.
# Restricted to platform_ip on port {{ lab_router_port | default('8443') }} at the host
# firewall (infra/firewall/rn-lab-host-01.rules) — this is the ONLY thing that makes host
# networking here safe, same defense-in-depth pattern as the Docker TLS port.
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/rootnode/lab-router-tls:/tls:ro
environment:
LAB_ROUTER_PORT: "{{ lab_router_port | default('8443') }}"
LAB_ROUTER_TLS_CA: /tls/ca.pem
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
node-exporter:
image: prom/node-exporter:latest
restart: unless-stopped
@@ -88,6 +88,11 @@ services:
image: "{{ registry }}/rootnode/platform-api:{{ image_tag }}"
restart: unless-stopped
env_file: /etc/rootnode/platform.env
environment:
# Phase 4.1 §25 — not a secret, just the deployed tag; set directly here rather than in
# the env template since it's the same value already used for the image tag above, not a
# separately-managed setting.
GIT_SHA: "{{ image_tag }}"
depends_on:
postgres:
condition: service_healthy
@@ -99,6 +104,10 @@ services:
image: "{{ registry }}/rootnode/gateway:{{ image_tag }}"
restart: unless-stopped
env_file: /etc/rootnode/gateway.env
volumes:
# mTLS client identity for the Lab Host Router (data plane) — see gateway.env.j2's
# LAB_ROUTER_TLS_* vars. Read-only; the private key never leaves this one bind mount.
- /etc/rootnode/lab-router-tls:/tls:ro
depends_on: [provisioner, api]
networks: [rn-platform-internal]
@@ -1,7 +1,12 @@
# Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev
# Unix socket. Same mechanism the Provisioner has had since Phase 1
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint —
# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7.
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at real remote endpoints.
#
# Phase 4.1 correction: routing_mode/router_endpoint is REQUIRED here — Phase 4's original
# claim that "no application code changes required" was true for the Provisioner's CONTROL
# plane (Docker mTLS) but false for the Gateway's DATA plane, which used to assume
# env.internal_ip was directly reachable. It is not, across two real VMs. See
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md.
#
# {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml.
# Rendered by the `platform` role; the raw file here keeps the placeholder for readability.
@@ -9,8 +14,14 @@
lab_hosts:
- lab_host_id: labhost-01
display_name: "RootNode Lab Host VM 01 (production)"
# CONTROL plane: Provisioner -> dockerd, mTLS, port 2376.
endpoint: "tcp://{{ lab_host_ip }}:2376"
tls_ca_path: /etc/rootnode/docker-tls/ca.pem
tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem
tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem
enabled: true
# DATA plane: Gateway -> Lab Host Router, mTLS, router's own port (8443 by default —
# infra/docker/lab-host/docker-compose.production.yml). Gateway never touches a
# Docker-internal address once routing_mode is 'router'.
routing_mode: router
router_endpoint: "https://{{ lab_host_ip }}:8443"
@@ -1,16 +1,17 @@
# Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev
# Unix socket. Same mechanism the Provisioner has had since Phase 1
# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint —
# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7.
#
# {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml.
# Rendered by the `platform` role; the raw file here keeps the placeholder for readability.
# Staging lab_hosts seed — see infra/environments/production/provisioner-config/lab-hosts.yaml
# for the full explanation; this is the same shape, lower stakes. Staging's `lab_host_ip` is
# distinct from production's own (infra/ansible/group_vars/staging.yml), so it needs the same
# router routing_mode as production once staging genuinely runs on two hosts. If staging is
# ever collapsed onto a single host instead, change routing_mode to `direct` and drop
# router_endpoint — that's the only edit needed either way.
lab_hosts:
- lab_host_id: labhost-01
display_name: "RootNode Lab Host VM 01 (production)"
display_name: "RootNode Lab Host VM 01 (staging)"
endpoint: "tcp://{{ lab_host_ip }}:2376"
tls_ca_path: /etc/rootnode/docker-tls/ca.pem
tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem
tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem
enabled: true
routing_mode: router
router_endpoint: "https://{{ lab_host_ip }}:8443"
+8 -7
View File
@@ -38,13 +38,14 @@
# endpoint: ALLOW" example in the Phase 4 spec, encoded literally here.
-A INPUT -p tcp --dport 2376 -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
# Gateway -> per-Lab published port range, rn-platform-01 only. REQUIRED so the Gateway can
# reach each student's Lab UI/terminal over the network once Labs run on a genuinely separate
# host — see docs/PRODUCTION_NETWORK_SECURITY.md §"Gateway-to-Lab cross-VM reachability" for
# why this exists and the small Provisioner/Gateway code change it depends on (NOT YET BUILT —
# this firewall rule is the network-policy half; the port-publishing half is an explicit,
# reviewed application-code follow-up, tracked there rather than silently assumed here).
-A INPUT -p tcp --dport {{ lab_gateway_port_range | default('30000:32767') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
# Lab Host Router (8443) — rn-platform-01 (the Gateway) only. This is the DATA-plane
# equivalent of the Docker TLS rule above: the Gateway never reaches a Lab's Docker-internal
# address directly (no per-Lab port publishing, no wide port range needed) — it reaches this
# one mTLS-authenticated Router instead, which resolves the request to a local container on
# ITS OWN host. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and
# docs/PRODUCTION_NETWORK_SECURITY.md — this closes the "Gateway-to-Lab cross-VM reachability"
# gap Phase 4 flagged as a required follow-up; it is no longer open.
-A INPUT -p tcp --dport {{ lab_router_port | default('8443') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT
# Prometheus (rn-platform-01) scraping this host's node-exporter/cadvisor — metrics only,
# read-only exposition endpoints, still restricted to the one legitimate scraper.
+1 -1
View File
@@ -21,7 +21,7 @@ render() {
-e 's/{{ lab_host_ip }}/10.10.10.20/g' \
-e 's/{{ lab_subnet_range }}/10.42.0.0\/16/g' \
-e 's/{{ proxmox_mgmt_cidr }}/203.0.113.0\/24/g' \
-e "s/{{ lab_gateway_port_range | default('30000:32767') }}/30000:32767/g" \
-e "s/{{ lab_router_port | default('8443') }}/8443/g" \
"$1" > "$2"
}
@@ -83,11 +83,10 @@ scrape_configs:
- target_label: __address__
replacement: localhost:9115
# RootNode application metrics — see docs/OBSERVABILITY.md §"exposing /metrics" for the
# documented, NOT-YET-BUILT follow-up (Platform API/Gateway/Provisioner don't currently
# expose a Prometheus /metrics endpoint; this job is ready for when that lands, disabled
# until then rather than scraping a 404 forever).
# - job_name: rootnode-platform-api
# static_configs:
# - targets: ["api:4300"]
# metrics_path: /metrics
# RootNode application metrics — Phase 5A §34 (platform/api/src/metrics.js): classroom/help-
# request/Lab-state gauges and student login counters. No student/instructor id or other
# high-cardinality value is ever a label here — see that file's header comment.
- job_name: rootnode-platform-api
static_configs:
- targets: ["api:4300"]
metrics_path: /metrics
+1
View File
@@ -22,6 +22,7 @@ declare -A IMAGES=(
[rootnode/gateway]="platform/gateway/Dockerfile platform/gateway"
[rootnode/provisioner]="platform/provisioner/Dockerfile platform/provisioner"
[rootnode/egress-proxy]="platform/egress-proxy/Dockerfile platform/egress-proxy"
[rootnode/lab-router]="platform/lab-router/Dockerfile platform/lab-router"
[rootnode/lab]="Dockerfile ."
)
+43 -12
View File
@@ -1,14 +1,21 @@
#!/usr/bin/env bash
# Generates a private CA and a mutual-TLS cert/key pair for the Provisioner <-> rn-lab-host-01
# Docker Engine connection (docs/PRODUCTION_NETWORK_SECURITY.md §7). The application already
# supports this (platform/provisioner/src/docker.js buildClient() has required mTLS since
# Phase 1) — this script only ever needed to exist for the CERTIFICATES, not new app code.
# Generates a private CA and TWO mutual-TLS cert/key pairs, both under that one CA:
# 1. Provisioner <-> rn-lab-host-01 Docker Engine (CONTROL plane, port 2376). The application
# already supports this (platform/provisioner/src/docker.js buildClient() has required
# mTLS since Phase 1) — this script only ever needed to exist for the CERTIFICATES.
# 2. Gateway <-> rn-lab-host-01 Lab Host Router (DATA plane, port 8443) — Phase 4.1, see
# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and docs/PRODUCTION_NETWORK_SECURITY.md.
# One CA for both keeps the PKI surface small (one thing to protect, one rotation runbook) —
# see docs/RUNBOOK_DOCKER_TLS_ROTATION.md, which now covers all four leaf certs, not just two.
#
# Output goes to $OUT_DIR (default ./out, gitignored) — never into a tracked path. Deploy the
# resulting files with the `docker-tls` Ansible role, which places:
# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd's --tlsverify config
# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem
# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd --tlsverify
# ca.pem, router-server-cert.pem, router-server-key.pem -> lab-router (data plane)
# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem
# -> /etc/rootnode/docker-tls/ (referenced by lab-hosts.yaml)
# ca.pem, gateway-client-cert.pem, gateway-client-key.pem
# -> /etc/rootnode/lab-router-tls/ (referenced by gateway's LAB_ROUTER_TLS_*)
#
# Usage:
# ./generate-docker-tls.sh [--out-dir DIR] [--lab-host-ip IP] [--lab-host-dns NAME] [--days N]
@@ -61,13 +68,37 @@ EOF
openssl x509 -req -days "$DAYS" -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out provisioner-client-cert.pem -extfile client-extfile.cnf
rm -f server.csr client.csr server-extfile.cnf client-extfile.cnf
chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem
chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem
echo "==> Generating server cert for rn-lab-host-01 (Lab Host Router, data plane)"
openssl genrsa -out router-server-key.pem 4096
openssl req -subj "/CN=$LAB_HOST_DNS" -sha256 -new -key router-server-key.pem -out router-server.csr
cat > router-server-extfile.cnf <<EOF
subjectAltName = DNS:$LAB_HOST_DNS,IP:$LAB_HOST_IP
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days "$DAYS" -sha256 -in router-server.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out router-server-cert.pem -extfile router-server-extfile.cnf
echo "==> Generating client cert for the Gateway (data plane)"
openssl genrsa -out gateway-client-key.pem 4096
openssl req -subj "/CN=rootnode-gateway" -new -key gateway-client-key.pem -out gateway-client.csr
openssl x509 -req -days "$DAYS" -sha256 -in gateway-client.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out gateway-client-cert.pem -extfile client-extfile.cnf
rm -f server.csr client.csr router-server.csr gateway-client.csr server-extfile.cnf client-extfile.cnf router-server-extfile.cnf
chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem router-server-key.pem gateway-client-key.pem
chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem router-server-cert.pem gateway-client-cert.pem
echo "==> Done. Generated in $OUT_DIR:"
ls -la
echo
echo "Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)."
echo "Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)."
echo "Verify with: ../verify-docker-tls.sh $OUT_DIR <lab-host-ip>"
echo "CONTROL plane (Provisioner -> dockerd, port 2376):"
echo " Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)."
echo " Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)."
echo "DATA plane (Gateway -> Lab Host Router, port 8443):"
echo " Deploy router-server-{cert,key}.pem + ca.pem to rn-lab-host-01 (lab-router Ansible role)."
echo " Deploy gateway-client-{cert,key}.pem + ca.pem to rn-platform-01 (platform Ansible role)."
echo
echo "Verify Docker control-plane TLS with: ../verify-docker-tls.sh $OUT_DIR <lab-host-ip>"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Orchestrates the Phase 4.1 §27 fake cohort dress rehearsal: brings up the real dev stack,
# bootstraps an owner, runs test-fake-cohort-rehearsal.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-fake-cohort-rehearsal.js
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Orchestrates the Phase 4.1 §21 Lab Host loss/rebuild drill: brings up the real dev stack,
# bootstraps an owner, runs test-lab-host-loss.js, tears down. See that file's header for what
# it actually proves.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-drill-shared-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for i in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the drill owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "LabHost Drill Owner" 'LabHostDrillPass!2026' )
echo "==> Running the drill"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='LabHostDrillPass!2026' \
node ../infra/scripts/drills/test-lab-host-loss.js
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Orchestrates the Phase 5A §30/§33 dress rehearsal: brings up the real dev stack, bootstraps
# an owner, runs test-phase5a-rehearsal.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P5A Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-phase5a-rehearsal.js
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Orchestrates the Phase 5B §18 self-paced dress rehearsal: brings up the real dev stack,
# bootstraps an owner, runs test-phase5b-selfpaced-rehearsal.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P5B Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
INTEGRATION_KEY="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js
@@ -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
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Phase 4.1 §23 — proves the CI pipeline (infra/ci/gitea-workflows/build-and-test.yml) fails
# CLOSED on every listed failure mode, i.e. the underlying command genuinely exits nonzero on
# broken input, which is what makes a Gitea/GitHub Actions step (and therefore the job, and
# therefore `needs: regression` gating build-images/deploy-staging) fail. Doesn't need a real
# CI run — checks the actual commands the workflow calls, directly, the same way Actions would
# invoke them.
set -uo pipefail # NOT -e — this script's whole point is deliberately triggering failures and checking their exit codes
cd "$(dirname "${BASH_SOURCE[0]}")/../../.." # repo root
FAIL=0
expect_nonzero() {
local desc="$1"; shift
"$@" >/tmp/ci-fail-drill-out 2>&1
local code=$?
if [ "$code" -eq 0 ]; then
echo " FAIL — $desc (command succeeded, but it should have failed)"; cat /tmp/ci-fail-drill-out; FAIL=1
else
echo " ok — $desc correctly failed (exit $code)"
fi
}
echo "Phase 4.1 CI-fails-closed drill"
echo
echo "== 1. Test/syntax-check failure =="
BAD_JS="$(mktemp).js"
echo 'function broken( {' > "$BAD_JS"
expect_nonzero "node --check on syntactically broken JS" node --check "$BAD_JS"
rm -f "$BAD_JS"
echo "== 2. Frontend build failure =="
cp frontend/src/App.jsx /tmp/App.jsx.bak
echo 'this is not valid jsx <<<' >> frontend/src/App.jsx
(cd frontend && expect_nonzero "npm run build with a broken component" npm run build)
cp /tmp/App.jsx.bak frontend/src/App.jsx; rm -f /tmp/App.jsx.bak
echo "== 3. Image build failure =="
TMPDOCKERFILE="$(mktemp -d)/Dockerfile"
cat > "$TMPDOCKERFILE" <<'EOF'
FROM node:20-slim
COPY this-file-does-not-exist.js /app/
EOF
expect_nonzero "docker build with a COPY of a nonexistent file" docker build -q -f "$TMPDOCKERFILE" "$(dirname "$TMPDOCKERFILE")"
echo "== 4. Config validation failure =="
BAD_COMPOSE="$(mktemp -d)/docker-compose.yml"
cat > "$BAD_COMPOSE" <<'EOF'
services:
a:
image: alpine
a:
image: alpine
EOF
expect_nonzero "docker compose config on a file with a duplicate service key" docker compose -f "$BAD_COMPOSE" config
echo "== 5. Migration preflight failure =="
( cd platform/api && expect_nonzero "runMigrations() against an unreachable Postgres" \
env PLATFORM_DB_URL="postgres://nobody:[email protected]:1/nope" \
node -e "require('./src/db').runMigrations().then(()=>process.exit(0)).catch((e)=>{console.error(e.message);process.exit(1)})" )
echo "== 6. Staging smoke test failure =="
expect_nonzero "staging-smoke-test.sh with no STAGING_GATEWAY_URL set" \
env -u STAGING_GATEWAY_URL bash infra/scripts/staging-smoke-test.sh
expect_nonzero "staging-smoke-test.sh against an unreachable staging URL" \
env STAGING_GATEWAY_URL="http://127.0.0.1:1" bash infra/scripts/staging-smoke-test.sh
echo
if [ "$FAIL" = "1" ]; then
echo "DRILL FAILED — at least one failure mode did NOT fail closed. See above."
exit 1
fi
echo "OK: every listed failure mode genuinely exits nonzero — CI fails closed on all of them."
echo "NOT TESTED HERE: an actual Gitea Actions run (needs Gitea Actions enabled on the real"
echo "instance and this workflow file activated at .gitea/workflows/ — see build-and-test.yml's"
echo "own header comment). This proves the underlying commands, which is what actually"
echo "determines pass/fail once a real run happens."
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Phase 4.1 §22 — deployment rollback drill. Builds two real gateway image versions (a known
# good build, and a deliberately broken one), "deploys" each in turn to a local container,
# proves the smoke test genuinely catches the regression, then rolls back using the EXACT same
# decision logic rollback-production.sh uses against deploy-history.log (tail -2 | head -1 |
# awk '{print $2}' — the tag before the current one), and proves the rollback recovers.
#
# What this proves: the deterministic-tag + deploy-history.log rollback MECHANISM is real and
# correct. What it does NOT prove: the actual `ansible-playbook site.yml -e image_tag=...` step
# rollback-production.sh runs against a real host — that needs the real hardware
# (docs/PRODUCTION_VALIDATION_CHECKLIST.md). This drill swaps a local Docker container's image
# tag directly instead, as the closest local equivalent.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform/gateway"
CONTAINER=rootnode-rollback-drill
PORT=4295
DEPLOY_LOG="$(mktemp)"
trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; rm -f "$DEPLOY_LOG"; rm -rf /tmp/rootnode-rollback-drill-broken' EXIT
smoke_test() {
curl -sf "http://localhost:$PORT/healthz" >/dev/null 2>&1
}
echo "==> [1/6] Building the known-good gateway image (current source)"
docker build -q -t rootnode-gateway-drill:known-good -f Dockerfile . >/dev/null
echo "==> [2/6] Building a deliberately broken version (healthz returns 500)"
rm -rf /tmp/rootnode-rollback-drill-broken
cp -r . /tmp/rootnode-rollback-drill-broken
sed -i "s#app.get('/healthz', (req, res) => res.json({ ok: true }));#app.get('/healthz', (req, res) => res.status(500).json({ ok: false, broken: 'DELIBERATE_DRILL_REGRESSION' }));#" \
/tmp/rootnode-rollback-drill-broken/src/server.js
grep -q DELIBERATE_DRILL_REGRESSION /tmp/rootnode-rollback-drill-broken/src/server.js || { echo "FAIL: sed didn't apply — healthz line text must have changed upstream"; exit 1; }
docker build -q -t rootnode-gateway-drill:broken -f /tmp/rootnode-rollback-drill-broken/Dockerfile /tmp/rootnode-rollback-drill-broken >/dev/null
deploy() {
local tag="$1"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" -p "$PORT:4200" -e GATEWAY_PORT=4200 -e PROVISIONER_URL=http://127.0.0.1:1 \
-e PROVISIONER_SHARED_SECRET=drill -e PLATFORM_API_URL=http://127.0.0.1:1 \
"rootnode-gateway-drill:$tag" >/dev/null
sleep 2
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $tag" >> "$DEPLOY_LOG"
}
echo "==> [3/6] Deploying known-good (deploy #1)"
deploy known-good
smoke_test && echo " smoke test: PASS (as expected)" || { echo "FAIL: known-good should have passed"; exit 1; }
echo "==> [4/6] Deploying the broken version (deploy #2 — the 'bad release' this drill catches)"
deploy broken
if smoke_test; then echo "FAIL: the broken deploy's regression was NOT caught by the smoke test — that defeats the whole point"; exit 1; fi
echo " smoke test: FAIL (correctly caught — this is exactly what should stop a real promotion, see docs/RUNBOOK_ROLLBACK.md)"
echo "==> [5/6] Computing the rollback target — same logic as infra/scripts/rollback-production.sh"
TARGET_TAG=$(tail -2 "$DEPLOY_LOG" | head -1 | awk '{print $2}')
[ "$TARGET_TAG" = "known-good" ] || { echo "FAIL: expected rollback target 'known-good', computed '$TARGET_TAG'"; exit 1; }
echo " rollback target: $TARGET_TAG (correctly identified as the tag before the broken one)"
echo "==> [6/6] Rolling back"
deploy "$TARGET_TAG"
smoke_test && echo " smoke test: PASS — rollback recovered service" || { echo "FAIL: rollback did not recover"; exit 1; }
echo
echo "OK: deployment rollback drill passed. Deterministic image tags + deploy-history.log"
echo "correctly identify and recover from a bad release."
echo
echo "On migration compatibility (docs/RUNBOOK_FAILED_MIGRATION.md, docs/RUNBOOK_ROLLBACK.md):"
echo "this script rolls back APPLICATION IMAGES ONLY. There is no automatic down-migration —"
echo "if the bad release also ran a schema-changing migration, rolling the image back does NOT"
echo "undo that migration, and old code may not run correctly against the new schema. That"
echo "case requires restoring from the pre-migration backup (docs/RUNBOOK_POSTGRES_RESTORE.md),"
echo "not a rollback, which is exactly why migrations in this project are check-before-alter"
echo "and additive-only (docs/ENGINEERING_SOP.md §6) — to make image-only rollback usually safe."
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// Phase 4.1 §27 — the fake cohort dress rehearsal. One continuous narrative through the real
// application (not synthetic unit assertions): instructor login, cohort + 4 students, assign
// curriculum, Prepare Labs, students launch, work different scenarios, use hints, fail a
// validation, complete scenarios, instructor checks analytics, an exam is assigned/taken,
// a Lab is reset, a Lab is reaped (destroyed out-of-band, the same consequence the real idle
// reaper produces — see the note below on why this doesn't wait out the real multi-minute
// reaper timer), the student returns and continues, capstone state is checked, and the cohort's
// Labs are destroyed at the end.
//
// Run via run-fake-cohort-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
// No framework, matching project convention. Problems found are printed, not silently passed —
// see the Phase 4.1 report for anything this surfaced.
const assert = require('assert');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
async function main() {
console.log('Phase 4.1 fake cohort dress rehearsal — slow, real k3s boots\n');
const instructor = makeSession(GW);
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const names = ['Rehearsal-Ada', 'Rehearsal-Bo', 'Rehearsal-Cy', 'Rehearsal-Di'];
const students = {};
const sessions = {};
await step('instructor: create a cohort and roster 4 students', async () => {
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: 'rehearsal-cohort', name: 'Rehearsal Cohort' });
for (const name of names) {
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: 'rehearsal-cohort' });
students[name] = body.student_id;
sessions[name] = makeSession(GW);
await sessions[name].request('POST', '/api/login', { student_id: body.student_id });
}
});
await step('instructor: assign curriculum to the cohort', async () => {
const { status } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/curriculum', { curriculum_path: 'rootnode-path' });
assert.ok([200, 201].includes(status));
});
await step('instructor: Prepare Labs for the whole cohort (concurrent)', async () => {
const { status, body } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/prepare-labs');
assert.equal(status, 200);
assert.ok(body.results.every((r) => r.status === 'running'), `expected every Lab running, got: ${JSON.stringify(body.results)}`);
});
const launches = {};
await step('students: everyone\'s prepared Lab is already running (no re-launch wait)', async () => {
for (const name of names) {
const { body } = await sessions[name].request('POST', '/api/environment/launch');
assert.equal(body.status, 'running');
launches[name] = body;
}
});
await step('students: Ada and Bo work DIFFERENT scenarios', async () => {
await sessions['Rehearsal-Ada'].request('POST', `${launches['Rehearsal-Ada'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
await sessions['Rehearsal-Bo'].request('POST', `${launches['Rehearsal-Bo'].launch_path}api/scenarios/pod-basics-mcq/start`);
});
await step('student: Cy uses a hint on a scenario', async () => {
const res = await sessions['Rehearsal-Cy'].request('POST', `${launches['Rehearsal-Cy'].launch_path}api/scenarios/pod-basics-mcq/hint`);
assert.equal(res.status, 200);
});
await step('student: Di fails a validation on purpose', async () => {
const res = await sessions['Rehearsal-Di'].request('POST', `${launches['Rehearsal-Di'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'a' });
assert.equal(res.status, 200);
assert.equal(res.body.correct, false, 'expected a deliberately wrong answer to be marked incorrect');
});
await step('instructor: cohort learning summary reflects real activity', async () => {
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/learning');
assert.equal(body.student_count, 4);
});
await step('instructor: needs-attention returns real signals for the struggling students, no punitive language', async () => {
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/needs-attention');
const text = JSON.stringify(body).toLowerCase();
assert.ok(!text.includes('fail') || text.includes('failed validations'), 'the word "fail" should only appear in a factual "N failed validations" context, never as a standalone judgment');
});
await step('instructor: reset Ada\'s Lab — academic progress is untouched', async () => {
const before = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
// Admin environment routes need the real environment_id, which publicEnvView doesn't echo
// back to the student directly — resolved via the student environment lookup instead.
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Ada']}/environment`);
const env = envRes.body.find((e) => e.status !== 'destroyed');
await instructor.request('POST', `/api/admin/environments/${env.environment_id}/reset`);
const after = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
assert.deepEqual(before.body.details, after.body.details, 'progress must be identical before/after a Lab reset');
});
await step('§27 — a Lab is reaped: same consequence as the real idle reaper (real multi-minute timer not waited out here — see reaper.js/resource-policy.yaml for the real thresholds; this exercises the destroy-and-preserve-progress consequence directly, already proven mechanistically by infra/scripts/drills/test-lab-host-loss.js)', async () => {
const before = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Bo']}/environment`);
const env = envRes.body.find((e) => e.status !== 'destroyed');
await instructor.request('DELETE', `/api/admin/environments/${env.environment_id}`);
const after = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
assert.deepEqual(before.body.details, after.body.details, 'progress must survive a reaped Lab unchanged');
});
await step('student: Bo returns later — a fresh Lab is created and curriculum continues', async () => {
const { status, body } = await sessions['Rehearsal-Bo'].request('POST', '/api/environment/launch');
assert.equal(status, 200); assert.equal(body.status, 'running');
const curriculum = await sessions['Rehearsal-Bo'].request('GET', '/api/curriculum/path');
assert.ok(curriculum.body.stages.length > 0, 'curriculum must still be there for the returning student');
});
await step('instructor: assign an exam to the cohort and Ada takes it', async () => {
const create = await instructor.request('POST', '/api/admin/assessments', {
id: 'rehearsal-exam', type: 'exam', title: 'Rehearsal Exam', duration_minutes: 30, hint_policy: 'disabled',
pool_spec: { required: ['pod-basics-mcq'], random: [] },
});
assert.ok([200, 201, 409].includes(create.status)); // 409 if a prior run already created it — fine, idempotent enough for a rehearsal
const assign = await instructor.request('POST', '/api/admin/assessments/rehearsal-exam/assign', { cohort_id: 'rehearsal-cohort' });
assert.ok([200, 201].includes(assign.status));
const start = await sessions['Rehearsal-Ada'].request('POST', '/api/assessments/rehearsal-exam/start');
assert.equal(start.status, 201);
assert.ok(start.body.scenarios.length > 0);
});
await step('instructor: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
const before = {};
for (const name of names) before[name] = (await sessions[name].request('GET', '/api/progress')).body.details;
await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/destroy-labs');
for (const name of names) {
const after = (await sessions[name].request('GET', '/api/progress')).body.details;
assert.deepEqual(before[name], after, `${name}'s progress must survive end-of-class Lab destruction`);
}
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
// Phase 4.1 §21 — Lab Host loss/rebuild drill. Proves disposable infrastructure is genuinely
// disposable: a student's Lab container is destroyed OUT FROM UNDER the Provisioner (no clean
// destroy call — simulating the whole rn-lab-host-01 VM disappearing), and the system must:
// - keep the Platform (API/Gateway) up throughout — it never depended on that one container
// - never present the now-gone environment as healthy/running once reconciliation runs
// - leave academic data (progress/attempts) completely untouched
// - let the student launch a brand-new Lab afterward and continue where they left off
//
// Run via run-lab-host-loss-drill.sh, which brings up platform/docker-compose.dev.yml first.
// No framework, matching project convention.
const assert = require('assert');
const { execFileSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
async function main() {
console.log('Phase 4.1 Lab Host loss/rebuild drill — slow, real k3s boots\n');
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(loginStatus, 200, 'owner login must succeed');
let studentId;
await test('roster: add a student', async () => {
const { body } = await admin.request('POST', '/api/admin/students', { display_name: 'Drill-LabHostLoss', cohort_id: null });
studentId = body.student_id;
});
const session = makeSession(GW);
await session.request('POST', '/api/login', { student_id: studentId });
let launch;
await test('launch: student gets a running Lab (real k3s boot)', async () => {
const { status, body } = await session.request('POST', '/api/environment/launch');
assert.equal(status, 200); assert.equal(body.status, 'running');
launch = body;
});
await test('progress: student completes a real scenario before the Lab Host is lost', async () => {
const res = await session.request('POST', `${launch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
assert.equal(res.status, 200);
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must show completed before we destroy anything');
});
const token = launch.launch_path.split('/')[2];
await test('§21 — simulate total Lab Host loss: kill the Lab container directly, bypassing the Provisioner entirely', async () => {
const containers = execFileSync('docker', ['ps', '--filter', 'name=rootnode-lab-', '--format', '{{.Names}}']).toString().trim().split('\n').filter(Boolean);
assert.ok(containers.length >= 1, 'expected at least one running Lab container to destroy');
for (const c of containers) execFileSync('docker', ['rm', '-f', c]);
console.log(` destroyed ${containers.length} Lab container(s) out from under the Provisioner`);
});
await test('§21 — Platform stays available throughout (it never depended on that container)', async () => {
const api = await fetch(`${API}/healthz`);
const gw = await fetch(`${GW}/healthz`);
assert.equal(api.status, 200);
assert.equal(gw.status, 200);
});
await test('§21 — academic progress is completely untouched by the infrastructure loss', async () => {
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must survive the Lab Host loss unchanged');
});
await test('§21 — the now-gone environment is NEVER presented as healthy once reconciliation runs (Provisioner restart, its real startup path)', async () => {
// cwd is already platform/ — run-lab-host-loss-drill.sh cd's there before invoking this script.
execFileSync('docker', ['compose', '-f', 'docker-compose.dev.yml', 'restart', 'provisioner'], { stdio: 'inherit' });
// Give the Provisioner a moment to finish its real reconcileOnStartup pass.
await new Promise((r) => setTimeout(r, 5000));
const res = await session.rawFetch(`/lab/${token}/`);
assert.ok([503, 404].includes(res.status), `expected the stale environment to be rejected (503/404), got ${res.status} — a healthy-looking response here would mean a destroyed environment is being silently presented as available`);
});
await test('§21 — a replacement Lab can be launched and boots successfully (the "replacement Lab Host" case, minus needing a literal second VM)', async () => {
const { status, body } = await session.request('POST', '/api/environment/launch');
assert.equal(status, 200); assert.equal(body.status, 'running');
assert.notEqual(body.launch_path, launch.launch_path, 'the replacement environment must be a genuinely new one, not the destroyed one still being served');
});
await test('§21 — previous academic progress is still there after the replacement Lab boots', async () => {
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed');
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
// Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous
// narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the
// real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real
// progress/attempt/achievement history, joins a live classroom, sends a help request, and the
// Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two
// of the five are then converted to real credentialed accounts and their entire prior history
// (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual
// migration-safety check §33 asks for, run against a live stack rather than asserted in the
// abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js).
//
// Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
const RUN_ID = Date.now();
const COHORT_ID = `p5a-rehearsal-${RUN_ID}`;
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
// Seeds one realistic progress/attempt/achievement record for a pilot student directly in
// Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full
// Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js.
function seedHistory(studentId) {
const sql = `
INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at)
VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days');
INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count)
VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4);
INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id)
VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations');
`;
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
}
async function main() {
console.log('Phase 5A student identity / remote classroom dress rehearsal\n');
const instructor = makeSession(GW);
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez'];
const students = {}; // name -> student_id
const sessions = {}; // name -> pilot login session
await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => {
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' });
for (const name of names) {
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`);
students[name] = body.student_id;
}
});
await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => {
for (const name of names) {
sessions[name] = makeSession(GW);
const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] });
assert.equal(status, 200);
}
});
await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => {
seedHistory(students['Reh-Ada']);
seedHistory(students['Reh-Bo']);
});
let classroomId;
await step('instructor: create and start a live classroom for the cohort', async () => {
const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' });
classroomId = body.classroom_session_id;
const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
assert.equal(status, 200);
});
await step('all 5 students join the classroom and heartbeat', async () => {
for (const name of names) {
const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`);
assert.equal(status, 200);
await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`);
}
});
await step('Reh-Cy sends a help request; instructor acknowledges it', async () => {
const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' });
assert.equal(status, 201);
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const req = dash.help_requests.find((h) => h.status === 'open');
assert.ok(req);
await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`);
});
await step('instructor posts an announcement and sets current activity', async () => {
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' });
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' });
});
// ── The restart: the whole point of §30's "prove recovery" requirement. ─────────────────
await step('Platform API restarts mid-rehearsal', () => {
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
});
await new Promise((r) => setTimeout(r, 4000));
await step('after restart: the classroom is still live and every session still works, with no re-login', async () => {
for (const name of names) {
const { status } = await sessions[name].request('GET', `/api/classroom/live`);
assert.equal(status, 200);
}
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
assert.equal(dash.classroom.status, 'live');
assert.equal(dash.students.length, 5);
assert.equal(dash.announcements.length, 1);
});
await step('after restart: heartbeats and help requests still work normally', async () => {
const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`);
assert.equal(status, 200);
});
// ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ───────────────
let adaActivation, boActivation;
await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => {
const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` });
const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` });
assert.equal(a.status, 200); assert.equal(b.status, 200);
adaActivation = a.body.activation_token; boActivation = b.body.activation_token;
});
await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => {
const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] });
assert.equal(adaRetry.status, 409);
const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] });
assert.equal(cyStillWorks.status, 200);
});
let adaNewSession, boNewSession;
await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => {
adaNewSession = makeSession(GW);
boNewSession = makeSession(GW);
const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' });
const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' });
assert.equal(a.status, 200); assert.equal(b.status, 200);
});
await step('every bit of prior history survived the conversion — nothing orphaned', async () => {
const adaProgress = await adaNewSession.request('GET', '/api/progress');
assert.equal(adaProgress.status, 200);
assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login');
assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed');
const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']);
assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created');
const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion');
});
await step('cleanup: end the classroom', async () => {
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
// Phase 5B §18 — the self-paced dress rehearsal. One continuous realistic story, not synthetic
// unit assertions: a student is created, activates, has no program, a simulated website
// enrollment event enrolls them, they work real curriculum in a real Lab, the Lab is destroyed,
// they come back later, Continue Learning resumes correctly, a fresh Lab provisions, and they
// complete enough of the program to prove the completion transition. A second, uninvolved
// student proves they cannot reach the first student's program or Lab. No framework, matching
// project convention (see test-fake-cohort-rehearsal.js / test-phase5a-rehearsal.js).
//
// Run via run-phase5b-selfpaced-rehearsal.sh, which brings up platform/docker-compose.dev.yml
// first.
const assert = require('assert');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
// Just enough of the real Foundation stage to prove stage-completion + capstone unlock without
// re-driving the entire curriculum a second time (e2e-phase5b.js already does that
// exhaustively) — this rehearsal seeds the REST of the curriculum's completion directly in
// Postgres (a real, documented technique — see test-phase5a-rehearsal.js's seedHistory) so the
// story can reach "program completed" without spending another 40+ real scenario round trips.
const FOUNDATION_FIRST = 'pod-basics-mcq';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function fireIntegrationEvent(payload) {
return fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
body: JSON.stringify(payload),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
}
async function main() {
console.log('Phase 5B self-paced dress rehearsal — slow, real k3s boots\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const { body: created } = await owner.request('POST', '/api/admin/students', {
display_name: 'Rehearsal Self-Paced Student', login_identifier: `p5b-selfpaced-${RUN_ID}`,
});
const session = makeSession(GW);
let enrollmentId, envId;
await step('student activates credentials and logs in', async () => {
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
await step('student initially has no program', async () => {
const { body } = await session.request('GET', '/api/student/programs');
assert.equal(body.length, 0);
});
await step('a simulated website enrollment event enrolls the student (server-to-server, not a browser session)', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5b-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { login_identifier: `p5b-selfpaced-${RUN_ID}`, display_name: 'Rehearsal Self-Paced Student' },
});
assert.equal(status, 201);
assert.equal(body.status, 'active');
enrollmentId = body.enrollment_id;
});
await step('the self-paced root program now appears on the student\'s dashboard, ready to go', async () => {
const { body } = await session.request('GET', '/api/student/programs');
assert.equal(body.length, 1);
assert.equal(body[0].can_launch, true);
assert.equal(body[0].next_activity.scenario_id, FOUNDATION_FIRST);
});
await step('student launches a REAL Lab and completes the first real scenario', async () => {
const { status, body } = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
const complete = await session.request('POST', '/api/progress/record', { scenario_id: FOUNDATION_FIRST, status: 'completed', environment_id: envId });
assert.equal(complete.status, 200);
});
await step('the Lab is destroyed (student closed the tab, session ended — the disposable-by-design lifecycle)', async () => {
await owner.request('DELETE', `/api/admin/environments/${envId}`);
});
await step('student logs out', async () => {
await session.request('POST', '/api/logout');
});
const returningSession = makeSession(GW);
await step('time passes — student logs back in on a new session', async () => {
const { status } = await returningSession.request('POST', '/api/student/login', { identifier: `p5b-selfpaced-${RUN_ID}`, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
await step('Continue Learning points to the correct next activity, unchanged by the Lab being gone', async () => {
const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`);
assert.notEqual(body.next_activity.scenario_id, FOUNDATION_FIRST); // moved past it
assert.ok(body.progress.percent_complete > 0);
});
await step('a new Lab provisions and prior progress is intact under it', async () => {
const { status, body } = await returningSession.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
});
await step('seed the rest of curriculum completion directly (realistic bulk backfill, not re-driving 40+ real requests — see file header)', async () => {
const client = `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c`;
const { execSync } = require('child_process');
const rest = ['kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics', 'multi-container-pod-basics',
'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics',
'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics',
'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq',
'deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq',
'services-mcq', 'expose-service-basics',
'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline',
'extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
const values = rest.map((sid) => `('${created.student_id}','${sid}','completed',1,now(),now())`).join(',');
execSync(`${client} "INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at) VALUES ${values} ON CONFLICT (student_id, scenario_id) DO UPDATE SET status='completed'"`, { cwd: `${__dirname}/../../../platform` });
});
await step('student completes the RootNode Capstone for real (the actual server-authoritative draw + validation)', async () => {
const start = await returningSession.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(start.status, 201);
for (const s of start.body.scenarios) {
const r = await returningSession.request('POST', '/api/progress/record', { scenario_id: s.scenario_id, status: 'completed', environment_id: envId });
assert.equal(r.status, 200);
}
});
await step('the program moves to Completed — server-derived, no client "mark complete" action', async () => {
const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(body.status, 'completed');
assert.ok(body.completed_at);
});
await step('the admin portal shows a durable completion record for this student', async () => {
const { body } = await owner.request('GET', `/api/admin/students/${created.student_id}/enrollments`);
const view = body.find((e) => e.enrollment_id === enrollmentId);
assert.equal(view.status, 'completed');
});
// ── A second, uninvolved student — proves entitlement is real, not just "logged in" ──────
const { body: otherCreate } = await owner.request('POST', '/api/admin/students', { display_name: 'Rehearsal Outsider', login_identifier: `p5b-outsider-${RUN_ID}` });
const outsider = makeSession(GW);
await outsider.request('POST', '/api/student/activate', { token: otherCreate.activation_token, password: 'OutsiderPass123!' });
await step('an uninvolved student cannot reach the first student\'s program or Lab path', async () => {
const read = await outsider.request('GET', `/api/student/programs/${enrollmentId}`);
assert.equal(read.status, 404);
const launch = await outsider.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 404);
const list = await outsider.request('GET', '/api/student/programs');
assert.equal(list.body.length, 0);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+165
View File
@@ -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); });
+51
View File
@@ -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 -3
View File
@@ -6,18 +6,26 @@ set -euo pipefail
PROD_URL="${PRODUCTION_URL:?set PRODUCTION_URL, e.g. https://academy.example.com}"
# Phase 4.1 §23: every `curl ... && echo "OK"` below used to be one statement — under `set -e`,
# a failing command that isn't the LAST one in an && list does not trigger the shell to exit (a
# well-known bash gotcha), so an unreachable/broken production endpoint used to fall through
# silently instead of failing this script. Split into two statements each, so the real exit
# status is what `set -e` sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
echo "==> Gateway health"
curl -sf "$PROD_URL/healthz" > /dev/null && echo "OK"
curl -sf "$PROD_URL/healthz" > /dev/null
echo "OK"
echo "==> TLS certificate is valid and not expiring soon"
EXPIRY=$(echo | openssl s_client -connect "$(echo "$PROD_URL" | sed -e 's#https://##')":443 -servername "$(echo "$PROD_URL" | sed -e 's#https://##')" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
echo "Certificate expires: $EXPIRY"
echo "==> Roster endpoint responds (public, unauthenticated, read-only)"
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null && echo "OK"
curl -sf "$PROD_URL/api/roster/cohorts" > /dev/null
echo "OK"
echo "==> Admin login page loads (does not attempt to log in)"
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200 && echo "OK"
curl -sf "$PROD_URL/admin" -o /dev/null -w '%{http_code}\n' | grep -q 200
echo "OK"
echo
echo "OK: production smoke test passed. For a full functional check (owner/instructor login,"
+50
View File
@@ -0,0 +1,50 @@
# Two-Host Routing Simulation
Proves, with real Docker network isolation (not mocks), that the Gateway-to-Lab data path
described in `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1 actually works before the real
Hetzner/Proxmox hardware exists. See that document for the full design; this is the test harness.
## Run it
```bash
cd infra/scripts/simulate-two-host
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh # builds, runs, tears down
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh --keep-up # leaves the stack up for debugging
```
Confirmed passing, 13/13, as of this phase's implementation — see the Phase 4.1 report for the
full run transcript.
## What this genuinely proves (TESTED LOCALLY, with real Docker networking)
- The `gateway` container, attached ONLY to an isolated bridge network with no route to any
Lab's per-environment bridge network, **cannot** open a TCP connection to a Lab container's
Docker-internal IP — verified via a raw connect attempt run from inside the actual gateway
container's own network namespace, not asserted from the test driver's (irrelevant) vantage
point.
- The Gateway **does** reach the Lab correctly through the Lab Host Router, over real mTLS,
for both a plain HTTP request and a full WebSocket terminal session — including a genuine
`kubectl get nodes` executed inside the Lab and its real output read back.
- Alice/Bob isolation (own lab ALLOW, other's lab DENY, including over WebSocket), garbage
tokens, anonymous requests, and destroyed-environment tokens are all rejected through the
real code path — the same `checkOwnership`/`resolveTarget` logic production will run.
- The Lab Host Router refuses a connection with no client certificate at all — mTLS is the
actual authentication mechanism, not a documented intention.
## What this does NOT prove (SIMULATED PRODUCTION BOUNDARY, not identical to real hardware)
- Real cross-VM latency, MTU, or anything about two genuinely separate kernels — this is one
Docker daemon with two isolated networks, a faithful stand-in for network *reachability*
rules but not a physical network.
- The actual Hetzner/Proxmox firewall enforcement (`infra/firewall/*.rules`) — this harness
doesn't touch iptables at all; Docker's own network isolation is what's under test here.
- Provisioner/API/Postgres reachability across the VM boundary — deliberately kept
host-networked and unchanged from `docker-compose.dev.yml` in this harness, because that
control-plane path was already proven separately (Phase 4's real mTLS handshake test against
the Docker Engine TLS port) and isn't what changed this phase. Only the Gateway's isolation
is new here.
## REQUIRES REAL HARDWARE (tracked in docs/PRODUCTION_VALIDATION_CHECKLIST.md, not here)
Running this exact flow against the real `rn-platform-01`/`rn-lab-host-01` VMs, through the
real firewall, with real network latency between two physical/virtual hosts.
@@ -0,0 +1,136 @@
# Phase 4.1 two-host routing simulation — docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
#
# Proves the ONE thing that matters using REAL Docker network isolation, not mocks: a Gateway
# container with no route to a Lab container's bridge network genuinely cannot reach it, and
# can only reach it through the Lab Host Router.
#
# What's genuinely simulated (real Docker networking facts, not faked):
# - `gateway` is attached ONLY to `sim-platform-net`. It has no network_mode: host, no
# attachment to any per-Lab bridge network (rootnode-lab-net-<id>, created dynamically by
# the Provisioner) — its network namespace simply has no route to one. This is the same
# kind of isolation two separate Proxmox VMs would have, achieved here with two Docker
# networks on one daemon instead of two physical hosts.
# - `lab-router` runs with network_mode: host — exactly like `egress-proxy` already does in
# docker-compose.dev.yml (see that file's comment on why: a host-networked process can
# reach every bridge network's containers via the host's own routing table, which is
# exactly what a real Lab Host Router process running directly on rn-lab-host-01 needs).
# It's also reachable FROM `sim-platform-net` via that network's own gateway IP — the same
# mechanism the egress-proxy already relies on for the reverse direction.
# - `provisioner`/`api`/`postgres` stay host-networked, unchanged from docker-compose.dev.yml
# — this phase's new proof target is the Gateway-to-Lab DATA plane, not the CONTROL plane
# (already proven with a real mTLS handshake in Phase 4). Keeping them as-is is a
# deliberate scope choice, not an oversight — see the README in this directory.
#
# What this does NOT prove (needs the real hardware — docs/PRODUCTION_VALIDATION_CHECKLIST.md):
# real cross-VM network latency/MTU, the actual Hetzner/Proxmox firewall enforcement, or
# anything about two genuinely separate kernels.
#
# Usage: see README.md in this directory.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rootnode_platform_sim
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
egress-proxy:
build: ../../../platform/egress-proxy
network_mode: host
provisioner:
build: ../../../platform/provisioner
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# run-simulation.sh generates this with the real routing_mode: router / router_endpoint
# once it knows sim-platform-net's actual gateway IP — overrides just this one file, the
# image's own resource-policy.yaml underneath is untouched.
- ${SIM_LAB_HOSTS_SEED:?set by run-simulation.sh}:/app/config/lab-hosts.seed.yaml:ro
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PROVISIONER_PORT: "4101"
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
EGRESS_PROXY_PORT: "3128"
DNS_RESOLVER: "127.0.0.11"
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
depends_on:
postgres:
condition: service_healthy
api:
build:
context: ../../..
dockerfile: platform/api/Dockerfile
network_mode: host
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
PROVISIONER_URL: "http://localhost:4101"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PLATFORM_API_PORT: "4301"
depends_on:
postgres:
condition: service_healthy
provisioner:
condition: service_started
# Simulates rn-platform-01's Gateway — deliberately NOT host-networked, deliberately not
# attached to any Lab bridge network. This is the isolation under test.
gateway:
build: ../../../platform/gateway
networks:
- sim-platform-net
environment:
GATEWAY_PORT: "4200"
# Reaches host-networked Provisioner/API via sim-platform-net's own gateway IP — the
# same "reachable via the bridge's gateway interface" mechanism egress-proxy already
# relies on (see docker-compose.dev.yml). run-simulation.sh fills these in.
PROVISIONER_URL: ${SIM_PROVISIONER_URL:?set by run-simulation.sh}
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PLATFORM_API_URL: ${SIM_PLATFORM_API_URL:?set by run-simulation.sh}
LAB_ROUTER_TLS_CA: /tls/ca.pem
LAB_ROUTER_TLS_CERT: /tls/gateway-client-cert.pem
LAB_ROUTER_TLS_KEY: /tls/gateway-client-key.pem
volumes:
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
ports:
- "4210:4200" # exposed to the test driver script only, not "public" in any real sense
depends_on:
- provisioner
- api
# Simulates rn-lab-host-01's Lab Host Router — host-networked like egress-proxy, for the same
# reason (needs to reach whatever per-Lab bridge networks the Provisioner creates, which are
# not known/attachable ahead of time).
lab-router:
build: ../../../platform/lab-router
network_mode: host
environment:
LAB_ROUTER_PORT: "8443"
LAB_ROUTER_TLS_CA: /tls/ca.pem
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
DOCKER_SOCKET: /var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
networks:
# Pre-created by run-simulation.sh (so it can inspect the real gateway IP BEFORE any
# container starts and hand it to provisioner/gateway/lab-router as config) — Compose is
# told to use that existing network rather than create its own.
sim-platform-net:
external: true
name: ${COMPOSE_NET_NAME:?set by run-simulation.sh}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Orchestrates the Phase 4.1 two-host routing simulation end-to-end: generates a real TLS
# bundle, computes the sim-platform-net gateway IP, renders the lab_hosts seed with it, brings
# the stack up, runs test-remote-routing.js, tears everything down. See README.md in this
# directory and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
#
# Usage: ./run-simulation.sh [--keep-up] (--keep-up skips teardown, for debugging)
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
KEEP_UP=false
[ "${1:-}" = "--keep-up" ] && KEEP_UP=true
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-sim-shared-secret-not-for-real-use}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
export SIM_TLS_DIR="$WORK_DIR/tls"
export SIM_LAB_HOSTS_SEED="$WORK_DIR/lab-hosts.seed.yaml"
echo "==> [1/6] Creating sim-platform-net so we know its real gateway IP before generating certs"
NET_NAME="rootnode-sim-platform-net"
docker network inspect "$NET_NAME" >/dev/null 2>&1 || docker network create "$NET_NAME" >/dev/null
GW_IP="$(docker network inspect "$NET_NAME" --format '{{(index .IPAM.Config 0).Gateway}}')"
echo " sim-platform-net gateway IP: $GW_IP (lab-router and provisioner/api, all host-networked, are reachable from Gateway's isolated network through this address)"
echo "==> [2/6] Generating a real mTLS bundle for this simulation run (SAN covers the real gateway IP above)"
../docker-tls/generate-docker-tls.sh --out-dir "$SIM_TLS_DIR" --lab-host-ip "$GW_IP" --lab-host-dns lab-router-sim >/dev/null
# Rewrite the compose file's network name to match what we just created/inspected, and point
# the sim gateway/router services at the shared network by that exact name.
export COMPOSE_NET_NAME="$NET_NAME"
echo "==> [3/6] Rendering lab-hosts.seed.yaml with routing_mode: router at the real gateway IP"
cat > "$SIM_LAB_HOSTS_SEED" <<EOF
lab_hosts:
- lab_host_id: sim-lab-host-01
display_name: "Simulated rn-lab-host-01"
endpoint: "unix:///var/run/docker.sock"
enabled: true
routing_mode: router
router_endpoint: "https://${GW_IP}:8443"
EOF
export SIM_PROVISIONER_URL="http://${GW_IP}:4101"
export SIM_PLATFORM_API_URL="http://${GW_IP}:4301"
echo "==> [4/6] Building and starting the simulated two-host stack"
docker compose -f docker-compose.simulate.yml -p rootnode-sim up -d --build
cleanup() {
if [ "$KEEP_UP" = true ]; then
echo "==> --keep-up set, leaving the stack running (docker compose -f docker-compose.simulate.yml -p rootnode-sim down to clean up)"
return
fi
echo "==> [6/6] Tearing down the simulation stack"
docker compose -f docker-compose.simulate.yml -p rootnode-sim down -v >/dev/null 2>&1 || true
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for services to become healthy"
for i in $(seq 1 60); do
if curl -sf http://localhost:4301/healthz >/dev/null 2>&1 && curl -sf http://localhost:4210/healthz >/dev/null 2>&1; then
break
fi
sleep 2
done
echo "==> [5/6] Running the routing test"
GATEWAY_URL=http://localhost:4210 \
PROVISIONER_URL=http://localhost:4101 \
PROVISIONER_SHARED_SECRET="$PROVISIONER_SHARED_SECRET" \
GATEWAY_CONTAINER=rootnode-sim-gateway-1 \
node test-remote-routing.js
@@ -0,0 +1,212 @@
#!/usr/bin/env node
// Phase 4.1 §6-9 — the real, unmocked proof that remote Lab routing works. Run via
// run-simulation.sh, which brings up docker-compose.simulate.yml first. No framework, per
// project convention (see platform/provisioner/test/run.js).
//
// Proves, against the real simulated stack:
// §6/7 Gateway cannot reach a Lab's Docker-internal IP directly, but reaches it correctly
// through the Lab Host Router — both HTTP and WebSocket terminal traffic, including a
// real `kubectl get nodes` inside the Lab.
// §8/9 Alice/Bob isolation, garbage tokens, destroyed environments, anonymous requests, and
// that the Router itself is unreachable without the Gateway's mTLS client identity —
// all through the real path, not asserted against mocks.
const assert = require('assert');
const https = require('https');
const { execFileSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4210';
const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4101';
const PROVISIONER_KEY = process.env.PROVISIONER_SHARED_SECRET;
const GATEWAY_CONTAINER = process.env.GATEWAY_CONTAINER || 'rootnode-sim-gateway-1';
const WS_URL = GW.replace(/^http/, 'ws');
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
async function provisionerCall(method, path) {
const res = await fetch(`${PROVISIONER_URL}${path}`, { method, headers: { 'X-Provisioner-Key': PROVISIONER_KEY } });
const body = await res.json().catch(() => null);
return { status: res.status, body };
}
// The "browser never learns internal_ip" invariant holds everywhere else in the app — this
// test is the one deliberate, documented exception: it needs the real Docker-internal address
// as ground truth to prove the Gateway genuinely CANNOT reach it, which requires calling the
// Provisioner's internal API directly (the same privilege level an operator/CI job has, never
// a student's).
async function internalAddressForToken(token) {
const { body } = await provisionerCall('GET', `/environments/by-token/${token}`);
return body;
}
// bootstrap-owner.js isn't copied into the api image (it's an operator-run one-off script, not
// a runtime dependency — same reason platform/test regression runs hit this before, see
// docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md's regression notes). Run it from the host against
// the sim Postgres's published port instead of `docker exec`ing into the container.
function bootstrapOwner(email, name, password) {
execFileSync('node', ['bootstrap-owner.js', email, name, password], {
cwd: require('path').join(__dirname, '../../../platform/api'),
env: { ...process.env, PLATFORM_DB_URL: 'postgres://postgres:postgres@localhost:5434/rootnode_platform_sim' },
stdio: 'inherit',
});
}
// Raw TCP connect attempt from WITHIN the gateway container's own network namespace — proves
// isolation from the gateway's actual point of view, not the test driver's (which runs on the
// host and has a completely different, irrelevant reachability picture).
function gatewayCanReach(ip, port, timeoutMs = 2500) {
const script = `
const net = require('net');
const s = net.createConnection({ host: '${ip}', port: ${port}, timeout: ${timeoutMs} });
s.on('connect', () => { console.log('REACHABLE'); s.destroy(); process.exit(0); });
s.on('timeout', () => { console.log('UNREACHABLE(timeout)'); process.exit(0); });
s.on('error', (e) => { console.log('UNREACHABLE(' + e.code + ')'); process.exit(0); });
`;
const out = execFileSync('docker', ['exec', GATEWAY_CONTAINER, 'node', '-e', script]).toString().trim();
return out.startsWith('REACHABLE');
}
function connectRouterWithoutClientCert(host, port, timeoutMs = 2500) {
return new Promise((resolve) => {
const req = https.request({ host, port, method: 'GET', rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
resolve({ ok: true, status: res.statusCode });
});
req.on('error', (e) => resolve({ ok: false, error: e.code || e.message }));
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
req.end();
});
}
function openTerminal(launchPath, cookieHeader) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
let buffer = '';
const timer = setTimeout(() => { ws.close(); reject(new Error(`terminal timed out, buffer so far: ${buffer.slice(-400)}`)); }, 30000);
ws.addEventListener('open', () => ws.send('kubectl get nodes\n'));
ws.addEventListener('message', (ev) => {
buffer += ev.data.toString();
if (/Ready\s/.test(buffer) || /node\.?\d*\s+Ready/i.test(buffer)) {
clearTimeout(timer);
ws.close();
resolve(buffer);
}
});
ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`ws error: ${e.message || e}`)); });
});
}
function expectWsRejected(launchPath, cookieHeader, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
const timer = setTimeout(() => { ws.close(); reject(new Error('expected the WS to be rejected, but it stayed open')); }, timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); ws.close(); reject(new Error('expected the WS upgrade to be rejected, but it succeeded')); });
ws.addEventListener('close', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); resolve(); });
});
}
async function main() {
console.log('Phase 4.1 two-host remote-routing simulation — slow, real k3s boots\n');
bootstrapOwner('[email protected]', 'Sim Owner', 'SimOwnerPass!2026');
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'SimOwnerPass!2026' });
assert.equal(loginStatus, 200, 'owner login must succeed through the Gateway');
let alice, bob;
await test('roster: add Alice and Bob via admin (through the Gateway)', async () => {
const a = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Alice', cohort_id: null });
const b = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Bob', cohort_id: null });
alice = a.body.student_id; bob = b.body.student_id;
assert.notEqual(alice, bob);
});
const aliceSession = makeSession(GW);
const bobSession = makeSession(GW);
await aliceSession.request('POST', '/api/login', { student_id: alice });
await bobSession.request('POST', '/api/login', { student_id: bob });
let aliceLaunch, bobLaunch, aliceEnv;
await test('launch: Alice and Bob each get a running environment on the simulated remote lab host', async () => {
const a = await aliceSession.request('POST', '/api/environment/launch');
const b = await bobSession.request('POST', '/api/environment/launch');
assert.equal(a.status, 200); assert.equal(a.body.status, 'running');
assert.equal(b.status, 200); assert.equal(b.body.status, 'running');
aliceLaunch = a.body; bobLaunch = b.body;
const aliceToken = aliceLaunch.launch_path.split('/')[2];
aliceEnv = await internalAddressForToken(aliceToken);
assert.equal(aliceEnv.lab_host_routing_mode, 'router', 'sim-lab-host-01 must be configured as routing_mode: router for this test to mean anything');
});
await test('§6/7 — the Gateway CANNOT reach the Lab\'s Docker-internal IP directly', async () => {
const reachable = gatewayCanReach(aliceEnv.internal_ip, aliceEnv.internal_port);
assert.equal(reachable, false, `gateway container reached ${aliceEnv.internal_ip}:${aliceEnv.internal_port} directly — the network isolation this whole phase depends on is not real`);
});
await test('§6/7 — but the Gateway DOES reach the Lab through the Lab Host Router (HTTP)', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
const text = await res.text();
assert.ok(text.length > 0, 'expected real HTML/asset content back from the Lab, routed through the Router');
});
await test('§8 — Alice -> her own Lab: ALLOW', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Bob -> his own Lab: ALLOW', async () => {
const res = await bobSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Alice -> Bob\'s Lab: DENY', async () => {
const res = await aliceSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 403);
});
await test('§8 — garbage token: DENY', async () => {
const res = await aliceSession.rawFetch('/lab/not-a-real-token-at-all/');
assert.equal(res.status, 404);
});
await test('§8 — anonymous Gateway request (no session at all): DENY', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
assert.equal(res.status, 401);
});
await test('§8/9 — direct public access to the Lab Host Router: NOT AVAILABLE (mTLS required, no client cert presented)', async () => {
const routerUrl = new URL(aliceEnv.lab_host_router_endpoint);
const result = await connectRouterWithoutClientCert(routerUrl.hostname, routerUrl.port);
assert.equal(result.ok, false, 'the Router accepted a connection with no client certificate at all — mTLS is not actually being enforced');
});
await test('§7 — WebSocket terminal connects and a real `kubectl get nodes` succeeds, through the Router', async () => {
const output = await openTerminal(bobLaunch.launch_path, `rn_session=${bobSession.getCookie('rn_session')}`);
assert.ok(/Ready/i.test(output), `expected a Ready node in kubectl output, got: ${output.slice(-300)}`);
});
await test('§8 — Alice cannot open Bob\'s terminal WebSocket even with a valid session (her own)', async () => {
await expectWsRejected(bobLaunch.launch_path, `rn_session=${aliceSession.getCookie('rn_session')}`);
});
await test('§8/9 — destroyed environment: routing token stops working, both at the Gateway and at the Router directly', async () => {
const aliceToken = aliceLaunch.launch_path.split('/')[2];
await provisionerCall('DELETE', `/environments/${aliceEnv.environment_id}`);
// Give the Gateway's short-TTL env-cache a moment to expire so this proves real
// destroyed-environment handling, not just a stale cache hit.
await new Promise((r) => setTimeout(r, 6000));
const res = await aliceSession.rawFetch(`/lab/${aliceToken}/`);
assert.ok([404, 503].includes(res.status), `expected the destroyed environment to be rejected, got ${res.status}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+7 -1
View File
@@ -9,7 +9,13 @@ set -euo pipefail
STAGING_URL="${STAGING_GATEWAY_URL:?set STAGING_GATEWAY_URL, e.g. https://staging.academy.example.com}"
echo "==> Health checks"
curl -sf "$STAGING_URL/healthz" > /dev/null && echo "OK: Gateway /healthz"
# Phase 4.1 §23: this was `curl ... && echo ...` as one statement — under `set -e`, a failing
# command that ISN'T the last one in an && list does not trigger the shell to exit (a
# well-known bash gotcha), so a genuinely unreachable staging Gateway used to fall through
# silently instead of failing this script. Split into two statements so curl's own exit status
# is what `set -e` actually sees. Found by infra/scripts/drills/test-ci-fails-closed.sh.
curl -sf "$STAGING_URL/healthz" > /dev/null
echo "OK: Gateway /healthz"
if [ -n "${OWNER_EMAIL:-}" ] && [ -n "${OWNER_PASSWORD:-}" ]; then
echo "==> Full acceptance run (platform/test/e2e-phase3b.js against staging)"
+14
View File
@@ -36,3 +36,17 @@ sed -e 's/{{ academy_domain }}/academy.example.com/g' -e 's/{{ management_cidr }
chmod a+r "$TMPDIR/Caddyfile"
docker run --rm -v "$TMPDIR:/etc/caddy:ro" caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile 2>&1 | grep -q "Valid configuration" \
&& echo "OK: Caddyfile is valid" || { echo "FAIL: Caddyfile did not validate"; exit 1; }
# Phase 4.1: rn-lab-host-01's compose file (egress-proxy, lab-router, observability agents)
# was never covered here before — added when the lab-router service landed.
echo
echo "--- lab-host production compose ---"
LABHOST_TMPDIR="$(mktemp -d)"
sed \
-e 's/{{ registry }}/registry.example.com/g' \
-e 's/{{ image_tag }}/test/g' \
-e "s/{{ lab_router_port | default('8443') }}/8443/g" \
../lab-host/docker-compose.production.yml > "$LABHOST_TMPDIR/docker-compose.yml"
docker compose -f "$LABHOST_TMPDIR/docker-compose.yml" config --quiet
echo "OK: lab-host production compose file is well-formed YAML and passes docker compose's own schema validation"
rm -rf "$LABHOST_TMPDIR"
+25 -8
View File
@@ -7,15 +7,21 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
- `api/` — the lightweight Platform API: students, cohorts and cohort membership, curriculum,
detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
capstone assessment engine, achievements, per-instructor accounts, persistent student/
instructor sessions, RBAC, audit logging, and the instructor/admin control plane. No k3s, no
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
(Phase 5A), self-paced programs/enrollment + the website integration boundary (Phase 5B), and
a provider-neutral enrollment-communications pipeline (activation/enrollment-confirmation/
password-reset email, Phase 5C), and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom),
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary),
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery). Operational
reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param — and,
@@ -23,6 +29,10 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
(persistent sessions, CSRF), `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3 (hint gating).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `lab-router/` — Phase 4.1 addition. Runs on the Lab Host VM (or `dev-local`'s stand-in in
local dev, though `direct` mode there never exercises it); resolves an opaque routing token
to a local Lab container purely from Docker state, over mTLS, so the Gateway never needs a
Docker-internal address. Design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1-3.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1.
@@ -77,13 +87,20 @@ those need a browser-equivalent client and aren't automated here.
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
2A), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A identity limitations), and
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (Phase 3B learning/assessment limitations) for the
full, honest lists.
full, honest lists. Phase 3B's "no automatic abandoned-attempt detection" and "no per-cohort
needs-attention threshold configuration" limitations were both closed in Phase 4.1 (§13/§17 of
`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`); its raw-JSON pool_spec limitation was replaced with
a structured editor (§18) with an owner-only raw-JSON fallback still available. Phase 4.1's own
known limitation: real remote/self-paced delivery needs student email/password authentication,
not built — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`.
## Running this in production
`../infra/` has the full production infrastructure package (Ansible, Terraform, firewall,
monitoring, backup, CI/CD) for deploying the Platform API/Gateway/Provisioner/Postgres onto a
dedicated `rn-platform-01` VM, with student Labs on a separate `rn-lab-host-01` VM. See
dedicated `rn-platform-01` VM, with student Labs — and a new `lab-router` service
(`../platform/lab-router/`) — on a separate `rn-lab-host-01` VM. See
`../docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` for the overview and
`../docs/PRODUCTION_NETWORK_SECURITY.md` for a known gap in Gateway-to-Lab reachability across
two real VMs that must be closed before production go-live.
`../docs/PHASE4_1_PREPRODUCTION_HARDENING.md` for how Gateway-to-Lab reachability across two
real VMs was closed (a Lab Host Router + mTLS data plane, simulation-tested in
`../infra/scripts/simulate-two-host/`) and for the rest of Phase 4.1's pre-production hardening.
@@ -0,0 +1,20 @@
-- Abandoned/interrupted attempt handling — Phase 4.1, docs/PHASE4_1_PREPRODUCTION_HARDENING.md
-- §13. 'abandoned' was already a valid scenario_attempts.status value (migration 012's CHECK
-- constraint) but nothing ever set it — an attempt left open when a Lab was reaped, an
-- environment destroyed, a session ended, or a student moved on to a different scenario just
-- stayed 'in_progress' forever, an honest but incomplete signal. This is NOT an academic
-- failure marker — an abandoned attempt has no `result`, doesn't count against the student,
-- and the row is never deleted (see src/attempts.js abandonAttempt for the enforcement of
-- that). It exists so instructor views and needs-attention logic can tell "still working" from
-- "stopped, for a reason we know."
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS abandoned_at TIMESTAMPTZ;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS abandoned_reason TEXT;
DO $$ BEGIN
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_abandoned_reason_check
CHECK (abandoned_reason IS NULL OR abandoned_reason IN
('environment_destroyed', 'session_ended', 'scenario_switched'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,15 @@
-- Postgres-backed login rate limiting — Phase 4.1 §15. Replaces the in-memory sliding-window
-- Map in security.js, which reset on every Platform API restart (an attacker could always just
-- wait for a deploy) and would silently stop being shared the moment there's more than one
-- Platform API instance. One small table, one cheap query per login attempt (this is a login
-- form, not a hot path) — no Redis, no new infrastructure dependency.
CREATE TABLE IF NOT EXISTS login_attempts (
id BIGSERIAL PRIMARY KEY,
rate_key TEXT NOT NULL, -- "ip:email", same composite key security.js always used
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The hot query is "count attempts for this key within the last N minutes" — this index makes
-- that a range scan on one key instead of a table scan.
CREATE INDEX IF NOT EXISTS idx_login_attempts_key_time ON login_attempts(rate_key, created_at);
@@ -0,0 +1,43 @@
-- Real student accounts — Phase 5A §2/§3/§7, docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md.
--
-- Additive only (check-before-alter, docs/ENGINEERING_SOP.md §6) — every existing pilot student
-- row stays a valid academic record with all columns NULL/default. A pilot student
-- (password_hash IS NULL) can still use the original bare student_id login
-- (POST /api/login, unchanged) until an instructor converts them; a converted/newly-created
-- student (password_hash IS NOT NULL) must use the new credentialed login
-- (POST /api/student/login) instead. See src/students-auth.js.
--
-- email vs login_identifier (Phase 5A §3): RootNode serves minors who may not have an email
-- address. Both are nullable, independently unique — an older/self-paced student gets `email`,
-- a younger instructor-managed student gets `login_identifier` (any safe non-email string the
-- instructor assigns, e.g. a classroom username). Exactly one of the two is required once an
-- account is credentialed (enforced in application code, not a CHECK, so the exact rule can
-- evolve without a migration). Neither column names or implies a parent/guardian relationship —
-- that's explicitly future work (docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md), not built here;
-- the schema deliberately doesn't need to change to add it later (a guardian would reference a
-- student_id, not the other way around).
ALTER TABLE students ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS login_identifier TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS password_hash TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- The activation/reset token is ONE mechanism serving two purposes (first-login activation and
-- forgot-password) — both are "a time-limited secure token that lets you set a new password,"
-- functionally identical, so one column set instead of two parallel ones. Only the hash is
-- ever stored (crypto.createHash('sha256'), same pattern as sessions.js's token_hash) — the
-- plaintext token exists only in the instructor's API response / student's activation link,
-- never persisted, never logged.
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_hash TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_purpose TEXT;
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_expires_at TIMESTAMPTZ;
DO $$ BEGIN
ALTER TABLE students ADD CONSTRAINT students_credential_token_purpose_check
CHECK (credential_token_purpose IS NULL OR credential_token_purpose IN ('activation', 'reset'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_email ON students(email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_login_identifier ON students(login_identifier) WHERE login_identifier IS NOT NULL;
@@ -0,0 +1,99 @@
-- Live classroom sessions — Phase 5A §11/§12. A classroom session is a distinct concept from a
-- cohort (a standing roster), an assessment session (one student's exam attempt), a Lab
-- environment (disposable infrastructure), or a login session (one browser's auth) — it
-- represents ONE live teaching event, e.g. "Week 3 — Kubernetes Workloads, Aug 29 6-8pm."
--
-- Deliberately five tables, not one JSONB blob: classroom_sessions (the event itself),
-- classroom_participants (who's in it + presence), classroom_events (append-only audit trail),
-- classroom_announcements and help_requests get their own tables rather than being folded into
-- classroom_events, since both need efficient direct queries ("show me the open help queue",
-- "show me this classroom's announcements in order") that a generic event log would make
-- awkward. classroom_events stays the audit trail (docs/PHASE5A... §25) — it does NOT duplicate
-- announcement/help-request content, just records that the action happened.
CREATE TABLE IF NOT EXISTS classroom_sessions (
classroom_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')),
scheduled_start TIMESTAMPTZ,
scheduled_end TIMESTAMPTZ,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_by UUID REFERENCES instructors(instructor_id),
-- "Current activity" (§20) — a scenario id, not a stage id, matching how students actually
-- work (one scenario at a time). NULL means the instructor hasn't set one yet. This is
-- guidance only — never used to redirect a browser or auto-mark work complete (enforced in
-- application code: nothing reads this to gate access or grade anything).
current_scenario_id TEXT,
-- Optional external video-conferencing link (§23) — a plain URL, RootNode never validates
-- which provider it points at and never proxies/embeds it.
meeting_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_cohort ON classroom_sessions(cohort_id);
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_status ON classroom_sessions(status);
-- At most one LIVE classroom per cohort at a time — an instructor starting a second live
-- session for the same cohort while one is already live is almost certainly a mistake, not a
-- real use case (see classroom.js's startClassroom for the app-level check this backs).
CREATE UNIQUE INDEX IF NOT EXISTS idx_classroom_sessions_one_live_per_cohort
ON classroom_sessions(cohort_id) WHERE status = 'live';
CREATE TABLE IF NOT EXISTS classroom_participants (
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
student_id TEXT NOT NULL REFERENCES students(student_id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Presence (§15) — updated by a throttled heartbeat while the student has this classroom
-- open, never by Lab terminal activity. online/recently-active/offline is DERIVED from this
-- at read time (see admin-classroom-routes.js), not stored as a separate status column, so
-- there's nothing to keep in sync or go stale on its own.
last_seen_at TIMESTAMPTZ,
left_at TIMESTAMPTZ,
PRIMARY KEY (classroom_session_id, student_id)
);
CREATE INDEX IF NOT EXISTS idx_classroom_participants_student ON classroom_participants(student_id);
CREATE TABLE IF NOT EXISTS classroom_events (
event_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'student', 'system')),
actor_id TEXT,
event_type TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_events_session ON classroom_events(classroom_session_id, created_at);
CREATE TABLE IF NOT EXISTS classroom_announcements (
announcement_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
instructor_id UUID REFERENCES instructors(instructor_id),
message TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classroom_announcements_session ON classroom_announcements(classroom_session_id, created_at);
CREATE TABLE IF NOT EXISTS help_requests (
help_request_id BIGSERIAL PRIMARY KEY,
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
student_id TEXT NOT NULL REFERENCES students(student_id),
category TEXT, -- free text ('lab_problem' | 'dont_understand' | 'validation_failing' | 'other' from the frontend, not a DB-enforced enum — see §18's example list)
message TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acknowledged_at TIMESTAMPTZ,
acknowledged_by UUID REFERENCES instructors(instructor_id),
resolved_at TIMESTAMPTZ,
instructor_note TEXT
);
CREATE INDEX IF NOT EXISTS idx_help_requests_session_status ON help_requests(classroom_session_id, status);
CREATE INDEX IF NOT EXISTS idx_help_requests_student ON help_requests(student_id);
+45
View File
@@ -0,0 +1,45 @@
-- Phase 5B §2 — programs: first-class learning products, distinct from curriculum JSON itself
-- (a program is the commercial/academic unit a student enrolls in; curriculum_path is still
-- just the content it points at, unchanged since Phase 1). program_id IS the slug — same
-- convention as assessment_definitions/cohorts (a single TEXT primary key, no separate numeric
-- id + slug pair).
--
-- family is the init_/compile_/root_ youth-engineering ladder (see
-- docs/PHASE5B_SELF_PACED_ENROLLMENT.md §1) — only 'root' has real curriculum content today;
-- init/compile rows can be added later without a schema change, they just won't have a working
-- curriculum_path yet. delivery_mode describes how the program is normally taken; an
-- individual enrollment can still snapshot a different mode (see 020_enrollments.sql).
--
-- version is a plain incrementing integer, matching assessment_definitions' existing
-- versioning convention — enrollments snapshot the version+curriculum_path they were created
-- against so a later edit to a program never silently changes what an already-enrolled
-- student is being held to.
CREATE TABLE IF NOT EXISTS programs (
program_id TEXT PRIMARY KEY, -- slug, e.g. 'root-kubernetes-foundations'
name TEXT NOT NULL,
description TEXT,
family TEXT NOT NULL CHECK (family IN ('init', 'compile', 'root')),
delivery_mode TEXT NOT NULL DEFAULT 'self_paced' CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid')),
curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path',
completion_assessment_id TEXT REFERENCES assessment_definitions(assessment_definition_id),
estimated_duration_hours INTEGER,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_by UUID REFERENCES instructors(instructor_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_programs_status ON programs(status);
CREATE INDEX IF NOT EXISTS idx_programs_family ON programs(family);
-- Seed: the one legitimate self-paced program this phase ships, wrapping the EXISTING
-- RootNode/Kubernetes curriculum and capstone — no new curriculum content invented. init_/
-- compile_ programs are intentionally NOT seeded (no real curriculum for them yet, per this
-- phase's explicit scope line).
INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours)
VALUES (
'root-kubernetes-foundations', 'ROOT — Kubernetes Foundations',
'Self-paced Linux, containers, and Kubernetes operations — the existing RootNode curriculum, from Foundations through the RootNode Capstone.',
'root', 'self_paced', 'rootnode-path', 'rootnode-capstone', 40
) ON CONFLICT (program_id) DO NOTHING;
@@ -0,0 +1,51 @@
-- Phase 5B §3 — enrollments: a student's academic access to a program. Deliberately separate
-- from cohort_enrollments (002_cohorts.sql) — a self-paced enrollment has no cohort at all, and
-- a student can hold both kinds independently (see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §4).
--
-- delivery_mode/program_version/curriculum_path are snapshotted from the program AT enrollment
-- time — same versioning principle as assessment_sessions (013_assessments.sql) snapshotting a
-- definition's fields at draw time, so editing a program later never silently changes what an
-- already-enrolled student is being held to.
--
-- status is server-transitioned only (enrollment.js) — pending -> active -> completed/expired,
-- or pending/active -> cancelled. 'expired' is set lazily/derived at read time from
-- access_ends_at (matching classroom.js's derivePresence pattern — no stored value to go
-- stale), so it is intentionally NOT a value the application ever writes directly except when
-- reconciling; see enrollment.js's deriveEnrollmentAccess.
CREATE TABLE IF NOT EXISTS enrollments (
enrollment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id TEXT NOT NULL REFERENCES students(student_id),
program_id TEXT NOT NULL REFERENCES programs(program_id),
delivery_mode TEXT NOT NULL, -- snapshot of programs.delivery_mode at enrollment time
program_version INTEGER NOT NULL, -- snapshot of programs.version at enrollment time
curriculum_path TEXT NOT NULL, -- snapshot of programs.curriculum_path at enrollment time
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'completed', 'cancelled', 'expired')),
source TEXT NOT NULL DEFAULT 'admin', -- free text provenance ('admin', or whatever the integration event's own "source" said, e.g. 'website') — NEVER used as a metrics label, see metrics.js
external_event_id TEXT, -- idempotency key for integration-created enrollments; null for admin-created ones
external_reference TEXT, -- optional business reference (order/payment id) — free text, not unique, audit-only
starts_at TIMESTAMPTZ, -- null = immediate access once active
access_ends_at TIMESTAMPTZ, -- null = no expiration
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
metadata JSONB,
created_by UUID REFERENCES instructors(instructor_id), -- null for integration-created enrollments
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_enrollments_student ON enrollments(student_id);
CREATE INDEX IF NOT EXISTS idx_enrollments_program ON enrollments(program_id);
CREATE INDEX IF NOT EXISTS idx_enrollments_status ON enrollments(status);
CREATE INDEX IF NOT EXISTS idx_enrollments_created_by ON enrollments(created_by);
-- Idempotency: the same external_event_id can never create two rows. A unique index (not just
-- app-level "SELECT first") is what actually makes concurrent duplicate submissions safe — see
-- enrollment.js's createFromIntegrationEvent, which relies on this via ON CONFLICT.
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_external_event ON enrollments(external_event_id) WHERE external_event_id IS NOT NULL;
-- At most one non-terminal (pending/active) enrollment per student+program at a time. A
-- completed/cancelled/expired enrollment doesn't block a new one — re-enrollment creates a new
-- row rather than reviving a historical one (§3: "do not corrupt historical records").
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_one_open_per_program ON enrollments(student_id, program_id) WHERE status IN ('pending', 'active');
@@ -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';
+175
View File
@@ -0,0 +1,175 @@
// Instructor-facing classroom API — Phase 5A §14/§21. RBAC via rbac.js's
// requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolve
// through to the classroom's cohort, same owner/admin/instructor/viewer rules as everything
// else cohort-scoped — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §19 for role_in_cohort).
//
// The dashboard aggregation (GET /api/admin/classroom-sessions/:id) is the one place in this
// file that reaches beyond classroom.js — it pulls in Provisioner Lab state (§16) and Phase 3B
// academic records (§17) alongside classroom.js's own participant/presence/help-queue data,
// because that combined view is the entire point of a live classroom dashboard. Neither the
// Provisioner nor the academic-records shape is duplicated here — both are read exactly the
// way admin-routes.js/admin-learning-routes.js already read them.
const classroom = require('./classroom');
const { prepareLabsForCohort } = require('./prepare-labs');
module.exports = function registerAdminClassroomRoutes(app, deps) {
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireAnyInstructor, requireCohortAccess, requireCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess } = rbac;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
};
// Maps classroom.js's INVALID_STATE/NOT_FOUND/ALREADY_LIVE error codes to HTTP status —
// every lifecycle route below funnels through this instead of repeating the same try/catch.
function classroomErrorStatus(code) {
if (code === 'NOT_FOUND') return 404;
if (code === 'ALREADY_LIVE' || code === 'INVALID_STATE') return 409;
return 500;
}
admin.post('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
const { title, description, scheduled_start, scheduled_end, meeting_url } = req.body || {};
if (!title) return res.status(400).json({ error: 'title is required' });
const created = await classroom.createClassroom(getPool(), {
cohortId: req.params.id, title, description, scheduledStart: scheduled_start, scheduledEnd: scheduled_end,
meetingUrl: meeting_url, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_created', resourceType: 'classroom_session', resourceId: created.classroom_session_id, result: 'success', sourceIp: req.ip });
res.status(201).json(created);
});
admin.get('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
res.json(await classroom.listClassroomsForCohort(getPool(), req.params.id));
});
// The dashboard itself — §14's "ROOTNODE LIVE CLASSROOM" view. Built from real available
// data, not a fixed mock: participant presence (classroom.js), each participant's Lab status
// (Provisioner, reusing the SAME environment-state values everywhere else in the app uses —
// no second state system, §16), and a lightweight per-student learning snapshot (§17) —
// current stage, most recent scenario worked, its status/validation-failure count, hints
// used. Never inspects terminal command history.
admin.get('/api/admin/classroom-sessions/:id', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
const pool = getPool();
const classroomRow = await classroom.getClassroom(pool, req.params.id);
const [participants, announcements, helpRequests] = await Promise.all([
classroom.listParticipants(pool, req.params.id),
classroom.listAnnouncements(pool, req.params.id),
classroom.listHelpRequests(pool, req.params.id),
]);
const path = curriculum.loadCurriculumPath();
const students = await Promise.all(participants.map(async (p) => {
const [envRes, progress, lastAttempt] = await Promise.all([
provisioner.listForStudent(p.student_id),
loadProgress(pool, p.student_id),
pool.query(
`SELECT scenario_id, status, validation_fail_count, hint_count FROM scenario_attempts
WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 1`,
[p.student_id]
),
]);
const env = envRes.body?.find((e) => e.status !== 'destroyed');
const completed = Object.values(progress).filter((pr) => pr.status === 'completed').length;
const currentStage = path ? path.stages.find((s) => (s.scenario_ids || []).some((sid) => progress[sid]?.status !== 'completed'))?.id || null : null;
return {
...p,
lab_status: env ? env.status : 'none', // 'none'|'requested'|'provisioning'|'running'|'resetting'|'error'|'destroying'|'destroyed' — the real Provisioner enum, unchanged
current_stage: currentStage,
current_scenario: lastAttempt.rows[0]?.scenario_id || null,
attempt_status: lastAttempt.rows[0]?.status || null,
validation_failures: lastAttempt.rows[0]?.validation_fail_count || 0,
hints_used: lastAttempt.rows[0]?.hint_count || 0,
scenarios_completed: completed,
};
}));
res.json({ classroom: classroomRow, students, announcements, help_requests: helpRequests });
});
admin.post('/api/admin/classroom-sessions/:id/start', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.startClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_started', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/end', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.endClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_ended', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/cancel', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.cancelClassroom(getPool(), req.params.id, req.instructor.instructor_id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_cancelled', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
// §21 — the same underlying prepareLabsForCohort() the cohort-level route uses, just entered
// from the classroom dashboard and scoped to req.classroomCohortId (set by
// requireClassroomWriteAccess). Respects admission control exactly the same way (Phase 4.1
// §26) — a full host surfaces as lab_status: 'capacity_waiting' per student, not a crash.
admin.post('/api/admin/classroom-sessions/:id/prepare-labs', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const results = await prepareLabsForCohort(getPool(), provisioner, req.classroomCohortId);
await classroom.emitEvent(getPool(), req.params.id, 'instructor', req.instructor.instructor_id, 'labs_prepared', { student_count: results.length });
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_labs_prepared', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
res.json({ results });
});
admin.post('/api/admin/classroom-sessions/:id/activity', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const { scenario_id } = req.body || {};
try {
const updated = await classroom.setCurrentActivity(getPool(), req.params.id, scenario_id, req.instructor.instructor_id);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/classroom-sessions/:id/announcements', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
const { message } = req.body || {};
if (!message || !message.trim()) return res.status(400).json({ error: 'message is required' });
const created = await classroom.postAnnouncement(getPool(), req.params.id, req.instructor.instructor_id, message.trim());
res.status(201).json(created);
});
admin.get('/api/admin/classroom-sessions/:id/help-requests', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
res.json(await classroom.listHelpRequests(getPool(), req.params.id, req.query.status));
});
admin.post('/api/admin/help-requests/:id/acknowledge', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
try {
const updated = await classroom.acknowledgeHelpRequest(getPool(), req.params.id, req.instructor.instructor_id);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
admin.post('/api/admin/help-requests/:id/resolve', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
const { note } = req.body || {};
try {
const updated = await classroom.resolveHelpRequest(getPool(), req.params.id, req.instructor.instructor_id, note);
res.json(updated);
} catch (e) {
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
}
});
};
+44 -10
View File
@@ -7,9 +7,13 @@
// can do, per the Phase 3B RBAC matrix.
const { computeCurriculumState } = require('./curriculum-rules');
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
// Phase 4.1 §17 — configurable via env (environment-backed policy, matching the pattern
// sessions.js already uses for TTLs), not a database-backed settings table — that's more
// machinery than two numbers need. Sensible defaults if unset.
const NEEDS_ATTENTION_FAIL_THRESHOLD = Number(process.env.NEEDS_ATTENTION_FAIL_THRESHOLD || 3); // repeated validation failures on one scenario
const NEEDS_ATTENTION_STALE_DAYS = Number(process.env.NEEDS_ATTENTION_STALE_DAYS || 7); // no progress in this many days
module.exports = function registerAdminLearningRoutes(app, deps) {
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
@@ -70,9 +74,13 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
[req.params.id]
);
// Phase 4.1 §16 — this is a per-student summary embedded in a wider dashboard view, not a
// standalone history list (that's /api/admin/assessments/:id/sessions, which got real
// pagination above) — a bounded LIMIT is the appropriate fix here, not a second paginator
// inside an already-composite response.
const { rows: assessmentSessions } = await pool.query(
`SELECT assessment_session_id, assessment_definition_id, type, title, status, started_at, submitted_at, result
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 50`,
[req.params.id]
);
res.json({
@@ -152,6 +160,21 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
res.json(signals);
});
// Phase 4.1 §18 — the scenario catalog (stage -> scenario id/title, no student-specific
// status) the structured assessment editor picks from, instead of an instructor hand-typing
// scenario ids into raw JSON. Same source data as the per-student progress view
// (admin-routes.js's /api/admin/students/:id/progress), just without the progress overlay.
admin.get('/api/admin/scenario-catalog', requireAnyInstructor, async (req, res) => {
const path = curriculum.loadCurriculumPath();
const scenarios = curriculum.loadScenarios();
if (!path) return res.json({ stages: [] });
const stages = path.stages.map((stage) => ({
id: stage.id, name: stage.name,
scenarios: stage.scenario_ids.map((sid) => ({ id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid })),
}));
res.json({ stages });
});
// ── Assessment definitions ───────────────────────────────────────────────────────────────
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
@@ -231,21 +254,32 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
res.json({ ok: true });
});
// Phase 4.1 §16 — was fully unbounded (no LIMIT at all — the one endpoint here that could
// genuinely grow into thousands of rows over a semester of exam sessions). Now real
// pagination, same shape as audit-events/student-attempts.
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const pg = parsePagination(req);
let scopeFilter = '';
const params = [req.params.id];
if (req.instructor.role === 'instructor') {
scopeFilter = `AND s.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = $2)`;
params.push(req.instructor.instructor_id);
}
const { rows } = await pool.query(
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC`,
params
);
res.json(rows);
const pageParams = [...params, pg.pageSize, pg.offset];
const [{ rows }, { rows: countRows }] = await Promise.all([
pool.query(
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
pageParams
),
pool.query(
`SELECT count(*)::int AS n FROM assessment_sessions s WHERE s.assessment_definition_id = $1 ${scopeFilter}`,
params
),
]);
res.json(paginatedResponse(rows, countRows[0].n, pg));
});
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
+180
View File
@@ -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);
});
};
+212 -33
View File
@@ -14,10 +14,18 @@
// destroy, events) — the Admin UI never talks to the Provisioner directly.
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
module.exports = function registerAdminRoutes(app, deps) {
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
const {
requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess,
requireCohortWriteAccess, requireStudentCohortWriteAccess,
} = rbac;
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
@@ -71,6 +79,9 @@ module.exports = function registerAdminRoutes(app, deps) {
`SELECT student_id, lab_host_id, status, error_reason, started_at
FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10`
);
// Phase 4.1 §26 — best-effort: a Provisioner hiccup here shouldn't break the whole
// dashboard, capacity is just omitted (null) rather than the request failing.
const capacity = await provisioner.capacity().then((r) => r.status === 200 ? r.body : null).catch(() => null);
res.json({
active_cohorts: cohorts.rows[0].n,
total_students: students.rows[0].n,
@@ -80,39 +91,198 @@ module.exports = function registerAdminRoutes(app, deps) {
error: envCounts.error || 0,
idle_or_destroyed: envCounts.destroyed || 0,
},
capacity, // { current, max } — max is null when admission control is unconfigured (see resource-policy.yaml)
recent_completions: recentCompletions.rows,
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
});
});
// ── Students ─────────────────────────────────────────────────────────────────────────
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
// credential_token_hash (migration 017), which must never reach an API response
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT s.* FROM students s
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
: await pool.query(
`SELECT student_id, display_name, status, email, login_identifier,
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
FROM students ORDER BY display_name`);
res.json(rows);
});
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
// self-paced student gets email, a younger instructor-managed one gets login_identifier
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
// an activation token immediately (§5's "instructor creates account -> temporary credential"
// flow) and returns it ONCE in this response — the instructor relays it to the student
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
const { display_name, cohort_id } = req.body || {};
const { display_name, cohort_id, email, login_identifier } = req.body || {};
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
const pool = getPool();
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
try {
await pool.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
if (cohort_id) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[cohort_id, studentId]
);
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
let activationToken = null;
if (email || login_identifier) {
const issued = await issueCredentialToken(pool, studentId, 'activation');
activationToken = issued.token;
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
// which would silently invalidate the one this response is about to hand the admin for
// manual relay). A student with only login_identifier and no email has no deliverable
// address here — enqueue() records that as an already-failed row (never silently
// dropped), and the admin's manual-relay token in the response still works either way.
await require('./notifications').enqueue(pool, {
studentId, notificationType: 'activation', plaintextToken: activationToken,
metadata: { display_name: display_name.trim() },
});
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
});
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
// account: assigns email/login_identifier and issues an activation token, same mechanism as
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
// membership is touched — student_id, the FK every other table hangs off of, never changes.
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
const { email, login_identifier } = req.body || {};
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
try {
await pool.query(
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
throw e;
}
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: nameRows[0]?.display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, activation_token: token });
});
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
// token and revokes every existing session, same "instructor never needs to know the
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
// password-reset email whenever the student has a deliverable address — the response still
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
// token" for the population with no deliverable address at all — see notification-routes.js's
// header comment).
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
metadata: { display_name: rows[0].display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, reset_token: token });
});
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
res.json(sessionsList);
});
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
// returned, rather than silently importing the valid rows and skipping the rest (the safer
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
// accepts a password/password_hash column — activation tokens are always generated server-side.
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
const { csv } = req.body || {};
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
const pool = getPool();
const client = await pool.connect();
const created = [];
try {
await client.query('BEGIN');
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
let nextN = countRows[0].n + 1;
for (const row of parsedRows) {
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
await client.query(
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
[studentId, row.display_name, row.email || null, row.login_identifier || null]
);
if (row.cohort_id) {
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
}
let activationToken = null;
if (row.email || row.login_identifier) {
// issueCredentialToken only ever calls .query() on what it's given — a transaction
// client works identically to a pool here, so the whole import (including every
// token) commits or rolls back together with everything else.
const issued = await issueCredentialToken(client, studentId, 'activation');
activationToken = issued.token;
}
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
throw e;
} finally {
client.release();
}
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
res.status(201).json({ created });
});
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
@@ -143,11 +313,15 @@ module.exports = function registerAdminRoutes(app, deps) {
res.json({ stages });
});
// Phase 4.1 §16 — was a flat LIMIT 100; now real pagination (see pagination.js).
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const { rows } = await getPool().query(
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
);
res.json(rows);
const pool = getPool();
const pg = parsePagination(req);
const [{ rows }, { rows: countRows }] = await Promise.all([
pool.query(`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [req.params.id, pg.pageSize, pg.offset]),
pool.query(`SELECT count(*)::int AS n FROM scenario_attempts WHERE student_id = $1`, [req.params.id]),
]);
res.json(paginatedResponse(rows, countRows[0].n, pg));
});
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
@@ -156,7 +330,7 @@ module.exports = function registerAdminRoutes(app, deps) {
res.json(body);
});
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortWriteAccess('id'), async (req, res) => {
const { status, body } = await provisioner.provision(req.params.id);
res.status(status === 409 ? 200 : status).json(body);
});
@@ -236,6 +410,12 @@ module.exports = function registerAdminRoutes(app, deps) {
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
const { instructor_id, role_in_cohort } = req.body || {};
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
// Phase 4.1 §19 — role_in_cohort is now actually enforced (rbac.js's
// requireCohortWriteAccess/requireStudentCohortWriteAccess), so it's validated here rather
// than accepting any string that would silently never match either check.
if (role_in_cohort && !['teacher', 'viewer'].includes(role_in_cohort)) {
return res.status(400).json({ error: 'role_in_cohort must be teacher or viewer' });
}
await getPool().query(
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
@@ -286,27 +466,13 @@ module.exports = function registerAdminRoutes(app, deps) {
});
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
[req.params.id]
);
const results = await Promise.allSettled(students.map(async (s) => {
const existing = await provisioner.listForStudent(s.student_id);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
const { status, body } = await provisioner.provision(s.student_id);
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
}));
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
res.json({
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
});
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
res.json({ results });
});
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
[req.params.id]
@@ -315,6 +481,9 @@ module.exports = function registerAdminRoutes(app, deps) {
const { body } = await provisioner.listForStudent(s.student_id);
const active = body?.find((e) => e.status !== 'destroyed');
if (!active) return { student_id: s.student_id, status: 'none' };
// Phase 4.1 §13 — before the destroy, so any still-open attempt is honestly abandoned
// rather than left dangling once its environment no longer exists.
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), active.environment_id);
await provisioner.destroy(active.environment_id);
return { student_id: s.student_id, status: 'destroyed' };
}));
@@ -328,12 +497,14 @@ module.exports = function registerAdminRoutes(app, deps) {
// resolving a bare environment id back to a cohort would need an extra Provisioner round
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
const { status, body } = await provisioner.reset(req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
res.status(status).json(body);
});
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
const { status, body } = await provisioner.destroy(req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
res.status(status).json(body);
@@ -423,8 +594,16 @@ module.exports = function registerAdminRoutes(app, deps) {
});
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
// Phase 4.1 §16 — was a flat LIMIT 200 with no way to see anything older; now real
// server-side pagination (see pagination.js). Default page size (50) intentionally smaller
// than the old flat cap since this is now genuinely browsable instead of "the last 200 ever."
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`);
res.json(rows);
const pool = getPool();
const pg = parsePagination(req);
const [{ rows }, { rows: countRows }] = await Promise.all([
pool.query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pg.pageSize, pg.offset]),
pool.query(`SELECT count(*)::int AS n FROM audit_events`),
]);
res.json(paginatedResponse(rows, countRows[0].n, pg));
});
};
+64 -6
View File
@@ -2,11 +2,55 @@
// session on a scenario (not per validation click, see migrations/012). Every mutation here
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
//
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
// — that would need a reaper-style background job wired to Provisioner environment_events,
// which is more machinery than the instructional-visibility goal here needs. An attempt just
// stays 'in_progress' if the student never returns to finish it; instructors can already see
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
// Phase 4.1 (docs/PHASE4_1_PREPRODUCTION_HARDENING.md §13) added real abandonment handling —
// see abandonAttempt/abandonOpenAttemptsFor* below and their call sites (environment
// destroy/reset in server.js and admin-routes.js, logout in server.js, the session sweeper in
// sessions.js, and the "different scenario" transition inline in getOrCreateOpenAttempt just
// below). Still deliberately NOT a new standalone background job — every trigger either rides
// an existing request handler or the session sweeper that already runs periodically.
// An attempt is never silently deleted or marked as a failure by abandonment — result stays
// NULL, the row stays queryable, instructors see "abandoned (reason)" as a real, honest status
// rather than a punitive one. No-op (and does not overwrite an existing abandoned_reason) if
// the attempt is already terminal.
async function abandonAttempt(pool, attemptId, reason) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = $2
WHERE attempt_id = $1 AND status = 'in_progress' RETURNING *`,
[attemptId, reason]
);
return rows[0] || null;
}
// Trigger: environment destroyed (idle reaper, student reset, admin reset/destroy — see
// server.js's student reset route and admin-routes.js's reset/destroy routes, all of which
// call this right after telling the Provisioner to tear the environment down). A reset creates
// a fresh environment_id in some flows and reuses the same one in others (see
// provisioner lifecycle.js's reset — same environment_id, fresh container) — either way, any
// attempt still open against the OLD container state is honestly "not going to be resumed as
// the same working session," so it's abandoned rather than left open indefinitely.
async function abandonOpenAttemptsForEnvironment(pool, environmentId) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'environment_destroyed'
WHERE environment_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
[environmentId]
);
return rows.length;
}
// Trigger: logout (server.js POST /api/logout) and the session sweeper's periodic pass for
// students whose sessions have all expired (sessions.js). Deliberately student-scoped, not
// environment-scoped — a student can log out while their Lab keeps running (it isn't
// destroyed until reaped or explicitly ended), but the WORKING SESSION on whatever scenario
// they had open is over the moment they're no longer authenticated to record more progress on it.
async function abandonOpenAttemptsForStudent(pool, studentId) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'session_ended'
WHERE student_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
[studentId]
);
return rows.length;
}
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
const { rows: existing } = await pool.query(
@@ -17,6 +61,17 @@ async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environment
);
if (existing[0]) return existing[0];
// Trigger: student starts a different scenario while a previous one is still open (started
// via /start or implicitly via /validate, never finished). Only one scenario is "the one
// currently being worked on" per student+environment at a time from the app's own UX, so an
// open attempt on ANY OTHER scenario in this same environment is abandoned before the new
// one is created — never silently left as a phantom "still in progress" row.
await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'scenario_switched'
WHERE student_id = $1 AND environment_id = $2 AND scenario_id != $3 AND status = 'in_progress'`,
[studentId, environmentId, scenarioId]
);
const { rows } = await pool.query(
`INSERT INTO scenario_attempts
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
@@ -80,4 +135,7 @@ async function getAttemptSummary(pool, { studentId, scenarioId, environmentId })
return rows[0] || null;
}
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
module.exports = {
getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary,
abandonAttempt, abandonOpenAttemptsForEnvironment, abandonOpenAttemptsForStudent,
};
+75
View File
@@ -0,0 +1,75 @@
// Student-facing classroom API — Phase 5A §22/§24. Every route here requires studentSession
// (session-derived student_id, never trusted from the request body/params — same rule as
// everywhere else a student acts) AND that the student is actually enrolled in the classroom's
// cohort (cohort_enrollments) — a student can never join/view/act on a classroom for a cohort
// they don't belong to, regardless of whether they know or can guess its id.
const classroom = require('./classroom');
module.exports = function registerClassroomRoutes(app, deps) {
const { studentSession, getPool, curriculum } = deps;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
// Resolves the classroom AND checks cohort enrollment in one place — every route below uses
// this instead of repeating the join. Sets req.classroom on success.
async function requireEnrolledClassroom(req, res, next) {
const classroomRow = await classroom.getClassroom(getPool(), req.params.id);
if (!classroomRow) return res.status(404).json({ error: 'not found' });
const { rowCount } = await getPool().query(
`SELECT 1 FROM cohort_enrollments WHERE cohort_id = $1 AND student_id = $2 AND enrollment_status = 'enrolled'`,
[classroomRow.cohort_id, req.studentId]
);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
req.classroom = classroomRow;
next();
}
// §22 — "LIVE NOW" banner on the student dashboard. Null if nothing is live for any cohort
// this student belongs to (deliberately not "their one cohort" — §27 self-paced compatible).
app.get('/api/classroom/live', studentSession, wrap(async (req, res) => {
const live = await classroom.getLiveClassroomForStudent(getPool(), req.studentId);
res.json({ classroom: live });
}));
app.get('/api/classroom/:id', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const [announcements, own] = await Promise.all([
classroom.listAnnouncements(getPool(), req.params.id),
getPool().query(`SELECT * FROM help_requests WHERE classroom_session_id = $1 AND student_id = $2 ORDER BY created_at DESC LIMIT 5`, [req.params.id, req.studentId]),
]);
res.json({ classroom: req.classroom, announcements, my_help_requests: own.rows });
}));
app.get('/api/classroom/:id/announcements', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
res.json(await classroom.listAnnouncements(getPool(), req.params.id));
}));
app.post('/api/classroom/:id/join', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
if (req.classroom.status !== 'live') return res.status(409).json({ error: 'not_live', message: 'This classroom is not currently live.' });
const participant = await classroom.joinClassroom(getPool(), req.params.id, req.studentId);
res.json(participant);
}));
app.post('/api/classroom/:id/leave', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.leaveClassroom(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §15 — throttled server-side (classroom.js's heartbeat only writes if the last recorded
// beat is old enough), so the frontend can just call this every 20-30s without either side
// needing to track timing beyond "did the interval fire."
app.post('/api/classroom/:id/heartbeat', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.heartbeat(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §18 — no chat, no video, no remote shell takeover: exactly one action (raise a flag with
// an optional category/message), visible only to instructors managing this cohort.
app.post('/api/classroom/:id/help-request', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const { category, message } = req.body || {};
const created = await classroom.createHelpRequest(getPool(), req.params.id, req.studentId, category, message);
res.status(201).json(created);
}));
};
+242
View File
@@ -0,0 +1,242 @@
// Classroom session lifecycle — Phase 5A §11-§20. A classroom session is a live teaching event,
// distinct from a cohort (standing roster), an assessment session (one exam attempt), a Lab
// environment (disposable infra), or a login session. This module owns the pure
// classroom_sessions/classroom_participants/classroom_events/classroom_announcements/
// help_requests CRUD and lifecycle rules; route files (admin-classroom-routes.js,
// classroom-routes.js) own RBAC/session/HTTP shape and the dashboard aggregation that pulls in
// Provisioner Lab state and Phase 3B academic records (this module doesn't know about either).
const PRESENCE_ONLINE_MS = 45 * 1000; // ~1.5x the 20-30s heartbeat interval the frontend uses
const PRESENCE_RECENTLY_ACTIVE_MS = 5 * 60 * 1000;
const HEARTBEAT_WRITE_THROTTLE_MS = 15 * 1000; // §15 "avoid excessive database writes" — coalesce heartbeats closer together than this into a no-op
async function emitEvent(pool, classroomSessionId, actorType, actorId, eventType, metadata) {
await pool.query(
`INSERT INTO classroom_events (classroom_session_id, actor_type, actor_id, event_type, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[classroomSessionId, actorType, actorId ? String(actorId) : null, eventType, metadata ? JSON.stringify(metadata) : null]
);
}
async function createClassroom(pool, { cohortId, title, description, scheduledStart, scheduledEnd, meetingUrl, createdBy }) {
const { rows } = await pool.query(
`INSERT INTO classroom_sessions (cohort_id, title, description, scheduled_start, scheduled_end, meeting_url, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[cohortId, title, description || null, scheduledStart || null, scheduledEnd || null, meetingUrl || null, createdBy]
);
const classroom = rows[0];
await emitEvent(pool, classroom.classroom_session_id, 'instructor', createdBy, 'created', { title });
return classroom;
}
async function getClassroom(pool, classroomSessionId) {
const { rows } = await pool.query(`SELECT * FROM classroom_sessions WHERE classroom_session_id = $1`, [classroomSessionId]);
return rows[0] || null;
}
async function listClassroomsForCohort(pool, cohortId) {
const { rows } = await pool.query(
`SELECT * FROM classroom_sessions WHERE cohort_id = $1 ORDER BY COALESCE(scheduled_start, created_at) DESC`,
[cohortId]
);
return rows;
}
// §13 lifecycle: scheduled -> live -> ended, or -> cancelled from scheduled/live. The DB's
// partial unique index (idx_classroom_sessions_one_live_per_cohort) is the actual enforcement
// for "at most one live classroom per cohort" — this pre-check just gives a clean error
// instead of a raw constraint-violation message.
async function startClassroom(pool, classroomSessionId, instructorId) {
const classroom = await getClassroom(pool, classroomSessionId);
if (!classroom) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
if (classroom.status !== 'scheduled') throw Object.assign(new Error(`cannot start a classroom in status '${classroom.status}'`), { code: 'INVALID_STATE' });
try {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'live', started_at = now(), updated_at = now() WHERE classroom_session_id = $1 RETURNING *`,
[classroomSessionId]
);
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'started', {});
return rows[0];
} catch (e) {
if (e.code === '23505') throw Object.assign(new Error('this cohort already has a live classroom'), { code: 'ALREADY_LIVE' });
throw e;
}
}
// Ending a classroom is purely a status change — never touches Labs, progress, attempts, or
// any academic data (§13/§26). Labs are only ever destroyed by an explicit, separate action
// (the existing cohort Destroy Labs / Prepare Labs machinery), never implicitly by this.
async function endClassroom(pool, classroomSessionId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'ended', ended_at = now(), updated_at = now()
WHERE classroom_session_id = $1 AND status = 'live' RETURNING *`,
[classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found or not live'), { code: 'INVALID_STATE' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'ended', {});
return rows[0];
}
async function cancelClassroom(pool, classroomSessionId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET status = 'cancelled', updated_at = now()
WHERE classroom_session_id = $1 AND status IN ('scheduled', 'live') RETURNING *`,
[classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found or already ended/cancelled'), { code: 'INVALID_STATE' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'cancelled', {});
return rows[0];
}
// §20 — guidance only. Never redirects a browser, never marks anything complete; students just
// see this value and choose to open that scenario themselves (see classroom-routes.js).
async function setCurrentActivity(pool, classroomSessionId, scenarioId, instructorId) {
const { rows } = await pool.query(
`UPDATE classroom_sessions SET current_scenario_id = $1, updated_at = now() WHERE classroom_session_id = $2 RETURNING *`,
[scenarioId || null, classroomSessionId]
);
if (!rows.length) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'activity_changed', { scenario_id: scenarioId });
return rows[0];
}
async function postAnnouncement(pool, classroomSessionId, instructorId, message) {
const { rows } = await pool.query(
`INSERT INTO classroom_announcements (classroom_session_id, instructor_id, message) VALUES ($1, $2, $3) RETURNING *`,
[classroomSessionId, instructorId, message]
);
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'announcement_posted', { announcement_id: rows[0].announcement_id });
return rows[0];
}
async function listAnnouncements(pool, classroomSessionId) {
const { rows } = await pool.query(
`SELECT announcement_id, message, created_at FROM classroom_announcements
WHERE classroom_session_id = $1 ORDER BY created_at DESC LIMIT 50`,
[classroomSessionId]
);
return rows;
}
// §11/§22 — join is idempotent: re-joining an already-joined classroom just clears left_at and
// bumps last_seen_at, it doesn't create a second row or duplicate joined_at (PRIMARY KEY is
// (classroom_session_id, student_id) — there can only ever be one participation row per
// student per classroom, matching how a student can leave and rejoin the same live session).
async function joinClassroom(pool, classroomSessionId, studentId) {
const { rows } = await pool.query(
`INSERT INTO classroom_participants (classroom_session_id, student_id, last_seen_at)
VALUES ($1, $2, now())
ON CONFLICT (classroom_session_id, student_id)
DO UPDATE SET last_seen_at = now(), left_at = NULL
RETURNING *`,
[classroomSessionId, studentId]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'joined', {});
return rows[0];
}
async function leaveClassroom(pool, classroomSessionId, studentId) {
await pool.query(
`UPDATE classroom_participants SET left_at = now() WHERE classroom_session_id = $1 AND student_id = $2`,
[classroomSessionId, studentId]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'left', {});
}
// §15 presence — throttled: a heartbeat within HEARTBEAT_WRITE_THROTTLE_MS of the last recorded
// one is a silent no-op (still returns success to the client, just skips the write) rather than
// hammering Postgres every 20s per online student. Deliberately NOT a full audit-logged event
// (§25 "do not turn presence heartbeats into audit spam") — no classroom_events row per beat.
async function heartbeat(pool, classroomSessionId, studentId) {
await pool.query(
`UPDATE classroom_participants SET last_seen_at = now()
WHERE classroom_session_id = $1 AND student_id = $2
AND (last_seen_at IS NULL OR last_seen_at < now() - ($3 || ' milliseconds')::interval)`,
[classroomSessionId, studentId, HEARTBEAT_WRITE_THROTTLE_MS]
);
}
function derivePresence(lastSeenAt, leftAt) {
if (leftAt) return 'offline';
if (!lastSeenAt) return 'offline';
const age = Date.now() - new Date(lastSeenAt).getTime();
if (age <= PRESENCE_ONLINE_MS) return 'online';
if (age <= PRESENCE_RECENTLY_ACTIVE_MS) return 'recently_active';
return 'offline';
}
async function listParticipants(pool, classroomSessionId) {
const { rows } = await pool.query(
`SELECT cp.student_id, s.display_name, cp.joined_at, cp.last_seen_at, cp.left_at
FROM classroom_participants cp JOIN students s ON s.student_id = cp.student_id
WHERE cp.classroom_session_id = $1 ORDER BY s.display_name`,
[classroomSessionId]
);
return rows.map((r) => ({ ...r, presence: derivePresence(r.last_seen_at, r.left_at) }));
}
// §27 — a student may be enrolled in more than one cohort eventually (self-paced compatible);
// this returns the FIRST live classroom across any cohort they're enrolled in, not "their one
// cohort's classroom" — matches "My Courses, not My Cohort" (§27) as the eventual shape.
async function getLiveClassroomForStudent(pool, studentId) {
const { rows } = await pool.query(
`SELECT cs.* FROM classroom_sessions cs
JOIN cohort_enrollments e ON e.cohort_id = cs.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND cs.status = 'live'
ORDER BY cs.started_at DESC LIMIT 1`,
[studentId]
);
return rows[0] || null;
}
// §18 help requests
async function createHelpRequest(pool, classroomSessionId, studentId, category, message) {
const { rows } = await pool.query(
`INSERT INTO help_requests (classroom_session_id, student_id, category, message) VALUES ($1, $2, $3, $4) RETURNING *`,
[classroomSessionId, studentId, category || null, message || null]
);
await emitEvent(pool, classroomSessionId, 'student', studentId, 'help_requested', { help_request_id: rows[0].help_request_id, category });
return rows[0];
}
async function listHelpRequests(pool, classroomSessionId, statusFilter) {
const { rows } = await pool.query(
`SELECT hr.*, s.display_name FROM help_requests hr JOIN students s ON s.student_id = hr.student_id
WHERE hr.classroom_session_id = $1 AND ($2::text IS NULL OR hr.status = $2)
ORDER BY hr.created_at ASC`,
[classroomSessionId, statusFilter || null]
);
return rows;
}
async function acknowledgeHelpRequest(pool, helpRequestId, instructorId) {
const { rows } = await pool.query(
`UPDATE help_requests SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $1
WHERE help_request_id = $2 AND status = 'open' RETURNING *`,
[instructorId, helpRequestId]
);
if (!rows.length) throw Object.assign(new Error('help request not found or not open'), { code: 'INVALID_STATE' });
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_acknowledged', { help_request_id: helpRequestId });
return rows[0];
}
async function resolveHelpRequest(pool, helpRequestId, instructorId, note) {
const { rows } = await pool.query(
`UPDATE help_requests SET status = 'resolved', resolved_at = now(), instructor_note = COALESCE($1, instructor_note)
WHERE help_request_id = $2 AND status IN ('open', 'acknowledged') RETURNING *`,
[note || null, helpRequestId]
);
if (!rows.length) throw Object.assign(new Error('help request not found or already resolved'), { code: 'INVALID_STATE' });
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_resolved', { help_request_id: helpRequestId });
return rows[0];
}
module.exports = {
createClassroom, getClassroom, listClassroomsForCohort,
startClassroom, endClassroom, cancelClassroom, setCurrentActivity,
postAnnouncement, listAnnouncements,
joinClassroom, leaveClassroom, heartbeat, derivePresence, listParticipants,
getLiveClassroomForStudent,
createHelpRequest, listHelpRequests, acknowledgeHelpRequest, resolveHelpRequest,
emitEvent,
};
+35
View File
@@ -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 };
+132
View File
@@ -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 };
+20
View File
@@ -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 };
+134
View File
@@ -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 "&amp;").
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
+244
View File
@@ -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,
};
+39
View File
@@ -0,0 +1,39 @@
// Shared "launch/view a student's disposable Lab" logic. Phase 5B extracted this out of
// server.js's original POST /api/environment/launch route body so the new entitlement-gated
// self-paced launch route (selfpaced-routes.js) can reuse the EXACT same Provisioner call and
// response shape instead of a second copy — the underlying disposable-Lab mechanism (Phase
// 2A-4.1) is completely unchanged either way, only who's allowed to reach it differs.
async function publicEnvView(provisioner, env) {
if (!env) return { status: 'none' };
const view = { status: env.status };
if (env.status === 'running') {
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
if (!token) {
const { status, body } = await provisioner.getToken(env.environment_id);
if (status === 200) token = body.routing_token;
}
if (token) view.launch_path = `/lab/${token}/`;
}
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
return view;
}
async function launchEnvironmentForStudent(provisioner, studentId) {
const existing = await provisioner.listForStudent(studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { status: 200, body: await publicEnvView(provisioner, active) };
const { status, body } = await provisioner.provision(studentId);
if (status === 409) {
const retry = await provisioner.listForStudent(studentId);
return { status: 200, body: await publicEnvView(provisioner, retry.body?.[0]) };
}
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return { status: 503, body: { error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' } };
}
if (status !== 201) return { status: 502, body: { error: 'provision_failed', message: body?.error } };
return { status: 200, body: await publicEnvView(provisioner, { environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }) };
}
module.exports = { publicEnvView, launchEnvironmentForStudent };

Some files were not shown because too many files have changed in this diff Show More