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.
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user