Files
rootnode-academy/platform/api/src/integration-routes.js
T
ced4568 3c7f0ca59c 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).
2026-08-25 14:42:34 -05:00

118 lines
6.3 KiB
JavaScript

// Phase 5B §12 — the website/commerce integration boundary. POST /api/integrations/enrollments
// is the ONLY thing an external system (the public website, a future payment provider) ever
// calls. It is completely separate from both student and instructor session auth — no
// rn_session/rn_instructor cookie is ever read here, and this route accepts none — so a
// browser cannot reach it just by having a logged-in student or admin tab open. See
// docs/WEBSITE_ENROLLMENT_INTEGRATION.md for the full contract the website team should build
// against.
//
// Auth follows the exact same pattern as platform/provisioner/src/server.js's
// X-Provisioner-Key (a pre-shared secret in an env var, checked via a custom header, fatal at
// startup if unset) — the same internal server-to-server trust boundary this codebase already
// established for Gateway<->Provisioner, not a new scheme. The header value must be held by
// the website's OWN backend only; it is never sent to or readable by browser JS.
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;
if (!INTEGRATION_SHARED_SECRET) {
console.error('WARNING: INTEGRATION_SHARED_SECRET is not set — POST /api/integrations/enrollments will refuse every request until it is. This is fine for a dev stack that never exercises the integration boundary, fatal for anything that needs to.');
}
// Small, deliberately provider-neutral vocabulary — NOT Stripe's (or anyone else's) object
// model. The website's backend translates whatever its payment provider says into one of these
// two values before calling this endpoint.
const STATUS_MAP = { paid: 'active', active: 'active', confirmed: 'active', pending: 'pending', awaiting_payment: 'pending' };
function enrollmentErrorStatus(code) {
if (code === 'PROGRAM_NOT_FOUND') return 404;
if (code === 'PROGRAM_INACTIVE') return 409;
if (code === 'EVENT_ID_REQUIRED' || code === 'STUDENT_IDENTIFIER_REQUIRED') return 400;
return 500;
}
module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
app.post('/api/integrations/enrollments', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
metrics.recordEnrollmentEvent('rejected');
return res.status(401).json({ error: 'unauthorized' });
}
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') {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'event_id is required' });
}
if (!program_slug) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'program_slug is required' });
}
if (!mappedStatus) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'status must be one of: ' + Object.keys(STATUS_MAP).join(', ') });
}
if (!email && !loginIdentifier) {
metrics.recordEnrollmentEvent('rejected');
return res.status(400).json({ error: 'student.email or student.login_identifier is required' });
}
let result;
try {
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
});
} catch (e) {
metrics.recordEnrollmentEvent('rejected');
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
metrics.recordEnrollmentEvent(result.created ? 'created' : 'duplicate');
// Audited regardless of created/duplicate — a replay is still a real, worth-recording
// event, just not a new enrollment. No payment/card data ever reaches this far (the
// payload never carries it — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md "what NOT to send").
await recordAudit(getPool(), {
actorType: 'integration', actorId: source || 'integration', action: result.created ? 'enrollment_integration_created' : 'enrollment_integration_duplicate',
resourceType: 'enrollment', resourceId: result.enrollment.enrollment_id, result: 'success', sourceIp: req.ip,
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,
program_id: result.enrollment.program_id,
status: result.enrollment.status,
created: result.created,
});
}));
};