diff --git a/platform/api/src/admin-programs-routes.js b/platform/api/src/admin-programs-routes.js index c5e8ba5..3f12800 100644 --- a/platform/api/src/admin-programs-routes.js +++ b/platform/api/src/admin-programs-routes.js @@ -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) => { diff --git a/platform/api/src/admin-routes.js b/platform/api/src/admin-routes.js index 81658b2..65e24b4 100644 --- a/platform/api/src/admin-routes.js +++ b/platform/api/src/admin-routes.js @@ -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 }); }); diff --git a/platform/api/src/integration-routes.js b/platform/api/src/integration-routes.js index 2427e84..c2e4000 100644 --- a/platform/api/src/integration-routes.js +++ b/platform/api/src/integration-routes.js @@ -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, diff --git a/platform/api/src/rbac.js b/platform/api/src/rbac.js index c12d568..5856d37 100644 --- a/platform/api/src/rbac.js +++ b/platform/api/src/rbac.js @@ -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, }; }; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index b38de23..ea76b1b 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -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}`)); } diff --git a/platform/api/src/student-auth-routes.js b/platform/api/src/student-auth-routes.js index 10e1eae..b6fda7e 100644 --- a/platform/api/src/student-auth-routes.js +++ b/platform/api/src/student-auth-routes.js @@ -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 }); }); diff --git a/platform/docker-compose.dev.yml b/platform/docker-compose.dev.yml index 97ff62a..1afa8f3 100644 --- a/platform/docker-compose.dev.yml +++ b/platform/docker-compose.dev.yml @@ -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: