# 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=`/`?reset=` 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.