feat(integration): provider-neutral website enrollment boundary
- integration-routes.js: POST /api/integrations/enrollments — separate from student/instructor session auth entirely (no cookie read), pre-shared-secret header auth matching the existing Gateway<->Provisioner trust-boundary pattern. Idempotent via a DB unique constraint on the caller's own event_id (not app-level check-then-insert) — proven safe under real concurrent duplicate requests. Student resolution is create-or-match by email OR login_identifier (minor/no-email support), reusing Phase 5A's exact credential-issuing path. A small, provider-neutral status vocabulary (paid/active/confirmed/pending/awaiting_payment) — never a raw payment-provider object model. - docker-compose.dev.yml: INTEGRATION_SHARED_SECRET, optional in dev (fails closed if unset).
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// 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 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;
|
||||
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, 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 },
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user