POST /api/student/activities/:activityId/complete previously checked only that the activity
exists and the student holds an active PROGRAM enrollment — never whether the activity's own
course/module was actually unlocked for that student. A direct POST could mark any
manual_marker activity (lesson/demonstration/knowledge_check/guided_exercise/reflection)
complete regardless of the real prerequisite chain, the exact class of gap Phase 6B already
fixed once for the capstone's own start route, just never closed for this more general one.
Found while proving the real ROOT 01->02->03->04->05 chain via direct API (this phase's own
explicit requirement) — and confirmed to predate Phase 6E entirely by reproducing it against an
unrelated, pre-existing ROOT 05/06 pair before touching any Phase 6E content.
Fix: the route now resolves the activity's own course/module through the exact same
buildCourseTree() lock computation every other route already trusts, and returns 403 { error:
'locked' } if either is locked — reusing the existing mechanism, not a second, duplicated check.
Verified live: both a ROOT 02 bypass attempt and a ROOT 06 bypass attempt are now correctly
rejected; legitimate unlocked completions are unaffected.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
POST /api/assessments/:definitionId/start only ever checked that the
definition existed and was active — it never checked unlock_rule. GET
/api/capstone correctly computed and displayed lock state, and the
student UI never exposed a start action while locked, but a direct POST
to this route bypassed the check entirely (documented as a known gap in
docs/PHASE6A_ROOT_COURSE_FOUNDATION.md §10). Student UI state is not the
enforcement boundary.
Fixed by binding to the exact same computeCurriculumState() gate
/api/capstone already relies on, rather than re-parsing
definition.unlock_rule independently — one computation of 'is this
unlocked,' not two that could drift. Only a definition the curriculum
path actually names as ITS capstone is gated this way; an ordinary exam
is completely unaffected. Returns a clean 403 { error: 'locked',
blocking_stage_ids } — the same shape /api/capstone already exposes.
Found while fixing this: e2e-phase5b.js's own capstone-completion test
had been passing only because of this exact gap — it never included the
real-ops stage in its completion loop before attempting to start the
capstone, and the missing unlock check let it succeed anyway. Fixed
alongside the platform code (REAL_OPS added to ALL_NON_CAPSTONE), not
routed around.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
course-routes.js — student-facing: the full course tree through an
enrollment (GET /api/student/programs/:id/courses) and the lesson
completion marker (POST /api/student/activities/:id/complete, refused
for anything but a manual_marker activity — a lab/assessment can never
be faked complete through this route).
admin-course-routes.js — instructor-facing, read-only (no WYSIWYG CMS
this phase): program structure with instructor notes
(GET /api/admin/programs/:id/courses) and per-student progress
(GET /api/admin/students/:id/courses), scoped via
requireStudentRelationshipAccess — the same self-paced-no-cohort rule
Phase 5C's notification routes already established, not
requireStudentCohortAccess (which would 403 a plain instructor out of
every self-paced student).
Dockerfile now also COPYs content/ alongside the existing scenarios/ —
same 'read-only, never executed' reasoning as that line.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
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].
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.
- 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.
- 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).
- 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).
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
Unauthenticated liveness/readiness probes for uptime monitoring and Ansible
deploy smoke tests (Phase 4). API's checks Postgres reachability via
SELECT 1; Gateway's answers locally without proxying anywhere, so it can
distinguish 'Gateway itself is down' from 'something behind it is down'.
No student/instructor data exposed.
Verified live against the real dev stack: both return 200 with the
expected JSON body. Full regression suite re-run after this change:
e2e-phase2a (10/10), e2e-phase2b (12/12), e2e-phase3a (21/21), e2e-phase3b
(24/24) - all passing, no regressions. Frontend build and the Lab image
(both the multi-service dev compose and the standalone single-container
Dockerfile) also confirmed still building/booting correctly.
New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).
New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
New learning-routes.js: scenario start/attempt-state/hint (sequential reveal, 409 once
exhausted, policy-aware under an active assessment), GET /api/curriculum/state (rule-driven,
alongside the unchanged /api/curriculum/path), assessments list/start/active/detail/submit,
GET /api/capstone, GET /api/achievements, GET /api/attempts/recent.
/api/progress/record now routes through attempts.recordValidation instead of a flat insert,
and, on a pass, checks curriculum/capstone achievement consequences and, if the scenario is
part of an active assessment, updates that session (assessment context is derived entirely
server-side from assessment_session_scenarios, never from anything the client asserts).
Gateway changes: two new relay routes (/lab/:token/api/scenarios/:id/start and .../hint,
mirroring the existing validate/answer pattern - pure Platform API operations, the Lab has no
attempt/hint concept) and a response rewrite on GET /lab/:token/api/scenarios/:id that strips
the Lab's own hints array (replacing it with a bare hint_count) so progressive reveal is
actually server-gated instead of just UI-collapsed - the Lab image's bytes stay untouched;
standalone/single-container dev mode never goes through the Gateway and keeps the full array
unaffected.
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.
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).
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).
Non-HttpOnly rn_csrf_s/rn_csrf_i cookies, set on login, echoed back as X-CSRF-Token on every
state-changing request — defense in depth on top of (not instead of) SameSite=Lax. In-memory
sliding-window rate limiting on instructor login (8 attempts / 5 min per ip:email, no new
dependency).
Real bug found while wiring this up: the Gateway's own progress-relay call (triggered when a
student solves a scenario) is a server-to-server POST to /api/progress/record relaying the
browser's session — it needs to forward the CSRF token too, and needs to forward BOTH the
rn_csrf_s cookie and a matching X-CSRF-Token header (forwarding only the header still 403'd,
since the double-submit check needs both on the request Platform API actually receives).
Also tightens the Gateway's session-resolution cache from 5000ms to 2000ms, so a
revoked/expired session stops working at the Gateway within a tighter, tested bound.
Frontend (admin + student API clients) now reads the relevant rn_csrf_* cookie via
document.cookie and attaches it on every POST/PATCH/DELETE.
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.
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.
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).
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.
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.