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
+17 -4
View File
@@ -9,6 +9,8 @@
const programsLib = require('./programs');
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
const studentsAuth = require('./students-auth');
const { buildProgramStatus } = require('./program-status');
function enrollmentErrorStatus(code) {
@@ -104,19 +106,30 @@ module.exports = function registerAdminProgramsRoutes(app, deps) {
});
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, metadata } = req.body || {};
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, contact_email, metadata } = req.body || {};
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
// Phase 5C §10 — same transitional contact-email convention as the integration endpoint;
// folded into the same metadata JSONB an admin could already pass, not a new column.
const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata;
let created;
try {
const created = await enrollmentLib.createManualEnrollment(getPool(), {
created = await enrollmentLib.createManualEnrollment(getPool(), {
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
deliveryMode: delivery_mode, externalReference: external_reference, metadata, createdBy: req.instructor.instructor_id,
deliveryMode: delivery_mode, externalReference: external_reference, metadata: mergedMetadata, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
res.status(201).json(created);
} catch (e) {
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
// §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation
// for a not-yet-credentialed student, enrollment confirmation for an already-active one.
const program = await programsLib.getProgram(getPool(), program_id);
const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, {
studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip });
res.status(201).json(created);
});
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
+25 -2
View File
@@ -153,6 +153,15 @@ module.exports = function registerAdminRoutes(app, deps) {
if (email || login_identifier) {
const issued = await issueCredentialToken(pool, studentId, 'activation');
activationToken = issued.token;
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
// which would silently invalidate the one this response is about to hand the admin for
// manual relay). A student with only login_identifier and no email has no deliverable
// address here — enqueue() records that as an already-failed row (never silently
// dropped), and the admin's manual-relay token in the response still works either way.
await require('./notifications').enqueue(pool, {
studentId, notificationType: 'activation', plaintextToken: activationToken,
metadata: { display_name: display_name.trim() },
});
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
@@ -179,20 +188,34 @@ module.exports = function registerAdminRoutes(app, deps) {
throw e;
}
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: nameRows[0]?.display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, activation_token: token });
});
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
// token and revokes every existing session, same "instructor never needs to know the
// student's password" principle as activation.
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
// password-reset email whenever the student has a deliverable address — the response still
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
// token" for the population with no deliverable address at all — see notification-routes.js's
// header comment).
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
metadata: { display_name: rows[0].display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, reset_token: token });
});
+19 -1
View File
@@ -14,6 +14,8 @@
const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth');
const notifications = require('./notifications');
const programsLib = require('./programs');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -47,6 +49,10 @@ module.exports = function registerIntegrationRoutes(app, deps) {
const { event_id, source, student, program_slug, status } = req.body || {};
const email = student?.email ? String(student.email).trim() : null;
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
// identity — see enrollment.js's enrollmentContactEmail.
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
const mappedStatus = STATUS_MAP[status];
if (!event_id || typeof event_id !== 'string') {
@@ -70,7 +76,7 @@ module.exports = function registerIntegrationRoutes(app, deps) {
try {
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
studentDisplayName: student?.display_name, programId: program_slug, status: mappedStatus,
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
});
} catch (e) {
metrics.recordEnrollmentEvent('rejected');
@@ -88,6 +94,18 @@ module.exports = function registerIntegrationRoutes(app, deps) {
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
});
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
// guarantees at most one caller ever sees created:true for a given event/race, so at most
// one notification is ever queued, with no separate notification-level dedup needed.
if (result.created) {
const program = await programsLib.getProgram(getPool(), program_slug);
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
});
}
res.status(result.created ? 201 : 200).json({
enrollment_id: result.enrollment.enrollment_id,
student_id: result.enrollment.student_id,
+33 -1
View File
@@ -179,6 +179,38 @@ module.exports = function createRbac(pool) {
};
}
// Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id
// directly instead of resolving through one enrollment first: an instructor may see/manage a
// student's notifications if they share a cohort with that student OR they created at least
// one of that student's enrollments — otherwise 403, never blanket visibility into every
// self-paced student's notifications ("Do not grant global notification visibility to every
// instructor," §16). Used by the per-student notification list and the resend-activation/
// send-enrollment-confirmation/send-password-reset admin actions.
async function checkStudentRelationshipAccess(req, res, studentId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2
UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $2`,
[studentId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
function requireStudentRelationshipAccess(studentIdParam = 'id') {
return async (req, res, next) => {
if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next();
};
}
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
@@ -216,6 +248,6 @@ module.exports = function createRbac(pool) {
requireCohortAccess, requireStudentCohortAccess,
requireCohortWriteAccess, requireStudentCohortWriteAccess,
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
requireEnrollmentAccess,
requireEnrollmentAccess, requireStudentRelationshipAccess,
};
};
+12
View File
@@ -395,6 +395,14 @@ require('./admin-programs-routes')(app, {
});
require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Phase 5C — enrollment operations & communications. notification-routes.js is the
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
// confirmation); the actual delivery worker starts in main() below, alongside the existing
// session/login-attempt sweepers — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md.
require('./notification-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
@@ -414,6 +422,10 @@ async function main() {
if (applied.length) console.log('Applied migrations:', applied.join(', '));
sessions.startSessionSweeper(getPool());
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
const { startNotificationWorker } = require('./notification-worker');
const { EMAIL_PROVIDER } = require('./email/provider');
console.log(`[notification-worker] starting (EMAIL_PROVIDER=${EMAIL_PROVIDER})`);
startNotificationWorker(getPool()); // Phase 5C §14
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
}
+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 });
});
+12 -1
View File
@@ -73,10 +73,21 @@ services:
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
# for anyone not exercising that boundary locally.
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
# Phase 5C — activation/reset email links are built from this origin (see
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
# network call no matter what recipient it's given.
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
# COOKIE_SECURE: "1" # set once served over real HTTPS
# Empty by default — unset/empty means the exact same dev behavior as before this was
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
# real, then unsets it again — see that test's own comment.
COOKIE_SECURE: ${COOKIE_SECURE:-}
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
depends_on:
postgres: