Files
rootnode-academy/platform/api/src/server.js
T
ced4568andClaude Sonnet 5 3510979196 feat(platform): business/product architecture — organizations, guardians, offerings, registrations
- organizations, locations, guardians, student_guardians (033) — additive, no existing
  table's meaning changes. Widens programs.family to accept 'bootcamp'.
- curriculum_packages/curriculum_package_items, offerings, instructor_offerings,
  offering_registrations, agreement_definitions/agreement_acceptances (034) — the full
  purchasable-delivery model. Server-defined status transitions only (no client-controlled
  offering/registration status). Capacity uses a single atomic UPDATE (reserved_seats),
  never a SELECT-count-then-INSERT race.
- offerings.js — business logic: reserveSeat/releaseSeat, transitionOffering/
  transitionRegistration (fixed transition tables), convertRegistrationToEnrollment
  (reuses enrollment.js's existing resolveOrCreateStudent/createManualEnrollment rather
  than duplicating student-matching logic).
- enrollment.js — resolveOrCreateStudent gains an optional, default-off returnToken param
  (used only by offerings.js's admin-only conversion route; the public website integration
  endpoint's contract is completely unchanged, per docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
- admin-offerings-routes.js — the full admin/owner API surface. Registration/guardian
  detail is admin/owner only; offering/location listing is any instructor (access_notes
  stripped for a plain instructor).
- A real bug found and fixed during this phase's own smoke testing before any commit: the
  registration route originally reserved a seat BEFORE validating the registration's own
  fields, letting a validation failure consume a real seat and get masked behind a false
  OFFERING_FULL under a full offering. Fixed: validate (create) first, reserve second, roll
  back the registration row if reservation then genuinely fails.

See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
docs/OFFERING_AND_REGISTRATION_MODEL.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:43:52 -05:00

455 lines
26 KiB
JavaScript

// RootNode Academy — Platform API (Phase 3A revision).
//
// Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students,
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, instructor
// accounts, persistent student/instructor sessions, RBAC, audit logging, and all Provisioner
// (environment orchestration) calls. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
//
// Reached only through the Gateway (platform/gateway) — never directly by browser JS.
const express = require('express');
const path = require('path');
const { getPool, runMigrations } = require('./db');
const sessions = require('./sessions');
const { getActiveInstructor } = require('./instructors');
const { provisioner } = require('./provisioner-client');
const curriculum = require('./curriculum');
const { loadProgress, saveOneProgress, touchStudent } = require('./progress');
const { recordAudit } = require('./audit');
const { parseCookies, checkCsrf, makeCsrfToken, startLoginAttemptSweeper } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const createRbac = require('./rbac');
const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes');
const attemptsLib = require('./attempts');
const assessments = require('./assessments');
const achievements = require('./achievements');
const { computeCurriculumState } = require('./curriculum-rules');
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
// Deliberate, not automatic: honoring X-Forwarded-For for req.ip (audit source_ip, login rate
// limiting) is only correct when there's a trusted reverse proxy in front of this process. Off
// by default (direct connections / dev); set TRUST_PROXY=1 once fronted by one in production.
const app = express();
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST));
// ── Student session middleware ──────────────────────────────────────────────────────────
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
// controls. CSRF is checked here too, for every state-changing student request.
// Phase 5A: now re-checks the student is still 'active' on every request, matching
// instructorSession's existing pattern below — a deactivation takes effect on the very next
// request instead of waiting out the session's natural expiry. Previously this middleware only
// checked the session itself existed, which was fine when every student was pilot/trusted-roster
// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist.
async function studentSession(req, res, next) {
const token = parseCookies(req)[STUDENT_COOKIE];
const studentId = await sessions.resolveStudentSession(getPool(), token);
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]);
if (!rows.length || rows[0].status !== 'active') {
await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued
return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
}
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.studentId = studentId;
req.sessionToken = token;
next();
}
// ── Instructor session middleware ───────────────────────────────────────────────────────
// Re-fetches the instructor row from Postgres on every request rather than trusting anything
// cached in the session — a role change or deactivation takes effect on the very next request,
// with no separate "revoke sessions on role change" mechanism needed.
async function instructorSession(req, res, next) {
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
const instructorId = await sessions.resolveInstructorSession(getPool(), token);
if (!instructorId) return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
const instructor = await getActiveInstructor(getPool(), instructorId);
if (!instructor) {
await sessions.destroyInstructorSession(getPool(), token); // deactivated since the session was issued
return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
}
if (!checkCsrf(req, INSTRUCTOR_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.instructor = instructor;
req.sessionToken = token;
next();
}
function wrap(fn) {
return (req, res, next) => fn(req, res, next).catch(next);
}
// GET /healthz — unauthenticated liveness/readiness probe for uptime monitoring and Ansible
// deploy smoke tests (Phase 4, docs/OBSERVABILITY.md §"uptime checks"). Confirms the process is
// up AND can reach Postgres — deliberately minimal, no student/instructor data, no auth.
app.get('/healthz', async (req, res) => {
try {
await getPool().query('SELECT 1');
res.json({ ok: true, db: 'ok' });
} catch (e) {
res.status(503).json({ ok: false, db: 'unreachable' });
}
});
// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry
// credentials here, matching every other exporter in infra/monitoring/) but restricted at the
// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already
// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public
// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js.
app.get('/metrics', async (req, res) => {
try {
res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner));
} catch (e) {
res.status(503).type('text/plain').send('# metrics collection failed\n');
}
});
// GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as
// minimal: an app version, the deployed Git SHA (or 'dev' locally — see
// infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same
// value used for the image tag), and the most recently applied migration as a stand-in for
// schema version. No infrastructure detail, no secrets — this is "what build is this," not a
// diagnostics endpoint.
const PACKAGE_VERSION = require('../package.json').version;
app.get('/api/version', async (req, res) => {
let schemaVersion = null;
try {
const { rows } = await getPool().query('SELECT version FROM schema_migrations ORDER BY applied_at DESC LIMIT 1');
schemaVersion = rows[0]?.version || null;
} catch (e) { /* best-effort — an unreachable DB shouldn't make /api/version itself fail */ }
res.json({
version: PACKAGE_VERSION,
git_sha: process.env.GIT_SHA || 'dev',
schema_version: schemaVersion,
});
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: roster / login / session
// ═══════════════════════════════════════════════════════════════════════════════════════
app.get('/api/roster/cohorts', async (req, res) => {
const { rows } = await getPool().query(
`SELECT DISTINCT c.cohort_id, c.name FROM cohorts c
JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id AND e.enrollment_status = 'enrolled'
WHERE c.status = 'active' ORDER BY c.name`
);
res.json(rows.map((r) => r.cohort_id));
});
app.get('/api/roster', async (req, res) => {
const { cohort } = req.query;
const pool = getPool();
const { rows } = cohort
? await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'
ORDER BY s.display_name`, [cohort])
: await pool.query(`SELECT student_id, display_name, last_active_at FROM students WHERE status = 'active' ORDER BY display_name`);
res.json(rows);
});
// Roster name-picking is CLASSROOM/PILOT IDENTIFICATION, not production authentication — there
// is no password or possession factor here, only "does this student_id exist." What Phase 3A
// hardens is everything AFTER that pick: the resulting session is server-controlled, persistent,
// can't be forged/switched by editing client state, and is checked on every Lab request. See
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
// institutional SSO) that upgrades identification without touching this session system.
// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real,
// credentialed account (password_hash set), this route refuses them and points at the real
// login, so a converted account can never be accessed by just knowing its bare student_id.
// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working
// exactly as before — no pilot data or access is lost.
app.post('/api/login', async (req, res) => {
const { student_id } = req.body || {};
const { rows } = await getPool().query(
`SELECT student_id, display_name, status, password_hash, credential_token_hash FROM students WHERE student_id = $1`,
[student_id]
);
const student = rows[0];
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
// Blocks on credential_token_hash too, not just password_hash — a student mid-conversion
// (instructor has issued an activation token but the student hasn't set a password yet) must
// not be reachable via the old insecure bare-id path either, or conversion doesn't actually
// close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js).
if (student.password_hash || student.credential_token_hash) {
return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' });
}
await touchStudent(getPool(), student.student_id);
const token = await sessions.createStudentSession(getPool(), student.student_id);
const csrf = makeCsrfToken();
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: 8 * 3600 }));
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: 8 * 3600, httpOnly: false }));
res.json({ student_id: student.student_id, display_name: student.display_name });
});
app.post('/api/logout', async (req, res) => {
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
const token = parseCookies(req)[STUDENT_COOKIE];
// Resolved BEFORE destroying, purely to know who to abandon open attempts for (Phase 4.1
// §13) — deliberately not behind studentSession middleware so logout still succeeds
// gracefully against an already-invalid/expired token, same as before this phase.
const studentId = await sessions.resolveStudentSession(getPool(), token);
await sessions.destroyStudentSession(getPool(), token);
if (studentId) await attemptsLib.abandonOpenAttemptsForStudent(getPool(), studentId);
res.append('Set-Cookie', clearCookie(STUDENT_COOKIE));
res.append('Set-Cookie', clearCookie(STUDENT_CSRF_COOKIE));
res.json({ ok: true });
});
// GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab
// ownership checks. Read-only, so it isn't behind the CSRF check (studentSession only enforces
// CSRF on non-GET methods — see checkCsrf).
app.get('/api/session/whoami', wrap(studentSession), (req, res) => {
res.json({ student_id: req.studentId });
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: curriculum / progress
// ═══════════════════════════════════════════════════════════════════════════════════════
app.get('/api/curriculum/path', wrap(studentSession), async (req, res) => {
const path = curriculum.loadCurriculumPath();
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
const scenarios = curriculum.loadScenarios();
const progress = await loadProgress(getPool(), req.studentId);
let firstIncompleteStage = null;
const stages = path.stages.map((stage) => {
const scenarioList = stage.scenario_ids.map((id) => {
const s = scenarios.find((x) => x.id === id);
return { id, title: s?.title || id, difficulty: s?.difficulty || null, status: progress[id]?.status || 'not_started' };
});
const total = scenarioList.length;
const completed = scenarioList.filter((s) => s.status === 'completed').length;
const isComplete = total > 0 && completed === total;
if (!isComplete && !stage.locked && firstIncompleteStage === null) firstIncompleteStage = stage.id;
return { id: stage.id, name: stage.name, icon: stage.icon, locked: !!stage.locked, scenarios: scenarioList, stats: { total, completed } };
});
res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage });
});
app.get('/api/progress', wrap(studentSession), async (req, res) => {
const scenarios = curriculum.loadScenarios();
const progress = await loadProgress(getPool(), req.studentId);
const total = scenarios.length;
const completed = Object.values(progress).filter((p) => p.status === 'completed').length;
res.json({ total, completed, details: progress });
});
// Phase 5B — found by testing: this route's handler was never passed through wrap() (a
// leftover from Phase 1/2A, before that convention existed), so any unexpected internal error
// here — including Postgres rejecting a malformed environment_id, a 22P02 "invalid input
// syntax for type uuid" — became an unhandled promise rejection that CRASHED THE ENTIRE
// PLATFORM API PROCESS for every student, not just a 500 for this one request. Now wrapped
// like every route added since Phase 3B, plus explicit UUID-shape validation so a bad
// environment_id is a clean 400 rather than ever reaching Postgres as a type error at all.
const ENV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
app.post('/api/progress/record', wrap(studentSession), wrap(async (req, res) => {
const { scenario_id, status, environment_id } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
}
if (environment_id && !ENV_ID_RE.test(environment_id)) {
return res.status(400).json({ error: 'environment_id must be a valid UUID' });
}
const pool = getPool();
const passed = status === 'completed';
// Attempt telemetry (Phase 3B) — one row per continuous working session, updated in place.
const curriculumPath = curriculum.loadCurriculumPath();
const stage = curriculumPath?.stages.find((s) => (s.scenario_ids || []).includes(scenario_id))?.id || null;
const { rows: enrolledRows } = await pool.query(
`SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`,
[req.studentId]
);
const cohortId = enrolledRows[0]?.cohort_id || null;
const assessmentCtx = await assessments.findActiveAssessmentContextForScenario(pool, req.studentId, scenario_id);
const attempt = await attemptsLib.recordValidation(pool, {
studentId: req.studentId, scenarioId: scenario_id, environmentId: environment_id || null,
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
}, passed);
// Legacy aggregate progress row (Phase 1/2A/2B) — kept for the existing curriculum/dashboard
// views that read it; attempt-level detail above is the new source of truth for telemetry.
const progress = await loadProgress(pool, req.studentId);
const prev = progress[scenario_id] || { attempts: 0 };
const now = new Date().toISOString();
await saveOneProgress(pool, req.studentId, scenario_id, {
...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now,
completed_at: passed ? now : prev.completed_at,
});
await touchStudent(pool, req.studentId);
if (passed) {
if (assessmentCtx) {
const finalizedSession = await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, true);
await achievements.recordAssessmentAchievement(pool, finalizedSession);
}
if (curriculumPath) {
const freshProgress = await loadProgress(pool, req.studentId);
const state = computeCurriculumState(curriculumPath, freshProgress);
await achievements.checkStageAndCurriculumAchievements(pool, { studentId: req.studentId, path: curriculumPath, curriculumState: state });
}
} else if (assessmentCtx) {
await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, false);
}
res.json({ ok: true });
}));
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: environment lifecycle
// ═══════════════════════════════════════════════════════════════════════════════════════
// Phase 5B — publicEnvView/the launch sequence now live in environment-view.js so
// selfpaced-routes.js's entitlement-gated launch route can call the identical logic instead of
// a second copy. Behavior here is unchanged from before the extraction.
const { publicEnvView: publicEnvViewFor, launchEnvironmentForStudent } = require('./environment-view');
const publicEnvView = (env) => publicEnvViewFor(provisioner, env);
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.studentId);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
res.json(await publicEnvView(active));
});
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
res.status(status).json(body);
});
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const env = existing.body?.find((e) => e.status !== 'destroyed');
if (!env) return res.status(404).json({ error: 'no_environment' });
// Before tearing the old container down — Phase 4.1 §13, see attempts.js's
// abandonOpenAttemptsForEnvironment. The reset reuses the same environment_id (fresh
// container, same DB row — provisioner lifecycle.js), so this must run BEFORE reset, not
// after, or the abandonment would incorrectly apply to attempts against the NEW container.
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), env.environment_id);
const { status, body } = await provisioner.reset(env.environment_id);
if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error });
res.json(await publicEnvView(body));
});
// Internal — the Gateway calls this to resolve a routing token to its owning environment.
app.get('/api/environment/by-token/:token', async (req, res) => {
const { status, body } = await provisioner.getEnvironmentByToken(req.params.token);
res.status(status).json(body);
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// INSTRUCTOR-FACING: auth + admin API
// ═══════════════════════════════════════════════════════════════════════════════════════
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal
// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding
// a second env var, to decide whether /api/student/request-password-reset may echo the reset
// token back for local/test use (never in a real deployment).
require('./student-auth-routes')(app, {
pool: getPool(), sessions, studentSession, curriculum, loadProgress,
isProduction: process.env.COOKIE_SECURE === '1',
});
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
const rbac = createRbac(getPool());
require('./admin-routes')(app, {
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum,
getPool, loadProgress, saveOneProgress, recordAudit,
});
require('./admin-learning-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half
// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor
// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/
// admin-learning-routes.js — a classroom session is a distinct concept from curriculum
// progress, even though its dashboard reads curriculum/progress data.
require('./classroom-routes')(app, { studentSession, getPool, curriculum });
require('./admin-classroom-routes')(app, {
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5B — programs/enrollment. selfpaced-routes.js is the student-facing half (My
// Programs, program detail, entitlement-gated launch); admin-programs-routes.js is the
// instructor/admin half (program authoring, manual enrollment, activate/cancel);
// integration-routes.js is the separate, non-session website/commerce boundary — see
// docs/PHASE5B_SELF_PACED_ENROLLMENT.md and docs/WEBSITE_ENROLLMENT_INTEGRATION.md.
require('./selfpaced-routes')(app, { studentSession, getPool, curriculum, loadProgress, provisioner, wrap });
require('./admin-programs-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Phase 6A — course/module/activity content model, layered on top of the existing
// scenario/assessment/achievement systems (never a duplicate of them — see
// docs/COURSE_CONTENT_MODEL.md). course-routes.js is the student-facing half (course tree
// through an enrollment, lesson completion marker); admin-course-routes.js is the instructor
// curriculum view (structure + per-student progress).
require('./course-routes')(app, { studentSession, getPool, wrap });
require('./admin-course-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool,
});
// 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,
});
// Phase 7 — the business/product architecture: organizations, locations, guardians,
// curriculum packages, offerings, registrations, and agreements. Admin/owner surface only —
// no student-facing routes yet (registration is admin-entered this phase; a future public
// registration flow is a Phase 8+ decision, see docs/OFFERING_AND_REGISTRATION_MODEL.md).
require('./admin-offerings-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'));
});
// Last-resort error handler — an unhandled DB error (or anything else) in any async route
// reaches here via next(err) instead of crashing the process or leaking a stack trace to the
// client. Every new async route/middleware added in Phase 3A funnels through this.
app.use((err, req, res, next) => {
console.error('[unhandled route error]', err);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'internal_error' });
});
async function main() {
const applied = await runMigrations();
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}`));
}
if (require.main === module) {
main().catch((e) => { console.error('FATAL:', e); process.exit(1); });
}
module.exports = { app };