Commit Graph
15 Commits
Author SHA1 Message Date
ced4568andClaude Sonnet 5 bee57d89fd feat(root): wire ROOT 05-10/12/Capstone curriculum expansion
023_phase6b_curriculum.sql — additive on top of 022_course_content.sql:
13 new teaching modules (ROOT 08/09 move from PLANNED to PARTIAL; ROOT
05-07/10 gain a new front-door module sequenced ahead of the existing
bare-lab bank; ROOT 12 gains an incident-response framing lesson +
reflection; Capstone gains a readiness module). No existing row is
deleted; a small number of existing rows have their sequence/
prerequisites updated so a new teaching module supersedes the bare-lab
bank as the front door — never their scenario_id/activity_type/
completion_behavior, so no student's derived completion changes meaning.

is_required (new module_activities column, defaults true — every Phase
6A activity unaffected) lets a module distinguish CORE teaching from
GUIDED/INDEPENDENT-practice duplicates and ADVANCED/optional extras.
course-progress.js's totals() now considers a module complete once
every REQUIRED activity is done, not literally every activity — an
incomplete optional one no longer blocks completion, though it still
counts toward the displayed percentage.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 09:04:55 -05:00
ced4568andClaude Sonnet 5 fe702754cd feat(content): add course/module/activity content model
New tables (courses, course_modules, module_activities,
activity_completions) layered over the existing scenario/assessment/
achievement systems — a lab activity references an existing scenario_id,
an assessment/capstone activity references an existing
assessment_definition_id, neither duplicated. Long-form lesson prose
lives in git-tracked Markdown (content/root/), split into shared vs.
instructor-only sections by a '## Instructor Notes' heading convention
(content.js) rather than a second file or a CMS.

course-progress.js derives completion from EXISTING sources only:
auto_scenario reads the progress table, auto_assessment reads
achievements by assessment_definition_id, manual_marker is the one
genuinely new piece of state (for lesson-only activities with nothing
else to derive completion from). Prerequisite locking treats a
zero-content (PLANNED) prerequisite as vacuously satisfied, so an
honestly-unbuilt course never permanently locks everything after it.

Found by testing: computing a level's prerequisite lock and stripping
its working totals in the same map iteration made the result depend on
iteration order. Fixed with two explicit passes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-25 23:29:13 -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 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 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 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 0beb0e8f5e feat(learning): add persistent achievement records
New achievements table: stage_completed/curriculum_completed/assessment_completed/
capstone_completed, idempotent (WHERE NOT EXISTS guard, not a unique-index dance — simple and
correct at this write volume). Not a certificate — a durable internal record Phase 4/5 can
render one from later. recordAssessmentAchievement only fires on an actual pass, defensively,
even though today's only completion_rule already guarantees that.
2026-08-18 14:35:11 -05:00
ced4568 b908ade8e0 feat(assessment): add exam and capstone definition/session engine
New tables: assessment_definitions (reusable template — pool_spec, duration, hint_policy,
completion/unlock rules, a plain incrementing version bumped only on semantic edits),
assessment_cohort_assignments, assessment_sessions (one per student attempt, full snapshot of
what was drawn and under what rules — never affected by a later definition edit),
assessment_session_scenarios (the drawn set, ordered, with a real FK to scenario_attempts).
Deliberately does not touch/reuse exam_sessions (migration 006) — that was already scoped in
Phase 2B as historical-import-only, never the live mechanism; these tables are the real thing.

