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:
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user