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).
This commit is contained in:
2026-08-25 14:42:34 -05:00
parent 9d382c95c5
commit 3c7f0ca59c
7 changed files with 140 additions and 22 deletions
+22 -13
View File
@@ -14,7 +14,8 @@ const {
const { recordAudit } = require('./audit');
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
const { recordStudentLoginSuccess, recordStudentLoginFailure, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C
const notifications = require('./notifications');
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
@@ -69,7 +70,11 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
}
const student = await consumeCredentialToken(pool, token, 'activation', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
if (!student) {
recordStudentActivation('invalid_or_expired_token');
return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
}
recordStudentActivation('success');
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
const csrf = makeCsrfToken();
@@ -105,27 +110,30 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
const newHash = hashPassword(new_password);
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
// Phase 5C §5D — a security-notice email, no token, purely informational.
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
// (explicitly not required this phase), so this deliberately does NOT invent fake email
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
// Always returns the same generic response regardless of whether the identifier exists
// (anti-enumeration). In non-production, the real token is included so local/test flows can
// complete the loop without pretending email exists; in production it is never returned by
// this endpoint — completing a real self-service reset needs email delivery, a documented,
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
// §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the
// SAME generic response returns regardless of whether the identifier exists, is inactive, or
// is a genuine hit — the only difference a caller can ever observe is whether an email
// eventually arrives, never anything in this response. Production NEVER returns the token
// (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see
// server.js); the dev-only echo exists purely so a local/CI run can complete the loop without
// a real inbox, exactly as before. What Phase 5C actually closes: the token is now also
// handed to notifications.enqueue() so a real email goes out — see
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here
// (synchronously, as before) rather than by the worker.
app.post('/api/student/request-password-reset', async (req, res) => {
const { identifier } = req.body || {};
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
if (!identifier) return res.json(generic);
const student = await getStudentByIdentifier(pool, identifier);
if (!student || student.status !== 'active') return res.json(generic);
if (!student || student.status !== 'active' || !student.password_hash) return res.json(generic);
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
res.json(generic);
@@ -139,6 +147,7 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
const student = await consumeCredentialToken(pool, token, 'reset', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});