drawScenarios: every 'required' scenario id included, plus N random picks per pool group,
shuffled together (tiny seeded PRNG, mulberry32 — no new dependency; the seed is recorded for
audit, never shown to the student before the draw). One assessment at a time per student.
Timer is server-authoritative (expires_at fixed at draw time) and lazily expired — checked
whenever a session is touched, no background cron. Ships two seeded defaults: rootnode-capstone
(Real Ops scenarios required, weighted toward operational ability) and rootnode-quick-assessment
(a general example exam matching the spec's category-draw example).
2026-08-18 14:35:11 -05:00
ced4568 01a862e007 feat(learning): add scenario attempt telemetry and curriculum completion rules
Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
2026-08-18 14:35:11 -05:00
ced4568 62726455d5 feat(rbac): enforce owner/admin/instructor permissions across the admin API
Three roles (owner > admin > instructor), enforced server-side on every /api/admin/* route
via reusable middleware (requireOwner/requireAdminOrOwner/requireAnyInstructor) — the
frontend hides controls a role can't use, but that's UX, never the actual boundary. A plain
'instructor' is further scoped to cohorts assigned via the new cohort_instructors table
(requireCohortAccess/requireStudentCohortAccess); owner/admin bypass that check entirely.

Owner-only: instructor account lifecycle, audit log. Admin/owner: cohort/student CRUD,
curriculum assignment. Any instructor role (cohort-scoped for 'instructor'): dashboard,
cohort/student views, Prepare/Destroy Labs, provisioning. Deliberately NOT cohort-scoped:
the raw /api/admin/environments/:id/* routes (no cohort context in a bare environment id
without an extra Provisioner round trip on every call) — a plain instructor still has full
Lab control via the cohort/student-scoped routes instead, which already carry the check.

server.js wires the new persistent-session middleware (studentSession/instructorSession —
re-fetches the instructor row from Postgres every request, so a role change or
deactivation takes effect on the very next request with no separate revoke needed) and adds
a last-resort error handler so a DB error in any async route returns 500 instead of
crashing the process or leaking a stack trace.
2026-08-18 13:36:11 -05:00
ced4568 37230d4ef1 feat(audit): add platform security audit log
New audit_events table, distinct from the Provisioner's environment_events (Lab-lifecycle
only). recordAudit() is called from the auth/RBAC/admin layers added in this phase — login
success/failure, logout, password change, instructor CRUD, role changes, session
revocation, cohort/roster changes, curriculum assignment, Prepare/Destroy Labs, and every
403 authorization denial (recorded centrally by the RBAC middleware, not per-route). Never
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens.
2026-08-18 13:36:11 -05:00
ced4568 85fa920f96 feat(auth): add per-instructor accounts and local password login
Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table
(email/display_name/role/status), scrypt password hashing with a fresh random salt PER
INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password —
that assumption is gone), auth_identities table so a future OIDC login can map to the same
instructor_id without a schema change.

New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
POST /api/auth/change-password (old password required, revokes other sessions). No account
enumeration: identical response whether the email doesn't exist or the password is wrong.

bootstrap-owner.js is the only supported way to create the first account — no manual
database editing to stand up a fresh deployment. Refuses to run if an active owner already
exists unless the target email is an existing instructor (zero-owners recovery path).
2026-08-18 13:35:54 -05:00
ced4568 52c17c1907 feat(identity): add persistent Postgres-backed session store
Replaces the in-memory studentSessions/adminSessions Maps (Phase 2B — gone on every
restart) with a sessions table. Browser holds an opaque CSPRNG bearer token; only its
SHA-256 hash is ever stored server-side, same principle as password hashing. Each session
has an absolute expiry (fixed at creation) and a sliding idle expiry (pushed forward on
every authenticated request, capped at the absolute one) — one atomic UPDATE...RETURNING
per resolve, no read-then-write race. Student and instructor sessions stay fully separate
token namespaces, matching Phase 2B's cookie-name separation.

cookies.js centralizes Set-Cookie construction so the Secure attribute (COOKIE_SECURE=1,
for real HTTPS deployments) is applied consistently instead of hand-built per call site.
2026-08-18 13:35:54 -05:00
ced4568 6b52c9e1d2 feat(platform-api): split lightweight platform service from Lab image
New platform/api/ service owns students, cohorts, curriculum, progress,
scenario attempts, and student/admin sessions. No k3s, no --privileged,
no terminal runtime — starts instantly, unlike the old identity service
which booted an unused k3s cluster just to serve roster/progress APIs.

Provisioner calls (student and admin) are consolidated here too, in
provisioner-client.js, so it's the only service that talks to the
Provisioner directly.
2026-08-18 12:22:58 -05:00