From 4bc202e5f322fe76ad15c58aeaac004083f0df77 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 12:09:02 -0500 Subject: [PATCH] feat(integration): provider-neutral website enrollment boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- platform/api/src/integration-routes.js | 99 ++++++++++++++++++++++++++ platform/docker-compose.dev.yml | 5 ++ 2 files changed, 104 insertions(+) create mode 100644 platform/api/src/integration-routes.js diff --git a/platform/api/src/integration-routes.js b/platform/api/src/integration-routes.js new file mode 100644 index 0000000..2427e84 --- /dev/null +++ b/platform/api/src/integration-routes.js @@ -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, + }); + })); +}; diff --git a/platform/docker-compose.dev.yml b/platform/docker-compose.dev.yml index 48595ce..97ff62a 100644 --- a/platform/docker-compose.dev.yml +++ b/platform/docker-compose.dev.yml @@ -68,6 +68,11 @@ services: PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform PROVISIONER_URL: "http://localhost:4100" PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default} + # Phase 5B — the website/commerce integration boundary's shared secret (see + # docs/WEBSITE_ENROLLMENT_INTEGRATION.md). Optional in dev: unset means + # 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:-} 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.